feat(vuln): vuln-scanner platform manifests, Flux tier, alerts, L0/L2/L3 tests
Hand-authored platform/vuln-scanner (non-gating Flux tier, digest-pinned Trivy 0.76.1 and python 3.14.9-alpine, read-only RBAC, per-pod oauth2-proxy, internal-only cve.<domain>), five alerts with promtool tests, R-VULN-01..09 registry entries, and vuln-* L0 checks with mutation self-tests. src/ and the bot workflow are integrated separately. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4c3a284cad
commit
b85d1e3ed7
47 files changed
+2572
-6
No files matched your search
@@ -54,6 +54,10 @@ ingress:
|
||||
# tunnel alongside decision 4.
|
||||
- hostname: headlamp.${BASE_DOMAIN}
|
||||
service: http_status:404
|
||||
# The CVE page (docs/17: internal only, a list of what is exploitable where). Its route is disowned
|
||||
# from public DNS (platform/vuln-scanner/httproute.yaml); this is the second layer.
|
||||
- hostname: cve.${BASE_DOMAIN}
|
||||
service: http_status:404
|
||||
# Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip
|
||||
# that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all.
|
||||
- hostname: unifi.${BASE_DOMAIN}
|
||||
|
||||
@@ -147,3 +147,4 @@ spec:
|
||||
- radarr
|
||||
- sonarr
|
||||
- headlamp
|
||||
- vuln-scanner
|
||||
@@ -43,6 +43,13 @@ data:
|
||||
href: https://grafana.${BASE_DOMAIN}
|
||||
description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager)
|
||||
icon: mdi-chart-areaspline
|
||||
# The CVE page (platform/vuln-scanner, docs/17): behind SSO, restricted to platform-admins.
|
||||
# Homepage has no per-group surfaces, so this tile is shown to everyone who can open this
|
||||
# dashboard; the page itself enforces platform-admins.
|
||||
- CVE Report:
|
||||
href: https://cve.${BASE_DOMAIN}
|
||||
description: Container image CVE inventory (platform-admins)
|
||||
icon: mdi-shield-bug
|
||||
- Semaphore:
|
||||
href: https://semaphore.${BASE_DOMAIN}
|
||||
description: Ansible automation & CI
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# vuln-scanner — scheduled CVE visibility for what is running
|
||||
|
||||
Answers three questions with no manual step: what is running, what is it vulnerable to, and can the
|
||||
answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09).
|
||||
Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier
|
||||
`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`.
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` |
|
||||
| Accepted risk (R-VULN-08) | `acceptances.yaml` |
|
||||
| Pod, security context, sidecar | `deployment.yaml` |
|
||||
| Read-only identity (R-VULN-09) | `rbac.yaml` |
|
||||
| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` |
|
||||
| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` |
|
||||
|
||||
The page is `cve.<domain>`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`.
|
||||
The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN.
|
||||
|
||||
## Operator step: add the Keycloak client (NOT done by the change that added this tree)
|
||||
|
||||
This change adds everything around the Keycloak client but not the client. Adding it needs a new key in
|
||||
`platform/keycloak/keycloak-realm-clients.sops.yaml`, an encrypted file an agent must not decrypt; the
|
||||
L0 check `keycloak-client-secret-reconciler` then requires the client and that key to appear in the
|
||||
same change. Until this is done the page cannot complete a login (`invalid_client`, fail-closed); the
|
||||
scanner, its metrics and its alerts are unaffected.
|
||||
|
||||
In one PR (the realm import does not re-run on its own for a NEW client):
|
||||
|
||||
1. Generate one value and set it in both encrypted files, through pipes so it is never echoed:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c '
|
||||
cs=$(openssl rand -hex 32)
|
||||
sops set platform/keycloak/keycloak-realm-clients.sops.yaml \
|
||||
"[\"stringData\"][\"vuln-scanner-client-secret\"]" "\"$cs\""
|
||||
sops set platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \
|
||||
"[\"stringData\"][\"client-secret\"]" "\"$cs\""'
|
||||
```
|
||||
|
||||
2. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client:
|
||||
|
||||
```yaml
|
||||
# spec.placeholders
|
||||
VULN_SCANNER_CLIENT_SECRET:
|
||||
secret:
|
||||
name: keycloak-realm-clients
|
||||
key: vuln-scanner-client-secret
|
||||
# spec.realm.clients
|
||||
- clientId: vuln-scanner
|
||||
name: CVE report (oauth2-proxy)
|
||||
protocol: openid-connect
|
||||
enabled: true
|
||||
publicClient: false
|
||||
standardFlowEnabled: true
|
||||
directAccessGrantsEnabled: false
|
||||
secret: "$(env:VULN_SCANNER_CLIENT_SECRET)"
|
||||
redirectUris:
|
||||
- https://cve.${BASE_DOMAIN}/oauth2/callback
|
||||
webOrigins:
|
||||
- https://cve.${BASE_DOMAIN}
|
||||
defaultClientScopes: [email, profile, roles]
|
||||
protocolMappers:
|
||||
- name: groups
|
||||
protocol: openid-connect
|
||||
protocolMapper: oidc-group-membership-mapper
|
||||
config:
|
||||
"full.path": "false"
|
||||
"id.token.claim": "true"
|
||||
"access.token.claim": "true"
|
||||
"userinfo.token.claim": "true"
|
||||
"claim.name": groups
|
||||
```
|
||||
|
||||
3. Regenerate the derived URL file: `python3 ci/render-keycloak-client-urls.py --write`. Commit all
|
||||
three, gate, merge.
|
||||
|
||||
4. After merge, the client must also exist in the live realm (a completed realm import does not re-run,
|
||||
per `platform/keycloak/README.md`, so create it once with `kcadm` or re-trigger the import). The
|
||||
client-secret reconciler then converges its secret and URLs.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Accepted CVE risk (R-VULN-08). A list; this file is the ONLY place risk can be accepted, and it reaches
|
||||
# the scanner through the merge gate (a ConfigMap generated by kustomization.yaml).
|
||||
#
|
||||
# Each entry:
|
||||
# - id: CVE-2026-00000 # the CVE or advisory id, exactly as the scanner reports it
|
||||
# image: registry/repository # exact image match; omit only for a platform-wide acceptance
|
||||
# reason: not reachable, feature disabled # mandatory, a real sentence
|
||||
# expires: 2027-02-01 # mandatory YYYY-MM-DD, at most 180 days out
|
||||
#
|
||||
# An accepted finding stays in the listing with its reason and expiry and stops counting toward the
|
||||
# critical-fix alert. When the expiry passes it reverts to open automatically. An entry with no reason
|
||||
# or no expiry, or an expiry more than 180 days out or malformed, is rejected by the merge gate
|
||||
# (conformance/tests/l0-static/vuln-acceptances-schema) and ignored at runtime with the run marked
|
||||
# degraded, so a finding is never hidden by a bad entry.
|
||||
[]
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
# promtool unit tests for platform/vuln-scanner/alerts.yaml (R-VULN-06).
|
||||
#
|
||||
# Found by platform/monitoring/tests/run.sh through the COMPANION convention. Run just this file with
|
||||
# platform/monitoring/tests/run.sh platform/vuln-scanner/alerts.promtool-test.yaml
|
||||
#
|
||||
# Each rule is pinned both ways. VulnScanStale and VulnTriageBotSilent additionally pin the two cases
|
||||
# the spec names by hand: the metric ABSENT must FIRE, and a metric that vanishes must RE-FIRE.
|
||||
# conformance/tests/l0-static/vuln-alerts-unit-tested reads this file and fails if any of those cases
|
||||
# is removed.
|
||||
#
|
||||
# Timestamps: promtool evaluates from t=0, so a timestamp series can equal its own evaluation time
|
||||
# with `0+60x<N>` at a 1m interval (time() - value == 0: fresh).
|
||||
rule_files:
|
||||
- alerts.rules.yaml
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
- interval: 1m
|
||||
name: healthy - fresh run, fresh database, heartbeat, zero backlog, nothing fires
|
||||
input_series:
|
||||
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0+60x60'
|
||||
- series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0+60x60'
|
||||
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0+60x60'
|
||||
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
|
||||
values: '0x60'
|
||||
- series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}'
|
||||
values: '0x60'
|
||||
promql_expr_test:
|
||||
- expr: 'count(ALERTS{alertname=~"Vuln.*"})'
|
||||
eval_time: 50m
|
||||
exp_samples: []
|
||||
|
||||
- interval: 1m
|
||||
name: scan stale - last complete run older than 26h fires after the for window
|
||||
input_series:
|
||||
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0x1600'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
|
||||
eval_time: 1559m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
|
||||
eval_time: 1590m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: scan stale ABSENT - metric never existed, VulnScanStale still fires
|
||||
input_series:
|
||||
# A healthy, unrelated metric proves this is the absent() arm, not a broken test file.
|
||||
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
|
||||
values: '0x60'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
|
||||
eval_time: 20m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: scan metric VANISHES - a scanner that dies re-fires instead of latching clear
|
||||
input_series:
|
||||
- series: 'vuln_scanner_last_complete_run_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0+60x20 stale'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
|
||||
eval_time: 18m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnScanStale", alertstate="firing"}'
|
||||
eval_time: 40m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnScanStale", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: database stale - older than 48h fires after the for window
|
||||
input_series:
|
||||
- series: 'vuln_scanner_database_updated_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0x2960'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}'
|
||||
eval_time: 2870m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing"}'
|
||||
eval_time: 2900m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnDatabaseStale", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: coverage gap - a failed target over 24h fires, zero is quiet
|
||||
input_series:
|
||||
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
|
||||
values: '0x20 3x40'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}'
|
||||
eval_time: 25m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing"}'
|
||||
eval_time: 40m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnCoverageGap", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: critical fix overdue - fires, one grouped alert instance
|
||||
input_series:
|
||||
- series: 'vuln_scanner_critical_fix_overdue{namespace="vuln-scanner"}'
|
||||
values: '0x20 12x40'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}'
|
||||
eval_time: 25m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"}'
|
||||
eval_time: 40m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
- expr: 'count(ALERTS{alertname="VulnCriticalFixAvailable", alertstate="firing"})'
|
||||
eval_time: 40m
|
||||
exp_samples:
|
||||
- labels: '{}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: triage bot silent - last heartbeat older than 26h fires after the for window
|
||||
input_series:
|
||||
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0x1600'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
|
||||
eval_time: 1559m
|
||||
exp_samples: []
|
||||
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
|
||||
eval_time: 1590m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: triage bot silent ABSENT - no heartbeat metric ever, VulnTriageBotSilent still fires
|
||||
input_series:
|
||||
- series: 'vuln_scanner_failed_targets_over_24h{namespace="vuln-scanner"}'
|
||||
values: '0x60'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
|
||||
eval_time: 20m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
|
||||
- interval: 1m
|
||||
name: triage heartbeat VANISHES - re-fires instead of latching clear
|
||||
input_series:
|
||||
- series: 'vuln_scanner_triage_heartbeat_timestamp_seconds{namespace="vuln-scanner"}'
|
||||
values: '0+60x20 stale'
|
||||
promql_expr_test:
|
||||
- expr: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing"}'
|
||||
eval_time: 40m
|
||||
exp_samples:
|
||||
- labels: 'ALERTS{alertname="VulnTriageBotSilent", alertstate="firing", severity="warning"}'
|
||||
value: 1
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
# Alert rules for the vulnerability scanner (R-VULN-06, docs/17 "Alerting").
|
||||
#
|
||||
# ONE ALERT, ONE GROUPED EMAIL PER CONDITION, never one per CVE. The scanner's metrics are aggregates
|
||||
# with no per-CVE, per-image or per-workload label; `max()` strips scrape labels, so each rule yields a
|
||||
# single alert instance, and Alertmanager's `group_by: [alertname, namespace]` route delivers one email.
|
||||
#
|
||||
# THE TWO RULES WHOSE ABSENT-METRIC CASE IS LOAD-BEARING: VulnScanStale and VulnTriageBotSilent. Their
|
||||
# metrics are absent until the first complete run / first bot heartbeat, and absent when the scanner
|
||||
# or the scrape is dead; both are `or absent(...)` so a scanner that has never run, or an unscraped
|
||||
# one, fires instead of reading as healthy. Pinned by alerts.promtool-test.yaml in both directions.
|
||||
# VulnCriticalFixAvailable is expected to fire on first deployment (the backlog made visible).
|
||||
#
|
||||
# Severity follows the platform-health convention: warning (a condition to act on, not a page).
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
groups:
|
||||
- name: platform-health.vulnerability-scanner
|
||||
rules:
|
||||
- alert: VulnScanStale
|
||||
# No complete run within 26 hours, or the metric is absent. 15m rides out a pod restart.
|
||||
expr: >-
|
||||
(time() - max(vuln_scanner_last_complete_run_timestamp_seconds) > 93600)
|
||||
or absent(vuln_scanner_last_complete_run_timestamp_seconds)
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: No complete vulnerability scan in over 26 hours
|
||||
description: >-
|
||||
The scanner has not completed a run within 26 hours, or no scan metric exists at all
|
||||
(the scanner or its scrape is down). Findings on the CVE page are ageing.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
|
||||
|
||||
- alert: VulnDatabaseStale
|
||||
# Vulnerability database older than 48 hours (docs/17): the run is degraded, not a success.
|
||||
expr: time() - max(vuln_scanner_database_updated_timestamp_seconds) > 172800
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: Vulnerability database is older than 48 hours
|
||||
description: >-
|
||||
The Trivy vulnerability database has not been refreshed in over 48 hours, so scans
|
||||
evaluate images against stale advisories and the run reports as degraded.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
|
||||
|
||||
- alert: VulnCoverageGap
|
||||
# A target has been `failed` for more than 24 hours. credential-required targets are not
|
||||
# `failed` and do not count, so a private registry cannot hold this alert firing.
|
||||
expr: max(vuln_scanner_failed_targets_over_24h) > 0
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $value }} image(s) have failed scanning for over 24 hours"
|
||||
description: >-
|
||||
One or more running images have failed scanning for more than 24 hours, so what is
|
||||
reported for them is out of date. The CVE page lists each target and the reason.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
|
||||
|
||||
- alert: VulnCriticalFixAvailable
|
||||
# An unaccepted CRITICAL with a fix, first seen over 72 hours ago. The grace period gives
|
||||
# Renovate's daily cycle time to act, so this marks what automation has not resolved.
|
||||
expr: max(vuln_scanner_critical_fix_overdue) > 0
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $value }} CRITICAL finding(s) with a fix available for over 72 hours"
|
||||
description: >-
|
||||
One or more unaccepted CRITICAL vulnerabilities have had a fix available for more than
|
||||
72 hours. The CVE page has the details; this message deliberately names none of them.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
|
||||
|
||||
- alert: VulnTriageBotSilent
|
||||
# No bot heartbeat for 26 hours, or the metric is absent (the bot has never run, or the
|
||||
# scanner or its scrape is down).
|
||||
expr: >-
|
||||
(time() - max(vuln_scanner_triage_heartbeat_timestamp_seconds) > 93600)
|
||||
or absent(vuln_scanner_triage_heartbeat_timestamp_seconds)
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: CVE triage bot has not reported in over 26 hours
|
||||
description: >-
|
||||
The scheduled CVE triage bot has not posted a heartbeat in over 26 hours (or never
|
||||
has), so remediation states are stale and no approvals are being requested.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/vuln-scanner"
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
# The vulnerability scanner (R-VULN-01..09, docs/17). One pod:
|
||||
# init trivy-tools copies the Trivy binary and CA bundle from the digest-pinned Trivy image into
|
||||
# an emptyDir, so the running container has a single python interpreter + one
|
||||
# static Go binary and nothing else from that image.
|
||||
# main scanner python3 stdlib only (no pip), source from a ConfigMap at /app: inventory,
|
||||
# sequential scans, report, the page on 127.0.0.1:8080, the internal API on
|
||||
# :8081, aggregates on :9090.
|
||||
# sidecar oauth2-proxy the only path to the page, restricted to platform-admins (R-IDP-06, docs/17
|
||||
# "Access"), reached from the shared Gateway on :4180.
|
||||
#
|
||||
# ONE REPLICA, Recreate: one scanner on one RWO report store; two would double-scan and contend for
|
||||
# the volume.
|
||||
#
|
||||
# SOFT affinity toward k3s-prod-2: prod-1 is the node whose etcd I/O starved historically, and scans
|
||||
# are CPU- and I/O-heavy. Soft on purpose: a scanner that cannot be placed must still run elsewhere.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
revisionHistoryLimit: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
# The direct egress path; never the VPN gateway (docs/17).
|
||||
homelab.keithjackson.dev/egress-class: internet
|
||||
# Per-pod oauth2-proxy gate in front of the page. This namespace is not app-namespace=true,
|
||||
# so the auth-mode/egress-class policies do not select it; the labels are kept so the pod is
|
||||
# compliant by construction if that ever changes.
|
||||
homelab.keithjackson.dev/auth-mode: forward-auth
|
||||
spec:
|
||||
serviceAccountName: vuln-scanner
|
||||
# The scanner reads the Kubernetes API with this token (read-only, rbac.yaml).
|
||||
automountServiceAccountToken: true
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
# Longhorn honours fsGroup on an RWO volume; this makes /data and the emptyDirs writable.
|
||||
fsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values:
|
||||
- k3s-prod-2
|
||||
initContainers:
|
||||
- name: trivy-tools
|
||||
# TRIVY 0.76.1 — pinned by digest (R-VULN-09). Not 0.69.4/5/6 (compromised release, March
|
||||
# 2026, CVE-2026-33634). Tag digest and config created timestamp read from the registry on
|
||||
# 2026-11-09: sha256:790fd66d…6c06c, created 2026-10-21T11:16:20Z (19 days old, i.e. past
|
||||
# the 7-day minimum release age; 0.76.2 was published 2026-11-05 and is too new).
|
||||
# SIGNATURE VERIFIED 2026-11-09 with cosign v3.1.3, keyless:
|
||||
# cosign verify ghcr.io/aquasecurity/trivy@sha256:790fd66d... \
|
||||
# --certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/\.github/workflows/.+' \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
# Renovate: blast-radius component, never auto-merges, 7-day minimum release age
|
||||
# (renovate.json5); this `tag@digest` form is what the `kubernetes` manager tracks.
|
||||
# The image's ENTRYPOINT is `trivy`, overridden below. /bin/cp (busybox) and
|
||||
# /etc/ssl/certs/ca-certificates.crt confirmed present by listing the layers via the
|
||||
# registry API (no image run).
|
||||
image: ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/cp
|
||||
- /usr/local/bin/trivy
|
||||
- /etc/ssl/certs/ca-certificates.crt
|
||||
- /tools/
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
ephemeral-storage: 256Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
ephemeral-storage: 512Mi
|
||||
volumeMounts:
|
||||
- name: tools
|
||||
mountPath: /tools
|
||||
containers:
|
||||
# ─────────────────────────────── scanner ──────────────────────────────────────────────
|
||||
- name: scanner
|
||||
# python 3.14 on alpine by digest, matching the interpreter the scanner's tests ran on.
|
||||
# 3.14.9-alpine, config created 2026-10-21T23:01:37Z, read from the registry on 2026-11-09
|
||||
# (19 days old; 3.14.10-alpine, the current 3.14-alpine, was built 2026-11-05 and is under
|
||||
# the 7-day age applied here by hand). Tracked by the `kubernetes` Renovate manager.
|
||||
# Runs as 65532 from this pod's securityContext; the image's default is root, overridden.
|
||||
image: docker.io/library/python:3.14.9-alpine@sha256:dbeb11d16bb95c3a7e3c25c842cb9f20cbc8c18f8be0461a4a0dcc861ad386a0
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- python3
|
||||
- /app/main.py
|
||||
env:
|
||||
- name: VULN_DATA_DIR
|
||||
value: /data
|
||||
- name: VULN_CACHE_DIR
|
||||
value: /cache
|
||||
# Scan caches and temp extraction live on the size-limited emptyDir, never on the
|
||||
# replicated volume or etcd (docs/17 "Load").
|
||||
- name: TMPDIR
|
||||
value: /cache
|
||||
- name: HOME
|
||||
value: /cache
|
||||
- name: VULN_TOOLS_DIR
|
||||
value: /tools
|
||||
- name: TRIVY_BIN
|
||||
value: /tools/trivy
|
||||
- name: SSL_CERT_FILE
|
||||
value: /tools/ca-certificates.crt
|
||||
- name: VULN_ACCEPTANCES
|
||||
value: /config/acceptances.yaml
|
||||
- name: PYTHONUNBUFFERED
|
||||
value: "1"
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
ports:
|
||||
- { name: internal, containerPort: 8081, protocol: TCP }
|
||||
- { name: metrics, containerPort: 9090, protocol: TCP }
|
||||
# Sized to trivy, the only heavy thing here: scans are sequential, the limit is the peak of
|
||||
# one large image; the emptyDir sizeLimit bounds disk.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
ephemeral-storage: 12Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# /healthz on the metrics port is answered by the HTTP server itself and must not depend
|
||||
# on scan state: a long scan or a failed one is reported, never a reason to restart.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: metrics
|
||||
periodSeconds: 5
|
||||
failureThreshold: 24
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: metrics
|
||||
periodSeconds: 20
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: metrics
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- name: src
|
||||
mountPath: /app
|
||||
readOnly: true
|
||||
- name: acceptances
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
- name: tools
|
||||
mountPath: /tools
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: cache
|
||||
mountPath: /cache
|
||||
# ─────────────────────────── oauth2-proxy reverse-proxy sidecar ───────────────────────
|
||||
- name: oauth2-proxy
|
||||
# The per-app sidecar pattern (platform/crafty, catalog/render.py): same tag as the
|
||||
# renderer's OAUTH2_PROXY_IMAGE so a Renovate bump lands every sidecar in one branch.
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- --http-address=0.0.0.0:4180
|
||||
- --reverse-proxy=true
|
||||
- --provider=oidc
|
||||
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||
# The page, bound to loopback in the scanner container; no Service reaches it.
|
||||
- --upstream=http://127.0.0.1:8080
|
||||
- --email-domain=*
|
||||
- --scope=openid email profile
|
||||
- --oidc-groups-claim=groups
|
||||
# R-IDP-06, docs/17 "Access": a valid session is not enough, the user must be a
|
||||
# platform-admin, because this page is the list of what is exploitable where.
|
||||
- --allowed-group=platform-admins
|
||||
- --skip-provider-button=true
|
||||
- --redirect-url=https://cve.${BASE_DOMAIN}/oauth2/callback
|
||||
# Unique name + host-scoped domain: the shared forward-auth proxy and the other sidecars
|
||||
# hold different cookie-secrets, so a shared cookie name would evict sessions
|
||||
# (oauth2-proxy-cookie-names-distinct).
|
||||
- --cookie-name=_oauth2_proxy_vuln_scanner
|
||||
- --cookie-domain=cve.${BASE_DOMAIN}
|
||||
- --whitelist-domain=.${BASE_DOMAIN}
|
||||
- --cookie-secure=true
|
||||
- --set-xauthrequest=true
|
||||
env:
|
||||
# client-secret MUST equal the Keycloak `vuln-scanner` client's secret (key
|
||||
# vuln-scanner-client-secret in platform/keycloak/keycloak-realm-clients.sops.yaml; see
|
||||
# README.md, Operator step). Secrets by name only (R-IDP-04).
|
||||
- name: OAUTH2_PROXY_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-id }
|
||||
- name: OAUTH2_PROXY_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: client-secret }
|
||||
- name: OAUTH2_PROXY_COOKIE_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: vuln-scanner-oauth2-proxy, key: cookie-secret }
|
||||
ports:
|
||||
- { name: http, containerPort: 4180, protocol: TCP }
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: src
|
||||
configMap:
|
||||
name: vuln-scanner-src
|
||||
- name: acceptances
|
||||
configMap:
|
||||
name: vuln-scanner-acceptances
|
||||
- name: tools
|
||||
emptyDir:
|
||||
sizeLimit: 512Mi
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: vuln-scanner-data
|
||||
# Trivy DB + per-image scan cache + TMPDIR (trivy extracts layers here). Size-limited; never
|
||||
# on a replicated volume or in etcd (docs/17 "Load"). A full run cleans it between images.
|
||||
- name: cache
|
||||
emptyDir:
|
||||
sizeLimit: 10Gi
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
# The CVE page at cve.<domain>. Internal exposure only (docs/17 "Access"): a list of what is exploitable
|
||||
# where is sensitive, so this hostname publishes NO public DNS record (the `controller: none` annotation
|
||||
# below disowns it from external-dns) and the Cloudflare Tunnel 404s it (platform/cloudflared/config.yaml).
|
||||
# It resolves on the LAN through PiHole's explicit list (catalog/pihole.yaml).
|
||||
#
|
||||
# The backend is the `vuln-scanner` Service's `http` port, which fronts the oauth2-proxy sidecar, so the
|
||||
# SSO gate is in the data path; nothing routes to the page (:8080) directly.
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
annotations:
|
||||
homelab.keithjackson.dev/auth-mode: oauth2-proxy
|
||||
homelab.keithjackson.dev/auth-groups: platform-admins
|
||||
external-dns.alpha.kubernetes.io/controller: "none"
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: shared
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
hostnames:
|
||||
- cve.${BASE_DOMAIN}
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: vuln-scanner
|
||||
port: 4180
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# The CVE visibility scanner (docs/17, ADR-0027). Reconciled by its OWN non-gating Flux Kustomization
|
||||
# (clusters/base/vuln-scanner.yaml: dependsOn platform-config, wait:false). A scanner that is failing,
|
||||
# or a pod that cannot get its report store, is a thing to alert on, never a thing that stalls the
|
||||
# platform. NOT a line in platform/kustomization.yaml.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# Required for the configMapGenerator (see platform/cloudflared/kustomization.yaml).
|
||||
namespace: vuln-scanner
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- rbac.yaml
|
||||
- pvc.yaml
|
||||
# The per-pod oauth2-proxy credential. SOPS-encrypted, decrypted per-env by the vuln-scanner
|
||||
# Kustomization (both overlays patch it). Referenced by name only (R-IDP-04). Its template sibling
|
||||
# is deliberately not named here (no-plaintext-secrets).
|
||||
- vuln-scanner-oauth2-proxy-credentials.sops.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
# Namespace default-deny, the three ingress paths and the egress allowances. One file, so a baseline
|
||||
# without its allows cannot ship alone and empty the TSDB.
|
||||
- networkpolicy.yaml
|
||||
- httproute.yaml
|
||||
- servicemonitor.yaml
|
||||
- alerts.yaml
|
||||
|
||||
# Scanner source and the risk-acceptance file, both as content-hashed ConfigMaps, so a change rolls
|
||||
# the pod. No `disableNameSuffixHash`: the hash is the rollout mechanism.
|
||||
configMapGenerator:
|
||||
- name: vuln-scanner-src
|
||||
files:
|
||||
- src/main.py
|
||||
- src/inventory.py
|
||||
- src/trivy.py
|
||||
- src/state.py
|
||||
- src/report.py
|
||||
- src/acceptances.py
|
||||
- src/page.py
|
||||
- src/metrics.py
|
||||
- src/server.py
|
||||
- src/report.schema.json
|
||||
options:
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
- name: vuln-scanner-acceptances
|
||||
files:
|
||||
- acceptances.yaml
|
||||
options:
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# vuln-scanner — the CVE visibility scanner (R-VULN-01..09, ADR-0027, docs/17). Hand-authored platform
|
||||
# delivery tooling, not a catalog service: its cluster-wide read identity is something a
|
||||
# ServiceDefinition cannot express.
|
||||
#
|
||||
# gateway-access=shared opts this namespace in to attach the cve.<domain> HTTPRoute to the shared
|
||||
# Gateway; the route targets the per-pod oauth2-proxy sidecar (:4180), never the page directly.
|
||||
#
|
||||
# INTENTIONALLY NOT homelab.keithjackson.dev/app-namespace=true (the crafty/cloudflared/pm-agent
|
||||
# posture): every Kyverno app policy matches on that label, and the generated default-deny would then
|
||||
# be double-owned with the one this tree ships (networkpolicy.yaml), the 2026-09-30 drift loop. The
|
||||
# pod still carries the egress-class and auth-mode labels those policies would require.
|
||||
#
|
||||
# PodSecurity `restricted` IS enforced: nothing here needs any privilege.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
gateway-access: "shared"
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
# Default-deny baseline (R-NET-01): deny all ingress and egress, allow only DNS. A platform namespace,
|
||||
# so Kyverno does not generate this and Flux is its only writer (namespace.yaml).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
homelab.keithjackson.dev/baseline: default-deny
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress: []
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
---
|
||||
# INGRESS — exactly three paths (docs/17 "Security of the scanner itself"), one client each:
|
||||
# :4180 from the shared Gateway only (the oauth2-proxy sidecar). The page itself (:8080, loopback)
|
||||
# has no Service port and no rule.
|
||||
# :8081 from the CI runner only. Job containers run inside the DinD sidecar of the act-runner pod in
|
||||
# namespace gitea-actions, so what the network sees is THAT pod's identity (verified live
|
||||
# 2026-11-09: pods carry app.kubernetes.io/name=act-runner). No wider than the runner pods.
|
||||
# :9090 from Prometheus only (verified live: app.kubernetes.io/name=prometheus in monitoring).
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: vuln-scanner-allow-ingress
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- ingress
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "4180", protocol: TCP }
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
io.kubernetes.pod.namespace: gitea-actions
|
||||
app.kubernetes.io/name: act-runner
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "8081", protocol: TCP }
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
io.kubernetes.pod.namespace: monitoring
|
||||
app.kubernetes.io/name: prometheus
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "9090", protocol: TCP }
|
||||
---
|
||||
# EGRESS (DNS is in the baseline above).
|
||||
# * kube-apiserver: the inventory.
|
||||
# * tcp/443 to the public internet for the vulnerability database and registries, OUT THE DIRECT
|
||||
# PATH (egress-class=internet, deployment.yaml; never the VPN gateway). Written as a CIDR rule
|
||||
# rather than `toEntities: world` because the spec excludes cluster and LAN ranges and `world`
|
||||
# includes every LAN address: RFC1918 + CGNAT + loopback + link-local + multicast are subtracted.
|
||||
# Consequence, recorded rather than worked around: the Gitea registry resolves to the LAN VIP, so
|
||||
# images hosted there are unreachable from here and report `failed` with that reason, until a
|
||||
# registry credential and a narrower LAN rule are decided (docs/17, "Private registry coverage").
|
||||
# * The two Keycloak rules below are the ONE addition beyond the spec's list, and they are required:
|
||||
# the oauth2-proxy sidecar shares this pod's network identity with the scanner, and it must
|
||||
# reach Keycloak for OIDC discovery and the token exchange. keycloak.<domain> resolves in-cluster
|
||||
# to the Gateway VIP (split-horizon CoreDNS), which Cilium hands to the Gateway's Envoy
|
||||
# (reserved:ingress, tcp/443), and Envoy then re-evaluates THIS pod's egress against Keycloak's
|
||||
# real backend identity (enforce_policy_on_l7lb), the same pair catalog/render.py gives every SSO
|
||||
# app (buildkit/networkpolicy.yaml has the measurement). Without them login 403s.
|
||||
# * NOT listed: anything else in-cluster, any node entity, the LAN, the VPN.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: vuln-scanner-allow-egress
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
egress:
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
- toCIDRSet:
|
||||
- cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.0.0.0/8
|
||||
- 100.64.0.0/10
|
||||
- 127.0.0.0/8
|
||||
- 169.254.0.0/16
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 224.0.0.0/4
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "443", protocol: TCP }
|
||||
- toEntities:
|
||||
- ingress
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "443", protocol: TCP }
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
io.kubernetes.pod.namespace: keycloak
|
||||
app: keycloak
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# The report store: state.json (firstSeenAt, failed-since, heartbeat, last complete run) and
|
||||
# report.json. 1Gi on the replicated tier is generous for two JSON files; it is DELIBERATELY NOT where
|
||||
# the vulnerability database or the per-image scan caches live, because etcd and Longhorn share a
|
||||
# partition on every node (docs/02, docs/17 "Load"). Those are on the size-limited /cache emptyDir.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vuln-scanner-data
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# Scanner identity (R-VULN-09). The whole of its Kubernetes reach is get/list/watch on the objects it
|
||||
# needs to answer "what is running, and which workload owns it". NO Secret, NO write verb, no
|
||||
# wildcard. conformance/tests/l0-static/vuln-scanner-supply-chain fails the gate if that changes.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: vuln-scanner-read
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
rules:
|
||||
# Running images, their digests, and the nodes a pod sits on (nodes are inventoried as not-covered).
|
||||
- apiGroups: [""]
|
||||
resources: [pods, nodes]
|
||||
verbs: [get, list, watch]
|
||||
# The owner chain Pod -> ReplicaSet -> Deployment, and the other two top-level controllers.
|
||||
- apiGroups: [apps]
|
||||
resources: [replicasets, deployments, statefulsets, daemonsets]
|
||||
verbs: [get, list, watch]
|
||||
# Pod -> Job -> CronJob, and CronJob templates with no running pod.
|
||||
- apiGroups: [batch]
|
||||
resources: [jobs, cronjobs]
|
||||
verbs: [get, list, watch]
|
||||
# KubeVirt VMs are inventoried as not-covered.
|
||||
- apiGroups: [kubevirt.io]
|
||||
resources: [virtualmachineinstances]
|
||||
verbs: [get, list, watch]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: vuln-scanner-read
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: vuln-scanner-read
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# One Service, three named ports (the page gate, the internal API and metrics).
|
||||
#
|
||||
# clusterIP IS PINNED ON PURPOSE (10.43.210.10, verified unused against the live Service list on
|
||||
# 2026-11-09, inside k3s' default 10.43.0.0/16 Service CIDR). The triage bot runs as a Gitea Actions
|
||||
# job inside the runner's DinD daemon, and whether in-cluster DNS resolves from a DinD job container
|
||||
# is UNVERIFIED. The pinned address is the bot's fallback when the Service name does not resolve:
|
||||
# `http://10.43.210.10:8081` (VULN_API_FALLBACK_URL in .gitea/workflows/cve-triage.yaml).
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: 10.43.210.10
|
||||
selector:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
ports:
|
||||
- name: http
|
||||
port: 4180
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: internal
|
||||
port: 8081
|
||||
targetPort: internal
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
port: 9090
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# Prometheus scrape of the aggregate-only /metrics (:9090). Only the `metrics` port of the shared
|
||||
# Service; networkpolicy.yaml admits only Prometheus on it. kube-prometheus-stack selects
|
||||
# ServiceMonitors from any namespace; the `release` label is kept for consistency.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: vuln-scanner
|
||||
labels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vuln-scanner
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 60s
|
||||
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# TEMPLATE, NOT APPLIED, NOT ENCRYPTED. The OIDC client and cookie secrets for the per-pod oauth2-proxy
|
||||
# sidecar gating the CVE page (deployment.yaml, container oauth2-proxy). The sidecar reads these three
|
||||
# keys by name only (R-IDP-04).
|
||||
#
|
||||
# The `client-secret` value MUST equal `vuln-scanner-client-secret` in
|
||||
# platform/keycloak/keycloak-realm-clients.sops.yaml (a cross-namespace mirror, like crafty). See
|
||||
# platform/vuln-scanner/README.md, "Operator step". Never decrypt this to a terminal or a log.
|
||||
#
|
||||
# Regenerate (encrypts to the repo's public recipient via .sops.yaml, values never echoed):
|
||||
# ./infra/devcontainer/dev.sh run bash -c '
|
||||
# cs=$(openssl rand -hex 32); ck=$(openssl rand -base64 32 | tr -- "+/" "-_")
|
||||
# sops -e --input-type yaml --output-type yaml \
|
||||
# --filename-override platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \
|
||||
# /dev/stdin <<YAML > platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml
|
||||
# ...this document with client-secret: $cs and cookie-secret: $ck...
|
||||
# YAML'
|
||||
# =============================================================================
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: vuln-scanner-oauth2-proxy
|
||||
namespace: vuln-scanner
|
||||
type: Opaque
|
||||
stringData:
|
||||
client-id: vuln-scanner
|
||||
client-secret: REPLACE-ME
|
||||
cookie-secret: REPLACE-ME
|
||||
Reference in new issue
Block a user