Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5.5 e1adc3963c docs(vuln): handoff for automated CVE visibility
gate / gate (pull_request) Failing after 54s
gate / conformance (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:40:15 -06:00
jakman85 4ca0ebfac6 Merge branch 'wip/vuln-fixes' into feat/vuln-visibility 2026-11-09 12:38:41 -06:00
jakman85 1ff527f546 Merge branch 'wip/vuln-platform' into feat/vuln-visibility 2026-11-09 12:38:41 -06:00
jakman85andClaude Opus 5.5 d8b25a67fe fix(vuln): survive OOM kills, isolate bad upstream strings, label findings per line
Review findings on the integrated scanner:
- An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart
  that image is marked failed and sits out full runs for 20h.
- One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase
  scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build
  repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a
  report is actually published.
- The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names
  it, and Trivy is not invoked.
- loadError clears after a full run against a good inventory.
- Every image under platform/vuln-scanner stays out of Renovate auto-merge.
- The page labels status and target state per line, and mixed acceptance reads as mixed.

The Keycloak client is still an operator action: its key must go in an existing encrypted file
that an agent must not modify. platform/vuln-scanner/README.md has the exact steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:28:29 -06:00
jakman85andClaude Opus 5.5 1763e27859 fix(vuln): metric label extractor handles escaped quotes in f-strings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:28:28 -06:00
jakman85andClaude Opus 5.5 4c82c64b2f docs(vuln): list the CVE triage workflow in the workflows README
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:30:43 -06:00
jakman85andClaude Opus 5.5 d06dff9a19 fix(vuln): no-per-cve-label check also matches escaped-quote label forms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:29:05 -06:00
jakman85 b1a6fe6503 Merge branch 'wip/vuln-platform' into feat/vuln-visibility 2026-11-09 10:29:05 -06:00
jakman85 b265d08278 Merge branch 'wip/vuln-bot' into feat/vuln-visibility 2026-11-09 10:29:05 -06:00
jakman85 2b9dcef6c7 Merge branch 'wip/vuln-scanner' into feat/vuln-visibility 2026-11-09 10:29:05 -06:00
jakman85andClaude Opus 5.5 d4bea8a78c feat(vuln): encrypted oauth2-proxy credential for the CVE page
Generated through pipes and encrypted to the repo's public age recipient; values never echoed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:43:12 -06:00
jakman85andClaude Opus 5.5 b85d1e3ed7 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]>
2026-11-09 09:42:52 -06:00
jakman85andClaude Opus 5.5 50ec2297ee feat(vuln): scanner service and fixture-driven L1 tests
Stdlib-only scanner per docs/17: Kubernetes API inventory, Trivy runner,
persisted state, schema-validated report.json, strict acceptances parser,
server-rendered page, aggregate metrics, internal API, and a scan loop
that stays live while scanning.

L1 tests under conformance/tests/l1-rendering/vuln-scanner-* run the
actual modules against recorded fixtures with a fake Trivy runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:41:23 -06:00
jakman85andClaude Opus 5.5 ec6b39bb91 feat(vuln): point the triage bot's fallback at the pinned scanner ClusterIP
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:39:55 -06:00
jakman85andClaude Opus 5.5 c3c72c47bb feat(vuln): CVE triage bot workflow, script and L1 tests
Scheduled Gitea Actions bot in the Renovate shape: maintains a
counts-only CVE Dashboard issue, derives remediation states from
Renovate's dashboard and PRs, ticks approve rows for exact-matched
images with an unaccepted CRITICAL with a fix, and posts the heartbeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:39:24 -06:00
renovate-bot 4c3a284cad Merge pull request 'chore(deps): Update lscr.io/linuxserver/jackett Docker tag to v0.24.3030-ls30' (#162) from renovate/lscr.io-linuxserver-jackett-0.x into main
gate / gate (push) Successful in 1m3s
gate / conformance (push) Skipped
2026-11-09 08:20:07 +00:00
renovate-bot 4a89aacfbc chore(deps): Update lscr.io/linuxserver/jackett Docker tag to v0.24.3030-ls30
gate / gate (pull_request) Successful in 46s
gate / conformance (pull_request) Skipped
2026-11-09 08:16:20 +00:00
jakman85 50c8719b12 Merge pull request 'fix(vpn): default client profiles to MTU 1280; correct the VPN runbook' (#159) from fix/wg-client-mtu-docs into main
gate / gate (push) Successful in 57s
gate / conformance (push) Skipped
Reviewed-on: #159
2026-11-09 03:26:02 +00:00
jakman85andClaude Opus 5.5 8f60e6103a fix(vpn): default client profiles to MTU 1280; correct the VPN runbook
gate / gate (pull_request) Successful in 57s
gate / conformance (pull_request) Skipped
new-peer.sh generated MTU 1420, which stalled traffic on mobile data
despite a healthy handshake (one phone, one carrier, 2026-11-09). Default
to 1280, overridable through WG_MTU.

Correct stale claims in the runbook and the DaemonSet header against
live BPF conntrack: LoadBalancer VIPs see 10.90.0.x as world, not a
SNATed host identity. Add a troubleshooting section.

The DaemonSet render is unchanged, so WireGuard pods do not roll.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:15:20 -06:00
jakman85 da0515c8c8 Merge pull request 'fix(vpn): enrol keith-phone-home and admit the WireGuard subnet to PiHole DNS' (#158) from fix/wg-home-peer-pihole-sources into main
gate / gate (push) Successful in 55s
gate / conformance (push) Skipped
Reviewed-on: #158
2026-11-09 02:50:04 +00:00
jakman85 3a2ec5a12c Merge pull request 'feat(dns): PiHole answers an explicit LAN name list, not a zone wildcard' (#157) from feat/pihole-explicit-lan-names into main
gate / gate (push) Canceled after 25s
gate / conformance (push) Canceled after 0s
Reviewed-on: #157
Reviewed-by: Keith Jackson <[email protected]>
2026-11-09 02:49:39 +00:00
jakman85andClaude Opus 5.5 f13d336630 fix(vpn): enrol keith-phone-home and admit the WireGuard subnet to PiHole DNS
gate / gate (pull_request) Successful in 1m12s
gate / conformance (pull_request) Skipped
Add the at-home peer (10.90.0.3) and list ${WG_CLIENT_CIDR} in the PiHole
DNS frontends' loadBalancerSourceRanges. wg0 is a Cilium device, so tunnel
clients reach the source-range check untranslated; a LAN-only list is
predicted to drop their DNS. Adds an L0 guard that both ranges are present
and every range is RFC1918, and corrects the README's SNAT and IPv6 claims.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:45:48 -06:00
jakman85andClaude Opus 5.5 8fe8d5df8a feat(dns): PiHole answers an explicit LAN name list, not a zone wildcard
gate / gate (pull_request) Successful in 1m14s
gate / conformance (pull_request) Skipped
Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and
work from the LAN, so they should resolve publicly there too. Replace the
address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names
(derived from every controller:none HTTPRoute), gitea/nextcloud (off
Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs.

Add L0 check pihole-lan-names, mutation-tested. Correct docs that still
described public names as CNAME to wan. and the cloudflared entrypoint comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:51:49 -06:00
jakman85 020e736541 Merge pull request 'feat(dns): public names via the Cloudflare Tunnel (Phase 2) — proxied CNAMEs, Plex/Headlamp LAN-only' (#156) from feat/dns-via-cloudflare-tunnel into main
gate / gate (push) Successful in 40s
gate / conformance (push) Skipped
Reviewed-on: #156
Reviewed-by: Keith Jackson <[email protected]>
2026-11-09 01:27:06 +00:00
120 changed files with 10321 additions and 100 deletions

No files matched your search

+3
View File
@@ -164,11 +164,14 @@ Cost: the job shares the single act_runner slot with the gate, but the build its
.gitea/workflows/
merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision)
images.yaml build/verify/publish this repo's container images (see above)
cve-triage.yaml scheduled CVE triage bot (R-VULN-05/07, ADR-0027; runs before Renovate)
scripts/
install-tools.sh installs the pinned tools per stage (from ci/versions.env) — used by the
dormant merge-gate.yaml blueprint only; gate.yml runs in the toolbox image
decide.sh the R-TEST-03 merge decision over job results
images.sh the image pipeline's driver (what to build, base, push, digest)
cve_triage.py the CVE triage bot: reads the scanner report, maintains the counts-only
CVE Dashboard issue, ticks Renovate approval rows (cve-triage.yaml)
README.md this file
```
+159
View File
@@ -0,0 +1,159 @@
# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as
# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it
# records what it did in the job summary. The logic and its reasoning live in
# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the
# schedule.
#
# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md §2), so the
# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script
# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent
# fires for every run that did not get all the way through, and when the schedule stops firing.
#
# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which
# is also what authored the dashboard this edits. It needs read:repository and write:issue.
name: cve-triage
on:
schedule:
# 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same
# morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body.
- cron: "15 7 * * *"
workflow_dispatch:
inputs:
dry_run:
description: "Dry run: read everything, write nothing, print counts."
required: false
default: "true"
concurrency:
group: cve-triage
cancel-in-progress: false
jobs:
triage:
runs-on: ubuntu-latest
# THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker
# daemon, so a job container is the only way to choose an image. The toolbox provides
# python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in
# the DinD store. It has no Node, so actions/checkout cannot run; a public python image has
# neither Node nor git and would add a second image to keep pinned. The cost is a cold pull
# after a runner restart, which the gate pays anyway.
container:
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
# Without a bound, a wedged run would hold the concurrency group and block every later run.
timeout-minutes: 15
steps:
- name: Preflight - the token must exist, and its absence must be LOUD
shell: bash
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
set -euo pipefail
if [ -z "${RENOVATE_TOKEN:-}" ]; then
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2
exit 1
fi
echo "RENOVATE_TOKEN is present (value not printed)."
- name: Preflight - Gitea is reachable and the token can do this job
shell: bash
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403
# its scope; the script's own errors would only show a bare status later.
run: |
set -uo pipefail
GITEA=https://gitea.home.keithjackson.dev
REPO=Home-Network/home-network-k3s-impl
fail=0
body=$(mktemp)
fetch() {
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
| curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true
}
probe() { # <what it proves> <path>
local code
code=$(fetch "$2")
if [ "$code" = 200 ]; then
echo "ok $1"
else
fail=1
echo "::error::$1: GET ${2%%\?*} -> $code"
fi
}
probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO"
if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then
fail=1
echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues."
fi
probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1"
probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91"
# A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes
# (names only). Same technique as renovate.yaml, advisory if the message format changes.
fetch "/api/v1/admin/orgs?limit=1" > /dev/null
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
if [ -n "$scopes" ]; then
case ",$scopes," in
*",write:issue,"*|*",all,"*) echo "ok write:issue" ;;
*) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;;
esac
else
echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED"
fi
rm -f "$body"
[ "$fail" -eq 0 ]
# Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough,
# and the job token goes to the single fetch only, never into .git/config.
- name: Checkout the script (git, one commit)
shell: bash
env:
GIT_TOKEN: ${{ github.token }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
umask 0022
git config --global --add safe.directory "$PWD"
git init -q .
git remote add origin "${SERVER_URL}/${REPOSITORY}"
auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \
git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \
"+${SHA}:refs/remotes/checkout/head"
unset auth GIT_TOKEN
git checkout -q --force --detach "$SHA"
[ "$(git rev-parse HEAD)" = "$SHA" ]
- name: Triage
shell: bash
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
GITEA_URL: https://gitea.home.keithjackson.dev
GITEA_REPOSITORY: Home-Network/home-network-k3s-impl
RENOVATE_DASHBOARD_ISSUE: "91"
VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081
# The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only
# when the name above fails to resolve. Keep the two in step.
VULN_API_FALLBACK_URL: http://10.43.210.10:8081
VULN_PAGE_URL: https://cve.home.keithjackson.dev
# Anything other than an explicit false on a dispatch is a dry run; a schedule is live.
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}
run: python3 .gitea/workflows/scripts/cve_triage.py
- name: Summary
if: always()
run: |
{
echo "## CVE triage"
echo
echo "- trigger: \`${{ github.event_name }}\`"
echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`"
echo "- outcome: \`${{ job.status }}\`"
echo
echo "Counts only: which CVE lands where is on the CVE page, never here."
} >> "$GITHUB_STEP_SUMMARY"
+820
View File
@@ -0,0 +1,820 @@
#!/usr/bin/env python3
"""CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07).
Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run:
1. reads report.json from the scanner's internal API;
2. creates or updates the single `CVE Dashboard` issue (aggregates only);
3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state;
4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an
unaccepted CRITICAL that has a fix;
5. POSTs the heartbeat and the remediation states to the scanner.
WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script
never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never
ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload,
because the repository is readable without authentication, and no label the in-cluster PM acts
on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`).
OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here
prints a report string. Every report value is untrusted: strings are validated, and only counts
and timestamps that passed RFC 3339 validation ever reach markdown.
HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN
---------------------------------------------------------------
Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner
pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no
`--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the
dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP
(measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and
policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to
reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only.
UNVERIFIED: in-cluster DNS from a DinD job container.
UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081.
Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to
resolve. Anything else, including refused and timed-out connections, fails the job: an
unreachable API is a red run, never a skip.
"""
from __future__ import annotations
import http.client
import json
import os
import re
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from typing import Callable, Iterable, NamedTuple
DASHBOARD_TITLE = "CVE Dashboard"
DASHBOARD_LABEL = "cve-dashboard"
DASHBOARD_LABEL_COLOR = "b60205"
# Labels the in-cluster PM dispatcher selects work by. Never applied to anything here.
FORBIDDEN_LABELS = frozenset({"agent"})
RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard"
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
FINDING_STATUSES = ("open", "fix-available", "accepted")
TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
TARGET_TYPES = ("image", "vm", "node")
RUN_STATUSES = ("complete", "degraded")
REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
MAX_REPORT_BYTES = 128 * 1024 * 1024
MAX_API_BYTES = 8 * 1024 * 1024
MAX_FIELD_LEN = 4096
MAX_IMAGE_LEN = 512
MAX_ISSUE_BODY = 2 * 1024 * 1024
MAX_DASHBOARD_ROWS = 2000
MAX_DETECTED_DEPS = 20000
MAX_REMEDIATION = 5000
MAX_PAGES = 200
PAGE_SIZE = 50
REPORT_STALE_HOURS = 26
DATABASE_STALE_HOURS = 48
HTTP_TIMEOUT = 60
RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})")
IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?")
HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?")
REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
# Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind
# is captured whole so `approvePr-branch` can never read as `approve-branch`.
ROW_RE = re.compile(r" - \[(?P<mark>[ xX])\] <!-- (?P<kind>[A-Za-z]+)-branch=(?P<branch>\S+) -->(?P<rest>.*)")
LINKED_TITLE_RE = re.compile(r"\[(?P<title>.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)")
PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$")
PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$")
TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b")
SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>")
DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*")
# Detected-dependency managers whose names are charts or actions, never container images.
NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"})
class BotError(Exception):
"""A failure that ends the run. The message must be safe to print: no report text, no secret."""
class DnsFailure(Exception):
"""The hostname did not resolve. The only condition that selects the fallback address."""
# ---------------------------------------------------------------- pure: normalisation + report
def normalise_image(value: object) -> str:
"""Canonical form for exact comparison, or "" when the value is not an image reference.
Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace.
`library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the
result is always string equality, never a substring test.
"""
if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN:
return ""
name = value.strip().lower().split("@", 1)[0]
head, sep, last = name.rpartition("/")
name = f"{head}{sep}{last.split(':', 1)[0]}"
for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"):
if name.startswith(hub):
name = name[len(hub):]
break
if name.startswith("library/"):
name = name[len("library/"):]
return name if IMAGE_RE.fullmatch(name) else ""
def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None:
if value is None and nullable:
return None
if not isinstance(value, str) or not RFC3339_RE.fullmatch(value):
raise BotError(f"report: {field} is not an RFC 3339 timestamp")
text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00")
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise BotError(f"report: {field} is not a valid timestamp") from exc
def _object(value: object, field: str) -> dict:
if not isinstance(value, dict):
raise BotError(f"report: {field} is not an object")
return value
def _array(value: object, field: str) -> list:
if not isinstance(value, list):
raise BotError(f"report: {field} is not an array")
return value
def _string(value: object, field: str, nullable: bool = False) -> str | None:
if value is None and nullable:
return None
if not isinstance(value, str) or len(value) > MAX_FIELD_LEN:
raise BotError(f"report: {field} is not a bounded string")
return value
def _member(value: object, allowed: Iterable[str], field: str) -> str:
if not isinstance(value, str) or value not in allowed:
raise BotError(f"report: {field} has a value outside its enumeration")
return value
def parse_report(raw: bytes) -> dict:
"""Decode and validate report.json into the only shape the rest of this file reads."""
try:
doc = json.loads(raw)
except (ValueError, RecursionError) as exc:
raise BotError("report: body is not valid JSON") from exc
doc = _object(doc, "document")
if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool):
raise BotError("report: unsupported schemaVersion")
parse_timestamp(doc.get("generatedAt"), "generatedAt")
database = _object(doc.get("database"), "database")
run = _object(doc.get("run"), "run")
parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True)
parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True)
parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True)
if run.get("status") is not None:
_member(run.get("status"), RUN_STATUSES, "run.status")
targets = []
known_ids = {}
for entry in _array(doc.get("targets"), "targets"):
entry = _object(entry, "target")
target_id = _string(entry.get("id"), "target.id")
kind = _member(entry.get("type"), TARGET_TYPES, "target.type")
state = _member(entry.get("state"), TARGET_STATES, "target.state")
image = _string(entry.get("image"), "target.image", nullable=True)
if kind == "image" and not normalise_image(image):
raise BotError("report: an image target has an invalid image reference")
known_ids[target_id] = image if kind == "image" else None
targets.append({"id": target_id, "type": kind, "state": state, "image": image})
findings = []
for entry in _array(doc.get("findings"), "findings"):
entry = _object(entry, "finding")
target_id = _string(entry.get("targetId"), "finding.targetId")
if target_id not in known_ids:
raise BotError("report: a finding references an unknown target")
findings.append({
"targetId": target_id,
"image": known_ids[target_id],
"severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"),
"status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"),
"fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True),
})
return {
"generatedAt": doc["generatedAt"],
"database": {"updatedAt": database.get("updatedAt")},
"run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")},
"targets": targets,
"findings": findings,
}
def qualifies(finding: dict) -> bool:
"""An unaccepted CRITICAL with a fix: the only thing that may cause a tick."""
return (
finding["severity"] == "CRITICAL"
and finding["status"] == "fix-available"
and bool(finding["fixedVersion"])
)
def qualifying_images(report: dict) -> set[str]:
return {
norm
for finding in report["findings"]
if qualifies(finding) and (norm := normalise_image(finding["image"]))
}
def aggregate(report: dict) -> dict:
severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES}
for finding in report["findings"]:
severity[finding["severity"]][finding["status"]] += 1
coverage = dict.fromkeys(TARGET_STATES, 0)
for target in report["targets"]:
coverage[target["state"]] += 1
return {
"severity": severity,
"coverage": coverage,
"targets_total": len(report["targets"]),
"critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)),
"generatedAt": report["generatedAt"],
"databaseUpdatedAt": report["database"]["updatedAt"],
"runFinishedAt": report["run"]["finishedAt"],
"runStatus": report["run"]["status"],
}
def render_dashboard(agg: dict, page_url: str, now: datetime) -> str:
"""The CVE Dashboard issue body: a fixed template over counts and validated timestamps."""
if not HTTP_URL_RE.fullmatch(page_url):
raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL")
warnings = []
generated = parse_timestamp(agg["generatedAt"], "generatedAt")
if now - generated > timedelta(hours=REPORT_STALE_HOURS):
warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.")
updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True)
if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS):
warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.")
if agg["runStatus"] != "complete":
warnings.append("The latest scan run did not complete cleanly.")
lines = [
"Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable",
"without authentication, so which CVE lands where is only on the",
f"[CVE page]({page_url}) (SSO, platform-admins).",
"",
]
lines += [f"> **WARNING:** {text}" for text in warnings]
if warnings:
lines.append("")
lines += [
"## Freshness",
"",
"| | |",
"|---|---|",
f"| Report generated | {agg['generatedAt']} |",
f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |",
f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |",
f"| Last run status | {agg['runStatus'] or 'none yet'} |",
f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |",
"",
"## Findings by severity",
"",
"| Severity | Open | Fix available | Accepted |",
"|---|---:|---:|---:|",
]
for sev in SEVERITIES:
row = agg["severity"][sev]
lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |")
lines += [
"",
f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**",
"",
"## Coverage",
"",
f"{agg['targets_total']} targets. A count in any state other than `scanned` is something"
" that was not freshly examined, and is not a clean result.",
"",
"| State | Targets |",
"|---|---:|",
]
lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES]
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------- pure: Renovate's dashboard
class Row(NamedTuple):
kind: str
mark: str
branch: str
packages: tuple[str, ...]
pr_number: int | None
offset: int # offset in the body of the first character of the line
def _title_package(title: str) -> str:
match = TITLE_PACKAGE_RE.search(title)
return normalise_image(match.group(1)) if match else ""
def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]:
"""Packages and PR number of a row. No recognisable package means no packages, so no tick."""
rest = rest.rstrip("\r")
number = None
title = rest
tail = rest
linked = LINKED_TITLE_RE.fullmatch(rest)
if linked:
title, tail = linked.group("title"), linked.group("tail")
link = PR_LINK_RE.search(linked.group("target"))
number = int(link.group(1)) if link else None
listed = PACKAGE_LIST_RE.search(tail)
if listed:
names = re.findall(r"`([^`]+)`", listed.group(1))
packages = tuple(n for n in map(normalise_image, names) if n)
else:
package = _title_package(title)
packages = (package,) if package else ()
return packages, number
def _is_detected_heading(line: str) -> bool:
return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies")
def parse_rows(body: str) -> list[Row]:
"""Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts."""
if len(body) > MAX_ISSUE_BODY:
raise BotError("renovate dashboard: body exceeds its bound")
rows: list[Row] = []
offset = 0
for line in body.split("\n"):
if _is_detected_heading(line):
break
match = ROW_RE.fullmatch(line.rstrip("\r"))
if match:
packages, number = _row_details(match.group("rest"))
rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"),
packages, number, offset))
if len(rows) > MAX_DASHBOARD_ROWS:
raise BotError("renovate dashboard: too many rows")
offset += len(line) + 1
return rows
def parse_detected(body: str) -> dict[str, bool]:
"""{normalised depName: has a pending update} from `## Detected Dependencies`."""
tracked: dict[str, bool] = {}
lines = body.split("\n")
in_section = False
manager = ""
count = 0
for index, raw in enumerate(lines):
line = raw.rstrip("\r")
if line.startswith("## "):
in_section = _is_detected_heading(line)
continue
if not in_section:
continue
summary = SUMMARY_RE.fullmatch(line)
if summary:
# A manager heading is the summary directly followed by <blockquote>; a file heading
# is not.
if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>":
manager = summary.group(1)
continue
match = DETECTED_RE.fullmatch(line)
if not match or manager in NON_IMAGE_MANAGERS:
continue
count += 1
if count > MAX_DETECTED_DEPS:
raise BotError("renovate dashboard: too many detected dependencies")
norm = normalise_image(match.group("name"))
if norm:
tracked[norm] = tracked.get(norm, False) or bool(match.group("updates"))
return tracked
def row_matches(row: Row, qualifying: set[str]) -> bool:
return any(package in qualifying for package in row.packages)
def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]:
return [
row for row in rows
if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying)
]
def apply_ticks(body: str, ticks: list[Row]) -> str:
"""Replace only the `[ ]` of each row, re-verifying the row where it stands.
Every byte other than the three of each marker is carried over unchanged.
"""
pieces = []
cursor = 0
for row in sorted(ticks, key=lambda r: r.offset):
expected = f" - [ ] <!-- approve-branch={row.branch} -->"
if row.kind != "approve" or not body.startswith(expected, row.offset):
raise BotError("renovate dashboard: a row changed between parse and write")
marker = row.offset + len(" - ")
pieces += [body[cursor:marker], "[x]"]
cursor = marker + len("[ ]")
pieces.append(body[cursor:])
return "".join(pieces)
def derive_remediation(
images: Iterable[str],
rows: list[Row],
open_prs: list[dict],
tracked: dict[str, bool],
pr_url: Callable[[int], str],
now_text: str,
) -> list[dict]:
"""One entry per report image.
pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an
approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked:
everything else, which deliberately includes "Renovate knows an update but no PR or approval
row exists" (awaiting schedule, rate limited): saying no update was identified would be
false, and the enumeration has no closer value, so it lands on the state that draws attention.
"""
by_branch = {pr["branch"]: pr["number"] for pr in open_prs}
result = []
for image in sorted(set(images)):
norm = normalise_image(image)
state = "not-tracked"
url = None
rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None)
pr = next((p for p in open_prs if p["package"] == norm), None)
if rebase is not None or pr is not None:
state = "pr-open"
number = None
if rebase is not None:
number = rebase.pr_number or by_branch.get(rebase.branch)
if number is None and pr is not None:
number = pr["number"]
url = pr_url(number) if number is not None else None
elif any(r.kind == "approve" and norm in r.packages for r in rows):
state = "approval-requested"
elif norm in tracked and not tracked[norm]:
state = "no-update-identified"
result.append({"image": image, "state": state, "url": url, "updatedAt": now_text})
return result
def count_states(remediation: list[dict]) -> dict[str, int]:
counts = dict.fromkeys(REMEDIATION_STATES, 0)
for entry in remediation:
counts[entry["state"]] += 1
return counts
# ---------------------------------------------------------------- I/O
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the Authorization header to wherever it points."""
def redirect_request(self, *args, **kwargs):
return None
class UrllibTransport:
"""(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy."""
def __init__(self) -> None:
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int):
request = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with self._opener.open(request, timeout=HTTP_TIMEOUT) as response:
data = response.read(max_bytes + 1)
return response.status, data
except urllib.error.HTTPError as exc:
return exc.code, b""
except urllib.error.URLError as exc:
if isinstance(exc.reason, socket.gaierror):
raise DnsFailure() from exc
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc
except (OSError, http.client.HTTPException) as exc:
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc
class GiteaClient:
def __init__(self, base_url: str, repo: str, token: str, transport) -> None:
self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}"
self._web = f"{base_url.rstrip('/')}/{repo}"
self._token = token
self._transport = transport
def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)):
url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
headers = {"Authorization": f"token {self._token}", "Accept": "application/json"}
data = None
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
try:
status, raw = self._transport(method, url, headers, data, MAX_API_BYTES)
except DnsFailure as exc:
raise BotError(f"gitea: {method} {path}: name resolution failed") from exc
if status not in expect:
raise BotError(f"gitea: {method} {path} -> HTTP {status}")
if len(raw) > MAX_API_BYTES:
raise BotError(f"gitea: {method} {path}: response exceeds its bound")
if not raw:
return None
try:
return json.loads(raw)
except ValueError as exc:
raise BotError(f"gitea: {method} {path}: response is not JSON") from exc
def _list(self, path: str, query: dict | None = None) -> list:
items: list = []
for page in range(1, MAX_PAGES + 1):
batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page})
if not isinstance(batch, list):
raise BotError(f"gitea: GET {path}: expected a list")
items += batch
if len(batch) < PAGE_SIZE:
return items
raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages")
def pr_url(self, number: int) -> str:
return f"{self._web}/pulls/{int(number)}"
def labels(self) -> list:
return self._list("/labels")
def create_label(self, name: str, color: str) -> dict:
return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,))
def issues_with_label(self, label: str) -> list:
return self._list("/issues", {"state": "all", "type": "issues", "labels": label})
def issues_matching(self, text: str) -> list:
return self._list("/issues", {"state": "all", "type": "issues", "q": text})
def create_issue(self, title: str, body: str, label_id: int) -> dict:
return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,))
def update_issue(self, number: int, fields: dict) -> None:
self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201))
def add_issue_label(self, number: int, label_id: int) -> None:
self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,))
def get_issue(self, number: int) -> dict:
issue = self._call("GET", f"/issues/{int(number)}")
if not isinstance(issue, dict):
raise BotError("gitea: issue response is not an object")
return issue
def open_renovate_prs(self) -> list[dict]:
result = []
for pr in self._list("/pulls", {"state": "open"}):
head = pr.get("head") if isinstance(pr, dict) else None
ref = head.get("ref") if isinstance(head, dict) else None
number = pr.get("number") if isinstance(pr, dict) else None
title = pr.get("title") if isinstance(pr, dict) else None
if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool):
raise BotError("gitea: a pull request has an unexpected shape")
if ref.startswith("renovate/"):
package = _title_package(title) if isinstance(title, str) else ""
result.append({"number": number, "branch": ref, "package": package})
return result
class ScannerClient:
"""The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only."""
def __init__(self, primary: str, fallback: str, transport) -> None:
self._bases = [base.rstrip("/") for base in (primary, fallback) if base]
self._transport = transport
self.used_fallback = False
def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int):
last = len(self._bases) - 1
for index, base in enumerate(self._bases):
try:
result = self._transport(method, base + path, headers, body, max_bytes)
except DnsFailure as exc:
if index == last:
raise BotError(f"scanner: {method} {path}: name resolution failed") from exc
continue
if index:
self.used_fallback = True
self._bases = self._bases[index:]
return result
raise BotError("scanner: no address configured")
def report(self) -> dict:
status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES)
if status != 200:
raise BotError(f"scanner: GET /report.json -> HTTP {status}")
if len(raw) > MAX_REPORT_BYTES:
raise BotError("scanner: report exceeds its bound")
return parse_report(raw)
def triage(self, heartbeat: str, remediation: list[dict]) -> None:
if len(remediation) > MAX_REMEDIATION:
raise BotError("scanner: more remediation entries than POST /triage accepts")
body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode()
status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096)
if status != 204:
raise BotError(f"scanner: POST /triage -> HTTP {status}")
# ---------------------------------------------------------------- orchestration
def _label_names(issue: dict) -> set:
return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)}
def find_dashboard_issues(gitea: GiteaClient) -> list[dict]:
"""Every issue that is the dashboard: by label, and by exact title in case a label was removed."""
found: dict[int, dict] = {}
for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE):
if (
isinstance(issue, dict)
and issue.get("title") == DASHBOARD_TITLE
and not issue.get("pull_request")
and isinstance(issue.get("number"), int)
):
found[issue["number"]] = issue
return list(found.values())
def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict:
"""Create or update the one dashboard issue. Returns counts of what it did or would do."""
if DASHBOARD_LABEL in FORBIDDEN_LABELS:
raise BotError("config: the dashboard label is one the PM agent acts on")
plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0}
existing = find_dashboard_issues(gitea)
if len(existing) > 1:
# Closing an issue on a guess is worse than a red run naming the numbers to look at.
numbers = sorted(issue["number"] for issue in existing)
raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}")
label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None)
if label is None:
plan["label_created"] = 1
if not dry_run:
label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR)
if not existing:
plan["issue_created"] = 1
if not dry_run:
created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"])
if not isinstance(created, dict) or not isinstance(created.get("number"), int):
raise BotError("gitea: issue creation returned an unexpected shape")
return plan
issue = existing[0]
fields: dict = {}
if issue.get("body") != body:
fields["body"] = body
if issue.get("state") != "open":
fields["state"] = "open"
plan["issue_reopened"] = 1
if fields:
plan["issue_updated"] = 1
if not dry_run:
if DASHBOARD_LABEL not in _label_names(issue):
gitea.add_issue_label(issue["number"], label["id"])
if fields:
gitea.update_issue(issue["number"], fields)
return plan
def _renovate_issue(gitea: GiteaClient, number: int) -> dict:
issue = gitea.get_issue(number)
body = issue.get("body")
if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str):
raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard")
return issue
def report_images(report: dict) -> list[str]:
return [t["image"] for t in report["targets"] if t["type"] == "image"]
def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict:
"""One triage pass. Any failure raises, and the heartbeat is the last thing it writes."""
dry_run = cfg["dry_run"]
report = scanner.report()
agg = aggregate(report)
now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]}
summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run)
number = cfg["renovate_issue"]
body = _renovate_issue(gitea, number)["body"]
remediation = derive_remediation(
report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body),
gitea.pr_url, now_text,
)
summary["remediation"] = count_states(remediation)
# Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to
# the body as it is now.
fresh = _renovate_issue(gitea, number)["body"]
ticks = select_ticks(parse_rows(fresh), qualifying_images(report))
summary["ticks"] = len(ticks)
if ticks and not dry_run:
gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)})
if not dry_run:
scanner.triage(now_text, remediation)
summary["heartbeat"] = 0 if dry_run else 1
summary["fallback_used"] = scanner.used_fallback
emit(render_summary(summary))
return summary
def render_summary(summary: dict) -> str:
verb = "would be" if summary["dry_run"] else "were"
dash = summary["dashboard"]
states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items())
return "\n".join([
f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})",
"",
f"- targets in report: {summary['targets']}",
f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}",
f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};"
f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)",
f"- remediation states: {states}",
f"- approval boxes that {verb} ticked: {summary['ticks']}",
f"- heartbeat posted: {summary['heartbeat']}",
f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}",
"",
])
def load_config(env: dict) -> dict:
token = env.get("RENOVATE_TOKEN", "")
if not token:
raise BotError("RENOVATE_TOKEN is not set")
repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl")
if not REPO_RE.fullmatch(repo):
raise BotError("config: GITEA_REPOSITORY is malformed")
urls = {
"gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"),
"vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"),
"page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"),
}
fallback = env.get("VULN_API_FALLBACK_URL", "")
for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items():
if not HTTP_URL_RE.fullmatch(value):
raise BotError(f"config: {name} is not a plain http(s) URL")
dry_run = env.get("DRY_RUN", "false")
if dry_run not in ("true", "false"):
raise BotError("config: DRY_RUN must be true or false")
issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91")
if not issue.isdigit():
raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number")
return {**urls, "token": token, "repo": repo, "fallback": fallback,
"dry_run": dry_run == "true", "renovate_issue": int(issue)}
def main() -> int:
def emit(text: str) -> None:
print(text)
path = os.environ.get("GITHUB_STEP_SUMMARY")
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(text + "\n")
try:
cfg = load_config(dict(os.environ))
transport = UrllibTransport()
gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport)
scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport)
run(cfg, gitea, scanner, datetime.now(timezone.utc), emit)
except BotError as exc:
print(f"::error::CVE triage failed: {exc}", file=sys.stderr)
emit(f"### CVE triage FAILED\n\n{exc}\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -76,7 +76,7 @@ bug but the honest answer, because proofs are claims about a specific tree on a
|---|---|
| 0 Foundations | ServiceDefinition schema + renderer, Kyverno L0 gate, conformance harness, CI |
| 1 Cluster bring-up | 3-node k3s HA, embedded etcd, kube-vip VIP, Cilium, Flux, SOPS. Originally Proxmox VMs; **now 3× mini-PC bare metal** (`k3s-prod-1/2/3`, `192.168.2.10/.11/.12`, k3s v1.36.4+k3s1, Debian 13) |
| 2 Platform core | Cilium · Longhorn · cert-manager (LE-prod wildcard) · ExternalDNS · Gateway API · split-horizon DNS, **both halves**: CoreDNS for pods, and **PiHole as the authoritative LAN resolver** for `${BASE_DOMAIN}` on udp/53 + tcp/53 at a pinned VIP (`192.168.2.23` on prod). The LAN half was claimed from the start but did not exist until 2026-09-14 — PiHole's Service was ClusterIP :80/:4180, so LAN clients ran on hand-maintained per-client `dnsmasq` files |
| 2 Platform core | Cilium · Longhorn · cert-manager (LE-prod wildcard) · ExternalDNS · Gateway API · split-horizon DNS, **both halves**: CoreDNS for pods, and **PiHole as the LAN resolver**, answering an explicit list of `${BASE_DOMAIN}` names (LAN-only apps, gitea/nextcloud/keycloak/auth, the L4 VIPs — everything else resolves publicly to Cloudflare, since 2026-11-09) on udp/53 + tcp/53 at a pinned VIP (`192.168.2.23` on prod). The LAN half was claimed from the start but did not exist until 2026-09-14 — PiHole's Service was ClusterIP :80/:4180, so LAN clients ran on hand-maintained per-client `dnsmasq` files |
| 3 Egress / VPN | ADR-0015 node-level OpenVPN split-tunnel + Cilium egress-gateway steering + host kill-switch |
| 4 Observability | kube-prometheus-stack · Loki · Alloy · Alertmanager. **Velero unblocked 2026-09-13** — backs up to Cloudflare R2 (OQ-BKP closed; the `velero-plugin-for-aws` tagging incompatibility needed a pinned plugin). Longhorn has **no** backup target by decision: volume data is protected by Velero + kopia to R2. image-automation authored but disabled |
| 5 IaaS | KubeVirt + CDI on real nested KVM; live-migratable RWX VM storage. The UniFi VM runs, but **UniFi OS Server is not yet installed in the guest** |
+2 -2
View File
@@ -176,8 +176,8 @@ or any UDP (`3478`/`10001`) — exactly the ports adoption rides. So:
**UI access:** `https://unifi.home.keithjackson.dev:11443`. That name resolves to the VIP and not to
the Gateway on purpose: both `platform/coredns/coredns-custom.yaml` (in-cluster) and
`apps/wave-0/pihole.yaml` (LAN split-horizon) carve `unifi.${BASE_DOMAIN}` out of the
zone-wide `${GATEWAY_VIP}` answer and point it at `${UNIFI_L4_VIP}`. `${BASE_DOMAIN}` on prod is the
`apps/wave-0/pihole.yaml` (LAN split-horizon, an explicit name list since 2026-11-09) point
`unifi.${BASE_DOMAIN}` at `${UNIFI_L4_VIP}` rather than `${GATEWAY_VIP}`. `${BASE_DOMAIN}` on prod is the
apex `home.keithjackson.dev` (confirmed live in the `cluster-vars` ConfigMap) after the 2026-09-13
flip, so this hostname is current, not a dev leftover.
+1 -1
View File
@@ -78,7 +78,7 @@ spec:
spec:
containers:
- name: jackett
image: lscr.io/linuxserver/jackett:v0.24.3025-ls29
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
ports:
- containerPort: 9117
volumeMounts:
+19 -5
View File
@@ -83,15 +83,25 @@
# and would also fix the same latent defect in unifi and crafty; it belongs in its own change.
#
# -------------------------------------------------------------------------------------------------
# loadBalancerSourceRanges — THE LAN RESTRICTION, ENFORCED IN THE DATAPATH.
# loadBalancerSourceRanges — THE LAN + VPN RESTRICTION, ENFORCED IN THE DATAPATH.
# -------------------------------------------------------------------------------------------------
# Cilium enforces this list in eBPF on the ingress node, on the VIP, BEFORE load-balancing and
# before SNAT. A packet to ${PIHOLE_DNS_VIP}:53 from outside ${LAN_CIDR} is dropped by the
# datapath — it never reaches a backend and never reaches a network policy. This is what makes
# before SNAT. A packet to ${PIHOLE_DNS_VIP}:53 from outside ${LAN_CIDR} and ${WG_CLIENT_CIDR} is
# dropped by the datapath — it never reaches a backend and never reaches a network policy. This is what makes
# "LAN clients only" a real, tested restriction rather than a comment: VERIFIED by narrowing the
# range to a /24 that excludes the test client, after which the same query that had been answering
# stopped being answered at all, and restoring it, after which it answered again.
#
# ${WG_CLIENT_CIDR} IS LISTED BECAUSE VPN CLIENTS REACH THIS CHECK UNTRANSLATED. wg0 is one of
# Cilium's kube-proxy-replacement devices (measured 2026-11-08: `cilium-dbg status` lists eno1, wg0,
# tun0), so a tunnel client's query is load-balanced as it enters from wg0, ahead of the iptables
# MASQUERADE in platform/vpn-ingress/daemonset.yaml, and the source tested here is still 10.90.0.x
# rather than the node's address. Without this entry a full-tunnel client's DNS is predicted to die
# with DROP_NOT_IN_SRC_RANGE (derived from Cilium's source; NOT observed live, because no peer has
# ever completed a handshake). The range is private and reachable only by a key in peers.conf, so
# PiHole stays off the internet; conformance/tests/l0-static/pihole-dns-source-ranges fails the gate
# if either entry goes missing or any range here stops being RFC1918.
#
# It is also the layer that survives the SNAT above. A CiliumNetworkPolicy cannot express this
# restriction once the source has been rewritten: measured on this cluster, the forwarded query
# arrives from the ingress node's internal router address and Cilium resolves that source to the
@@ -113,6 +123,7 @@ spec:
externalTrafficPolicy: Cluster
loadBalancerSourceRanges:
- ${LAN_CIDR}
- ${WG_CLIENT_CIDR}
selector:
app.kubernetes.io/name: pihole
ports:
@@ -133,6 +144,7 @@ spec:
externalTrafficPolicy: Cluster
loadBalancerSourceRanges:
- ${LAN_CIDR}
- ${WG_CLIENT_CIDR}
selector:
app.kubernetes.io/name: pihole
ports:
@@ -158,7 +170,9 @@ spec:
# "Policy denied ... identity world" drops in `cilium-dbg monitor`). So `world` is the only peer
# selector that can describe LAN traffic that has been through the LoadBalancer, and the set of
# addresses that can actually BE that traffic is fixed by loadBalancerSourceRanges before this
# policy is consulted. Together: ${LAN_CIDR} in the datapath, udp/53 + tcp/53 here.
# policy is consulted. Together: ${LAN_CIDR} + ${WG_CLIENT_CIDR} in the datapath, udp/53 + tcp/53
# here. A VPN client delivered to a backend on its own ingress node keeps its 10.90.0.x source,
# which Cilium also classifies as `world`, so this rule needs no new peer for it.
#
# This is NOT wider than the precedent it follows. unifi-allow-l4-ingress and crafty-allow-ingress
# admit `fromEntities: [world, cluster, host]` on their L4 ports with NO source-range filter at all;
@@ -187,7 +201,7 @@ spec:
app.kubernetes.io/name: pihole
ingress:
- fromEntities:
- world # LAN clients, post-LoadBalancer: see the block above for why not fromCIDR
- world # LAN + VPN clients, post-LoadBalancer: see the block above for why not fromCIDR
- host # this node's kubelet probes
- remote-node # the node-to-node hop that forwards a query to wherever the pod runs
toPorts:
+1 -1
View File
@@ -73,7 +73,7 @@ spec:
- name: FTLCONF_dns_rateLimit_count
value: '0'
- name: FTLCONF_misc_dnsmasq_lines
value: local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
- name: FTLCONF_webserver_api_password
valueFrom:
secretKeyRef:
+1 -1
View File
@@ -16,7 +16,7 @@
name: jackett
kind: container
source:
image: lscr.io/linuxserver/jackett:v0.24.3025-ls29
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
exposure: internal # LAN-only web UI at jackett.${BASE_DOMAIN} (docs/13 row 4; R-DNS/R-NET)
egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
storage:
+58 -31
View File
@@ -85,7 +85,7 @@ env:
# Default is LOCAL, which answers only clients on the same subnet as the listening interface. The
# pod's interface is on the CLUSTER pod CIDR, so every LAN client would be refused. ALL is
# required for a LoadBalancer-fronted resolver; what keeps PiHole from being an OPEN resolver is
# the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}]` on the DNS Services,
# the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}, ${WG_CLIENT_CIDR}]` on the DNS Services,
# enforced by Cilium in the datapath before load-balancing, plus a CiliumNetworkPolicy that admits
# nothing but udp/53 and tcp/53. See apps/wave-0/pihole-dns.yaml.
- name: FTLCONF_dns_listeningMode
@@ -108,41 +108,61 @@ env:
# `loadBalancer.mode: dsr`, which preserves the client address.
- name: FTLCONF_dns_rateLimit_count
value: "0"
# THE SPLIT-HORIZON ANSWERS. A dnsmasq `address=/<domain>/<ip>` entry answers that domain AND
# every subdomain of it, and dnsmasq resolves overlapping entries by LONGEST MATCHING DOMAIN —
# so the first line is a wildcard for the whole zone and each later line is a specific override.
# PRECEDENCE IS THEREFORE STRUCTURAL, not order-dependent: `unifi.${BASE_DOMAIN}` matches both
# line 1 and line 2, line 2 is the longer domain, line 2 wins. Rewriting these in any order
# changes nothing.
# THE LAN ANSWERS — AN EXPLICIT LIST, NOT A ZONE WILDCARD (2026-11-09). A dnsmasq
# `address=/<domain>/<ip>` entry answers that domain AND every subdomain of it (on LABEL
# boundaries: `pihole.` does not match `pihole-dns.`), and dnsmasq resolves overlapping entries by
# LONGEST MATCHING DOMAIN, so the order below is irrelevant. Every name NOT listed here is
# FORWARDED to the upstreams above and gets the PUBLIC answer.
#
# A NEWLY ONBOARDED SERVICE NEEDS NO CHANGE HERE. Every catalog ingressRoute host is
# `<label>.${BASE_DOMAIN}` served by the shared Gateway, so the wildcard already answers it with
# ${GATEWAY_VIP} — the same guarantee, and the same reasoning, as the `template IN A` block in
# platform/coredns/coredns-custom.yaml. Add a line here ONLY for a service that does not live
# behind the Gateway: an L4 LoadBalancer on its own pinned VIP. Those are enumerated from the
# repo, not from memory — grep `external-dns.alpha.kubernetes.io/hostname` on a Service, which
# is the annotation that means "this Service publishes its own hostname at its own address":
# apps/vms/unifi/50-service-l4.yaml (unifi -> ${UNIFI_L4_VIP}) and
# platform/crafty/service-l4.yaml (crafty-mc -> ${CRAFTY_L4_VIP}). Those two, plus PiHole's own
# resolver address, are the complete set of exceptions today.
# WHY NO WILDCARD ANY MORE. Until 2026-11-09 the first entry was `address=/${BASE_DOMAIN}/
# ${GATEWAY_VIP}`, answering the whole zone. That made sense while every public record was a
# DNS-only CNAME to `wan.${BASE_DOMAIN}` (no NAT hairpin on this router, so the LAN NEEDED a
# private answer). Since PR #156 every public name is a PROXIED CNAME to the Cloudflare Tunnel
# (platform/externaldns/README.md), which works identically from the LAN — so a name that is
# public now SHOULD resolve publicly from the LAN too, and the LAN no longer depends on PiHole for
# it: a client that bypasses PiHole (DoH browser, phone private DNS, the router's fallback) gets
# the same working answer. The wildcard had also become actively misleading: it answered names
# that exist nowhere (`wan.` itself resolved to the Gateway VIP on the LAN).
#
# AAAA needs nothing: dnsmasq answers a AAAA query for an `address=`d name with NODATA
# (NOERROR, no answer), so IPv6-capable clients fall back to the A record instead of getting
# NXDOMAIN — the same behaviour coredns-custom hand-writes a `template IN AAAA` block for.
# WHAT IS LISTED, and why each — enforced by conformance/tests/l0-static/pihole-lan-names:
# -> ${GATEWAY_VIP}, LAN-ONLY: every HTTPRoute disowned from public DNS
# (`external-dns.alpha.kubernetes.io/controller: none`, i.e. catalog `exposure: internal`,
# plus plex and headlamp) — jackett, pihole, plex, qbittorrent, radarr, semaphore, sonarr,
# headlamp. These have NO public record and the tunnel 404s them, so THIS LINE IS THEIR ONLY
# PATH: drop one and the app is unreachable everywhere. A NEWLY ONBOARDED `internal` SERVICE
# MUST BE ADDED HERE (the L0 check fails until it is). A new `public` service needs nothing.
# -> ${GATEWAY_VIP}, PUBLIC BUT KEPT ON THE LAN PATH: gitea (git push, LFS, container registry)
# and nextcloud (uploads) — Cloudflare's proxy caps a request body at 100 MB and a response
# at 100 s, which a large push or upload exceeds; keycloak and auth (oauth2-proxy) — so the
# OIDC login of those two LAN-path apps stays on the same path as the apps themselves.
# -> their own L4 VIPs: unifi (${UNIFI_L4_VIP}) and crafty-mc (${CRAFTY_L4_VIP}) are not behind
# the Gateway (grep `external-dns.alpha.kubernetes.io/hostname` on a Service:
# apps/vms/unifi/50-service-l4.yaml, platform/crafty/service-l4.yaml); their public records
# are DNS-only A records to the same RFC1918 VIPs, which this router's rebind protection
# strips, so the LAN answer is still required. pihole-dns (${PIHOLE_DNS_VIP}) is PiHole's own
# resolver address; plex-vip (${PLEX_L4_VIP}) is below.
# NOT listed, deliberately, so they resolve via Cloudflare from the LAN as from anywhere: the apex,
# grafana, crafty, seerr. In-cluster (platform/coredns/coredns-custom.yaml) and node
# (/etc/hosts pins, node_baseline_hosts_pins) resolution are separate and unchanged.
#
# AAAA: dnsmasq answers a AAAA query for an `address=`d name with NODATA (NOERROR, no answer),
# so IPv6-capable clients fall back to the A record instead of getting NXDOMAIN — the same
# behaviour coredns-custom hand-writes a `template IN AAAA` block for. Unlisted (proxied) names
# now get Cloudflare's real A and AAAA answers.
#
# Array-valued FTLCONF variables are SEMICOLON-separated. A JSON-looking list is accepted and
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
# while resolution for the zone does not work. Confirmed both ways against the live image.
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
# every client that takes the ADVERTISED connection cannot reach the server at all.
#
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the name of the tcp/32400 LoadBalancer
# (apps/wave-0/plex-remote.yaml) and the domain of the certificate Plex serves there
# (catalog/plex.yaml CERTDOMAIN / customCertificateDomain, Secret `plex-vip-tls`). It is NOT what
# Plex advertises any more (ADVERTISE_IP is a resolver-independent plex.direct name since
# 2026-10-05) and it has NO public record, so this entry is its only resolution — kept for
# reaching the VIP by name with a matching certificate from a LAN shell. Measured 2026-10-04,
# before it existed the zone wildcard answered it with ${GATEWAY_VIP}, where nothing listens on
# 32400; without it now, the name is NXDOMAIN.
#
# ── local-ttl=60 — THE SPLIT-HORIZON ANSWER MUST BE CACHEABLE ────────────────────────────────
# MEASURED 2026-10-18, diagnosing "DNS flapping between the cluster ingress and the public IP":
@@ -172,10 +192,17 @@ env:
# cache expires, the router falls back, and the public answer latches again. That is a real
# outage, and the fix for it is PiHole availability, not DNS caching.
#
# SINCE 2026-11-09 THE LATCH IS MOSTLY HARMLESS. The measurement above predates the tunnel: the
# public answer was then `wan.`, unreachable from the LAN. Now the public answer for every
# proxied name is Cloudflare, which works from the LAN, and those names are not listed below at
# all. A latch still bites only the listed names: gitea/nextcloud/keycloak/auth fall back to
# Cloudflare (working, but under its 100 MB / 100 s limits); the LAN-only names and the L4 VIPs
# get NXDOMAIN or a stripped answer until the router re-queries PiHole.
#
# Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from
# configuration, /etc/hosts and DHCP leases), so one directive covers all five names.
# configuration, /etc/hosts and DHCP leases), so one directive covers every listed name.
- name: FTLCONF_misc_dnsmasq_lines
value: "local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
# carries the full rationale and the measurement that found it.
+4
View File
@@ -78,6 +78,10 @@ resources:
# `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have
# stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch.
- vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config)
# CVE visibility scanner (docs/17, ADR-0027) — POST-platform tier (dependsOn platform-config,
# wait:false): a failing scanner must never gate the platform, and nothing dependsOn it. Carries the
# oauth2-proxy credential SOPS file, so both overlays add a decryption patch by name.
- vuln-scanner.yaml # Flux Kustomization -> ./platform/vuln-scanner (dependsOn platform-config)
# Cloudflare Tunnel connector (north-south INGRESS from the Cloudflare edge) — POST-platform tier
# (dependsOn platform-config, wait:false), and NOTHING dependsOn it. Non-gating is load-bearing,
# as for vpn-ingress: the Deployment cannot become Ready until the operator enrols the tunnel
+41
View File
@@ -0,0 +1,41 @@
---
# Flux Kustomization: the CVE visibility scanner (docs/17, ADR-0027) — reconciles ./platform/vuln-scanner.
#
# ISOLATION IS THE POINT, as for clusters/base/cloudflared.yaml. A failing scanner, an unbound PVC or a
# pending Certificate must never become a stalled platform, so:
# * ITS OWN Kustomization, not a line in platform/kustomization.yaml;
# * wait: false — its health is reported by the VulnScanStale/VulnDatabaseStale/VulnCoverageGap/
# VulnCriticalFixAvailable/VulnTriageBotSilent alerts (platform/vuln-scanner/alerts.yaml), not by
# this object's Ready condition, which reaches Ready on apply success;
# * NOTHING dependsOn THIS. conformance/tests/l0-static/vuln-scanner-isolation fails the gate if any
# Kustomization gains `dependsOn: vuln-scanner` or this one stops being wait:false.
#
# dependsOn: [platform-config] — a real edge: PrometheusRule/ServiceMonitor/Gateway API CRDs, the
# Longhorn StorageClass, the Gateway, Keycloak's realm, and Longhorn itself come up through
# `platform`/`platform-config` first.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: vuln-scanner
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
prune: true
wait: false
dependsOn:
- name: platform-config
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/vuln-scanner
# REQUIRED: the HTTPRoute hostname, the oauth2-proxy issuer/redirect/cookie-domain args and every
# alert's runbook_url are written `${BASE_DOMAIN}`. Substitution fails OPEN, so the file
# conformance/tests/l0-static/flux-postbuild-substitution audits this tier.
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
# SOPS decryption is layered per-environment: clusters/dev and clusters/prod each patch THIS
# Kustomization by name with a `decryption:` stanza for the oauth2-proxy credentials.
+15
View File
@@ -140,6 +140,21 @@ patches:
provider: sops
secretRef:
name: sops-age
# vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries
# platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy
# client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the
# sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band.
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: vuln-scanner
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
+15
View File
@@ -131,6 +131,21 @@ patches:
provider: sops
secretRef:
name: sops-age
# vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries
# platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy
# client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the
# sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band.
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: vuln-scanner
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
+38
View File
@@ -135,6 +135,44 @@ requirements:
safety_critical: false
summary: "Update policy per-service configurable (cadence, channel, pinning)."
# --- Vulnerability visibility ---
- id: R-VULN-01
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Inventory every running container image by digest with its workloads, from the cluster."
- id: R-VULN-02
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Every running image evaluated against a current database at least every 24h; new images within 2h."
- id: R-VULN-03
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Any target not evaluated is visibly reported with its reason; absence is never shown as clean."
- id: R-VULN-04
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Authenticated page lists every CVE with affected images, containers and workloads; searchable and filterable."
- id: R-VULN-05
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Inventory, scanning, reporting and triage run on a schedule with no human or AI action; loud failure."
- id: R-VULN-06
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Scan failure, staleness and unaccepted CRITICAL fixes reported by email as grouped alerts."
- id: R-VULN-07
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Scheduled bot keeps one dashboard issue and requests remediation via Renovate; no CVE detail exposed."
- id: R-VULN-08
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Accepted risk declared in git with reason and mandatory expiry; reverts to open on expiry."
- id: R-VULN-09
doc: docs/17-vulnerability-visibility.md
safety_critical: false
summary: "Scanner digest-pinned, no auto-merged updates, no Secret read or write access, no git credential."
# --- Observability & alerting ---
- id: R-OBS-01
doc: docs/10-observability-and-alerting.md
@@ -411,6 +411,28 @@ SCRAPE_OWNERS: list[dict] = [
"Keeping the two in one file makes that pair inseparable",
},
},
# ── ADDED 2026-11-09 (CVE visibility, ADR-0027). A NEW OWNER, NOT A RELAXED ASSERTION. ──────
# Registers the vuln_scanner_* family and the Service+ServiceMonitor that scrapes it.
{
"owner": "vuln-scanner: Service + ServiceMonitor on port `metrics` (:9090)",
"prefixes": ("vuln_scanner_",),
"file": "platform/vuln-scanner/servicemonitor.yaml",
"require": [
r"^kind:\s*ServiceMonitor\s*$",
r"^\s*-\s*port:\s*metrics\s*$",
],
"forbid": [],
"why": "vuln_scanner_* powers the five VulnScan alerts, the only email path for scan "
"failure, staleness and overdue critical fixes (R-VULN-06). Two of those rules fire "
"on an absent metric, so a lost scrape is loud rather than inert",
"companion": {
"file": "platform/vuln-scanner/kustomization.yaml",
"require": [r"^\s*-\s*networkpolicy\.yaml\s*$"],
"why": "platform/vuln-scanner/networkpolicy.yaml ships the namespace's default-deny "
"and the ingress allow for Prometheus on :9090 in one file, so a baseline "
"without its allow cannot empty the TSDB",
},
},
# ── ADDED 2026-11-08 (feat/cloudflare-tunnel). A NEW OWNER, NOT A RELAXED ASSERTION. ───────
# Purely additive, the same shape as the kured entry: it registers the cloudflared_* family
# and the Service+ServiceMonitor that scrapes it, and makes the check STRICTER for that component.
@@ -86,10 +86,10 @@ if not CONFIG.is_file():
# headlamp: operator decision 2026-11-08 (LAN/VPN-only). qbittorrent/radarr/sonarr: catalog
# `exposure: internal`, like jackett/pihole/semaphore.
EXCLUDED_HOSTS = ("plex", "plex-vip", "jackett", "pihole", "pihole-dns", "semaphore",
"qbittorrent", "radarr", "sonarr", "headlamp", "unifi", "crafty-mc")
"qbittorrent", "radarr", "sonarr", "headlamp", "cve", "unifi", "crafty-mc")
# Their Gateway-backend namespaces (unifi / crafty-mc are L4 VIPs, not Gateway backends).
EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr",
"sonarr", "headlamp")
"sonarr", "headlamp", "vuln-scanner")
FORBIDDEN_ENTITIES = {"kube-apiserver", "host", "remote-node", "cluster", "all"}
# Any of these in a `service` means a route toward the Kubernetes API (or a raw-TCP/SSH bastion).
API_MARKERS = ("kubernetes", "kube-apiserver", "apiserver", ":6443", "10.43.0.1", "tcp://",
@@ -187,6 +187,10 @@ KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = {
"decision: Headlamp is LAN/VPN-only) and the tunnel 404s the name. A Kubernetes web UI "
"with native Keycloak OIDC; listed because it is a hand-authored route, and pinned "
"disowned by (9d).",
"platform/vuln-scanner/httproute.yaml:vuln-scanner/vuln-scanner":
"publishes NOTHING: the route carries `controller: none` (docs/17: the CVE page is internal "
"only) and the tunnel 404s the name. Hand-authored because the scanner is not a catalog "
"service (its cluster-wide read role cannot be expressed in a ServiceDefinition, ADR-0027).",
"platform/homepage/httproute.yaml:homepage/homepage":
"publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex "
"listener. The apex is not a per-service name and cannot be withdrawn by an exposure "
@@ -0,0 +1,39 @@
# =============================================================================
# L0 STATIC — PiHole's DNS frontends admit the LAN and the VPN, and nothing public
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# loadBalancerSourceRanges is the only thing keeping PiHole from being an open resolver, and it
# must also carry the WireGuard client subnet, because Cilium load-balances tunnel traffic on wg0
# before the iptables MASQUERADE (so the source it tests is still 10.90.0.x). check.sh's header
# states the assertions and what they do not prove.
#
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (standard library + PyYAML);
# conformance/run.sh --layer l0 runs it directly and this Chainsaw Test wraps the SAME script.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: pihole-dns-source-ranges
labels:
conformance.home.k3s/R-DNS-03: "true"
annotations:
conformance.home.k3s/requirements: "R-DNS-03"
conformance.home.k3s/layer: "L0"
# WHOLE-REPO SCOPE: ci/touched-requirements.sh maps apps/* to no R-DNS requirement, so a change
# to these Services would otherwise select nothing.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
Every PiHole LoadBalancer Service lists both the LAN and the WireGuard client subnet in
loadBalancerSourceRanges, and every range resolves to RFC1918, so VPN clients can resolve
and PiHole stays unreachable from the internet.
spec:
steps:
- name: assert-pihole-source-ranges
description: >-
Collect the PiHole LoadBalancer Services, resolve each source range through cluster-vars,
and assert both required ranges are present and every range is private.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — pihole-dns-source-ranges
# Serves: R-DNS-03 (LAN split-horizon DNS — spec docs/09).
#
# THE BUG CLASS THIS CLOSES. PiHole's LoadBalancer frontends are the only thing stopping it being
# an open resolver, and their `loadBalancerSourceRanges` is pulled in two opposite directions:
# * Too WIDE, and the resolver is open: a public range, or 0.0.0.0/0, in this list is the whole
# restriction gone, with nothing else in the path to catch it (the CiliumNetworkPolicy admits
# `world` on :53, because that is the only peer that can describe post-LoadBalancer traffic).
# * Too NARROW, and VPN DNS dies: wg0 is a Cilium kube-proxy-replacement device, so a tunnel
# client's query is load-balanced with its 10.90.0.x source intact, ahead of the iptables
# MASQUERADE. A LAN-only list is predicted to drop it as DROP_NOT_IN_SRC_RANGE, and the
# tunnel would still report a healthy handshake.
#
# GIVEN the repository as git tracks it,
# WHEN every type=LoadBalancer Service in namespace `pihole` is collected and each source range
# is resolved through cluster-vars (base value AND the prod overlay's replacement),
# THEN (1) the frontends exist and cover both udp/53 and tcp/53;
# (2) every frontend lists ${LAN_CIDR} and ${WG_CLIENT_CIDR};
# (3) every range resolves to a valid IPv4 CIDR inside RFC1918 — so no 0.0.0.0/0, no public
# range, and no unresolvable token that could hide one;
# AND (4) the check is NON-VACUOUS: a real checkout and a floor on the assertion count.
#
# WHAT THIS DOES NOT PROVE: that a query over the tunnel is actually answered. That needs a
# handshake from a real client; see platform/vpn-ingress/README.md.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
PYTHON="${PYTHON:-python3}"
cd "${ROOT}"
"${PYTHON}" - "${ROOT}" <<'PY'
import ipaddress, re, subprocess, sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
FAILURES: list[str] = []
CHECKS = 0
REQUIRED_TOKENS = ("LAN_CIDR", "WG_CLIENT_CIDR")
RFC1918 = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
TOKEN = re.compile(r"^\$\{([A-Z0-9_]+)\}$")
def check(cond, msg):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(msg)
def docs_of(rel: str):
try:
return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace"))
if isinstance(d, dict)]
except Exception:
return []
FILES = [p for p in subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
capture_output=True, text=True, check=True).stdout.split("\0")
if p]
YAML_FILES = [f for f in FILES if re.search(r"\.ya?ml$", f)]
check(len(YAML_FILES) > 50, f"only {len(YAML_FILES)} YAML files seen — not a real checkout")
# ------------------------------------------------- every value a ${TOKEN} can take on this tree
values: dict[str, set[str]] = {}
for d in docs_of("clusters/base/cluster-vars.yaml"):
if d.get("kind") == "ConfigMap":
for k, v in (d.get("data") or {}).items():
values.setdefault(k, set()).add(str(v))
for d in docs_of("clusters/prod/kustomization.yaml"):
for p in d.get("patches") or []:
ops = p.get("patch") if isinstance(p, dict) else None
ops = yaml.safe_load(ops) if isinstance(ops, str) else ops
for op in ops if isinstance(ops, list) else []:
m = re.fullmatch(r"/data/([A-Z0-9_]+)", str(op.get("path", "")))
if m and op.get("op") in ("add", "replace"):
values.setdefault(m.group(1), set()).add(str(op.get("value")))
for tok in REQUIRED_TOKENS:
check(values.get(tok), f"clusters/base/cluster-vars.yaml defines no {tok} (R-DNS-03)")
def assert_private(where: str, cidr: str):
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
check(False, f"{where}: {cidr!r} is not a valid CIDR (R-DNS-03)")
return
check(net.version == 4 and any(net.subnet_of(r) for r in RFC1918),
f"{where}: {cidr} is not inside RFC1918, so it opens PiHole beyond the LAN and the VPN "
"(R-DNS-03)")
# ---------------------------------------------------------------------------- the frontends
frontends = []
for rel in YAML_FILES:
for d in docs_of(rel):
md, spec = d.get("metadata") or {}, d.get("spec") or {}
if d.get("kind") == "Service" and md.get("namespace") == "pihole" \
and spec.get("type") == "LoadBalancer":
frontends.append((rel, md.get("name"), spec))
protocols = {(p.get("protocol", "TCP"), p.get("port"))
for _, _, spec in frontends for p in spec.get("ports") or []}
for want in (("UDP", 53), ("TCP", 53)):
check(want in protocols, f"no PiHole LoadBalancer Service serves {want[0]}/{want[1]} — this "
"check's corpus has moved and must be re-derived (R-DNS-03)")
for rel, name, spec in frontends:
where = f"{rel}: Service/{name}"
ranges = spec.get("loadBalancerSourceRanges") or []
check(ranges, f"{where} has no loadBalancerSourceRanges, so it answers anyone who can reach "
"the VIP (R-DNS-03)")
tokens = {m.group(1) for m in (TOKEN.match(str(r)) for r in ranges) if m}
for tok in REQUIRED_TOKENS:
check(tok in tokens, f"{where} does not list ${{{tok}}} in loadBalancerSourceRanges "
"(R-DNS-03)")
for r in ranges:
m = TOKEN.match(str(r))
if not m:
assert_private(where, str(r))
continue
resolved = values.get(m.group(1))
check(resolved, f"{where}: {r} is not defined in cluster-vars, so its value cannot be "
"shown to be private (R-DNS-03)")
for v in sorted(resolved or []):
assert_private(f"{where} ({r})", v)
check(CHECKS >= 15, f"only {CHECKS} assertions ran — this check has gone vacuous (R-DNS-03)")
if FAILURES:
for f in FAILURES:
print(f"check FAIL: {f}", file=sys.stderr)
raise SystemExit(1)
print(f"check OK: pihole-dns-source-ranges — {CHECKS} assertions over "
f"{len(frontends)} PiHole LoadBalancer Service(s)")
PY
@@ -0,0 +1,45 @@
# =============================================================================
# L0 STATIC — PiHole answers an explicit LAN name list, and no LAN-only name is left out of it
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# Since PR #156 every public name is a proxied CNAME to the Cloudflare Tunnel and works from the
# LAN, so PiHole's zone wildcard was replaced by an explicit list (catalog/pihole.yaml). That makes
# the list load-bearing: a route disowned from public DNS and absent from it resolves nowhere. This
# test derives the required names from the tree (every `controller: none` HTTPRoute, every hostname
# the tunnel 404s, the L4 Services' own VIPs) and asserts the list covers them, carries no
# zone-wide entry, and pins gitea and nextcloud to the Gateway VIP. It proves itself non-vacuous by
# mutation. Rationale and the honest NOT-proven list: check.sh.
#
# WHY SCOPE, NOT REQUIREMENTS: the property spans catalog/, apps/, platform/ and runbooks/; a new
# internal service in any of them can break it, and ci/touched-requirements.sh maps apps/* to no
# requirement at all, so a requirement-scoped selection would skip it on precisely those changes.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: pihole-lan-names
labels:
conformance.home.k3s/R-DNS-03: "true"
annotations:
conformance.home.k3s/requirements: "R-DNS-03"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "true"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
PiHole answers an explicit list of LAN names rather than the whole zone; every HTTPRoute
disowned from public DNS has an entry to the Gateway VIP; every hostname the Cloudflare
Tunnel 404s has an entry, with the L4 names on their own VIPs; and gitea and nextcloud stay
on the LAN path, off Cloudflare's request-size and timeout limits.
spec:
steps:
- name: assert-pihole-explicit-lan-names
description: >-
Parse FTLCONF_misc_dnsmasq_lines from catalog/pihole.yaml and the rendered
apps/wave-0/pihole.yaml, derive the required names from every `controller: none` HTTPRoute,
the http_status:404 hostnames in platform/cloudflared/config.yaml and the L4 Services'
lbipam annotations, assert the list covers them and contains no zone-wide entry, and run
the evaluator on mutated lists to prove it is not vacuous.
try:
- script:
content: bash ./check.sh
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — pihole-lan-names
# Serves: R-DNS-03 (LAN split-horizon answers), and decisions/2026-11-09-pihole-explicit-lan-names.md.
#
# WHY THIS EXISTS. Since PR #156 every PUBLIC name is a proxied CNAME to the Cloudflare Tunnel and
# works from the LAN, so PiHole no longer answers the whole zone with a wildcard: it answers an
# EXPLICIT list (catalog/pihole.yaml, FTLCONF_misc_dnsmasq_lines) and forwards everything else to
# the public resolvers. That inverts the old failure mode. With the wildcard, a forgotten name still
# resolved; now a forgotten LAN-ONLY name — a route disowned from public DNS, which the tunnel also
# 404s — resolves NOWHERE, and the app is silently unreachable from every client. Nothing in the
# cluster turns red when that happens. So the list is derived from the tree and asserted here.
#
# GIVEN the repository as authored,
# THEN (0) the dnsmasq value parses (only `local-ttl=` and `address=/<domain>/<${VAR}>` entries,
# every VAR is a cluster-var), the catalog value and the rendered apps/wave-0/pihole.yaml
# value are identical, and the inventories below are non-empty (anti-vacuous-pass);
# (a) NO entry covers the whole zone: no `address=/${BASE_DOMAIN}/`, no `address=/#/`, and no
# entry for any parent of the zone (dnsmasq matches every subdomain of an entry);
# (b) every HTTPRoute hostname disowned from public DNS
# (`external-dns.alpha.kubernetes.io/controller: none`, anywhere in apps/ platform/
# runbooks/) has an explicit entry to ${GATEWAY_VIP} — a disowned route with no
# `hostnames:` is itself a FAIL, because it cannot be checked;
# (c) every hostname platform/cloudflared/config.yaml maps to http_status:404 has an entry;
# the L4 DNS-only names (derived: Services carrying both
# `external-dns.alpha.kubernetes.io/hostname` and `lbipam.cilium.io/ips` — unifi,
# crafty-mc) must map to THAT Service's VIP, not the Gateway;
# (d) gitea and nextcloud are pinned to ${GATEWAY_VIP} (Cloudflare's 100 MB body / 100 s
# limits break git push/LFS and large uploads);
# AND (e) the evaluator is not vacuous: it is run against mutated copies of the real entry list
# (zone wildcard added; a disowned host dropped; a 404'd host dropped; gitea repointed;
# unifi repointed to the Gateway) and must flag each one.
#
# NOT proven (static): that the running PiHole pods carry this value (FTL applies FTLCONF_* at
# start; the env change rolls the StatefulSet), that public resolution of the unlisted names
# reaches Cloudflare, or the substituted IPs. Runtime check: `dig @${PIHOLE_DNS_VIP} <name>`.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
exec python3 - "$ROOT" <<'PY'
import copy
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("CHECK FAIL (pihole-lan-names): PyYAML is required", file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
FAILURES, CHECKS = [], 0
NAME = "pihole-lan-names"
ZONE = "${BASE_DOMAIN}"
GW = "${GATEWAY_VIP}"
DISOWN = "external-dns.alpha.kubernetes.io/controller"
EDNS_HOST = "external-dns.alpha.kubernetes.io/hostname"
LB_IPS = "lbipam.cilium.io/ips"
# Public, but must bypass Cloudflare's 100 MB request body / 100 s response limits.
LAN_PINNED = ("gitea", "nextcloud")
# Trees that hold applied manifests. conformance/ fixtures and .claude/ worktrees are not authored
# surface (CLAUDE.md §0); decisions/ is prose.
SCAN = ("apps", "platform", "runbooks")
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def docs(path):
try:
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
except yaml.YAMLError:
return []
def dnsmasq_value(objs):
"""The FTLCONF_misc_dnsmasq_lines value from a list of docs (a ServiceDefinition or manifests)."""
found = []
for d in objs:
envs = list(d.get("env") or [])
tmpl = (((d.get("spec") or {}).get("template") or {}).get("spec") or {})
for c in tmpl.get("containers") or []:
envs += c.get("env") or []
found += [e.get("value") for e in envs if e.get("name") == "FTLCONF_misc_dnsmasq_lines"]
return found
def die(msg):
print(f"CHECK FAIL ({NAME}): {msg}", file=sys.stderr)
raise SystemExit(1)
CATALOG = ROOT / "catalog" / "pihole.yaml"
RENDERED = ROOT / "apps" / "wave-0" / "pihole.yaml"
CF = ROOT / "platform" / "cloudflared" / "config.yaml"
for p in (CATALOG, RENDERED, CF):
if not p.is_file():
die(f"{p.relative_to(ROOT)} is absent — the surface this check guards moved; re-point or "
"delete this check in the SAME change")
cat_vals = dnsmasq_value(docs(CATALOG))
ren_vals = dnsmasq_value(docs(RENDERED))
check(len(cat_vals) == 1, f"(0) catalog/pihole.yaml declares FTLCONF_misc_dnsmasq_lines exactly "
f"once (found {len(cat_vals)})")
check(len(ren_vals) == 1, f"(0) apps/wave-0/pihole.yaml carries FTLCONF_misc_dnsmasq_lines exactly "
f"once (found {len(ren_vals)})")
if not cat_vals:
die("no dnsmasq value to check")
check(cat_vals[:1] == ren_vals[:1],
"(0) the rendered apps/wave-0/pihole.yaml dnsmasq value differs from catalog/pihole.yaml — "
"re-render pihole (catalog/render.py --no-image-automation catalog/pihole.yaml)")
cv = next(d for d in docs(ROOT / "clusters" / "base" / "cluster-vars.yaml")
if d.get("kind") == "ConfigMap")
CLUSTER_VARS = set(cv["data"])
BASE_DOMAIN_VALUE = cv["data"]["BASE_DOMAIN"]
ENTRY = re.compile(r"^address=/([^/]+)/(\$\{[A-Z0-9_]+\})$")
def parse(value):
"""Return (entries {domain: target}, problems)."""
entries, problems = {}, []
for raw in value.split(";"):
line = raw.strip()
if raw != line:
problems.append(f"entry {raw!r} has surrounding whitespace (FTL writes it verbatim)")
if not line:
problems.append("empty entry (doubled or trailing `;`)")
continue
if re.fullmatch(r"local-ttl=\d+", line):
continue
m = ENTRY.match(line)
if not m:
problems.append(f"entry {line!r} is not `local-ttl=N` or `address=/<domain>/${{VAR}}` — "
f"this check cannot reason about it")
continue
dom, tgt = m.groups()
if tgt[2:-1] not in CLUSTER_VARS:
problems.append(f"{dom} -> {tgt}: not a key of clusters/base/cluster-vars.yaml")
if dom in entries and entries[dom] != tgt:
problems.append(f"{dom} is listed twice with different targets")
entries[dom] = tgt
return entries, problems
entries, problems = parse(cat_vals[0])
for pr in problems:
check(False, f"(0) {pr}")
# ── inventories, derived from the tree ──────────────────────────────────────────────────────────
disowned, unnamed = set(), []
l4 = {}
for top in SCAN:
for f in sorted((ROOT / top).rglob("*.yaml")):
for d in docs(f):
meta = d.get("metadata") or {}
ann = meta.get("annotations") or {}
if d.get("kind") == "HTTPRoute" and str(ann.get(DISOWN, "")).strip() == "none":
hosts = (d.get("spec") or {}).get("hostnames") or []
if not hosts:
unnamed.append(f"{f.relative_to(ROOT)}:{meta.get('name')}")
disowned.update(hosts)
if d.get("kind") == "Service" and EDNS_HOST in ann and LB_IPS in ann:
for h in str(ann[EDNS_HOST]).split(","):
l4[h.strip()] = str(ann[LB_IPS]).strip()
for u in unnamed:
check(False, f"(b) disowned HTTPRoute {u} declares no `hostnames:` — it inherits a listener "
f"hostname this check cannot see; name it explicitly")
check(len(disowned) >= 5, f"(0) only {len(disowned)} disowned HTTPRoute hostnames found — the scan "
f"is vacuous (the disown annotation was renamed?)")
check(len(l4) >= 2, f"(0) only {len(l4)} L4 Services publishing their own hostname found (expected "
f"unifi and crafty-mc) — the scan is vacuous")
cf = yaml.safe_load(CF.read_text())
cf_404 = {r["hostname"] for r in cf.get("ingress") or []
if r.get("hostname") and r.get("service") == "http_status:404"}
check(len(cf_404) >= 5, f"(0) only {len(cf_404)} http_status:404 hostnames in the cloudflared "
f"config — the scan is vacuous")
def covers_zone(dom):
"""True if an entry for `dom` answers every name in the zone (dom is the zone or a parent)."""
if dom in ("#", "", "."):
return True
if dom == ZONE:
return True
# A literal parent (e.g. `keithjackson.dev`, `dev`) of the zone's substituted value.
real = dom.replace(ZONE, BASE_DOMAIN_VALUE)
return real == BASE_DOMAIN_VALUE or BASE_DOMAIN_VALUE.endswith("." + real)
def evaluate(entries):
bad = []
# (a)
for dom in entries:
if covers_zone(dom):
bad.append(f"(a) `address=/{dom}/` answers EVERY name in the zone — the wildcard is gone "
f"on purpose (public names must resolve to Cloudflare from the LAN)")
# (b)
for h in sorted(disowned):
if entries.get(h) != GW:
bad.append(f"(b) {h} is disowned from public DNS but PiHole maps it to "
f"{entries.get(h, 'NOTHING')} — expected {GW}; without it the app is "
f"unreachable everywhere")
# (c)
for h in sorted(cf_404):
if h in l4:
if entries.get(h) != l4[h]:
bad.append(f"(c) {h} is an L4 DNS-only name; PiHole maps it to "
f"{entries.get(h, 'NOTHING')}, expected its own VIP {l4[h]}")
elif h not in entries:
bad.append(f"(c) the tunnel 404s {h} but PiHole has no entry for it — it would resolve "
f"only publicly, and the tunnel refuses it")
# (d)
for n in LAN_PINNED:
h = f"{n}.{ZONE}"
if entries.get(h) != GW:
bad.append(f"(d) {h} maps to {entries.get(h, 'NOTHING (public/Cloudflare)')}; it must be "
f"pinned to {GW} — Cloudflare caps request bodies at 100 MB and responses at "
f"100 s")
return bad
for v in evaluate(entries):
check(False, v)
# (e) mutation self-test
mutants = {}
m = dict(entries); m[ZONE] = GW
mutants["zone wildcard added"] = m
m = dict(entries); m[BASE_DOMAIN_VALUE.split(".", 1)[1]] = GW
mutants["parent-of-zone wildcard added"] = m
m = dict(entries); m.pop(sorted(disowned)[0], None)
mutants[f"disowned host {sorted(disowned)[0]} dropped"] = m
pure_404 = sorted(h for h in cf_404 if h not in l4 and h not in disowned)
if pure_404:
m = dict(entries); m.pop(pure_404[0], None)
mutants[f"404'd host {pure_404[0]} dropped"] = m
m = dict(entries); m.pop(f"gitea.{ZONE}", None)
mutants["gitea unpinned"] = m
m = dict(entries); m[f"nextcloud.{ZONE}"] = "${UNIFI_L4_VIP}"
mutants["nextcloud repointed"] = m
if l4:
h = sorted(l4)[0]
m = dict(entries); m[h] = GW
mutants[f"{h} repointed to the Gateway"] = m
for name, mut in mutants.items():
check(evaluate(mut), f"(e) self-test: the evaluator did NOT flag the mutant '{name}' — vacuous")
check(len(mutants) >= 5, f"(e) only {len(mutants)} mutants built — the self-test degenerated")
_, pr = parse("local-ttl=60;address=/x.${BASE_DOMAIN}/${NOT_A_CLUSTER_VAR}; address=/y/${GATEWAY_VIP}")
check(len(pr) >= 2, "(e) self-test: the parser accepted an unknown VAR or a whitespace-padded entry")
if CHECKS < 15:
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions; {len(entries)} PiHole names, {len(disowned)} disowned "
f"routes, {len(cf_404)} tunnel-404 names, {len(l4)} L4 names")
PY
@@ -0,0 +1,28 @@
# =============================================================================
# L0 STATIC — accepted CVE risk has a reason and a bounded expiry (R-VULN-08, docs/17 "Risk acceptance").
# Offline, no cluster.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-acceptances-schema
labels:
conformance.home.k3s/R-VULN-08: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-08"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
platform/vuln-scanner/acceptances.yaml rejects any entry without a reason or expiry, with a
malformed expiry, or expiring more than 180 days out. Proven with mutated negative fixtures.
spec:
steps:
- name: assert-acceptance-schema
description: >-
Validate acceptances.yaml against the docs/17 shape, then run the validator on mutated
negative fixtures to prove it can fail.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-acceptances-schema
# Serves: R-VULN-08 (docs/17 "Risk acceptance": "An entry with no reason or expiry is rejected by the gate").
#
# GIVEN platform/vuln-scanner/acceptances.yaml,
# THEN (1) it is a YAML list;
# (2) every entry has a non-empty id, a non-empty reason, an expires of the form YYYY-MM-DD
# at most 180 days from today, and only the permitted keys (image is optional);
# AND (3) the validator rejects mutated negative fixtures for each rule and accepts a good entry.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import datetime as dt, re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-acceptances-schema"
FAILURES, CHECKS = [], 0
TODAY = dt.datetime.now(dt.timezone.utc).date()
MAX_DAYS = 180
ALLOWED_KEYS = {"id", "image", "reason", "expires"}
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def validate(entries):
errs = []
if not isinstance(entries, list):
return ["acceptances must be a YAML list"]
for i, e in enumerate(entries):
if not isinstance(e, dict):
errs.append(f"entry {i} is not a mapping")
continue
if set(e) - ALLOWED_KEYS:
errs.append(f"entry {i} has unknown keys {sorted(set(e) - ALLOWED_KEYS)}")
for field in ("id", "reason"):
if not isinstance(e.get(field), str) or not e[field].strip():
errs.append(f"entry {i} has no non-empty {field}")
if "image" in e and (not isinstance(e["image"], str) or not e["image"].strip()):
errs.append(f"entry {i} has an empty image")
exp = e.get("expires")
if isinstance(exp, dt.date) and not isinstance(exp, dt.datetime):
exp_s = exp.isoformat()
elif isinstance(exp, str):
exp_s = exp
else:
errs.append(f"entry {i} has no expires")
continue
if not DATE_RE.match(exp_s):
errs.append(f"entry {i} expires is malformed: {exp_s!r}")
continue
try:
d = dt.date.fromisoformat(exp_s)
except ValueError:
errs.append(f"entry {i} expires is not a real date: {exp_s!r}")
continue
if (d - TODAY).days > MAX_DAYS:
errs.append(f"entry {i} expires {exp_s}, more than {MAX_DAYS} days out")
return errs
path = ROOT / "platform" / "vuln-scanner" / "acceptances.yaml"
if not path.is_file():
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/acceptances.yaml is absent", file=sys.stderr)
raise SystemExit(1)
entries = yaml.safe_load(path.read_text())
check(entries == [] or (isinstance(entries, list) and entries), "(1) acceptances.yaml parsed to a list")
errs = validate(entries)
check(not errs, f"(2) acceptances.yaml is valid: {errs}")
soon = (TODAY + dt.timedelta(days=30)).isoformat()
far = (TODAY + dt.timedelta(days=200)).isoformat()
good = {"id": "CVE-2026-00000", "image": "docker.io/library/x", "reason": "not reachable", "expires": soon}
check(validate([good]) == [], "(3) a well-formed entry is accepted")
check(validate([{k: v for k, v in good.items() if k != "image"}]) == [], "(3) image is optional (platform-wide)")
for name, entry in {
"no reason": {k: v for k, v in good.items() if k != "reason"},
"empty reason": dict(good, reason=""),
"no expires": {k: v for k, v in good.items() if k != "expires"},
"no id": {k: v for k, v in good.items() if k != "id"},
"malformed expiry": dict(good, expires="next-tuesday"),
"impossible date": dict(good, expires="2027-13-45"),
"past 180 days": dict(good, expires=far),
"unknown key": dict(good, owner="me"),
}.items():
check(bool(validate([entry])), f"(3) the validator accepted a bad entry: {name}")
check(bool(validate({"not": "a list"})), "(3) the validator accepted a non-list")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,29 @@
# =============================================================================
# L0 STATIC — the five scanner alerts exist and are unit-tested, including the absent-metric cases
# (R-VULN-06, docs/17 "Acceptance gates"). Offline, no cluster.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-alerts-unit-tested
labels:
conformance.home.k3s/R-VULN-06: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-06"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
VulnScanStale, VulnDatabaseStale, VulnCoverageGap, VulnCriticalFixAvailable and
VulnTriageBotSilent exist, each promtool-tested firing, and the two absent-metric rules fire
in a test with no such series. Proven by mutation.
spec:
steps:
- name: assert-alert-unit-tests
description: >-
Parse alerts.yaml and alerts.promtool-test.yaml, assert every alert and the two absent cases
are pinned, run the harness when promtool is present, and run the evaluator on mutated copies.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-alerts-unit-tested
# Serves: R-VULN-06 (docs/17 "Acceptance gates": alerts unit-tested, including the absent-metric cases).
#
# GIVEN platform/vuln-scanner/alerts.yaml and alerts.promtool-test.yaml,
# THEN (1) the rules are exactly the five contract alerts, with the two absent()-bearing rules;
# (2) each alert is asserted FIRING in a promtool test;
# (3) VulnScanStale and VulnTriageBotSilent each fire in a test with NO input series for their
# own metric (the absent case) and in one where it vanishes (re-fire);
# (4) each alert carries severity and a runbook_url;
# (5) when promtool is present (the toolbox), the actual harness passes on this file;
# AND (6) the evaluator is run on mutated copies (the absent arm deleted, a firing assertion
# removed, an alert dropped) and must flag each (non-vacuity).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import copy, re, shutil, subprocess, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-alerts-unit-tested"
FAILURES, CHECKS = [], 0
ALERTS = {"VulnScanStale", "VulnDatabaseStale", "VulnCoverageGap", "VulnCriticalFixAvailable", "VulnTriageBotSilent"}
ABSENT_ALERTS = {
"VulnScanStale": "vuln_scanner_last_complete_run_timestamp_seconds",
"VulnTriageBotSilent": "vuln_scanner_triage_heartbeat_timestamp_seconds",
}
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
COMP = ROOT / "platform" / "vuln-scanner"
for f in ("alerts.yaml", "alerts.promtool-test.yaml"):
if not (COMP / f).is_file():
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr)
raise SystemExit(1)
rules = yaml.safe_load((COMP / "alerts.yaml").read_text())
tests = yaml.safe_load((COMP / "alerts.promtool-test.yaml").read_text())
def alert_rules(doc):
return {r["alert"]: r for g in doc["spec"]["groups"] for r in g["rules"]}
def eval_alerts(doc):
bad = []
got = alert_rules(doc)
if set(got) != ALERTS:
bad.append(f"alerts are {sorted(got)}, expected {sorted(ALERTS)}")
for name, metric in ABSENT_ALERTS.items():
if name in got and f"absent({metric})" not in got[name]["expr"].replace(" ", ""):
bad.append(f"{name} lacks the absent({metric}) arm")
for name, r in got.items():
if not (r.get("labels") or {}).get("severity"):
bad.append(f"{name} has no severity label")
if "/runbooks/" not in ((r.get("annotations") or {}).get("runbook_url") or ""):
bad.append(f"{name} has no runbook_url")
return bad
def firing_assertions(test):
"""(alertname -> assertion list) for the firing expectations of one promtool test."""
out = {}
for pe in test.get("promql_expr_test") or []:
expr, samples = pe.get("expr", ""), pe.get("exp_samples") or []
m = re.search(r'alertname="(Vuln\w+)"', expr)
if m and samples and 'alertstate="firing"' in (expr + " " + " ".join(s.get("labels", "") for s in samples)):
out.setdefault(m.group(1), []).append(pe)
return out
def series_for(test):
return [s.get("series", "") for s in test.get("input_series") or []]
def eval_tests(doc):
bad = []
all_tests = doc.get("tests") or []
fired = set()
for t in all_tests:
fired |= set(firing_assertions(t))
missing = ALERTS - fired
if missing:
bad.append(f"no firing promtool assertion for {sorted(missing)}")
for alert, metric in ABSENT_ALERTS.items():
absent_case = any(
alert in firing_assertions(t) and not any(s.startswith(metric) for s in series_for(t))
for t in all_tests)
if not absent_case:
bad.append(f"{alert} has no firing test with NO {metric} series (the absent case)")
vanish_case = any(
alert in firing_assertions(t) and any(s.startswith(metric) and "stale" in str(i.get("values", ""))
for i in t.get("input_series", []) for s in [i.get("series", "")])
for t in all_tests)
if not vanish_case:
bad.append(f"{alert} has no test where its metric goes stale (the re-fire case)")
return bad
for v in eval_alerts(rules):
check(False, f"(1,4) {v}")
for v in eval_tests(tests):
check(False, f"(2,3) {v}")
check(len(alert_rules(rules)) == 5, "(1) five alert rules parsed")
# (5) the real harness, only when the rule engine is present.
if shutil.which("promtool"):
rc = subprocess.run(["bash", str(ROOT / "platform/monitoring/tests/run.sh"),
"platform/vuln-scanner/alerts.promtool-test.yaml"],
cwd=str(ROOT), capture_output=True, text=True)
check(rc.returncode == 0, f"(5) the promtool harness failed: rc={rc.returncode}\n{rc.stdout[-800:]}{rc.stderr[-800:]}")
# (6) non-vacuity
m = copy.deepcopy(rules)
for g in m["spec"]["groups"]:
for r in g["rules"]:
if r["alert"] == "VulnTriageBotSilent":
r["expr"] = r["expr"].split(" or ")[0]
check(bool(eval_alerts(m)), "(6) evaluator missed: absent arm deleted")
m = copy.deepcopy(rules)
m["spec"]["groups"][0]["rules"].pop(1)
check(bool(eval_alerts(m)), "(6) evaluator missed: alert dropped")
m = copy.deepcopy(rules)
for g in m["spec"]["groups"]:
for r in g["rules"]:
r["annotations"].pop("runbook_url", None)
check(bool(eval_alerts(m)), "(6) evaluator missed: runbook_url removed")
m = copy.deepcopy(tests)
for t in m["tests"]:
t["promql_expr_test"] = [p for p in t["promql_expr_test"]
if 'VulnCoverageGap' not in p.get("expr", "")]
check(bool(eval_tests(m)), "(6) evaluator missed: firing assertion removed")
m = copy.deepcopy(tests)
m["tests"] = [t for t in m["tests"] if "ABSENT" not in t.get("name", "")]
check(bool(eval_tests(m)), "(6) evaluator missed: absent-case tests removed")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,28 @@
# =============================================================================
# L0 STATIC — Prometheus receives aggregates only (R-VULN-06, docs/17 "Alerting").
# Offline, no cluster. Depends on platform/vuln-scanner/src/metrics.py (the SCANNER's file).
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-metrics-no-per-cve-label
labels:
conformance.home.k3s/R-VULN-06: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-06"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
No vuln_scanner_* metric or alert rule carries a label other than state, severity or fixable,
so Prometheus can never hold a per-CVE, per-image or per-workload series. Proven by mutation.
spec:
steps:
- name: assert-metric-labels-are-aggregates
description: >-
Extract every vuln_scanner_* metric label from metrics.py and alerts.yaml, assert the
allowlist, and run the extractor on mutated text to prove it can fail.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-metrics-no-per-cve-label
# Serves: R-VULN-06 ("Prometheus receives aggregates only ... MUST NOT receive a per-CVE series").
#
# GIVEN platform/vuln-scanner/src/metrics.py and platform/vuln-scanner/alerts.yaml,
# THEN (1) every label name attached to a vuln_scanner_* metric is in the allowlist
# (state, severity, fixable);
# (2) metrics.py exposes every metric name from the contract;
# (3) alerts.yaml selects vuln_scanner_* metrics with no label matcher outside the allowlist;
# AND (4) the extractor is proven on mutated text: a `cve=` label, a `{image=...}` selector and a
# per-workload label are each flagged, and label-free text is not.
# A missing metrics.py is a FAIL: this is a whole-repo-scoped check and the file is part of the same
# delivery (the check has no skip path).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-metrics-no-per-cve-label"
FAILURES, CHECKS = [], 0
ALLOWED = {"state", "severity", "fixable"}
METRICS = (
"vuln_scanner_report_generated_timestamp_seconds",
"vuln_scanner_last_complete_run_timestamp_seconds",
"vuln_scanner_database_updated_timestamp_seconds",
"vuln_scanner_targets",
"vuln_scanner_findings",
"vuln_scanner_failed_targets_over_24h",
"vuln_scanner_critical_fix_overdue",
"vuln_scanner_triage_heartbeat_timestamp_seconds",
)
# Label keys that could only carry per-CVE or per-target identity, anywhere a metric could be
# written. Catches label construction the name{...} scan cannot see (e.g. "cve_id": cve_id).
IDENTITY_LABEL = re.compile(r'''["'](cve|cve_id|advisory|package|pkg|image|digest|workload|namespace|pod|container|target|target_id)["']\s*[:=]''')
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def label_names(text):
"""Every label key in `vuln_scanner_x{key="value",...}` occurrences (Python or PromQL text),
tolerating f-strings' doubled braces and multi-line calls."""
found = set()
for m in re.finditer(r"vuln_scanner_[a-z0-9_]+\{", text):
tail = text[m.end(): m.end() + 200]
close = tail.find("}\n")
chunk = tail[: close if close != -1 else None].split("\n", 1)[0]
for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*\\?[\"'{]", chunk):
found.add(key)
return found
METRICS_PY = ROOT / "platform" / "vuln-scanner" / "src" / "metrics.py"
ALERTS = ROOT / "platform" / "vuln-scanner" / "alerts.yaml"
if not METRICS_PY.is_file():
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/src/metrics.py is absent (the SCANNER's "
f"deliverable); the aggregate-only property cannot be asserted without it", file=sys.stderr)
raise SystemExit(1)
src = METRICS_PY.read_text()
check(all(m in src for m in METRICS), f"(2) metrics.py names every contract metric "
f"(missing: {[m for m in METRICS if m not in src]})")
labels = label_names(src)
extra = labels - ALLOWED
check(not extra, f"(1) metrics.py attaches labels outside the allowlist: {sorted(extra)}")
hits = IDENTITY_LABEL.findall(src)
check(not hits, f"(1) metrics.py constructs an identity-bearing metric label: {sorted(set(hits))}")
rules = yaml.safe_load(ALERTS.read_text())
exprs = [r["expr"] for g in rules["spec"]["groups"] for r in g["rules"]]
check(len(exprs) >= 5, "(3) alerts.yaml parsed five or more rules")
alert_labels = set()
for e in exprs:
alert_labels |= label_names(e)
check(not (alert_labels - ALLOWED),
f"(3) alerts.yaml selects labels outside the allowlist: {sorted(alert_labels - ALLOWED)}")
# (4) non-vacuity
negatives = {
"cve label": 'vuln_scanner_findings{severity="HIGH",cve="CVE-1"} 1\n',
"image selector": 'max(vuln_scanner_findings{image="docker.io/x"})\n',
"workload label": 'f"vuln_scanner_targets{{workload=\\"{w}\\",state=\\"x\\"}} 1"\n',
}
for name, text in negatives.items():
check(bool(label_names(text) - ALLOWED), f"(4) the extractor missed: {name}")
ok_text = 'vuln_scanner_targets{state="scanned"} 3\nvuln_scanner_findings{severity="LOW",fixable="true"} 1\n'
check(not (label_names(ok_text) - ALLOWED), "(4) the extractor flagged the allowed labels")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,30 @@
# =============================================================================
# L0 STATIC — the scanner's Flux tier cannot stall the platform, and its network reach is exactly what
# docs/17 "Security of the scanner itself" allows (R-VULN-09). Offline, no cluster.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-isolation
labels:
conformance.home.k3s/R-VULN-09: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-09"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
The vuln-scanner Flux tier is wait:false, in neither gating tier, and nothing dependsOn it;
ingress is exactly the Gateway to 4180, the CI runner to 8081 and Prometheus to 9090;
egress reaches the API, DNS, the public internet on 443 only with cluster and LAN excluded,
and the Keycloak path the sidecar needs. Proven by mutation.
spec:
steps:
- name: assert-isolation-and-network-shape
description: >-
Parse the Flux wiring and networkpolicy.yaml, assert isolation and the exact network shape,
and run the evaluators on mutated copies to prove they can fail.
try:
- script:
content: bash ./check.sh
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-scanner-isolation
# Serves: R-VULN-09 (docs/17 "Security of the scanner itself").
#
# GIVEN the repository as authored,
# THEN (1) the Flux tier is wait:false, dependsOn platform-config, in neither gating tier, nothing
# anywhere dependsOn it, and both overlays carry its SOPS decryption patch;
# (2) ingress is EXACTLY: reserved:ingress to 4180, act-runner to 8081, prometheus to 9090;
# (3) egress to the world is tcp/443 only through a CIDR rule excluding every private range,
# and the only other destinations are the API entity, the Gateway entity on 443 and Keycloak
# (the oauth2-proxy sidecar's OIDC path, the one deliberate addition to the spec's list);
# no `world`, `all`, `cluster`, `host` or `remote-node` entity;
# (4) the namespace ships a default-deny baseline and is not app-namespace=true (single owner);
# AND (5) each evaluator is run on a mutated copy and must flag it (non-vacuity).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import copy, re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-scanner-isolation"
FAILURES, CHECKS = [], 0
FORBIDDEN_ENTITIES = {"world", "all", "cluster", "host", "remote-node"}
PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def docs(path):
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
fk_path = ROOT / "clusters" / "base" / "vuln-scanner.yaml"
np_path = ROOT / "platform" / "vuln-scanner" / "networkpolicy.yaml"
for p in (fk_path, np_path):
if not p.is_file():
print(f"CHECK FAIL ({NAME}): {p.relative_to(ROOT)} is absent", file=sys.stderr)
raise SystemExit(1)
# (1) Flux wiring
fk = next(d for d in docs(fk_path) if d.get("kind") == "Kustomization")
check(fk["spec"].get("wait") is False, "(1) clusters/base/vuln-scanner.yaml must be wait: false")
check([d.get("name") for d in fk["spec"].get("dependsOn") or []] == ["platform-config"],
"(1) the tier dependsOn exactly platform-config")
check(fk["spec"].get("path") == "./platform/vuln-scanner", "(1) the tier reconciles ./platform/vuln-scanner")
check((fk["spec"].get("postBuild") or {}).get("substituteFrom"),
"(1) postBuild.substituteFrom is present (the route/alerts use ${BASE_DOMAIN})")
check("vuln-scanner.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(),
"(1) clusters/base/kustomization.yaml lists vuln-scanner.yaml")
check("vuln-scanner" not in (ROOT / "platform" / "kustomization.yaml").read_text(),
"(1) platform/kustomization.yaml must not include vuln-scanner (the gating tier)")
for f in sorted((ROOT / "clusters").rglob("*.yaml")):
for d in docs(f):
for dep in (d.get("spec") or {}).get("dependsOn") or []:
check(not (isinstance(dep, dict) and dep.get("name") == "vuln-scanner"),
f"(1) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn vuln-scanner — "
"a broken scanner must never stall anything")
for env in ("dev", "prod"):
text = (ROOT / "clusters" / env / "kustomization.yaml").read_text()
check(re.search(r"name:\s*vuln-scanner\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n\s*decryption:",
text) is not None,
f"(1) clusters/{env}/kustomization.yaml carries the vuln-scanner SOPS decryption patch")
# Network shape
np_docs = docs(np_path)
dd = [d for d in np_docs if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny"]
check(len(dd) == 1 and set(dd[0]["spec"]["policyTypes"]) == {"Ingress", "Egress"} and dd[0]["spec"].get("ingress") == [],
"(4) default-deny baseline denies both directions with no ingress")
ns = next(d for d in docs(ROOT / "platform" / "vuln-scanner" / "namespace.yaml") if d.get("kind") == "Namespace")
check("homelab.keithjackson.dev/app-namespace" not in (ns["metadata"].get("labels") or {}),
"(4) the namespace must not be app-namespace=true (the default-deny would be double-owned)")
def cnp(name):
return next((d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy" and d["metadata"]["name"] == name), None)
def ports_of(rule):
return {(p.get("port"), p.get("protocol", "TCP")) for tp in rule.get("toPorts") or [] for p in tp.get("ports") or []}
def eval_ingress(policy):
bad = []
rules = policy["spec"].get("ingress") or []
if len(rules) != 3:
bad.append(f"expected exactly 3 ingress rules, found {len(rules)}")
seen = set()
for r in rules:
ports = ports_of(r)
if r.get("fromEntities") == ["ingress"] and ports == {("4180", "TCP")}:
seen.add("gateway")
elif len(r.get("fromEndpoints") or []) == 1:
sel = r["fromEndpoints"][0].get("matchLabels") or {}
if (sel.get("io.kubernetes.pod.namespace") == "gitea-actions"
and sel.get("app.kubernetes.io/name") == "act-runner" and ports == {("8081", "TCP")}):
seen.add("ci")
elif (sel.get("io.kubernetes.pod.namespace") == "monitoring"
and sel.get("app.kubernetes.io/name") == "prometheus" and ports == {("9090", "TCP")}):
seen.add("prometheus")
else:
bad.append(f"unexpected ingress rule {r}")
else:
bad.append(f"unexpected ingress rule {r}")
missing = {"gateway", "ci", "prometheus"} - seen
if missing:
bad.append(f"ingress paths missing: {sorted(missing)}")
return bad
def eval_egress(policy):
bad = []
rules = policy["spec"].get("egress") or []
if len(rules) != 4:
bad.append(f"expected exactly 4 egress rules, found {len(rules)}")
world = 0
for r in rules:
ents = set(r.get("toEntities") or [])
if ents & FORBIDDEN_ENTITIES:
bad.append(f"egress admits forbidden entity {sorted(ents & FORBIDDEN_ENTITIES)}")
if ents == {"kube-apiserver"} and not r.get("toPorts"):
continue
if ents == {"ingress"} and ports_of(r) == {("443", "TCP")}:
continue
if r.get("toEndpoints"):
sel = r["toEndpoints"][0].get("matchLabels") or {}
if not (len(r["toEndpoints"]) == 1 and sel.get("io.kubernetes.pod.namespace") == "keycloak"
and sel.get("app") == "keycloak"):
bad.append(f"egress to unexpected endpoints {r}")
continue
if r.get("toCIDRSet"):
world += 1
sets = r["toCIDRSet"]
if len(sets) != 1 or sets[0].get("cidr") != "0.0.0.0/0":
bad.append(f"unexpected CIDR rule {r}")
continue
if not PRIVATE_RANGES.issubset(set(sets[0].get("except") or [])):
bad.append(f"the world CIDR rule does not exclude {sorted(PRIVATE_RANGES - set(sets[0].get('except') or []))}")
if ports_of(r) != {("443", "TCP")}:
bad.append(f"the world CIDR rule is not tcp/443 only ({sorted(ports_of(r))})")
continue
if r.get("toCIDR"):
bad.append(f"egress names a bare CIDR rule {r}")
continue
if not ents:
bad.append(f"unexpected egress rule {r}")
if world != 1:
bad.append(f"expected exactly one world CIDR rule, found {world}")
return bad
ing, eg = cnp("vuln-scanner-allow-ingress"), cnp("vuln-scanner-allow-egress")
check(ing is not None, "(2) the ingress CiliumNetworkPolicy exists")
check(eg is not None, "(3) the egress CiliumNetworkPolicy exists")
if ing is not None:
for v in eval_ingress(ing):
check(False, f"(2) {v}")
if eg is not None:
for v in eval_egress(eg):
check(False, f"(3) {v}")
check(len([d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]) == 2,
"(3) exactly two CiliumNetworkPolicies, so no other path is added silently")
# (5) non-vacuity
if ing is not None and eg is not None:
def m_ing_open(p): p["spec"]["ingress"].append({"fromEntities": ["world"], "toPorts": [{"ports": [{"port": "8081", "protocol": "TCP"}]}]})
def m_ing_ns(p): p["spec"]["ingress"][1]["fromEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default"
def m_ing_port(p): p["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"] = "8080"
for name, fn in (("extra world ingress", m_ing_open), ("CI rule moved to another namespace", m_ing_ns),
("Gateway reaches the page port", m_ing_port)):
m = copy.deepcopy(ing)
fn(m)
check(bool(eval_ingress(m)), f"(5) ingress evaluator missed mutant: {name}")
def m_eg_world(p): p["spec"]["egress"].append({"toEntities": ["world"]})
def m_eg_lan(p):
for r in p["spec"]["egress"]:
if r.get("toCIDRSet"):
r["toCIDRSet"][0]["except"] = ["10.0.0.0/8"]
def m_eg_port(p):
for r in p["spec"]["egress"]:
if r.get("toCIDRSet"):
r["toPorts"][0]["ports"].append({"port": "80", "protocol": "TCP"})
def m_eg_endpoint(p):
for r in p["spec"]["egress"]:
if r.get("toEndpoints"):
r["toEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default"
for name, fn in (("world entity added", m_eg_world), ("LAN not excluded", m_eg_lan),
("port 80 added", m_eg_port), ("endpoints moved", m_eg_endpoint)):
m = copy.deepcopy(eg)
fn(m)
check(bool(eval_egress(m)), f"(5) egress evaluator missed mutant: {name}")
if CHECKS < 18:
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,34 @@
# =============================================================================
# L0 STATIC — the CVE scanner's supply chain and privileges are what docs/17 "Security of the scanner
# itself" says (R-VULN-09). Offline, no cluster.
#
# WHY SCOPE, NOT REQUIREMENTS: the property spans platform/vuln-scanner and renovate.json5. A
# requirement-scoped selection would miss a Renovate edit that quietly lets the scanner auto-merge.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-supply-chain
labels:
conformance.home.k3s/R-VULN-09: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-09"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
The scanner image is pinned by digest and is not a banned Trivy version; Renovate never
auto-merges it and proposes it only after 7 days; its RBAC holds get/list/watch only and no
Secret; the pod is unprivileged with bounded resources and a size-limited cache. Each is
proven by mutation.
spec:
steps:
- name: assert-supply-chain-and-privileges
description: >-
Parse platform/vuln-scanner/deployment.yaml, rbac.yaml and renovate.json5, assert the
digest/version/Renovate/RBAC/security-context contract, and run each evaluator on mutated
copies to prove it can fail.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,351 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-scanner-supply-chain
# Serves: R-VULN-09 (docs/17 "Security of the scanner itself").
#
# GIVEN the repository as authored,
# THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:<ver>@sha256:<64hex>` — by digest, and a
# tag so Renovate has something to follow;
# (2) that version is not 0.69.4/0.69.5/0.69.6 (the compromised March 2026 releases);
# (3) every pod image is digest-pinned except the oauth2-proxy sidecar, which follows the
# per-app sidecar pattern so one Renovate branch moves them all;
# (4) renovate.json5 keeps Trivy in the blast-radius list, automerge:false, minimumReleaseAge
# of at least 7 days, and no later rule re-enables automerge for it;
# (5) rbac.yaml: only ClusterRole/ClusterRoleBinding to the scanner SA, only get/list/watch, no
# Secret, no wildcard, and only the resources the inventory needs;
# (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host
# namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage
# limit on the scanner, and a size-limited /cache emptyDir.
# (8) a renovate.json5 rule covers every file under platform/vuln-scanner: automerge:false,
# minimumReleaseAge of at least 7 days, and no later rule turns auto-merge back on (the
# python image holds the cluster-read token);
# (9) the scanner names the private registry it cannot reach, so that registry reports
# credential-required rather than a permanent failed.
# AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import copy, json, re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-scanner-supply-chain"
FAILURES, CHECKS = [], 0
COMP = ROOT / "platform" / "vuln-scanner"
BANNED = {"0.69.4", "0.69.5", "0.69.6"}
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
ALLOWED_VERBS = {"get", "list", "watch"}
ALLOWED_RESOURCES = {
"": {"pods", "nodes"},
"apps": {"replicasets", "deployments", "statefulsets", "daemonsets"},
"batch": {"jobs", "cronjobs"},
"kubevirt.io": {"virtualmachineinstances"},
}
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
def docs(path):
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
for f in ("deployment.yaml", "rbac.yaml"):
if not (COMP / f).is_file():
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr)
raise SystemExit(1)
def split_ref(image):
# A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port
# stays in the repository part).
ref, _, digest = image.partition("@")
head, colon, tail = ref.rpartition(":")
if colon and "/" not in tail:
return head, tail, digest or None
return ref, None, digest or None
def containers(pod):
return (pod.get("initContainers") or []) + (pod.get("containers") or [])
def eval_images(pod):
bad = []
trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"]
if len(trivy) != 1:
bad.append(f"expected one Trivy image, found {len(trivy)}")
for c in containers(pod):
repo, tag, digest = split_ref(c["image"])
if repo == "quay.io/oauth2-proxy/oauth2-proxy":
if not tag or tag == "latest":
bad.append("oauth2-proxy sidecar has no version tag")
continue
if not digest or not DIGEST.match(digest):
bad.append(f"{c['name']} image {c['image']} is not pinned by digest")
if not tag:
bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow")
if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED:
bad.append(f"Trivy {tag} is a banned (compromised) version")
return bad
def strip_json5(text):
# Drop // comments outside strings, then trailing commas.
out, i, in_str, esc = [], 0, False, False
while i < len(text):
ch = text[i]
if in_str:
out.append(ch)
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
i += 1
continue
if ch == '"':
in_str = True
out.append(ch)
i += 1
continue
if text.startswith("//", i):
while i < len(text) and text[i] != "\n":
i += 1
continue
out.append(ch)
i += 1
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
TRIVY_PKG = "ghcr.io/aquasecurity/trivy"
VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$")
def eval_renovate(rules):
bad = []
trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])]
if not trivy_rules:
bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)")
return bad
if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or [])
for r in trivy_rules):
bad.append("Trivy is not in a blast-radius rule with automerge:false")
ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r]
if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7
for a in ages):
bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})")
# Renovate applies packageRules in order, later wins; the last rule that names automerge for
# Trivy (or matches it generically by update type) must be false.
last = None
for r in rules:
if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or [])
or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}):
last = r
if last is None or last.get("automerge") is not False:
bad.append("the last packageRule deciding automerge for Trivy leaves it true")
return bad
SCANNER_FILES = "platform/vuln-scanner/**"
def eval_renovate_files(rules):
positions = [i for i, r in enumerate(rules) if SCANNER_FILES in (r.get("matchFileNames") or [])]
if not positions:
return [f"no packageRule has matchFileNames {SCANNER_FILES}"]
bad = []
rule = rules[positions[-1]]
if rule.get("automerge") is not False:
bad.append("the platform/vuln-scanner file rule is not automerge:false")
age = VERSION_UPDATE_RULE_RE.match(rule.get("minimumReleaseAge") or "")
if not age or int(age.group(1)) < 7:
bad.append("the platform/vuln-scanner file rule has no minimumReleaseAge of at least 7 days")
if any(r.get("automerge") is True for r in rules[positions[-1] + 1:]):
bad.append("a rule after the platform/vuln-scanner file rule re-enables auto-merge")
return bad
def eval_private_registries(pod):
scanner = next(c for c in pod["containers"] if c["name"] == "scanner")
env = {e["name"]: e.get("value") for e in scanner.get("env") or []}
if env.get("VULN_PRIVATE_REGISTRIES") != "gitea.${BASE_DOMAIN}":
return ["the scanner does not name the private registry gitea.${BASE_DOMAIN}"]
return []
def eval_rbac(rbac):
bad = []
croles = [d for d in rbac if d.get("kind") == "ClusterRole"]
if len(croles) != 1:
bad.append(f"expected one ClusterRole, found {len(croles)}")
return bad
for rule in croles[0].get("rules") or []:
verbs = set(rule.get("verbs") or [])
if verbs - ALLOWED_VERBS:
bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}")
if "*" in verbs:
bad.append("rule grants the verb wildcard")
for group in rule.get("apiGroups") or []:
if group == "*" or group not in ALLOWED_RESOURCES:
bad.append(f"rule names apiGroup {group!r}")
continue
for res in rule.get("resources") or []:
if res == "*" or res not in ALLOWED_RESOURCES[group]:
bad.append(f"rule grants resource {group}/{res}")
if "secrets" in (rule.get("resources") or []):
bad.append("rule grants Secret access")
kinds = {d.get("kind") for d in rbac}
if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}:
bad.append(f"unexpected RBAC kinds {sorted(kinds)}")
for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"):
if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]:
bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole")
return bad
def eval_pod(pod):
bad = []
psc = pod.get("securityContext") or {}
if psc.get("runAsNonRoot") is not True:
bad.append("pod runAsNonRoot is not true")
if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
bad.append("pod seccompProfile is not RuntimeDefault")
for f in ("hostNetwork", "hostPID", "hostIPC"):
if pod.get(f):
bad.append(f"pod sets {f}")
if any("hostPath" in v for v in pod.get("volumes") or []):
bad.append("pod mounts a hostPath volume")
for c in containers(pod):
sc = c.get("securityContext") or {}
if sc.get("readOnlyRootFilesystem") is not True:
bad.append(f"{c['name']} root filesystem is writable")
if sc.get("allowPrivilegeEscalation") is not False:
bad.append(f"{c['name']} allows privilege escalation")
if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []):
bad.append(f"{c['name']} does not drop ALL capabilities")
limits = (c.get("resources") or {}).get("limits") or {}
for lim in ("cpu", "memory"):
if lim not in limits:
bad.append(f"{c['name']} has no {lim} limit")
if c["name"] == "scanner" and "ephemeral-storage" not in limits:
bad.append("the scanner has no ephemeral-storage limit")
vols = {v["name"]: v for v in pod.get("volumes") or []}
if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"):
bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)")
return bad
dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment")
pod = dep["spec"]["template"]["spec"]
for v in eval_images(pod) + eval_pod(pod):
check(False, f"(1-3,6) {v}")
check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)")
check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy")
renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text()))
rules = renovate.get("packageRules") or []
check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)")
for v in eval_renovate(rules):
check(False, f"(4) {v}")
for v in eval_renovate_files(rules):
check(False, f"(8) {v}")
for v in eval_private_registries(pod):
check(False, f"(9) {v}")
rbac = docs(COMP / "rbac.yaml")
for v in eval_rbac(rbac):
check(False, f"(5) {v}")
# (7) non-vacuity
def mutated(fn, base):
m = copy.deepcopy(base)
fn(m)
return m
# Image mutants
def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1"
def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"])
def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine"
for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned),
("python image unpinned", img_nopin)):
check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}")
# RBAC mutants
def rbac_secret(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][0]["resources"].append("secrets")
def rbac_write(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][0]["verbs"].append("delete")
def rbac_wild(r):
role = next(d for d in r if d.get("kind") == "ClusterRole")
role["rules"][1]["resources"] = ["*"]
for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write),
("resource wildcard granted", rbac_wild)):
check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}")
# Renovate mutants
def ren_auto(r):
for x in r:
if TRIVY_PKG in (x.get("matchPackageNames") or []):
x["automerge"] = True
def ren_age(r):
for x in r:
if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x:
x["minimumReleaseAge"] = "2 days"
def ren_drop(r):
r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])]
def ren_later(r):
r.append({"matchUpdateTypes": ["digest"], "automerge": True})
for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age),
("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)):
check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}")
# Renovate file-rule mutants
def files_rule(r):
return next(x for x in r if SCANNER_FILES in (x.get("matchFileNames") or []))
def files_auto(r): files_rule(r)["automerge"] = True
def files_age(r): files_rule(r)["minimumReleaseAge"] = "1 day"
def files_drop(r): r.remove(files_rule(r))
def files_later(r): r.append({"matchDatasources": ["docker"], "automerge": True})
for name, fn in (("file rule automerge true", files_auto), ("file rule age shortened", files_age),
("file rule removed", files_drop), ("later rule re-enables auto-merge", files_later)):
check(bool(eval_renovate_files(mutated(fn, rules))), f"(7) file-rule evaluator missed mutant: {name}")
def reg_drop(p):
next(c for c in p["containers"] if c["name"] == "scanner")["env"][:] = [
e for e in next(c for c in p["containers"] if c["name"] == "scanner")["env"]
if e["name"] != "VULN_PRIVATE_REGISTRIES"]
check(bool(eval_private_registries(mutated(reg_drop, pod))), "(7) private-registry evaluator missed the removal")
# Pod mutants
def pod_root(p): p["securityContext"]["runAsNonRoot"] = False
def pod_host(p): p["hostNetwork"] = True
def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False
def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"]
def pod_nocap(p):
for v in p["volumes"]:
if v["name"] == "cache":
v["emptyDir"].pop("sizeLimit", None)
for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw),
("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)):
check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}")
if CHECKS < 15:
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,31 @@
# =============================================================================
# L0 STATIC — the CVE triage bot workflow fails loudly and is shaped like Renovate's (R-VULN-05,
# R-VULN-07, docs/17 "The triage bot"). Offline, no cluster. Depends on .gitea/workflows/cve-triage.yaml
# (the BOT's file); fails until that file is integrated.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-triage-bot-workflow-guards
labels:
conformance.home.k3s/R-VULN-05: "true"
conformance.home.k3s/R-VULN-07: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-05, R-VULN-07"
conformance.home.k3s/layer: "L0"
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
The bot workflow runs on the 07:15 cron, has a workflow_dispatch dry-run input, never runs
concurrently with itself, and a step fails the job hard when RENOVATE_TOKEN is empty.
Proven by mutation.
spec:
steps:
- name: assert-workflow-guards
description: >-
Parse .gitea/workflows/cve-triage.yaml and assert the trigger, concurrency and hard
credential failure, then run the evaluator on mutated copies to prove it can fail.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — vuln-triage-bot-workflow-guards
# Serves: R-VULN-05 (runs on a schedule, fails loudly), R-VULN-07 (bot conventions, docs/17).
#
# GIVEN .gitea/workflows/cve-triage.yaml,
# THEN (1) it runs on `15 7 * * *` and has a workflow_dispatch `dry_run` input;
# (2) concurrency is set (the workflow never runs against itself);
# (3) a step fails the job HARD when RENOVATE_TOKEN is empty (exit non-zero, not skip);
# AND (4) the evaluator is run on mutated copies and must flag each (non-vacuity).
# A missing workflow file is a FAIL: this is whole-repo scoped and has no skip path.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)"
exec python3 - "$ROOT" <<'PY'
import copy, re, sys
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
NAME = "vuln-triage-bot-workflow-guards"
FAILURES, CHECKS = [], 0
WF = ROOT / ".gitea" / "workflows" / "cve-triage.yaml"
def check(cond, desc):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(desc)
if not WF.is_file():
print(f"CHECK FAIL ({NAME}): .gitea/workflows/cve-triage.yaml is absent (the BOT's deliverable); "
f"whether it fails on a missing credential cannot be asserted without it", file=sys.stderr)
raise SystemExit(1)
doc = yaml.safe_load(WF.read_text())
def evaluate(doc):
bad = []
triggers = doc.get(True) if True in doc else doc.get("on") # YAML reads `on:` as boolean True
if not isinstance(triggers, dict):
return ["no triggers parsed"]
if "15 7 * * *" not in [s.get("cron") for s in triggers.get("schedule") or [] if isinstance(s, dict)]:
bad.append("no schedule with cron 15 7 * * *")
inputs = ((triggers.get("workflow_dispatch") or {}).get("inputs") or {})
if "dry_run" not in inputs:
bad.append("workflow_dispatch has no dry_run input")
conc = doc.get("concurrency")
jobs = doc.get("jobs") or {}
if not conc and not any(isinstance(j, dict) and j.get("concurrency") for j in jobs.values()):
bad.append("no concurrency set (the bot must not run against itself)")
hard_fail = False
for j in jobs.values():
for step in (j.get("steps") or []) if isinstance(j, dict) else []:
run = str(step.get("run", ""))
if ("RENOVATE_TOKEN" in run and re.search(r"(-z|!\s*-n)[^\n]*RENOVATE_TOKEN|RENOVATE_TOKEN[^\n]*(-z)", run)
and re.search(r"exit\s+[1-9]", run)):
hard_fail = True
if not hard_fail:
bad.append("no step fails hard (exit non-zero) when RENOVATE_TOKEN is empty")
return bad
for v in evaluate(doc):
check(False, f"(1-3) {v}")
check(isinstance(doc.get("jobs"), dict) and doc["jobs"], "(1) the workflow declares jobs")
# (4) non-vacuity
def mutate(fn):
m = copy.deepcopy(doc)
fn(m)
return m
def trig(m):
return m.get(True) if True in m else m.get("on")
def no_cron(m): trig(m).pop("schedule", None)
def no_dry(m): trig(m)["workflow_dispatch"] = {}
def no_conc(m):
m.pop("concurrency", None)
for j in m.get("jobs", {}).values():
if isinstance(j, dict):
j.pop("concurrency", None)
def no_guard(m):
for j in m.get("jobs", {}).values():
for step in j.get("steps") or []:
if "RENOVATE_TOKEN" in str(step.get("run", "")):
step["run"] = 'echo "::warning::RENOVATE_TOKEN unset, skipping"'
for name, fn in (("cron removed", no_cron), ("dry_run input removed", no_dry),
("concurrency removed", no_conc), ("credential check softened", no_guard)):
check(bool(evaluate(mutate(fn))), f"(4) evaluator missed mutant: {name}")
if FAILURES:
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
print(f"PASS ({NAME}): {CHECKS} assertions")
PY
@@ -0,0 +1,25 @@
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-acceptances
labels:
conformance.home.k3s/R-VULN-08: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-08"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
# a change to the scanner would select no vuln-scanner test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Given acceptance files, When parsed and applied, Then expiry reverts to open and malformed entries never hide a finding.
spec:
steps:
- name: run-offline-check
description: Run check.sh, the same assertions the offline harness runs.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""R-VULN-08: acceptance parsing, expiry and fail-safe behaviour."""
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
import acceptances # noqa: E402
import report # noqa: E402
FAILED = []
TODAY = date(2026, 11, 9)
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
A1 = "sha256:" + "a1" * 32
def check(name, condition):
print(("ok " if condition else "FAIL ") + name)
if not condition:
FAILED.append(name)
def parse(text):
return acceptances.parse_text(text, TODAY)
entries, errors = parse("""# header comment
- id: CVE-2026-1
image: docker.io/library/nginx # exact match
reason: not reachable - feature disabled
expires: 2027-02-01
- id: CVE-2026-2
reason: "platform wide"
expires: 2026-12-01
""")
check("valid flat entries parse with no errors", len(entries) == 2 and errors == [])
check("inline comment is stripped", entries[0]["image"] == "docker.io/library/nginx")
check("omitted image means platform-wide", entries[1]["image"] is None)
check("empty list form is accepted", parse("# none\n[]\n") == ([], []))
check("empty file is accepted", parse("") == ([], []))
for name, text in (
("missing reason", "- id: CVE-1\n expires: 2026-12-01\n"),
("missing expires", "- id: CVE-1\n reason: x\n"),
("expires beyond 180 days", "- id: CVE-1\n reason: x\n expires: 2027-06-01\n"),
("unknown key", "- id: CVE-1\n reason: x\n expires: 2026-12-01\n scope: all\n"),
("not a date", "- id: CVE-1\n reason: x\n expires: soon\n"),
("impossible date", "- id: CVE-1\n reason: x\n expires: 2026-13-45\n"),
("duplicate key", "- id: CVE-1\n reason: x\n reason: y\n expires: 2026-12-01\n"),
("flow value", "- id: CVE-1\n reason: [x]\n expires: 2026-12-01\n"),
):
found, problems = parse(text)
check("rejected: " + name, found == [] and len(problems) == 1)
found, problems = parse("- id: CVE-1\n reason: x\n expires: 2026-12-01\n- id: bad entry\n expires: 2026-12-01\n")
check("one bad entry is ignored, the valid one kept", len(found) == 1 and len(problems) == 1)
found, problems = parse("- id: CVE-1\n reason: x\n")
check("bad indentation invalidates the whole file", found == [] and problems)
found, problems = parse("garbage\n- id: CVE-1\n reason: x\n expires: 2026-12-01\n")
check("a stray line invalidates the whole file", found == [] and problems)
check("missing file is an error, not an empty allowlist", acceptances.load("/nonexistent/acceptances.yaml")[1])
entry = {"id": "CVE-1", "image": None, "reason": "r", "expires": date(2026, 11, 9)}
check("acceptance holds on its expiry day", acceptances.match([entry], "CVE-1", "any/image", TODAY) is entry)
check("acceptance gone the day after", acceptances.match([entry], "CVE-1", "any/image", TODAY + timedelta(days=1)) is None)
scoped = {"id": "CVE-1", "image": "docker.io/library/nginx", "reason": "r", "expires": date(2026, 12, 1)}
check("image match is exact", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx-extra", TODAY) is None)
check("image match accepts the exact image", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx", TODAY) is scoped)
def state_with(findings):
target = {"id": "docker.io/library/nginx@" + A1, "type": "image", "image": "docker.io/library/nginx", "digest": A1,
"ref": "", "reason": None, "workloads": []}
return {
"inventory": [target], "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
"remediation": [], "inventoryError": None, "loadError": None,
"scans": {A1: {"firstDiscoveredAt": "2026-11-01T00:00:00Z", "lastInventoriedAt": "2026-11-09T09:00:00Z",
"lastAttemptAt": "2026-11-09T09:00:00Z", "lastSuccessAt": "2026-11-09T09:00:00Z", "outcome": "ok",
"reason": None, "failedSince": None, "findings": findings,
"firstSeen": {f["id"]: "2026-11-01T00:00:00Z" for f in findings}}},
}
FINDING = {"id": "CVE-1", "severity": "CRITICAL", "cvss": 9.0, "package": "p", "installedVersion": "1", "fixedVersion": "2",
"title": "t", "url": ""}
ST = state_with([FINDING])
def status(when, accepted, errs=()):
built = report.build(ST, accepted, list(errs), when)
return built, built["findings"][0]
def reason_entry(length):
return acceptances.parse_text("- id: CVE-1\n reason: " + "r" * length + "\n expires: 2026-12-01\n", NOW.date())
found, problems = reason_entry(3000)
check("a reason over 2048 characters ignores the entry and reports it", found == [] and any("2048" in p for p in problems))
found, problems = reason_entry(2048)
check("mutation: a reason of exactly 2048 characters is valid", len(found) == 1 and problems == [])
entry = {"id": "CVE-1", "image": None, "reason": "not reachable", "expires": date(2026, 11, 9)}
built, finding = status(NOW, [entry])
check("unexpired acceptance marks the finding accepted", finding["status"] == "accepted"
and finding["acceptance"] == {"reason": "not reachable", "expires": "2026-11-09"})
check("accepted report is schema-valid", report.validate(built) == [])
_, finding = status(NOW + timedelta(days=1), [entry])
check("expired acceptance reverts to the finding's own status", finding["status"] == "fix-available" and "acceptance" not in finding)
_, finding = status(NOW, [])
check("mutation: no acceptance leaves it open/fix-available", finding["status"] == "fix-available")
built, finding = status(NOW, [], ["bad entry"])
check("an invalid entry degrades the run", built["run"]["status"] == "degraded")
check("an invalid entry never hides the finding", finding["status"] == "fix-available")
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
sys.exit(1 if FAILED else 0)
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1
exec python3 "${HERE}/check.py"
@@ -0,0 +1,28 @@
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-api-metrics
labels:
conformance.home.k3s/R-VULN-02: "true"
conformance.home.k3s/R-VULN-03: "true"
conformance.home.k3s/R-VULN-05: "true"
conformance.home.k3s/R-VULN-06: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05, R-VULN-06"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
# a change to the scanner would select no vuln-scanner test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Given a running engine, When the HTTP surfaces are exercised, Then the API validates, metrics are aggregates only, and listeners answer during a scan.
spec:
steps:
- name: run-offline-check
description: Run check.sh, the same assertions the offline harness runs.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""R-VULN-02/03/05/06: internal API, metrics, publish refusal, and HTTP during a scan."""
import copy
import http.client
import json
import logging
import os
import re
import sys
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
import main # noqa: E402
import metrics # noqa: E402
import report # noqa: E402
import server # noqa: E402
import state as st # noqa: E402
logging.disable(logging.CRITICAL)
FAILED = []
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
A1 = "sha256:" + "a1" * 32
def check(name, condition):
print(("ok " if condition else "FAIL ") + name)
if not condition:
FAILED.append(name)
class Clock:
def __init__(self):
self.now = NOW
def __call__(self):
return self.now
class Client:
def list_all(self, path):
if path.endswith("pods"):
return [{"metadata": {"namespace": "apps", "name": "p"}, "spec": {"containers": [{"name": "c", "image": "nginx:1"}]},
"status": {"containerStatuses": [{"name": "c", "imageID": "docker.io/library/nginx@" + A1}]}}]
return []
class Runner:
"""Blocks on release while a scan is 'running', so a test can prove the HTTP path stays live."""
def __init__(self):
self.entered = threading.Event()
self.release = threading.Event()
self.release.set()
self.result = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
{"VulnerabilityID": "CVE-1", "Severity": "CRITICAL", "PkgName": "openssl", "InstalledVersion": "1",
"FixedVersion": "2", "Title": "t", "PrimaryURL": "https://example.test/1"}]}]}
def download_db(self):
pass
def db_info(self):
return {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"}
def scan(self, _reference):
self.entered.set()
self.release.wait(10)
return self.result
def clean_cache(self):
pass
def request(port, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(method, path, body, headers or {})
resp = conn.getresponse()
data = resp.read()
conn.close()
return resp.status, data, resp
def post(port, document, content_type="application/json"):
return request(port, "POST", "/triage", json.dumps(document), {"Content-Type": content_type})
with tempfile.TemporaryDirectory() as tmp:
accept = os.path.join(tmp, "a.yaml")
Path(accept).write_text("[]\n")
clock = Clock()
runner = Runner()
eng = main.Engine(tmp, Client(), runner, accept, clock=clock, publish_seconds=0)
servers = server.start_all(eng, ("127.0.0.1", 0), ("127.0.0.1", 0), ("127.0.0.1", 0))
page_port, internal, metrics_port = (s.server_address[1] for s in servers)
status, body, _ = request(internal, "GET", "/report.json")
check("report.json is 503 before any report", status == 503)
status, body, resp = request(page_port, "GET", "/")
check("page is 503 before any report, with the CSP", status == 503 and resp.getheader("Content-Security-Policy"))
text = request(metrics_port, "GET", "/metrics")[1].decode()
check("last-complete-run metric is absent before the first run", "last_complete_run" not in text)
check("triage heartbeat metric is absent before the first POST", "triage_heartbeat" not in text)
# HTTP must stay live while a scan is blocked mid-flight.
runner.release.clear()
worker = threading.Thread(target=eng.tick, daemon=True)
worker.start()
check("the scan is genuinely in flight", runner.entered.wait(10))
status_h, _, _ = request(internal, "GET", "/healthz")
status_p, _, _ = request(page_port, "GET", "/healthz")
status_m, _, _ = request(metrics_port, "GET", "/metrics")
check("listeners answer during a scan", (status_h, status_p, status_m) == (200, 200, 200))
runner.release.set()
worker.join(10)
check("the tick then completes", not worker.is_alive())
status, body, _ = request(internal, "GET", "/report.json")
check("report.json serves a schema-valid report after a tick", status == 200 and report.validate(json.loads(body)) == [])
# Page and CSP over HTTP
status, body, resp = request(page_port, "GET", "/?view=inventory")
check("page served over HTTP carries the CSP and nosniff",
status == 200 and resp.getheader("Content-Security-Policy").startswith("default-src 'none'") and resp.getheader("X-Content-Type-Options") == "nosniff")
status, body, resp = request(page_port, "GET", "/missing")
check("a 404 from the page listener also carries the CSP", status == 404 and resp.getheader("Content-Security-Policy"))
status, body, resp = request(page_port, "POST", "/")
check("an unsupported method on the page listener also carries the CSP", status == 501 and resp.getheader("Content-Security-Policy"))
# Triage API
valid = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [
{"image": "docker.io/library/nginx", "state": "pr-open", "url": "https://gitea.test/pulls/9", "updatedAt": "2026-11-09T11:00:00Z"}]}
status, body, _ = post(internal, valid)
check("valid triage is 204", status == 204 and body == b"")
check("remediation surfaces in the report", eng.report["remediation"][0]["state"] == "pr-open")
text = request(metrics_port, "GET", "/metrics")[1].decode()
check("heartbeat metric appears and uses server receipt time, not the bot's", f"triage_heartbeat_timestamp_seconds {int(NOW.timestamp())}" in text)
for name, mutate in (
("unknown top-level key", lambda d: d.update(extra=1)),
("missing heartbeat", lambda d: d.pop("heartbeatAt")),
("unknown remediation key", lambda d: d["remediation"][0].update(x=1)),
("bad remediation state", lambda d: d["remediation"][0].update(state="merged")),
("javascript url", lambda d: d["remediation"][0].update(url="javascript:x")),
("bad timestamp", lambda d: d.update(heartbeatAt="yesterday")),
):
document = copy.deepcopy(valid)
mutate(document)
status, _, _ = post(internal, document)
check("triage rejects " + name, status == 400)
for name, url in (("an uppercase scheme", "HTTP://gitea.test/pulls/1"), ("an empty url", ""), ("a javascript url", "javascript:alert(1)")):
bad = json.loads(json.dumps(valid))
bad["remediation"][0]["url"] = url
check("triage rejects " + name, post(internal, bad)[0] == 400)
check("triage rejects a wrong content type", post(internal, valid, "text/plain")[0] == 415)
status, _, _ = request(internal, "POST", "/triage", b"x" * 300000, {"Content-Type": "application/json"})
check("triage rejects an oversized body", status == 413)
many = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [valid["remediation"][0]] * 5001}
check("triage rejects more than 5000 entries", post(internal, many)[0] in (400, 413))
check("rejected triage leaves state untouched", eng.state["remediation"][0]["url"] == "https://gitea.test/pulls/9")
# Metrics labels and values
text = request(metrics_port, "GET", "/metrics")[1].decode()
allowed = {"state", "severity", "fixable"}
labels = {k for line in text.splitlines() for k in re.findall(r"([A-Za-z_]+)=\"", line)}
check("metric labels are within the allowlist", labels and labels <= allowed)
check("no metric carries a CVE, package, image or workload", not re.search(r"CVE-|openssl|nginx|apps", text))
for state in report.STATES:
check("targets gauge present for " + state, 'vuln_scanner_targets{state="' + state + '"}' in text)
check("critical fix findings counted with fixable true", 'vuln_scanner_findings{severity="CRITICAL",fixable="true"} 1' in text)
check("every severity and fixability appears", text.count("vuln_scanner_findings{") == 10)
check("failed-over-24h gauge exists", "vuln_scanner_failed_targets_over_24h 0" in text)
check("critical fix overdue is 0 inside the 72h grace", "vuln_scanner_critical_fix_overdue 0" in text)
clock.now += timedelta(hours=73)
text = request(metrics_port, "GET", "/metrics")[1].decode()
check("critical fix overdue is 1 after 72h", "vuln_scanner_critical_fix_overdue 1" in text)
check("last-complete-run metric present after a complete run", "vuln_scanner_last_complete_run_timestamp_seconds" in text)
for srv in servers:
srv.shutdown()
# Invalid report: refuse and keep the old one.
with tempfile.TemporaryDirectory() as tmp:
good = {"schemaVersion": 1, "generatedAt": "2026-11-09T10:00:00Z", "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
"targets": [], "findings": [], "remediation": []}
check("a valid report publishes", report.publish(good, tmp) is True)
before = Path(tmp, "report.json").read_bytes()
for name, mutate in (
("wrong schemaVersion", lambda r: r.update(schemaVersion=2)),
("extra top-level key", lambda r: r.update(extra=1)),
("bad timestamp", lambda r: r.update(generatedAt="soon")),
("unknown state", lambda r: r.update(targets=[{"id": "x", "type": "image", "image": "x", "digest": None, "state": "fine",
"reason": None, "lastScannedAt": None, "workloads": []}])),
("dangling targetId", lambda r: r.update(findings=[{"id": "CVE-1", "targetId": "nope", "severity": "LOW", "cvss": None,
"package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t",
"url": "", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "open"}])),
):
broken = copy.deepcopy(good)
mutate(broken)
check("invalid report refused: " + name, report.publish(broken, tmp) is False)
check("an invalid report writes nothing", Path(tmp, "report.json").read_bytes() == before)
check("accepted without acceptance is invalid", report.validate({**good, "targets": [{"id": "x", "type": "image", "image": "x", "digest": None,
"state": "scanned", "reason": None, "lastScannedAt": None, "workloads": []}], "findings": [{"id": "CVE-1", "targetId": "x", "severity": "LOW",
"cvss": None, "package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t", "url": "", "firstSeenAt": "2026-11-01T00:00:00Z",
"status": "accepted"}]}) != [])
Path(tmp, "report.json").write_text("{not json")
check("a corrupt published report is not served", report.load_published(tmp) is None)
# Crash-consistency of state: the temp+rename leaves no temp files behind and the file parses.
with tempfile.TemporaryDirectory() as tmp:
store = st.Store(tmp)
state = st.new_state()
state["triageHeartbeatAt"] = "2026-11-09T11:00:00Z"
store.save(state)
check("state saves with no temp file left behind", sorted(os.listdir(tmp)) == ["state.json"])
check("state survives a reload", st.Store(tmp).load()["triageHeartbeatAt"] == "2026-11-09T11:00:00Z")
Path(tmp, "state.json").write_text("not json")
loaded = st.Store(tmp).load()
check("corrupt state starts empty and marks degraded", loaded["loadError"] and loaded["scans"] == {})
check("corrupt state is preserved for inspection", Path(tmp, "state.json.corrupt").exists())
# Metrics module directly, without the report.
empty_state = st.new_state()
text = metrics.render(None, empty_state, NOW)
check("with no report only the state-derived gauge exists", text == "vuln_scanner_failed_targets_over_24h 0\n")
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
sys.exit(1 if FAILED else 0)
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1
exec python3 "${HERE}/check.py"
@@ -0,0 +1,25 @@
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-inventory
labels:
conformance.home.k3s/R-VULN-01: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-01"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
# a change to the scanner would select no vuln-scanner test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Given recorded API lists, When the inventory is built, Then owner chains, digests, by-tag CronJobs and not-covered targets resolve per R-VULN-01.
spec:
steps:
- name: run-offline-check
description: Run check.sh, the same assertions the offline harness runs.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""R-VULN-01: inventory from recorded API lists."""
import copy
import http.server
import json
import os
import sys
import tempfile
import threading
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
import inventory # noqa: E402
FAILED = []
def check(name, condition):
print(("ok " if condition else "FAIL ") + name)
if not condition:
FAILED.append(name)
CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text())
A1 = "sha256:" + "a1" * 32
B2 = "sha256:" + "b2" * 32
C3 = "sha256:" + "c3" * 32
D4 = "sha256:" + "d4" * 32
E5 = "sha256:" + "e5" * 32
F6 = "sha256:" + "f6" * 32
def by_id(targets):
return {t["id"]: t for t in targets}
def workloads(target):
return {(w["namespace"], w["kind"], w["name"]): w["containers"] for w in target["workloads"]}
targets = inventory.build(CLUSTER)
ids = by_id(targets)
nginx = ids["docker.io/library/nginx@" + A1]
check("ReplicaSet resolves to Deployment, not the ReplicaSet", workloads(nginx) == {
("apps", "Deployment", "web"): ["app"], ("apps", "Deployment", "api"): ["server"]})
check("docker-pullable:// prefix stripped and digest taken from imageID", nginx["digest"] == A1)
check("init container inventoried", ("apps", "Deployment", "web") in workloads(ids["ghcr.io/org/tool@" + B2]))
check("ephemeral container inventoried", "debug" in workloads(ids["docker.io/library/busybox@" + C3])[("apps", "Deployment", "web")])
backup = ids["ghcr.io/org/backup@" + E5]
check("Job resolves to CronJob", workloads(backup) == {("ops", "CronJob", "backup"): ["backup"]})
check("a CronJob with a running pod is not also inventoried from its template",
not any(t["id"].startswith("ghcr.io/org/backup:") for t in targets))
nightly = ids["ghcr.io/org/nightly:2.1"]
check("idle CronJob inventoried from template with no digest", nightly["digest"] is None
and workloads(nightly) == {("ops", "CronJob", "nightly"): ["job"]})
solo = ids["docker.io/library/alpine:3.20"]
check("empty imageID gives a by-tag target from the spec image", solo["digest"] is None)
check("bare Pod is its own workload", ("ops", "Pod", "solo") in workloads(solo))
check("Succeeded pod's image is not inventoried", not any(F6 in t["id"] for t in targets))
check("virt-launcher stays owned by its VirtualMachineInstance",
("kubevirt-vms", "VirtualMachineInstance", "vm1") in workloads(ids["quay.io/kubevirt/virt-launcher@sha256:" + "ab" * 32]))
vm, node = ids["vm/kubevirt-vms/vm1"], ids["node/prod-1"]
check("VM inventoried as non-container target", vm["type"] == "vm" and vm["digest"] is None and vm["reason"])
check("node inventoried as non-container target", node["type"] == "node" and node["reason"])
# Non-vacuous: mutating the fixture changes the answer the assertions above rely on.
broken = copy.deepcopy(CLUSTER)
broken["replicasets"] = []
broken["jobs"] = []
broken_ids = by_id(inventory.build(broken))
check("mutation: losing the ReplicaSet record leaves the ReplicaSet as the workload",
("apps", "ReplicaSet", "web-5f") in workloads(broken_ids["docker.io/library/nginx@" + A1]))
check("mutation: losing the Job record leaves the Job as the workload",
("ops", "Job", "backup-123") in workloads(broken_ids["ghcr.io/org/backup@" + E5]))
check("mutation: a Running phase on the Succeeded pod does inventory its image", any(
F6 in t["id"] for t in inventory.build({**CLUSTER, "pods": [
{**p, "status": {**p["status"], "phase": "Running"}} if p["metadata"]["name"] == "done-1" else p
for p in CLUSTER["pods"]]})))
for reference, expected in (
("nginx:1.27", ("docker.io/library/nginx", "1.27", None)),
("nginx", ("docker.io/library/nginx", None, None)),
("bitnami/redis:7", ("docker.io/bitnami/redis", "7", None)),
("ghcr.io/org/app:v1", ("ghcr.io/org/app", "v1", None)),
("registry.local:5000/x/y:2", ("registry.local:5000/x/y", "2", None)),
("index.docker.io/library/nginx:1", ("docker.io/library/nginx", "1", None)),
("ghcr.io/org/app:v1@" + A1, ("ghcr.io/org/app", "v1", A1)),
):
check("normalise " + reference, inventory.normalise_reference(reference) == expected)
check("bare config id is not a digest", inventory.digest_from_image_id("sha256:" + "a1" * 32) is None)
check("empty imageID has no digest", inventory.digest_from_image_id("") is None)
class FakeListClient:
"""list_all replaced; proves collect() tolerates exactly a 404 on the VMI API."""
def __init__(self, vmi_error):
self.vmi_error = vmi_error
def list_all(self, path):
if "kubevirt" in path and self.vmi_error:
raise self.vmi_error
return []
check("VMI API 404 tolerated as no VMs", inventory.collect(FakeListClient(inventory.NotFound("x")))["vmis"] == [])
try:
inventory.collect(FakeListClient(inventory.InventoryError("403")))
check("a non-404 VMI error is not swallowed", False)
except inventory.InventoryError:
check("a non-404 VMI error is not swallowed", True)
class PagedAPI(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
return
def do_GET(self):
if self.headers.get("Authorization") != "Bearer fixture-token":
self.send_response(403)
self.end_headers()
return
if self.path.startswith("/apis/kubevirt"):
self.send_response(404)
self.end_headers()
return
if "continue=" in self.path:
body = {"items": [{"metadata": {"name": "b"}}], "metadata": {}}
else:
body = {"items": [{"metadata": {"name": "a"}}], "metadata": {"continue": "page2"}}
payload = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
with tempfile.TemporaryDirectory() as tmp:
token = os.path.join(tmp, "token")
Path(token).write_text("fixture-token\n")
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), PagedAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
client = inventory.KubeClient("http://127.0.0.1:" + str(server.server_address[1]), token, os.devnull)
items = client.list_all("/api/v1/pods")
check("pagination follows the continue token with the bearer token", [i["metadata"]["name"] for i in items] == ["a", "b"])
check("collect over real HTTP tolerates VMI 404", inventory.collect(client)["vmis"] == [])
server.shutdown()
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
sys.exit(1 if FAILED else 0)
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1
exec python3 "${HERE}/check.py"
@@ -0,0 +1,395 @@
{
"cronjobs": [
{
"metadata": {
"name": "backup",
"namespace": "ops"
},
"spec": {
"jobTemplate": {
"spec": {
"template": {
"spec": {
"containers": [
{
"image": "ghcr.io/org/backup:1.0",
"name": "job"
}
]
}
}
}
}
}
},
{
"metadata": {
"name": "nightly",
"namespace": "ops"
},
"spec": {
"jobTemplate": {
"spec": {
"template": {
"spec": {
"containers": [
{
"image": "ghcr.io/org/nightly:2.1",
"name": "job"
}
]
}
}
}
}
}
}
],
"jobs": [
{
"metadata": {
"name": "backup-123",
"namespace": "ops",
"ownerReferences": [
{
"controller": true,
"kind": "CronJob",
"name": "backup"
}
]
}
},
{
"metadata": {
"name": "backup-99",
"namespace": "ops",
"ownerReferences": [
{
"controller": true,
"kind": "CronJob",
"name": "backup"
}
]
}
}
],
"nodes": [
{
"metadata": {
"name": "prod-1"
}
},
{
"metadata": {
"name": "prod-2"
}
}
],
"pods": [
{
"metadata": {
"name": "web-1",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "web-5f"
}
]
},
"spec": {
"containers": [
{
"image": "nginx:1.27",
"name": "app"
}
],
"ephemeralContainers": [
{
"image": "busybox:1.37",
"name": "debug"
}
],
"initContainers": [
{
"image": "ghcr.io/org/tool:v2",
"name": "init-db"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker-pullable://docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"name": "app"
}
],
"ephemeralContainerStatuses": [
{
"imageID": "docker.io/library/busybox@sha256:c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
"name": "debug"
}
],
"initContainerStatuses": [
{
"imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
"name": "init-db"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "web-2",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "web-5f"
}
]
},
"spec": {
"containers": [
{
"image": "nginx:1.27",
"name": "app"
}
],
"initContainers": [
{
"image": "ghcr.io/org/tool:v2",
"name": "init-db"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"name": "app"
}
],
"initContainerStatuses": [
{
"imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
"name": "init-db"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "api-1",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "api-77"
}
]
},
"spec": {
"containers": [
{
"image": "nginx:1.27",
"name": "server"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"name": "server"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "db-0",
"namespace": "data",
"ownerReferences": [
{
"controller": true,
"kind": "StatefulSet",
"name": "db"
}
]
},
"spec": {
"containers": [
{
"image": "postgres:16",
"name": "postgres"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
"name": "postgres"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "backup-123-x",
"namespace": "ops",
"ownerReferences": [
{
"controller": true,
"kind": "Job",
"name": "backup-123"
}
]
},
"spec": {
"containers": [
{
"image": "ghcr.io/org/backup:1.0",
"name": "backup"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "ghcr.io/org/backup@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
"name": "backup"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "solo",
"namespace": "ops"
},
"spec": {
"containers": [
{
"image": "alpine:3.20",
"name": "toolbox"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "",
"name": "toolbox"
}
],
"phase": "Pending"
}
},
{
"metadata": {
"name": "done-1",
"namespace": "ops",
"ownerReferences": [
{
"controller": true,
"kind": "Job",
"name": "backup-99"
}
]
},
"spec": {
"containers": [
{
"image": "ghcr.io/org/old-backup:0.1",
"name": "backup"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "ghcr.io/org/old-backup@sha256:f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6",
"name": "backup"
}
],
"phase": "Succeeded"
}
},
{
"metadata": {
"name": "virt-launcher-vm1-abc",
"namespace": "kubevirt-vms",
"ownerReferences": [
{
"controller": true,
"kind": "VirtualMachineInstance",
"name": "vm1"
}
]
},
"spec": {
"containers": [
{
"image": "quay.io/kubevirt/virt-launcher:v1.8",
"name": "compute"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "quay.io/kubevirt/virt-launcher@sha256:abababababababababababababababababababababababababababababababab",
"name": "compute"
}
],
"phase": "Running"
}
}
],
"replicasets": [
{
"metadata": {
"name": "web-5f",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "Deployment",
"name": "web"
}
]
}
},
{
"metadata": {
"name": "api-77",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "Deployment",
"name": "api"
}
]
}
}
],
"vmis": [
{
"metadata": {
"name": "vm1",
"namespace": "kubevirt-vms"
}
}
]
}
@@ -0,0 +1,26 @@
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-page
labels:
conformance.home.k3s/R-VULN-03: "true"
conformance.home.k3s/R-VULN-04: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-03, R-VULN-04"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
# a change to the scanner would select no vuln-scanner test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Given a report fixture, When the page renders, Then all CVEs, containers and workloads appear in all views and hostile input is inert.
spec:
steps:
- name: run-offline-check
description: Run check.sh, the same assertions the offline harness runs.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""R-VULN-03/04: the rendered page. Assertions run against the actual HTML output."""
import base64
import copy
import hashlib
import json
import re
import sys
from datetime import datetime, timedelta, timezone
from html.parser import HTMLParser
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
import page # noqa: E402
FAILED = []
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
REPORT = json.loads((HERE / "fixtures/report.json").read_text())
CLEAN_RE = re.compile(r"(?i)\bclean\b|no vulnerabilit")
def check(name, condition):
print(("ok " if condition else "FAIL ") + name)
if not condition:
FAILED.append(name)
def render(report=REPORT, query="", now=NOW):
status, headers, body = page.render(report, query, now)
return status, headers, body.decode("utf-8")
class Tags(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
self.attrs = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
self.attrs.append((tag, dict(attrs)))
def tags(body):
parser = Tags()
parser.feed(body)
return parser
def covers_everything(body, report):
"""Every fixture CVE id, workload name and container name appears in the document."""
missing = [f["id"] for f in report["findings"] if f["id"] not in body]
for target in report["targets"]:
for workload in target["workloads"]:
if workload["name"] not in body:
missing.append(workload["name"])
missing.extend(c for c in workload["containers"] if c not in body)
return missing
def workload_view_covers(body, report):
"""Every workload row with its containers, and every CVE in the expansions."""
missing = covers_everything(body, report)
return missing
for view in ("cves", "workloads", "inventory"):
status, headers, body = render(query="view=" + view)
check(view + " view returns 200", status == 200)
check(view + " view has the Content-Security-Policy with no script source", "script-src" not in headers["Content-Security-Policy"]
and headers["Content-Security-Policy"].startswith("default-src 'none'"))
check(view + " view sets nosniff", headers["X-Content-Type-Options"] == "nosniff")
check(view + " view has no script element", "script" not in tags(body).tags)
style_body = re.search(r"<style>(.*?)</style>", body, re.S).group(1)
expected = base64.b64encode(hashlib.sha256(style_body.encode()).digest()).decode()
check(view + " view's CSP hash matches its inline style", "'sha256-" + expected + "'" in headers["Content-Security-Policy"])
check(view + " view header shows every state count", all(state + ": <strong>" in body for state in page.report_mod.STATES))
check(view + " view shows a coverage warning", "INCOMPLETE COVERAGE" in body)
check(view + " view makes no clean claim with unscanned targets", not CLEAN_RE.search(body))
status, headers, cves = render(query="view=cves")
check("CVE view contains every CVE", all(f["id"] in cves for f in REPORT["findings"]))
affected = {f["targetId"] for f in REPORT["findings"]}
expected_in_cves = {(t["id"], w["name"]) for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"]}
check("CVE view expansions name every workload of an affected target", all(name in cves for _, name in expected_in_cves))
check("CVE view expansions name every container of an affected target",
all(c in cves for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"] for c in w["containers"]))
for name in ("web", "api", "app", "server"):
check("CVE view expansion names " + name, name in cves)
status, headers, workloads = render(query="view=workloads")
check("workloads view contains every workload and container, including zero-finding ones", covers_everything(workloads, REPORT) == [])
status, headers, inventory = render(query="view=inventory")
check("inventory view contains every target", all(t["id"] in inventory for t in REPORT["targets"]))
check("inventory view contains every workload and container", covers_everything(inventory, REPORT) == [])
mutated = copy.deepcopy(REPORT)
mutated["findings"] = [f for f in mutated["findings"] if f["id"] != "CVE-2026-1003"]
check("mutation: the coverage evaluator catches a dropped CVE", "CVE-2026-1003" in covers_everything(render(mutated, "view=workloads")[2], REPORT))
_, _, body = render(query="view=cves")
check("hostile title is escaped", "&lt;script&gt;alert(1)&lt;/script&gt;" in body)
check("hostile title is never live", "<script>" not in body)
hostile = copy.deepcopy(REPORT)
hostile["findings"][0]["url"] = "javascript:alert(1)"
hostile["findings"][0]["title"] = '"><img src=x onerror=alert(1)>'
hostile["targets"][0]["reason"] = "<b onmouseover=alert(1)>x</b>"
hostile["remediation"][0]["url"] = "javascript:evil()"
_, _, body = render(hostile, "view=cves")
parsed = tags(body)
check("a javascript: advisory URL never becomes a link", not any("javascript:" in (a.get("href") or "") for t, a in parsed.attrs if t == "a"))
check("no element carries a hostile inline handler", not any(k.startswith("on") for _, a in parsed.attrs for k in a))
check("no img element was injected", "img" not in parsed.tags)
_, _, body = render(hostile, "view=inventory")
check("hostile reason is escaped in inventory", "<b onmouseover" not in body and "&lt;b onmouseover" in body)
_, _, body = render(query="view=cves&q=" + "%3Cscript%3E")
check("a hostile search term is escaped", "<script>" not in body)
check("a linked CVE points at its advisory", 'href="https://example.test/CVE-2026-1001"' in render(query="view=cves")[2])
stale = copy.deepcopy(REPORT)
stale["generatedAt"] = (NOW - timedelta(hours=27)).strftime("%Y-%m-%dT%H:%M:%SZ")
_, _, body = render(stale)
check("stale report shows a warning", "STALE REPORT" in body)
check("stale report shows no normal state", "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
check("mutation: a fresh report has no stale warning", "STALE REPORT" not in render(REPORT)[2])
degraded = copy.deepcopy(REPORT)
degraded["run"]["status"] = "degraded"
_, _, body = render(degraded)
check("degraded run shows a warning and no normal state", "DEGRADED RUN" in body and "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
allgood = copy.deepcopy(REPORT)
allgood["targets"] = [t for t in allgood["targets"] if t["state"] == "scanned"]
allgood["findings"] = []
_, _, body = render(allgood)
check("the clean claim appears when every target is scanned with zero findings", "Clean: all 1 targets" in body)
check("mutation: an all-scanned report shows the current state", "Status: CURRENT" in body)
one_pending = copy.deepcopy(allgood)
one_pending["targets"].append({"id": "x@sha256:" + "0" * 64, "type": "image", "image": "x", "digest": "sha256:" + "0" * 64,
"state": "pending", "reason": None, "lastScannedAt": None, "workloads": []})
_, _, body = render(one_pending)
check("one pending target removes the clean claim", "Clean:" not in body and not CLEAN_RE.search(body))
for state in ("failed", "stale", "credential-required", "by-tag", "not-covered"):
variant = copy.deepcopy(allgood)
variant["targets"].append({**one_pending["targets"][-1], "state": state})
check("a " + state + " target removes the clean claim", not CLEAN_RE.search(render(variant)[2]))
empty = copy.deepcopy(allgood)
empty["targets"] = []
check("zero targets is never a clean claim", not CLEAN_RE.search(render(empty)[2]))
degraded_clean = copy.deepcopy(allgood)
degraded_clean["run"]["status"] = "degraded"
check("a degraded run never makes a clean claim", not CLEAN_RE.search(render(degraded_clean)[2]))
status, headers, body = render(None)
check("no report yet is a 503 with the CSP", status == 503 and "Content-Security-Policy" in headers)
check("no report yet makes no clean claim", not CLEAN_RE.search(body))
_, _, body = render(query="view=cves&severity=CRITICAL")
check("the severity filter narrows the list", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
check("a filter is carried in pagination and sort links", "severity=CRITICAL" in body)
_, _, body = render(query="view=cves&status=accepted")
check("accepted findings stay listed with their reason", "CVE-2026-1003" in body and "not reachable" in body)
_, _, body = render(query="view=cves&fix=no")
check("the fix filter excludes fixed CVEs", "CVE-2026-1001" not in body and "CVE-2026-1002" in body)
_, _, body = render(query="view=inventory&status=credential-required")
check("inventory filters on coverage state", "ghcr.io/org/private" in body and "postgres" not in body.split("<main>")[1])
_, _, body = render(query="view=workloads&namespace=ops")
check("the namespace filter narrows workloads", "nightly" in body and "<td>web</td>" not in body)
_, _, body = render(query="view=cves&q=openssl")
check("free text search matches packages", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
_, _, body = render()
check("default view excludes nothing", all(f["id"] in body for f in REPORT["findings"]))
check("remediation column shows pr-open with a link", 'href="https://gitea.test/pulls/1"' in body and "pr-open" in body)
# Status and state belong to the line they describe (R-VULN-04).
images = [t for t in REPORT["targets"] if t["type"] == "image" and t["workloads"]]
check("fixture has two image targets with workloads", len(images) >= 2)
mixed = copy.deepcopy(REPORT)
first, second = next(t for t in mixed["targets"] if t["id"] == images[0]["id"]), next(t for t in mixed["targets"] if t["id"] == images[1]["id"])
first["state"], first["reason"] = "scanned", None
second["state"], second["reason"] = "stale", "last success long ago"
base = dict(REPORT["findings"][0], id="CVE-MIXED")
base.pop("acceptance", None)
mixed["findings"] = [
dict(base, targetId=first["id"], status="accepted", acceptance={"reason": "only-on-the-first", "expires": "2026-12-01"}),
dict(base, targetId=second["id"], status="fix-available"),
]
_, _, body = render(mixed, "view=cves&q=CVE-MIXED")
check("a mixed CVE reads as mixed, with counts", "mixed: 1 fix-available, 1 accepted" in body)
check("a mixed CVE's row does not carry one image's acceptance", "<td>mixed: 1 fix-available, 1 accepted</td>" in body)
first_line = page.e(first["id"]) + " — target scanned — status accepted (only-on-the-first, until 2026-12-01)"
second_line = page.e(second["id"]) + " — target stale (last success long ago) — status fix-available"
check("the accepted image's line carries its own acceptance", first_line in body)
check("the other image's line carries its own status and state", second_line in body)
check("no line attaches the acceptance to the unaccepted status", "status fix-available (only-on-the-first" not in body)
check("workload lines inside the expansion carry state and status",
"target stale (last success long ago) — status fix-available</li>" in body
and "containers: " + page.e(", ".join(second["workloads"][0]["containers"]) or "none") + " — target stale" in body)
_, _, plain = render(query="view=cves&status=accepted")
check("mutation: a uniformly accepted CVE does not read as mixed", "mixed:" not in plain and "CVE-2026-1003" in plain)
_, _, body = render(mixed, "view=workloads")
check("the workloads images column labels each image's state", page.e(second["id"]) + " — target stale (last success long ago)" in body)
check("the workloads expansion labels each finding's status, image and state",
"status fix-available — " + page.e(second["id"]) + " — target stale (last success long ago)</li>" in body)
check("the workloads expansion keeps an acceptance on its own line",
"status accepted (only-on-the-first, until 2026-12-01) — " + page.e(first["id"]) + " — target scanned</li>" in body)
big = copy.deepcopy(REPORT)
big["findings"] = [dict(REPORT["findings"][0], id="CVE-2026-" + format(i, "05d")) for i in range(250)]
_, _, body = render(big, "view=cves&size=50&page=2")
check("pagination states the total", "of 250 CVEs (page 2 of 5)" in body)
check("pagination shows only its page", "CVE-2026-00049" not in body and "CVE-2026-00050" in body and "CVE-2026-00100" not in body)
check("every page's links include next and previous", ">next<" in body and ">previous<" in body)
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
sys.exit(1 if FAILED else 0)
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1
exec python3 "${HERE}/check.py"
@@ -0,0 +1,199 @@
{
"database": {
"updatedAt": "2026-11-09T06:00:00Z",
"version": "2"
},
"findings": [
{
"cvss": 9.1,
"firstSeenAt": "2026-11-01T00:00:00Z",
"fixedVersion": "3.0.2",
"id": "CVE-2026-1001",
"installedVersion": "1.0",
"package": "openssl",
"severity": "CRITICAL",
"status": "fix-available",
"targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"title": "OpenSSL flaw",
"url": "https://example.test/CVE-2026-1001"
},
{
"cvss": null,
"firstSeenAt": "2026-11-01T00:00:00Z",
"fixedVersion": null,
"id": "CVE-2026-1002",
"installedVersion": "1.0",
"package": "zlib",
"severity": "LOW",
"status": "open",
"targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"title": "<script>alert(1)</script>",
"url": "https://example.test/CVE-2026-1002"
},
{
"acceptance": {
"expires": "2027-01-01",
"reason": "not reachable"
},
"cvss": null,
"firstSeenAt": "2026-11-01T00:00:00Z",
"fixedVersion": "16.2",
"id": "CVE-2026-1003",
"installedVersion": "1.0",
"package": "libpq",
"severity": "HIGH",
"status": "accepted",
"targetId": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
"title": "libpq flaw (retained from a prior scan)",
"url": "https://example.test/CVE-2026-1003"
},
{
"cvss": null,
"firstSeenAt": "2026-11-01T00:00:00Z",
"fixedVersion": null,
"id": "CVE-2026-1004",
"installedVersion": "1.0",
"package": "curl",
"severity": "MEDIUM",
"status": "open",
"targetId": "ghcr.io/org/nightly:2.1",
"title": "curl flaw",
"url": "https://example.test/CVE-2026-1004"
}
],
"generatedAt": "2026-11-09T10:00:00Z",
"remediation": [
{
"image": "docker.io/library/nginx",
"state": "pr-open",
"updatedAt": "2026-11-09T10:00:00Z",
"url": "https://gitea.test/pulls/1"
}
],
"run": {
"finishedAt": "2026-11-09T09:30:00Z",
"startedAt": "2026-11-09T09:00:00Z",
"status": "complete"
},
"schemaVersion": 1,
"targets": [
{
"digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"id": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"image": "docker.io/library/nginx",
"lastScannedAt": "2026-11-09T10:00:00Z",
"reason": null,
"state": "scanned",
"type": "image",
"workloads": [
{
"containers": [
"app"
],
"kind": "Deployment",
"name": "web",
"namespace": "apps"
},
{
"containers": [
"server"
],
"kind": "Deployment",
"name": "api",
"namespace": "apps"
}
]
},
{
"digest": "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
"id": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
"image": "docker.io/library/postgres",
"lastScannedAt": "2026-11-07T09:00:00Z",
"reason": "trivy timed out",
"state": "failed",
"type": "image",
"workloads": [
{
"containers": [
"postgres"
],
"kind": "StatefulSet",
"name": "db",
"namespace": "data"
}
]
},
{
"digest": "sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
"id": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
"image": "ghcr.io/org/private",
"lastScannedAt": null,
"reason": "registry ghcr.io refused anonymous access",
"state": "credential-required",
"type": "image",
"workloads": [
{
"containers": [
"priv"
],
"kind": "Deployment",
"name": "priv",
"namespace": "apps"
}
]
},
{
"digest": null,
"id": "ghcr.io/org/nightly:2.1",
"image": "ghcr.io/org/nightly",
"lastScannedAt": "2026-11-09T10:00:00Z",
"reason": null,
"state": "by-tag",
"type": "image",
"workloads": [
{
"containers": [
"job"
],
"kind": "CronJob",
"name": "nightly",
"namespace": "ops"
}
]
},
{
"digest": null,
"id": "vm/kubevirt-vms/vm1",
"image": "",
"lastScannedAt": null,
"reason": "guest OS packages are not assessed by this scanner",
"state": "not-covered",
"type": "vm",
"workloads": [
{
"containers": [],
"kind": "VirtualMachineInstance",
"name": "vm1",
"namespace": "kubevirt-vms"
}
]
},
{
"digest": null,
"id": "node/prod-1",
"image": "",
"lastScannedAt": null,
"reason": "node OS packages and the k3s binary are not assessed by this scanner",
"state": "not-covered",
"type": "node",
"workloads": [
{
"containers": [],
"kind": "Node",
"name": "prod-1",
"namespace": ""
}
]
}
]
}
@@ -0,0 +1,27 @@
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-scanner-scan-loop
labels:
conformance.home.k3s/R-VULN-02: "true"
conformance.home.k3s/R-VULN-03: "true"
conformance.home.k3s/R-VULN-05: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
# a change to the scanner would select no vuln-scanner test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Given a fake Trivy runner and clock, When ticks run, Then shared digests scan once, failures isolate, and coverage states and first-seen persist.
spec:
steps:
- name: run-offline-check
description: Run check.sh, the same assertions the offline harness runs.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""R-VULN-02/03/05: scan loop semantics with a fake Trivy runner and a controllable clock."""
import json
import logging
import os
import sys
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
import main # noqa: E402
import report # noqa: E402
import trivy # noqa: E402
logging.disable(logging.CRITICAL)
FAILED = []
A1 = "sha256:" + "a1" * 32
D4 = "sha256:" + "d4" * 32
E5 = "sha256:" + "e5" * 32
NGINX = "docker.io/library/nginx@" + A1
POSTGRES = "docker.io/library/postgres@" + D4
PRIVATE = "ghcr.io/org/private@" + E5
NIGHTLY = "ghcr.io/org/nightly:2.1"
CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text())
def fixture(name):
return json.loads((HERE / "fixtures" / name).read_text())
def check(name, condition):
print(("ok " if condition else "FAIL ") + name)
if not condition:
FAILED.append(name)
class Clock:
def __init__(self):
self.now = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
def __call__(self):
return self.now
def advance(self, **delta):
self.now += timedelta(**delta)
class FakeClient:
def __init__(self, cluster):
self.cluster = cluster
self.fail = False
def list_all(self, path):
if self.fail:
raise RuntimeError("api down")
for name, item in (("pods", "/api/v1/pods"), ("replicasets", "replicasets"), ("jobs", "/jobs"),
("cronjobs", "cronjobs"), ("nodes", "/nodes"), ("vmis", "virtualmachineinstances")):
if path.endswith(item):
return self.cluster[name]
return []
class FakeRunner:
"""Recorded results, no Trivy binary."""
def __init__(self):
self.scans = []
self.outcomes = {
NGINX: fixture("trivy-nginx.json"), POSTGRES: fixture("trivy-postgres.json"),
NIGHTLY: fixture("trivy-nightly.json"),
PRIVATE: trivy.classify_failure("ghcr.io", "FATAL: GET https://ghcr.io/: UNAUTHORIZED: authentication required"),
}
self.db_age_hours = 1
self.db_fails = False
self.clock = None
def download_db(self):
if self.db_fails:
raise trivy.ScanFailure("failed", "database update failed")
def db_info(self):
return {"updatedAt": (self.clock() - timedelta(hours=self.db_age_hours)).strftime("%Y-%m-%dT%H:%M:%SZ"), "version": "2"}
def scan(self, reference):
self.scans.append(reference)
outcome = self.outcomes[reference]
if isinstance(outcome, Exception):
raise outcome
return outcome
def clean_cache(self):
pass
def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER, private=()):
path = os.path.join(tmp, "acceptances.yaml")
if not os.path.exists(path):
Path(path).write_text(acc)
clock = clock or Clock()
runner = runner or FakeRunner()
runner.clock = clock
return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0, private_registries=private), runner, clock
def targets(eng):
return {t["id"]: t for t in eng.report["targets"]}
def findings(eng, target_id):
return [f for f in eng.report["findings"] if f["targetId"] == target_id]
def one_scan_per_digest(scans):
return len(scans) == len(set(scans))
with tempfile.TemporaryDirectory() as tmp:
eng, runner, clock = engine(tmp)
eng.tick()
t = targets(eng)
check("two workloads sharing a digest produce exactly one scan", runner.scans.count(NGINX) == 1 and one_scan_per_digest(runner.scans))
check("mutation: the digest-uniqueness evaluator rejects a double scan", not one_scan_per_digest(["a", "a"]))
check("the shared digest maps back to both workloads",
{(w["kind"], w["name"]) for w in t[NGINX]["workloads"]} == {("Deployment", "web"), ("Deployment", "api")})
check("shared digest findings appear once per target", len(findings(eng, NGINX)) == 2)
check("digest target is scanned", t[NGINX]["state"] == "scanned")
check("idle CronJob template is by-tag after scanning, not scanned", t[NIGHTLY]["state"] == "by-tag")
check("node is not-covered", t["node/prod-1"]["state"] == "not-covered")
check("report is schema-valid", report.validate(eng.report) == [])
check("credential-required is its own state, not failed", t[PRIVATE]["state"] == "credential-required")
check("credential-required reason names the registry", "ghcr.io" in t[PRIVATE]["reason"])
check("credential-required does not count toward failed_over_24h", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0)
check("report is written to disk", json.loads(Path(tmp, "report.json").read_text())["generatedAt"])
first_seen = {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)}
clock.advance(hours=21)
runner.outcomes[POSTGRES] = trivy.ScanFailure("failed", "pull error: manifest unknown")
eng.tick()
t = targets(eng)
check("a failed image leaves the run continuing", runner.scans.count(NGINX) == 2 and runner.scans.count(NIGHTLY) == 2)
check("the failed target is failed", t[POSTGRES]["state"] == "failed")
check("the failed target's reason is shown", "manifest unknown" in t[POSTGRES]["reason"])
check("the failed target keeps its prior findings", [f["id"] for f in findings(eng, POSTGRES)] == ["CVE-2026-0010"])
check("other targets stay scanned", t[NGINX]["state"] == "scanned")
check("first-seen survives a re-scan", {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)} == first_seen)
check("run with one failed image is still complete (failure is per-target)", eng.report["run"]["status"] == "complete")
clock.advance(hours=25)
check("failed target is counted over 24h after 25h", report.failed_over_24h(eng.state, clock()) == 1)
eng.publish()
check("a target whose last success aged past 24h is stale", targets(eng)[NGINX]["state"] == "stale")
check("stale target keeps findings", len(findings(eng, NGINX)) == 2)
# Restart: a new Engine loads everything from disk.
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
check("report survives restart", eng2.report is not None and eng2.report_bytes() is not None)
check("last complete run survives restart", eng2.state["lastCompleteRunAt"] is not None)
eng2.publish()
check("first-seen survives restart", {f["id"]: f["firstSeenAt"] for f in findings(eng2, NGINX)} == first_seen)
with tempfile.TemporaryDirectory() as tmp:
# Not-scanned is never zero findings: the first tick has not scanned anything yet.
eng, runner, clock = engine(tmp)
eng.refresh_inventory()
eng.publish()
t = targets(eng)
check("before scanning, digest targets are pending", t[NGINX]["state"] == "pending" and t[POSTGRES]["state"] == "pending")
check("run object exists and is degraded before the first run", eng.report["run"]["status"] == "degraded")
check("no fabricated findings before scanning", eng.report["findings"] == [])
check("zero findings report still counts every target", len(eng.report["targets"]) == 5)
clock.advance(hours=3)
eng.publish()
t = targets(eng)
check("pending beyond 2h becomes failed, never silently absent", t[NGINX]["state"] == "failed")
check("failed reason is the contract's text", t[NGINX]["reason"] == report.NOT_SCANNED_REASON)
check("a never-scanned failed target has lastScannedAt null", t[NGINX]["lastScannedAt"] is None)
clock.advance(hours=24)
check("never-scanned target counts as failed over 24h", report.failed_over_24h(eng.state, clock()) >= 1)
with tempfile.TemporaryDirectory() as tmp:
# Database problems degrade the run but it still reports.
eng, runner, clock = engine(tmp)
runner.db_fails = True
eng.tick()
check("DB update failure degrades the run", eng.report["run"]["status"] == "degraded")
check("DB update failure still scans and reports", runner.scans and eng.report["findings"])
check("a degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None)
with tempfile.TemporaryDirectory() as tmp:
eng, runner, clock = engine(tmp)
runner.db_age_hours = 49
eng.tick()
check("a DB older than 48h degrades the run", eng.report["run"]["status"] == "degraded")
runner.db_age_hours = 1
clock.advance(hours=21)
eng.tick()
check("mutation: a fresh DB gives a complete run", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"])
with tempfile.TemporaryDirectory() as tmp:
# A target leaving the inventory leaves the report, with its findings.
eng, runner, clock = engine(tmp)
eng.tick()
check("postgres is present first", POSTGRES in targets(eng))
gone = dict(CLUSTER)
gone["pods"] = [p for p in CLUSTER["pods"] if p["metadata"]["name"] != "db-0"]
eng.client.cluster = gone
clock.advance(minutes=15)
eng.tick()
check("a target leaving the inventory leaves the report", POSTGRES not in targets(eng))
check("its findings go with it", not findings(eng, POSTGRES))
with tempfile.TemporaryDirectory() as tmp:
# Unexpected exceptions are contained at each level.
eng, runner, clock = engine(tmp)
runner.outcomes[POSTGRES] = RuntimeError("boom")
eng.tick()
check("an unexpected scan exception fails only that target", targets(eng)[POSTGRES]["state"] == "failed")
check("the run continued past it", runner.scans.count(NIGHTLY) == 1)
eng.client.fail = True
clock.advance(minutes=15)
eng.tick()
check("an inventory failure keeps the previous inventory", NGINX in targets(eng))
check("an inventory failure degrades the report", eng.report["run"]["status"] == "degraded")
calls = []
def crashing_tick():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("cycle crash")
eng.stop.set()
eng.tick = crashing_tick
eng.tick_seconds = 0
thread = threading.Thread(target=eng.run_forever, daemon=True)
thread.start()
thread.join(10)
check("a crash in one cycle is logged and the loop continues", len(calls) == 2 and not thread.is_alive())
class Killed(BaseException):
"""What an OOM kill looks like from inside the process: no `except Exception` runs."""
class KillingRunner(FakeRunner):
def __init__(self, victim, data_dir):
super().__init__()
self.victim = victim
self.data_dir = data_dir
self.on_disk = None
def scan(self, reference):
if reference == self.victim:
self.on_disk = json.loads(Path(self.data_dir, "state.json").read_text()).get("inFlight")
raise Killed()
return super().scan(reference)
with tempfile.TemporaryDirectory() as tmp:
# R-VULN-05 BLOCKER: a scan that kills the container must cost that image only.
eng, runner, clock = engine(tmp, runner=KillingRunner(POSTGRES, tmp))
killed = False
try:
eng.tick()
except Killed:
killed = True
check("kill: the simulated OOM really aborted the process path", killed)
check("kill: inFlight is on disk BEFORE the scan starts", runner.on_disk == D4)
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
check("kill: startup clears inFlight", eng2.state["inFlight"] is None)
check("kill: clearing is persisted", json.loads(Path(tmp, "state.json").read_text())["inFlight"] is None)
eng2.publish()
t = targets(eng2)
check("kill: the target is failed", t[POSTGRES]["state"] == "failed")
check("kill: the reason is the killed-scan text", t[POSTGRES]["reason"] == main.KILLED_REASON)
eng2.tick()
t = targets(eng2)
check("kill: images after the killed one get scanned", PRIVATE in runner2.scans and t[PRIVATE]["state"] == "credential-required")
check("kill: the killed image is not retried on restart (no wedge)", POSTGRES not in runner2.scans)
check("kill: the killed target stays failed with its reason", t[POSTGRES]["reason"] == main.KILLED_REASON)
check("kill: inFlight is clear after a normal cycle", eng2.state["inFlight"] is None)
clock.advance(hours=21)
eng2.tick()
check("kill: the killed image is retried after the sit-out", runner2.scans.count(POSTGRES) == 1)
check("kill: a successful retry clears the failure", targets(eng2)[POSTGRES]["state"] == "scanned")
check("kill: a successful retry clears killedAt", eng2.state["scans"][D4]["killedAt"] is None)
with tempfile.TemporaryDirectory() as tmp:
eng, runner, clock = engine(tmp)
eng.refresh_inventory()
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
eng2.publish()
check("mutation: a restart with no inFlight fails nothing", targets(eng2)[POSTGRES]["state"] == "pending")
with tempfile.TemporaryDirectory() as tmp:
# A registry the scanner cannot reach is credential-required, and Trivy is never invoked.
eng, runner, clock = engine(tmp, private=[" GHCR.io ", ""])
eng.tick()
t = targets(eng)
check("private registry: reported credential-required", t[PRIVATE]["state"] == "credential-required")
check("private registry: the reason names the registry", "ghcr.io" in t[PRIVATE]["reason"])
check("private registry: Trivy is never invoked for it", PRIVATE not in runner.scans and NIGHTLY not in runner.scans)
check("private registry: other registries still scan", NGINX in runner.scans)
check("private registry: not counted as failed", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0)
with tempfile.TemporaryDirectory() as tmp:
eng, runner, clock = engine(tmp)
eng.tick()
check("mutation: with no private registry configured Trivy is invoked", PRIVATE in runner.scans)
with tempfile.TemporaryDirectory() as tmp:
# A corrupt state.json degrades the run that rebuilds from it, not every run thereafter.
Path(tmp, "state.json").write_text("{not json")
eng, runner, clock = engine(tmp)
check("loadError: set after a corrupt state.json", eng.state["loadError"])
eng.tick()
check("loadError: the rebuilding run is degraded", eng.report["run"]["status"] == "degraded")
check("loadError: cleared after a full run with a good inventory", eng.state["loadError"] is None)
clock.advance(hours=21)
eng.tick()
check("loadError: the next full run is complete", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"])
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "state.json").write_text("{not json")
eng, runner, clock = engine(tmp)
eng.client.fail = True
eng.tick()
check("mutation: loadError is kept when the inventory failed", eng.state["loadError"])
with tempfile.TemporaryDirectory() as tmp:
# lastCompleteRunAt means a report reached the store; a refused report must not advance it.
eng, runner, clock = engine(tmp)
real_publish = report.publish
report.publish = lambda *_: False
try:
eng.tick()
finally:
report.publish = real_publish
check("a refused publish does not advance lastCompleteRunAt", eng.state["lastCompleteRunAt"] is None)
clock.advance(hours=21)
eng.tick()
check("mutation: a successful publish does advance it", eng.state["lastCompleteRunAt"] is not None)
with tempfile.TemporaryDirectory() as tmp:
# One upstream string costs that string. Each of these used to freeze the whole report.
long_reason = "x" * 3000
eng, runner, clock = engine(tmp, acc="- id: CVE-2026-0010\n reason: " + long_reason + "\n expires: 2026-12-01\n")
runner.outcomes[NGINX] = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
{"VulnerabilityID": "CVE-UP", "Severity": "HIGH", "PkgName": "p", "InstalledVersion": "1",
"PrimaryURL": "HTTPS://upper.example/x", "Title": "t\ud800"}]}]}
eng.tick()
check("hostile strings: the report still publishes", json.loads(Path(tmp, "report.json").read_text())["findings"] and report.validate(eng.report) == [])
up = [f for f in findings(eng, NGINX) if f["id"] == "CVE-UP"][0]
check("hostile strings: an uppercase scheme drops only the URL", up["url"] == "")
check("hostile strings: a lone surrogate is removed", "\ud800" not in up["title"])
check("hostile strings: an over-long acceptance is ignored, the finding stays open", [f for f in findings(eng, POSTGRES) if f["id"] == "CVE-2026-0010"][0]["status"] != "accepted")
check("hostile strings: an over-long acceptance degrades the run", eng.report["run"]["status"] == "degraded")
check("hostile strings: the degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None)
with tempfile.TemporaryDirectory() as tmp:
# Isolation: values already in state, whatever put them there, cannot refuse the whole report.
eng, runner, clock = engine(tmp)
eng.tick()
clean = report.build(eng.state, [], [], clock())
check("mutation: a clean state is not reported as repaired", clean["run"]["status"] == "complete" and clean == report.build(eng.state, [], [], clock()))
record = eng.state["scans"][A1]
record["findings"][0].update(url="HTTP://x", title="t" * 900, severity="weird", cvss=99, package="p" * 900)
eng.state["database"] = {"updatedAt": "not-a-time", "version": "1"}
eng.state["remediation"] = [{"image": "a/b", "state": "pr-open", "url": "HTTP://x", "updatedAt": "2026-11-09T00:00:00Z"}]
target = next(i for i in eng.state["inventory"] if i["id"] == NIGHTLY)
target["id"] = "ghcr.io/" + "o" * 3000 + ":1"
target["image"] = "ghcr.io/" + "o" * 3000
eng.state["scans"][target["id"]] = eng.state["scans"].pop(NIGHTLY) # a by-tag unit is keyed by its id
built = report.build(eng.state, [], [], clock())
check("isolation: the repaired report is schema-valid", report.validate(built) == [])
check("isolation: a repair degrades the run", built["run"]["status"] == "degraded")
bad = [f for f in built["findings"] if f["targetId"] == NGINX and f["title"] == "t" * 512][0]
check("isolation: the bad finding survives with sanitised fields",
bad["url"] == "" and bad["severity"] == "UNKNOWN" and bad["cvss"] is None and len(bad["package"]) == 256)
check("isolation: every other finding is untouched", len(built["findings"]) == len(clean["findings"]))
check("isolation: a bad remediation loses only its url", built["remediation"][0]["url"] is None)
check("isolation: an unusable database is reported as unavailable", built["database"]["version"] == "unavailable")
long_ids = [t for t in built["targets"] if len(t["id"]) > 2000]
check("isolation: an over-long target stays listed", len(long_ids) == 1 and len(long_ids[0]["id"]) <= 2048)
check("isolation: findings follow the shortened target id",
any(f["targetId"] == long_ids[0]["id"] for f in built["findings"]))
class FakeProcess:
returncode = 0
def __init__(self, stdout):
self.stdout = stdout
class VersionRunner(trivy.Runner):
def __init__(self, stdout):
super().__init__("/nonexistent", "/nonexistent")
self.stdout = stdout
def _run(self, args, timeout):
return FakeProcess(self.stdout)
def db_info(updated, version):
return VersionRunner(json.dumps({"VulnerabilityDB": {"UpdatedAt": updated, "Version": version}})).db_info()
check("db_info rejects an unparseable timestamp", db_info("yesterday", "2") is None)
check("db_info rejects a non-string timestamp", db_info(5, "2") is None)
check("db_info bounds the version", len(db_info("2026-11-09T06:13:52.123456789Z", "v" * 500)["version"]) == 128)
check("mutation: db_info keeps a good database", db_info("2026-11-09T06:13:52Z", "2") == {"updatedAt": "2026-11-09T06:13:52Z", "version": "2"})
check("a credential failure's reason is bounded", len(trivy.classify_failure("r" * 3000, "401 unauthorized").reason) <= 1024)
for url, want in (("https://a.test/x", "https://a.test/x"), ("http://a.test/", "http://a.test/"), ("HTTPS://a.test/x", ""),
("HTTP://a.test/x", ""), ("Https://a.test/x", ""), ("javascript:alert(1)", ""), ("", "")):
check("safe_url " + repr(url), trivy.safe_url(url) == want)
check("every url safe_url keeps satisfies the report schema",
report.re.search(report.SCHEMA["properties"]["findings"]["items"]["properties"]["url"]["pattern"], trivy.safe_url("https://a.test/x")))
# Recorded Trivy output through the normaliser: untrusted text.
parsed = trivy.parse_results({"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
{"VulnerabilityID": "CVE-1", "Severity": "weird", "PkgName": "p", "InstalledVersion": "1", "Title": "x\x07y",
"PrimaryURL": "javascript:alert(1)", "CVSS": {"nvd": {"V3Score": 11}}},
{"VulnerabilityID": "CVE-2", "Severity": "HIGH", "PkgName": "q", "InstalledVersion": "1", "FixedVersion": "2",
"PrimaryURL": "https://example.test/x", "CVSS": {"nvd": {"V3Score": 7.5}}},
]}]})
check("unknown severity becomes UNKNOWN", parsed[0]["severity"] == "UNKNOWN")
check("control characters are stripped from upstream text", "\x07" not in parsed[0]["title"])
check("non-http(s) advisory URL becomes empty", parsed[0]["url"] == "")
check("out-of-range CVSS is dropped", parsed[0]["cvss"] is None)
check("http(s) URL is kept", parsed[1]["url"] == "https://example.test/x")
check("a recorded report with no Results is zero findings", trivy.parse_results(fixture("trivy-empty.json")) == [])
try:
trivy.parse_results({"not": "a report"})
check("an unrecognised document is a failure, never zero findings", False)
except trivy.ScanFailure as exc:
check("an unrecognised document is a failure, never zero findings", exc.kind == "failed")
failure = trivy.classify_failure("ghcr.io", "GET failed: 401 Unauthorized")
check("401 is credential-required", failure.kind == "credential-required" and "ghcr.io" in failure.reason)
check("permission denied on local disk is a plain failure", trivy.classify_failure("r", "open /cache/x: permission denied").kind == "failed")
check("a pull timeout is failed, not credential-required", trivy.classify_failure("r", "context deadline exceeded").kind == "failed")
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
sys.exit(1 if FAILED else 0)
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONDONTWRITEBYTECODE=1
exec python3 "${HERE}/check.py"
@@ -0,0 +1,198 @@
{
"cronjobs": [
{
"metadata": {
"name": "nightly",
"namespace": "ops"
},
"spec": {
"jobTemplate": {
"spec": {
"template": {
"spec": {
"containers": [
{
"image": "ghcr.io/org/nightly:2.1",
"name": "job"
}
]
}
}
}
}
}
}
],
"jobs": [],
"nodes": [
{
"metadata": {
"name": "prod-1"
}
}
],
"pods": [
{
"metadata": {
"name": "web-1",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "web-5f"
}
]
},
"spec": {
"containers": [
{
"image": "nginx:1.27",
"name": "app"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"name": "app"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "api-1",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "api-77"
}
]
},
"spec": {
"containers": [
{
"image": "nginx:1.27",
"name": "server"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
"name": "server"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "db-0",
"namespace": "data",
"ownerReferences": [
{
"controller": true,
"kind": "StatefulSet",
"name": "db"
}
]
},
"spec": {
"containers": [
{
"image": "postgres:16",
"name": "postgres"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
"name": "postgres"
}
],
"phase": "Running"
}
},
{
"metadata": {
"name": "priv-1",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "ReplicaSet",
"name": "priv-1a"
}
]
},
"spec": {
"containers": [
{
"image": "ghcr.io/org/private:1",
"name": "priv"
}
]
},
"status": {
"containerStatuses": [
{
"imageID": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
"name": "priv"
}
],
"phase": "Running"
}
}
],
"replicasets": [
{
"metadata": {
"name": "web-5f",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "Deployment",
"name": "web"
}
]
}
},
{
"metadata": {
"name": "api-77",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "Deployment",
"name": "api"
}
]
}
},
{
"metadata": {
"name": "priv-1a",
"namespace": "apps",
"ownerReferences": [
{
"controller": true,
"kind": "Deployment",
"name": "priv"
}
]
}
}
],
"vmis": []
}
@@ -0,0 +1,4 @@
{
"ArtifactName": "x",
"SchemaVersion": 2
}
@@ -0,0 +1,33 @@
{
"ArtifactName": "x",
"Results": [
{
"Target": "t",
"Vulnerabilities": [
{
"CVSS": {
"nvd": {
"V3Score": 9.8
}
},
"FixedVersion": "3.0.2",
"InstalledVersion": "1.0",
"PkgName": "openssl",
"PrimaryURL": "https://example.test/CVE-2026-0001",
"Severity": "CRITICAL",
"Title": "OpenSSL flaw",
"VulnerabilityID": "CVE-2026-0001"
},
{
"InstalledVersion": "1.0",
"PkgName": "zlib",
"PrimaryURL": "https://example.test/CVE-2026-0002",
"Severity": "LOW",
"Title": "t",
"VulnerabilityID": "CVE-2026-0002"
}
]
}
],
"SchemaVersion": 2
}
@@ -0,0 +1,19 @@
{
"ArtifactName": "x",
"Results": [
{
"Target": "t",
"Vulnerabilities": [
{
"InstalledVersion": "1.0",
"PkgName": "curl",
"PrimaryURL": "https://example.test/CVE-2026-0020",
"Severity": "MEDIUM",
"Title": "t",
"VulnerabilityID": "CVE-2026-0020"
}
]
}
],
"SchemaVersion": 2
}
@@ -0,0 +1,25 @@
{
"ArtifactName": "x",
"Results": [
{
"Target": "t",
"Vulnerabilities": [
{
"CVSS": {
"nvd": {
"V3Score": 7.5
}
},
"FixedVersion": "16.2",
"InstalledVersion": "1.0",
"PkgName": "libpq",
"PrimaryURL": "https://example.test/CVE-2026-0010",
"Severity": "HIGH",
"Title": "libpq flaw",
"VulnerabilityID": "CVE-2026-0010"
}
]
}
],
"SchemaVersion": 2
}
@@ -0,0 +1,32 @@
# =============================================================================
# L1 RENDERING — the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07)
# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the
# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks,
# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change,
# by exactly their marker, and every negative row stays byte-identical.
#
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-triage-bot-approval-ticks
labels:
conformance.home.k3s/R-VULN-05: "true"
conformance.home.k3s/R-VULN-07: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
# not attribute to R-VULN-*, so a change to it must still select this test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
A tick is applied only to an approve row for an image with a qualifying finding, and dry
runs, duplicate prevention and heartbeat ordering hold through the whole run.
spec:
steps:
- name: plan-and-assert-ticks
try:
- script:
content: bash ./render-check.sh
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07).
fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected
Dependencies) plus a constructed Pending Approval section, because the live dashboard has none.
Runs the real functions and the real run() over a fake Gitea and a fake scanner.
What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format
across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's
API matches the fake; or anything about the runner's network path to the scanner.
"""
import copy
import importlib.util
import json
import sys
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[3]
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
bot = importlib.util.module_from_spec(spec)
sys.modules["cve_triage"] = bot
spec.loader.exec_module(bot)
RAW = (HERE / "fixtures/report.json").read_bytes()
FIXTURE = json.loads(RAW)
BODY = (HERE / "fixtures/renovate-dashboard.md").read_text()
OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text())
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
GITEA = "https://gitea.example.test"
REPO = "o/r"
VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"
FALLBACK = "http://10.43.0.99:8081"
EXPECTED = {
"renovate/nginx-2.x",
"renovate/ghcr.io-example-app-2.x",
"renovate/example-group",
}
failures = []
def check(name, condition):
print(f"{'ok ' if condition else 'FAIL'} {name}")
if not condition:
failures.append(name)
def fails(function):
try:
function()
except AssertionError:
return True
return False
def raises(function):
try:
function()
except bot.BotError:
return True
return False
def planned(report_doc, body):
report = bot.parse_report(json.dumps(report_doc).encode())
rows = bot.parse_rows(body)
return bot.select_ticks(rows, bot.qualifying_images(report))
def assert_ticks(report_doc, body, expected):
"""The tick plan is exactly `expected`, and writing it changes exactly those markers."""
ticks = planned(report_doc, body)
assert {row.branch for row in ticks} == expected, "tick plan differs"
after = bot.apply_ticks(body, ticks)
want = body
for branch in expected:
line = f" - [ ] <!-- approve-branch={branch} -->"
assert want.count(line) == 1, "expected row not found exactly once"
want = want.replace(line, line.replace("[ ]", "[x]", 1))
assert after == want, "output differs from a marker-only edit"
check("exactly the three qualifying approve rows are ticked, and nothing else changes",
not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
after = bot.apply_ticks(BODY, planned(FIXTURE, BODY))
changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b]
check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after))
for label, needle in {
"rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr",
"approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated",
"approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras",
"approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only",
"approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only",
"approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix",
"approve row with no container package": "approve-branch=renovate/mychart",
"approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge",
"unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched",
"decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy",
}.items():
original = next(line for line in BODY.split("\n") if needle in line)
check(f"untouched: {label}", original in after.split("\n"))
check("untouched: the approve-all row", "- [ ] <!-- approve-all-pending-prs -->" in after)
check("untouched: the already ticked row keeps its mark",
" - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->" in after)
# Non-vacuity: each mutation must make the exact assertion above fail.
def mutated_finding(image, **fields):
doc = copy.deepcopy(FIXTURE)
target = next(t for t in doc["targets"] if t["image"] == image)
finding = next(f for f in doc["findings"] if f["targetId"] == target["id"])
finding.update(fields)
return doc
for label, doc in {
"HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"),
"accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"),
"qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"),
"CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"),
}.items():
check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED)))
mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr")
check("non-vacuity: a rebase row rewritten as approve is then ticked",
fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED)))
real_match = bot.row_matches
bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying)
check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
bot.row_matches = real_match
for raw, want in {
"docker.io/library/nginx": "nginx",
"library/nginx": "nginx",
"nginx:1.27": "nginx",
"Docker.IO/Pihole/Pihole": "pihole/pihole",
"ghcr.io/library/x": "ghcr.io/library/x",
"registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b",
"bad image": "",
"": "",
}.items():
check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want)
ticks = planned(FIXTURE, BODY)
moved = BODY.replace(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->")
check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks)))
crlf = BODY.replace("\n", "\r\n")
crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf))
check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4)
class FakeWorld:
"""A fake Gitea and scanner reached through the transport interface. Records every write."""
def __init__(self, renovate_body=BODY, report=RAW):
self.report = report
self.labels = []
self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}}
self.next_issue = 200
self.writes = []
self.triage = None
self.fail = None
self.dns_down = set()
self.seen_hosts = []
def __call__(self, method, url, headers, body, max_bytes):
parts = urllib.parse.urlsplit(url)
self.seen_hosts.append(parts.netloc)
if parts.netloc in self.dns_down:
raise bot.DnsFailure()
if self.fail and self.fail[0] == method and self.fail[1] in parts.path:
return 500, b""
payload = json.loads(body) if body else None
if parts.netloc != "gitea.example.test":
if method == "GET" and parts.path == "/report.json":
return 200, self.report
if method == "POST" and parts.path == "/triage":
self.writes.append(("POST", "triage"))
self.triage = payload
return 204, b""
return 404, b""
path = parts.path.removeprefix(f"/api/v1/repos/{REPO}")
query = dict(urllib.parse.parse_qsl(parts.query))
page = int(query.get("page", "1"))
if method != "GET":
self.writes.append((method, path))
if method == "GET" and path == "/labels":
return 200, json.dumps(self.labels if page == 1 else []).encode()
if method == "POST" and path == "/labels":
label = {"id": 900 + len(self.labels), "name": payload["name"]}
self.labels.append(label)
return 201, json.dumps(label).encode()
if method == "GET" and path == "/pulls":
return 200, json.dumps(OPEN_PRS if page == 1 else []).encode()
if method == "GET" and path == "/issues":
items = list(self.issues.values())
if "labels" in query:
items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}]
if "q" in query:
items = [i for i in items if query["q"] in i["title"]]
return 200, json.dumps(items if page == 1 else []).encode()
if method == "POST" and path == "/issues":
number = self.next_issue
self.next_issue += 1
names = [x for x in self.labels if x["id"] in payload["labels"]]
self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None}
return 201, json.dumps(self.issues[number]).encode()
if path.startswith("/issues/") and path.endswith("/labels") and method == "POST":
number = int(path.split("/")[2])
self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]]
return 200, b"[]"
if path.startswith("/issues/"):
number = int(path.split("/")[2])
if method == "GET":
return 200, json.dumps(self.issues[number]).encode()
if method == "PATCH":
self.issues[number].update(payload)
return 201, json.dumps(self.issues[number]).encode()
return 404, b""
def execute(world, dry_run=False, fallback=FALLBACK):
cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91}
gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world)
scanner = bot.ScannerClient(VULN, fallback, world)
lines = []
summary = bot.run(cfg, gitea, scanner, NOW, lines.append)
return summary, "\n".join(lines)
def dashboards(world):
return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE]
# Dry run: reads everything, writes nothing, reports the plan as counts.
world = FakeWorld()
summary, text = execute(world, dry_run=True)
check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world))
check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1)
check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY)
NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"}
def leaks(text_value):
return [name for name in NAMES if name.lower() in text_value.lower()]
check("dry-run summary is counts only", not leaks(text))
# Live run.
world = FakeWorld()
summary, text = execute(world)
board = dashboards(world)
check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1)
check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"])
check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text))
check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1)
want_body = BODY
for branch in EXPECTED:
line = f" - [ ] <!-- approve-branch={branch} -->"
want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1))
check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body)
check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage"))
check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z")
for entry in world.triage["remediation"]:
if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES:
check("remediation entry shape", False)
states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]}
for image, want in {
"docker.io/library/nginx": ("approval-requested", None),
"ghcr.io/example/app": ("approval-requested", None),
"ghcr.io/example/grouped": ("approval-requested", None),
"ghcr.io/example/already-ticked": ("approval-requested", None),
"ghcr.io/example/high-only": ("approval-requested", None),
"ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"),
"ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"),
"ghcr.io/example/tracked-clean": ("no-update-identified", None),
"ghcr.io/foo/bar": ("no-update-identified", None),
"ghcr.io/example/untracked": ("not-tracked", None),
"ghcr.io/example/pending-sched": ("not-tracked", None),
}.items():
check(f"remediation for {image}", states.get(image) == want)
check("one remediation entry per image", len(states) == len(FIXTURE["targets"]))
# A second run updates the same issue.
before = len(world.issues)
second = copy.deepcopy(FIXTURE)
second["targets"][-1]["state"] = "stale"
world.report = json.dumps(second).encode()
summary, _ = execute(world)
check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before)
check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"])
check("a second run has nothing left to tick", summary["ticks"] == 0)
# The dashboard issue is found even without its label, and a closed one is reopened.
world = FakeWorld()
world.labels.append({"id": 900, "name": "cve-dashboard"})
world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None}
summary, _ = execute(world)
check("an unlabelled closed dashboard is reused, relabelled and reopened",
len(dashboards(world)) == 1 and world.issues[150]["state"] == "open"
and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0)
world = FakeWorld()
world.labels.append({"id": 900, "name": "cve-dashboard"})
for number in (150, 151):
world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None}
check("two dashboard issues fail the run", raises(lambda: execute(world)))
check("two dashboard issues write nothing", world.writes == [])
# Failures end the run before the heartbeat.
world = FakeWorld()
world.fail = ("PATCH", "/issues/91")
check("a failed tick PATCH fails the run", raises(lambda: execute(world)))
check("a failed run posts no heartbeat", world.triage is None)
world = FakeWorld(report=b"{not json")
check("an unreadable report fails the run", raises(lambda: execute(world)))
check("an unreadable report writes nothing", world.writes == [])
world = FakeWorld()
world.issues[91]["title"] = "Something else"
check("issue 91 being something else fails the run", raises(lambda: execute(world)))
world = FakeWorld()
world.issues[91]["state"] = "closed"
check("a closed Renovate dashboard fails the run", raises(lambda: execute(world)))
# DNS fallback, and nothing but DNS failure selects it.
world = FakeWorld()
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
summary, _ = execute(world)
check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None)
check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts)
world = FakeWorld()
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback="")))
def refused(method, url, headers, body, max_bytes):
raise bot.BotError("GET /report.json: ConnectionRefusedError")
check("a refused connection fails the run and does not fall back",
raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report()))
for label, env, expected_ok in (
("missing token", {}, False),
("present token", {"RENOVATE_TOKEN": "x"}, True),
("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False),
("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False),
):
try:
bot.load_config(env)
ok = True
except bot.BotError:
ok = False
check(f"load_config: {label}", ok is expected_ok)
print(f"{len(failures)} failure(s)")
sys.exit(1 if failures else 0)
@@ -0,0 +1,5 @@
[
{"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"},
{"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"},
{"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"}
]
@@ -0,0 +1,71 @@
This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more.
## Pending Approval
The following branches are pending approval. To create them, click on a checkbox below.
- [ ] <!-- approve-branch=renovate/nginx-2.x -->chore(deps): Update nginx Docker tag to v2
- [ ] <!-- approve-branch=renovate/ghcr.io-example-app-2.x -->chore(deps): Update ghcr.io/example/app Docker tag to v2
- [ ] <!-- approve-branch=renovate/example-group -->chore(deps): Update example group (`ghcr.io/example/grouped`, `ghcr.io/example/other`)
- [ ] <!-- approve-branch=renovate/ghcr.io-foo-bar-extras-2.x -->chore(deps): Update ghcr.io/foo/bar-extras Docker tag to v2
- [ ] <!-- approve-branch=renovate/ghcr.io-other-unrelated-2.x -->chore(deps): Update ghcr.io/other/unrelated Docker tag to v2
- [ ] <!-- approve-branch=renovate/ghcr.io-example-high-only-2.x -->chore(deps): Update ghcr.io/example/high-only Docker tag to v2
- [ ] <!-- approve-branch=renovate/ghcr.io-example-accepted-only-2.x -->chore(deps): Update ghcr.io/example/accepted-only Docker tag to v2
- [ ] <!-- approve-branch=renovate/ghcr.io-example-nofix-2.x -->chore(deps): Update ghcr.io/example/nofix Docker tag to v2
- [ ] <!-- approve-branch=renovate/mychart-2.x -->chore(deps): Update Helm release mychart to v2
- [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->chore(deps): Update ghcr.io/example/already-ticked Docker tag to v2
- [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
## Awaiting Schedule
The following updates are awaiting their schedule. To get an update now, click on a checkbox below.
- [ ] <!-- unschedule-branch=renovate/ghcr.io-example-pending-sched-2.x -->chore(deps): Update ghcr.io/example/pending-sched Docker tag to v2
## Pending Branch Automerge
The following branches have automerge enabled. To abort a branch automerge, click on a checkbox below.
- [ ] <!-- approvePr-branch=renovate/ghcr.io-example-app-automerge -->chore(deps): Update ghcr.io/example/app Docker tag to v2
## Open
The following updates have all been created. To force a retry/rebase of any, click on a checkbox below.
- [ ] <!-- rebase-branch=renovate/gitea.home.keithjackson.dev-home-network-toolbox-main -->[chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to 83a3501](pulls/161)
- [ ] <!-- rebase-branch=renovate/kube-prometheus-stack-93.x -->[chore(deps): Update Helm release kube-prometheus-stack to v93](pulls/164)
- [ ] <!-- rebase-branch=renovate/ghcr.io-example-has-pr-2.x -->[chore(deps): Update ghcr.io/example/has-pr Docker tag to v2](pulls/173)
- [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once**
## Detected Dependencies
<details><summary>flux (13)</summary>
<blockquote>
<details><summary>platform/cert-manager/helmrelease.yaml (1)</summary>
- `cert-manager >=1.21.0 <1.22.0` → [Updates: `>=1.21.3 <1.22.0`]
</details>
</blockquote>
</details>
<details><summary>kubernetes (69)</summary>
<blockquote>
<details><summary>apps/wave-0/jackett.yaml (4)</summary>
- `lscr.io/linuxserver/jackett v0.24.3025-ls29` → [Updates: `v0.24.3030-ls30`]
- `quay.io/oauth2-proxy/oauth2-proxy v7.15.6`
- `NetworkPolicy networking.k8s.io/v1`
- `ghcr.io/example/tracked-clean 1.0`
- `ghcr.io/foo/bar 1.0`
- `ghcr.io/example/pending-sched 1.0` → [Updates: `2.0`]
- `ghcr.io/example/app 1.0` → [Updates: `2.0`]
- [ ] <!-- approve-branch=renovate/decoy-below-detected -->chore(deps): Update nginx Docker tag to v9
</details>
</blockquote>
</details>
@@ -0,0 +1,425 @@
{
"schemaVersion": 1,
"generatedAt": "2026-11-09T06:00:00Z",
"database": {
"updatedAt": "2026-11-09T01:00:00Z",
"version": "fixture-db"
},
"run": {
"startedAt": "2026-11-09T05:00:00Z",
"finishedAt": "2026-11-09T05:30:00Z",
"status": "complete"
},
"targets": [
{
"id": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"type": "image",
"image": "docker.io/library/nginx",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-0",
"kind": "Deployment",
"name": "fixture-workload-0",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
"type": "image",
"image": "ghcr.io/example/app",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-1",
"kind": "Deployment",
"name": "fixture-workload-1",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
"type": "image",
"image": "ghcr.io/example/grouped",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-2",
"kind": "Deployment",
"name": "fixture-workload-2",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
"type": "image",
"image": "ghcr.io/foo/bar",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-3",
"kind": "Deployment",
"name": "fixture-workload-3",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
"type": "image",
"image": "ghcr.io/example/has-pr",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000004",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-4",
"kind": "Deployment",
"name": "fixture-workload-4",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
"type": "image",
"image": "ghcr.io/example/pr-by-title",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000005",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-5",
"kind": "Deployment",
"name": "fixture-workload-5",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
"type": "image",
"image": "ghcr.io/example/already-ticked",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000006",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-6",
"kind": "Deployment",
"name": "fixture-workload-6",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
"type": "image",
"image": "ghcr.io/example/untracked",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000007",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-7",
"kind": "Deployment",
"name": "fixture-workload-7",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
"type": "image",
"image": "ghcr.io/example/pending-sched",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000008",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-8",
"kind": "Deployment",
"name": "fixture-workload-8",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
"type": "image",
"image": "ghcr.io/example/high-only",
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000009",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-9",
"kind": "Deployment",
"name": "fixture-workload-9",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
"type": "image",
"image": "ghcr.io/example/accepted-only",
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000a",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-10",
"kind": "Deployment",
"name": "fixture-workload-10",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
"type": "image",
"image": "ghcr.io/example/nofix",
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000b",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-11",
"kind": "Deployment",
"name": "fixture-workload-11",
"containers": [
"main"
]
}
]
},
{
"id": "ghcr.io/example/tracked-clean@sha256:000000000000000000000000000000000000000000000000000000000000000c",
"type": "image",
"image": "ghcr.io/example/tracked-clean",
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000c",
"state": "scanned",
"reason": null,
"lastScannedAt": "2026-11-09T05:00:00Z",
"workloads": [
{
"namespace": "fixture-ns-12",
"kind": "Deployment",
"name": "fixture-workload-12",
"containers": [
"main"
]
}
]
}
],
"findings": [
{
"id": "CVE-2026-1000",
"targetId": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-0",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1010",
"targetId": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-1",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1020",
"targetId": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-2",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1030",
"targetId": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-3",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1040",
"targetId": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-4",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1050",
"targetId": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-5",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1060",
"targetId": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-6",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1070",
"targetId": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-7",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1080",
"targetId": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-8",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1090",
"targetId": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
"severity": "HIGH",
"cvss": 9.0,
"package": "fixture-pkg-9",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "fix-available"
},
{
"id": "CVE-2026-1100",
"targetId": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-10",
"installedVersion": "1.0",
"fixedVersion": "9.9.9",
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "accepted",
"acceptance": {
"reason": "fixture",
"expires": "2027-01-01"
}
},
{
"id": "CVE-2026-1110",
"targetId": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
"severity": "CRITICAL",
"cvss": 9.0,
"package": "fixture-pkg-11",
"installedVersion": "1.0",
"fixedVersion": null,
"title": "fixture",
"url": "https://advisory.example/x",
"firstSeenAt": "2026-11-01T00:00:00Z",
"status": "open"
}
],
"remediation": []
}
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
# their limits are in check.py. rc 0 pass, 1 fail.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
@@ -0,0 +1,31 @@
# =============================================================================
# L1 RENDERING — CVE Dashboard issue carries aggregates only (R-VULN-05, R-VULN-07)
# Spec: docs/17 "The triage bot". GIVEN a report fixture with hostile and distinctive strings,
# WHEN the bot renders the dashboard issue, THEN no CVE id, package, image, workload, namespace or
# other report string appears, the counts are exact, and a deliberately leaking body is caught.
#
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-triage-bot-dashboard-aggregates
labels:
conformance.home.k3s/R-VULN-05: "true"
conformance.home.k3s/R-VULN-07: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
# not attribute to R-VULN-*, so a change to it must still select this test.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
A dashboard issue rendered from a fixture contains counts and timestamps only, never a CVE id,
package, image or workload name.
spec:
steps:
- name: render-and-assert-aggregates-only
try:
- script:
content: bash ./render-check.sh
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""L1: the CVE Dashboard issue is aggregates only (R-VULN-05, R-VULN-07).
Exercises the real renderer in .gitea/workflows/scripts/cve_triage.py over fixtures/report.json,
whose every name is distinctive so a leak cannot be confused with template text.
What this does NOT prove: that Gitea accepts the issue, or that the issue is the one the live bot
wrote. Those need a real run and are not exercised by any L1 test.
"""
import copy
import importlib.util
import json
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[3]
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
bot = importlib.util.module_from_spec(spec)
sys.modules["cve_triage"] = bot
spec.loader.exec_module(bot)
RAW = (HERE / "fixtures/report.json").read_bytes()
FIXTURE = json.loads(RAW)
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
PAGE = "https://cve.example.test"
failures = []
def check(name, condition):
print(f"{'ok ' if condition else 'FAIL'} {name}")
if not condition:
failures.append(name)
def sensitive_names(doc):
"""Every string in the fixture that must never reach the issue."""
names = set()
for target in doc["targets"]:
for field in ("id", "image", "digest", "reason"):
if target.get(field):
names.add(target[field])
if target.get("image"):
names.add(bot.normalise_image(target["image"]))
names.add(target["image"].rsplit("/", 1)[-1])
for workload in target["workloads"]:
names.update((workload["namespace"], workload["name"]))
names.update(workload["containers"])
for finding in doc["findings"]:
for field in ("id", "package", "installedVersion", "fixedVersion", "title", "url"):
if finding.get(field):
names.add(finding[field])
if finding.get("acceptance"):
names.add(finding["acceptance"]["reason"])
names.add(doc["database"]["version"])
return {name for name in names if name}
def assert_no_names(body, names):
lowered = body.lower()
leaked = sorted(name for name in names if name.lower() in lowered)
assert not leaked, f"{len(leaked)} fixture names leaked into the issue body"
def rendered(raw=RAW, now=NOW):
return bot.render_dashboard(bot.aggregate(bot.parse_report(raw)), PAGE, now)
def fails(function):
try:
function()
except AssertionError:
return True
return False
def rejected(doc):
try:
bot.parse_report(json.dumps(doc).encode())
except bot.BotError:
return True
return False
body = rendered()
names = sensitive_names(FIXTURE)
check("fixture carries a non-trivial set of names", len(names) >= 25)
check("rendered body contains no fixture name", not fails(lambda: assert_no_names(body, names)))
check("body has no CVE or GHSA identifier at all", not re.search(r"CVE-\d|GHSA-", body, re.I))
check("hostile title's markup is absent", "onerror" not in body and "evil.example" not in body and "@keith" not in body)
# Non-vacuity: the assertion above must detect each class of leak.
for label, leak in {
"CVE id": "CVE-2026-11111",
"package": "fixture-libcrit",
"image": "ghcr.io/fixture-org/fixture-api",
"workload": "fixture-workload-db",
"namespace": "fixture-ns-gamma",
}.items():
check(f"non-vacuity: a leaked {label} is caught", fails(lambda: assert_no_names(body + leak, names)))
for row in (
"| CRITICAL | 1 | 1 | 1 |",
"| HIGH | 0 | 1 | 0 |",
"| MEDIUM | 0 | 0 | 0 |",
"| LOW | 1 | 0 | 0 |",
"| UNKNOWN | 0 | 0 | 0 |",
"| scanned | 1 |",
"| pending | 0 |",
"| stale | 1 |",
"| failed | 0 |",
"| credential-required | 1 |",
"| by-tag | 0 |",
"| not-covered | 1 |",
"Unaccepted CRITICAL with a fix: **1**",
"4 targets.",
"| Report generated | 2026-11-09T06:00:00Z |",
f"[CVE page]({PAGE})",
):
check(f"body states {row!r}", row in body)
check("a fresh report carries no warning", "WARNING" not in body)
mutated = copy.deepcopy(FIXTURE)
extra = copy.deepcopy(mutated["findings"][0])
extra["id"] = "CVE-2026-55555"
mutated["findings"].append(extra)
changed = rendered(json.dumps(mutated).encode())
check("non-vacuity: a mutated fixture changes the counts", "| CRITICAL | 1 | 2 | 1 |" in changed and changed != body)
check("stale report warns", "older than 26 hours" in rendered(now=NOW + timedelta(hours=30)))
degraded = copy.deepcopy(FIXTURE)
degraded["run"]["status"] = "degraded"
check("degraded run warns", "did not complete cleanly" in rendered(json.dumps(degraded).encode()))
old_db = copy.deepcopy(FIXTURE)
old_db["database"]["updatedAt"] = "2026-11-01T00:00:00Z"
check("stale database warns", "database is older" in rendered(json.dumps(old_db).encode()))
for label, mutate in {
"wrong schemaVersion": lambda d: d.update(schemaVersion=2),
"unknown severity": lambda d: d["findings"][0].update(severity="SEVERE"),
"unknown status": lambda d: d["findings"][0].update(status="ignored"),
"unknown target state": lambda d: d["targets"][0].update(state="clean"),
"finding on an unknown target": lambda d: d["findings"][0].update(targetId="nope"),
"bad timestamp": lambda d: d.update(generatedAt="yesterday <b>"),
"invalid image": lambda d: d["targets"][0].update(image="bad image <x>"),
}.items():
doc = copy.deepcopy(FIXTURE)
mutate(doc)
check(f"parse rejects {label}", rejected(doc))
check("dashboard label is not one the PM acts on", bot.DASHBOARD_LABEL not in bot.FORBIDDEN_LABELS)
configmap = (ROOT / "platform/pm-agent/configmap.yaml").read_text()
match = re.search(r'^\s*PM_REQUIRE_LABEL:\s*"([^"]*)"', configmap, re.M)
check("PM_REQUIRE_LABEL is readable", match is not None)
check("the PM's label is in FORBIDDEN_LABELS and is not ours",
match is not None and match.group(1) in bot.FORBIDDEN_LABELS and match.group(1) != bot.DASHBOARD_LABEL)
print(f"{len(failures)} failure(s)")
sys.exit(1 if failures else 0)
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"generatedAt": "2026-11-09T06:00:00Z",
"database": {"updatedAt": "2026-11-09T01:00:00Z", "version": "fixture-db-v2-20261109"},
"run": {"startedAt": "2026-11-09T05:00:00Z", "finishedAt": "2026-11-09T05:30:00Z", "status": "complete"},
"targets": [
{"id": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "type": "image", "image": "ghcr.io/fixture-org/fixture-api", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "state": "scanned", "reason": null, "lastScannedAt": "2026-11-09T05:10:00Z", "workloads": [{"namespace": "fixture-ns-alpha", "kind": "Deployment", "name": "fixture-workload-api", "containers": ["fixture-container-api"]}]},
{"id": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "type": "image", "image": "docker.io/library/fixturedb", "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "state": "stale", "reason": "fixture-reason-stale-text", "lastScannedAt": "2026-11-07T05:10:00Z", "workloads": [{"namespace": "fixture-ns-beta", "kind": "StatefulSet", "name": "fixture-workload-db", "containers": ["fixture-container-db"]}]},
{"id": "fixture.example:5000/priv/fixture-private:v1", "type": "image", "image": "fixture.example:5000/priv/fixture-private", "digest": null, "state": "credential-required", "reason": "fixture.example:5000 refused anonymous access", "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-gamma", "kind": "CronJob", "name": "fixture-workload-cron", "containers": ["fixture-container-cron"]}]},
{"id": "vm/fixture-ns-vm/fixture-vm-name", "type": "vm", "image": null, "digest": null, "state": "not-covered", "reason": null, "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-vm", "kind": "VirtualMachineInstance", "name": "fixture-vm-name", "containers": []}]}
],
"findings": [
{"id": "CVE-2026-11111", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.8, "package": "fixture-libcrit", "installedVersion": "1.0.1-installed", "fixedVersion": "1.0.2-fixed", "title": "<img src=x onerror=alert(1)> @keith [click](http://evil.example/hook) | injected |", "url": "https://advisory.example/CVE-2026-11111", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "fix-available"},
{"id": "GHSA-fixt-ure0-0001", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.1, "package": "fixture-libopen", "installedVersion": "2.0.0-installed", "fixedVersion": null, "title": "fixture title without a fix", "url": "https://advisory.example/ghsa", "firstSeenAt": "2026-11-02T00:00:00Z", "status": "open"},
{"id": "CVE-2026-22222", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "CRITICAL", "cvss": 9.0, "package": "fixture-libaccepted", "installedVersion": "3.0.0-installed", "fixedVersion": "3.0.1-fixed", "title": "fixture accepted title", "url": "https://advisory.example/accepted", "firstSeenAt": "2026-10-01T00:00:00Z", "status": "accepted", "acceptance": {"reason": "fixture-acceptance-reason", "expires": "2027-01-01"}},
{"id": "CVE-2026-33333", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "HIGH", "cvss": 7.5, "package": "fixture-libhigh", "installedVersion": "4.0.0-installed", "fixedVersion": "4.0.1-fixed", "title": "fixture high title", "url": "https://advisory.example/high", "firstSeenAt": "2026-11-03T00:00:00Z", "status": "fix-available"},
{"id": "CVE-2026-44444", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "LOW", "cvss": null, "package": "fixture-liblow", "installedVersion": "5.0.0-installed", "fixedVersion": null, "title": "fixture low title", "url": "", "firstSeenAt": "2026-11-04T00:00:00Z", "status": "open"}
],
"remediation": []
}
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
# their limits are in check.py. rc 0 pass, 1 fail.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
@@ -0,0 +1,35 @@
# =============================================================================
# L2 BEHAVIORAL SCAFFOLD — the internal API is unreachable from an unrelated namespace (R-VULN-09)
# Catalog: docs/17 "Acceptance gates" (L2).
#
# A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081.
#
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-internal-api-isolated
labels:
conformance.home.k3s/R-VULN-09: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-09"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/intent: >-
A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081.
spec:
steps:
- name: given-an-unrelated-namespace
try:
- script:
content: 'echo "TODO: create a throwaway namespace and probe pod"'
- name: when-connecting
try:
- script:
content: 'echo "TODO: attempt tcp to vuln-scanner:8081 and :9090"'
- name: then-denied
try:
- script:
content: 'echo "TODO: assert a timeout on both, and success from the act-runner identity on :8081"'
@@ -0,0 +1,36 @@
# =============================================================================
# L2 BEHAVIORAL SCAFFOLD — new workload digest appears in the report (R-VULN-01, R-VULN-02)
# Catalog: docs/17 "Acceptance gates" (L2).
#
# A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window.
#
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-new-digest-reported
labels:
conformance.home.k3s/R-VULN-01: "true"
conformance.home.k3s/R-VULN-02: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-01, R-VULN-02"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/intent: >-
A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window.
spec:
steps:
- name: given-a-new-workload
try:
- script:
content: 'echo "TODO: start a pod from an image digest not yet in the report"'
- name: when-the-scanner-ticks
try:
- script:
content: 'echo "TODO: wait, bounded by the 2h SLO, polling GET /report.json from the CI runner identity"'
- name: then-digest-is-reported
try:
- script:
content: 'echo "TODO: assert a target with that digest and the workload mapping, state not pending past 2h"'
@@ -0,0 +1,39 @@
# =============================================================================
# L2 BEHAVIORAL SCAFFOLD — the CVE page requires a platform-admins login (R-VULN-04)
# Catalog: docs/17 "Acceptance gates" (L2).
#
# An unauthenticated request to cve.<domain> is redirected to Keycloak, and a logged-in user outside platform-admins gets 403.
#
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-page-requires-admin
labels:
conformance.home.k3s/R-VULN-04: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-04"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/intent: >-
An unauthenticated request to cve.<domain> is redirected to Keycloak, and a logged-in user outside platform-admins gets 403.
spec:
steps:
- name: given-the-page-route
try:
- script:
content: 'echo "TODO: resolve cve.<domain> through the Gateway"'
- name: when-unauthenticated
try:
- script:
content: 'echo "TODO: request the page with no session"'
- name: then-redirected-to-keycloak
try:
- script:
content: 'echo "TODO: assert a 302 to the homelab realm"'
- name: then-non-admin-forbidden
try:
- script:
content: 'echo "TODO: assert 403 for a session outside platform-admins"'
@@ -0,0 +1,40 @@
# =============================================================================
# L3 FAULT-INJECTION SCAFFOLD — breaking registry access produces failed targets and the coverage alert (R-VULN-03, R-VULN-06)
# Catalog: docs/17 "Acceptance gates" (L3).
#
# With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h.
#
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: vuln-registry-failure
labels:
conformance.home.k3s/R-VULN-03: "true"
conformance.home.k3s/R-VULN-06: "true"
annotations:
conformance.home.k3s/requirements: "R-VULN-03, R-VULN-06"
conformance.home.k3s/layer: "L3"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/intent: >-
With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h.
spec:
steps:
- name: given-a-scanned-target
try:
- script:
content: 'echo "TODO: record a scanned target and its findings"'
- name: when-registry-access-is-broken
try:
- script:
content: 'echo "TODO: inject a fault blocking that registry"'
- name: then-target-failed
try:
- script:
content: 'echo "TODO: assert state failed, reason present, findings retained, run continues"'
- name: then-coverage-alert-fires
try:
- script:
content: 'echo "TODO: assert VulnCoverageGap fires once the failure passes 24h"'
@@ -0,0 +1,74 @@
---
date: 2026-11-08
branch: fix/wg-client-mtu-docs
title: WireGuard client profiles default to MTU 1280; VPN runbook corrected and given a troubleshooting section
---
## What changed
- **`infra/vpn-ingress/new-peer.sh`**: `WG_MTU` default 1420 -> 1280, still overridable, reason in a
comment. Only new profiles change; the phone's existing profiles are not touched.
- **`runbooks/vpn-remote-access.md`**: the verification table no longer says the Gateway sees a
`host`-identity source; the limitations no longer say everything arrives SNATed or that IPv6 leaks
on Android; the "if nothing handshakes" advice no longer claims `wg show` separates a bad forward
from a bad key. New "Troubleshooting" section covering the four cases in the brief.
- **`platform/vpn-ingress/daemonset.yaml`**: header comment only, SNAT claim corrected.
- **`platform/vpn-ingress/README.md`**: one sentence, so `MTU = 1420` reads as the server's value.
Rollout on merge: the WireGuard DaemonSet does **not** roll. The `runbooks` ConfigMap is content
hashed and `vpn-remote-access.md` is in it, so the runbooks site rolls.
Left unchanged, for the operator:
- **`platform/vpn-ingress/peers.conf`** still says "No peers enrolled yet". A comment-only edit on a
throwaway copy moved the ConfigMap from `wg-peers-29mf6k867t` to `wg-peers-gggkk5d4tm` and changed
the DaemonSet, so correcting it restarts every `wg0`. Fix it alongside the next peer change.
- **`clusters/base/cluster-vars.yaml`** coverage comment still omits `WG_CLIENT_CIDR` (sensitive path).
- **Server `wg0` MTU stays 1420**, because changing it restarts all WireGuard pods. For TCP a 1280
client should be enough on its own: it advertises an MSS of 1240, which caps what the far end
sends. That protection does not reach UDP. A server-to-phone datagram with an inner size of
1281-1420 bytes is still sent as up to 1480 outer bytes and could be lost on the same path; QUIC
should recover by probing, large DNS answers are the likeliest casualty. If that shows up, the
fix is a lower server MTU, as a deliberate rollout.
- **Other stale text, outside this brief:** `platform/vpn-ingress/README.md` still says no peer has
ever completed a handshake, and the runbook's status line still calls the VPN inert.
## What I verified
- Upstream `wireguard-android` `GoBackend.java`, master (last commit to that file `ecc3c0a`,
2026-03-15): `builder.setMtu(config.getInterface().getMtu().orElse(1280))`, and `allowFamily()` is
skipped when `sawDefaultRoute && config.getPeers().size() == 1`.
- Live, read-only, 2026-11-09 UTC, on the `k3s-prod-1` pod and agent:
- `wg0` MTU is 1420 on all three pods. Both peers have handshakes and transfer on prod-1 only; the
other two pods read `0`.
- BPF conntrack holds `UDP SVC 10.90.0.x -> 192.168.2.23:53` and flows to `192.168.2.24:32400`.
Their backend entries carry `SourceSecurityID=2` (`world`). So the source stays `10.90.0.x`
through the load balancer on those two VIPs.
- `/proc/net/nf_conntrack` is readable from the pod, which has no `conntrack` binary.
- Offline, pinned toolbox: `kubectl kustomize platform/vpn-ingress` gives identical ConfigMap and
DaemonSet hashes on `origin/main` and on this branch.
- `new-peer.sh` writes `MTU = 1280` by default and `MTU = 1420` with `WG_MTU=1420`. Run with
throwaway keys and stdout discarded; profiles shredded; nothing enrolled.
- No test or conformance check pins the client MTU (`grep` for `WG_MTU` and `1420`).
- Gate (pinned toolbox, from this worktree, on the committed tree): **rc 3, UNDETERMINED, not green.**
`gitea-settings` is the only stage counted as not run (no token locally). `conformance` (L2/L3)
also skipped as the declared no-cluster gap, so R-ENV-01 and R-OBS-04 are not certified.
`spec-immutability`, `approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden` and `l1`
passed. No sensitive path is touched.
## What I did NOT verify
- **That MTU was the cause.** It is inferred from one before-and-after, on one phone and one carrier,
reported by the operator. The path MTU was never measured.
- **That 1280 is the best value.** It is the value that worked, and the IPv6 minimum. A higher one
may work too.
- That the phone's app is on `GoBackend` rather than the kernel backend, so that a blank field meant
1280 on this device. Only the upstream default was read.
- That TCP MSS behaves as described, or that large UDP replies are actually lost. No packet capture
was taken.
- That the Gateway VIP `.20` sees `10.90.0.x` / `world`. Conntrack held no tunnel flows to it when
checked.
- Android's IPv6 blocking on a real device; read from source only.
- `wg-quick` IPv6 behaviour on Linux, and any non-Android client at 1280.
- `/proc/net/nf_conntrack` output during a key mismatch. The `[UNREPLIED]` reading comes from the
earlier measurement in the brief; I saw only healthy entries.
@@ -0,0 +1,103 @@
---
date: 2026-11-08
branch: fix/wg-home-peer-pihole-sources
title: Enrol keith-phone-home and admit the WireGuard subnet to PiHole's DNS frontends
---
## What changed
- **`platform/vpn-ingress/peers.conf`**: new peer `keith-phone-home`, `10.90.0.3/32`, key generated
on the phone. It is the `home` half of a two-tunnel profile (Endpoint `192.168.2.10:51820`),
because the router has no hairpin NAT and `wan.` is unusable from inside the house.
- **`apps/wave-0/pihole-dns.yaml`**: `${WG_CLIENT_CIDR}` added after `${LAN_CIDR}` on both
`pihole-dns-udp` and `pihole-dns-tcp`. This file is hand-written, not rendered (`catalog/render.py`
only lists it in `EXTRA_UNITS`), so it is the source of truth and `--emit-waves` was not run.
`catalog/pihole.yaml` changes in one comment only, so nothing renders differently.
- **New L0 guard `conformance/tests/l0-static/pihole-dns-source-ranges`** (whole-repo scope,
R-DNS-03): every PiHole LoadBalancer Service lists both tokens, and every range resolves through
cluster-vars (base and prod overlay) to RFC1918.
- **`platform/vpn-ingress/README.md`**: corrected "clients arrive SNATed" (false for LoadBalancer
VIPs) and the IPv6 claim (Android's `GoBackend` blocks IPv6 for a single-peer default route);
documented the home/away pattern, why `wan.` fails from inside, and the prod-1 dependency.
Rollout on merge: the `wg-peers` ConfigMap hash changes (`m9kcckcb5t` -> `29mf6k867t`), so the
DaemonSet rolls, `maxUnavailable: 1`, `maxSurge: 0`, one node at a time in an order the controller
chooses. Each roll deletes and recreates that node's `wg0`, so an established session on that node
drops and must re-handshake. `keith-phone` has no session today (see below), so nothing is
interrupted in practice.
**The one risk worth watching:** `wg0` is in Cilium's auto-detected device list and `devices` is
still unpinned. On 2026-10-06 a crash-looping `wg0` churned that list and all three nodes lost their
main-table default route. A rollout is one delete/create per node, not a loop, so it should not
repeat that, but after merge check `ip route get 1.1.1.1` on each node.
The Service change is an in-place update: same VIP, same Service objects, no pod restart.
Checked on the rest of the path and left unchanged:
- Gateway VIP `.20` (`gateway/cilium-gateway-shared`): no source ranges, live. Nothing needed.
- `pihole-allow-lan-dns-ingress` admits `world` on :53, which covers an untranslated 10.90.0.x
source. `platform/vpn-ingress/networkpolicy.yaml` does not apply to a hostNetwork pod.
- No L0 check pinned these ranges to the LAN; none needed updating.
Flagged, not changed (out of scope):
- `runbooks/vpn-remote-access.md` "Known limitations" repeats both corrected claims, and step 3 of
its verification table says the Gateway sees a `host`-identity source.
- `platform/vpn-ingress/daemonset.yaml`'s header repeats the SNAT claim.
- `clusters/base/cluster-vars.yaml`'s substitution-coverage comment does not list
`WG_CLIENT_CIDR` under apps-wave-0. The variable is defined and substituted there regardless.
- `peers.conf` still says "No peers enrolled yet" above the enrolled peers.
- PiHole's NodePorts are not covered by source ranges (already acknowledged in `pihole-dns.yaml`).
Tunnel clients can reach node addresses, so they can reach those too; they are enrolled devices.
- Pinning Cilium's `devices` remains the open hardening for the churn risk above.
## What I verified
Live, read-only, 2026-11-08:
- `cilium-dbg status` on one agent: `KubeProxyReplacement: True [eno1 ..., wg0 10.90.0.1, tun0 ...]`
and `Masquerading: BPF [eno1, wg0, tun0]`. So `wg0` really is a load-balancing device.
- `pihole-dns-tcp` and `-udp` carry `ranges=[192.168.2.0/24]`; no other LoadBalancer Service has
source ranges. Live `cluster-vars`: `WG_CLIENT_CIDR=10.90.0.0/24`, `LAN_CIDR=192.168.2.0/24`.
- `wg show wg0 latest-handshakes` on all three pods: `keith-phone` = `0`.
- `wan.home.keithjackson.dev` resolves publicly to a single A record (via 1.1.1.1).
Offline, in the pinned toolbox:
- `flux envsubst --strict` renders both Services to `[192.168.2.0/24, 10.90.0.0/24]`.
- `kubectl kustomize platform/vpn-ingress`: `wg-peers-m9kcckcb5t` on the old `peers.conf`,
`wg-peers-29mf6k867t` on the new one.
- Guard mutation test, each restored afterwards. Baseline passes (22 assertions, 2 Services). All
seven fail with rc 1 and a named message: WG range dropped from one Service; `0.0.0.0/0` added;
`8.8.8.0/24` added; `WG_CLIENT_CIDR` made public in cluster-vars; prod overlay sets `LAN_CIDR` to
`0.0.0.0/0`; ranges block removed; undefined token added.
- The Android claim was read from upstream `GoBackend.java` (`wireguard-android`, master): the
`allowFamily` calls are skipped when `sawDefaultRoute && config.getPeers().size() == 1`.
- Gate (pinned toolbox, from this worktree, on the committed tree): **rc 3, UNDETERMINED, not
green.** `gitea-settings` is the only stage counted as not run (no token locally). `conformance`
(L2/L3) also skipped as the declared no-cluster gap. `spec-immutability`, `decisions-log`, `lint`,
`l0` (including the new guard), `render-golden` and `l1` passed. `approval-policy` reported N/A
offline; it will be red in CI until the operator overrides, because `conformance/**` is sensitive.
- `git merge-tree` against `feat/pihole-explicit-lan-names` (#157): no conflicts.
## What I did NOT verify
- **`DROP_NOT_IN_SRC_RANGE` was NOT observed live.** It is a prediction from Cilium's source. Only
the `wg0` device membership was measured; the drop itself has never been seen.
- **Full-tunnel behaviour was NOT observed live**, on either profile. **No handshake has ever been
recorded for any peer**: `keith-phone` reads `0` on all three pods (a counter that resets when a
pod restarts, last 22 days ago, so it covers only the time since; earlier entries here also
record none).
- That tunnel DNS works after this change, including the reply path from a backend on another node
and from one on the ingress node. That needs the phone: `dig` over the tunnel, while watching
`cilium-dbg monitor --type drop`.
- That the Gateway serves tunnel clients. Its source is predicted to be `world`, as for a LAN
browser, which is what the apps admit; not tested.
- That the new peer's key is the one on the phone. I copied the public key from the brief.
- That the rollout leaves node default routes intact. Reasoned from the 2026-10-06 incident being
a loop, not measured.
- That a LAN client is unaffected while the Service's source ranges are updated. Expected, not
measured.
- IPv6 behaviour on any non-Android client.
- That the WAN forward targets prod-1: it is off-repo.
@@ -0,0 +1,97 @@
---
date: 2026-11-09
branch: feat/pihole-explicit-lan-names
title: PiHole answers an explicit LAN name list instead of a zone wildcard (public names resolve to Cloudflare from the LAN)
---
## What changed
**`catalog/pihole.yaml` → `apps/wave-0/pihole.yaml`.** `FTLCONF_misc_dnsmasq_lines` no longer carries
`address=/${BASE_DOMAIN}/${GATEWAY_VIP}`. Every name not listed is now forwarded to 8.8.8.8/8.8.4.4 and
gets the public answer. The final list (`local-ttl=60` kept):
| Name (`.${BASE_DOMAIN}`) | Answer | Why |
|---|---|---|
| jackett, pihole, plex, qbittorrent, radarr, semaphore, sonarr, headlamp | `${GATEWAY_VIP}` | LAN-only: every HTTPRoute with `external-dns.alpha.kubernetes.io/controller: none`, derived from the tree. No public record, tunnel 404s them; this entry is their only path |
| gitea, nextcloud | `${GATEWAY_VIP}` | public, but Cloudflare's proxy caps bodies at 100 MB / responses at 100 s (git push, LFS, uploads) |
| keycloak, auth | `${GATEWAY_VIP}` | keep the OIDC flow of the LAN-path apps on one path |
| unifi | `${UNIFI_L4_VIP}` | L4 VIP, not behind the Gateway |
| crafty-mc | `${CRAFTY_L4_VIP}` | L4 VIP, not behind the Gateway |
| pihole-dns | `${PIHOLE_DNS_VIP}` | the resolver's own address |
| plex-vip | `${PLEX_L4_VIP}` | kept, see below |
Not listed, so they resolve to Cloudflare from the LAN: the apex, grafana, crafty, seerr.
**The derived LAN-only set matches the brief's list exactly** (8 names; the tree was walked, not the
list trusted). The `runbooks/serving` route has no `hostnames:` but is not disowned (it inherits the
apex, public).
**plex-vip is kept.** Nothing advertises it any more (`ADVERTISE_IP` is a `plex.direct` name since
2026-10-05), but it is not unused: it is Plex's `customCertificateDomain` (`CERTDOMAIN`, Secret
`plex-vip-tls`), the name of the tcp/32400 VIP, and the 404'd name in the tunnel config. It has no
public record, so this entry is its only resolution.
**New L0 check `conformance/tests/l0-static/pihole-lan-names`** (`scope: whole-repo`, so picked up by
the runner's scope selection with no other wiring). Asserts: (a) no zone-wide entry (zone, `#`, or any
parent of the zone); (b) every `controller: none` hostname → `${GATEWAY_VIP}`, and a disowned route
with no `hostnames:` fails; (c) every tunnel-404 hostname has an entry, with the L4 names (derived
from Services carrying both the external-dns hostname and an lbipam VIP) on their own VIP; (d) gitea
and nextcloud → `${GATEWAY_VIP}`; plus catalog == rendered value and a built-in mutation self-test.
**Stale docs corrected:** `platform/pm-agent/README.md`, `runbooks/pm-agent.md`,
`platform/externaldns/README.md`, `platform/cloudflared/README.md`, `platform/gateway/gateway.yaml`
(comment), `platform/headlamp/httproute.yaml` (comment), `apps/vms/unifi/README.md`, `README.md`,
`schemas/renderer-contract.md`; `runbooks/cloudflare-tunnel.md` gains the two consequences below.
`platform/cloudflared/deployment.yaml`: the image entrypoint comment was wrong (comment-only).
## What I verified
- **Render:** the baseline `render.py --no-image-automation catalog/pihole.yaml` was byte-identical to
the committed `apps/wave-0/pihole.yaml` before the change, and the file was replaced by that
command's output after it; `--emit-waves` was not run.
- **Mutation, against the real files (10 mutants, each rc 1 with the right assertion, each restored):**
(a) zone wildcard restored; (b) headlamp and sonarr entries removed; (b) a disowned route with no
`hostnames:`; (c) a new tunnel-404 host with no entry; (c) pihole-dns removed; (c) unifi pointed at
the Gateway; (d) gitea removed; (d) nextcloud repointed; (0) rendered value drifted from the catalog.
- **Rollout mechanics, measured live (read-only):** StatefulSet `updateStrategy` RollingUpdate
partition 0, `podManagementPolicy: Parallel`, no `minReadySeconds`, 2 replicas, PDB minAvailable 1.
The last rollout (2026-11-08 06:47–06:48Z) replaced pihole-1 then pihole-0: FTL logged "started" ~1 s
after the container, the pod went Ready ~16 s after the container, and pihole-0 started ~9 s later.
- **Expected LAN DNS blip:** no total outage; one replica answers throughout. Each of the two pod
replacements can lose a few UDP queries for ~1–2 s while the terminating pod leaves the Service
(clients retry). For roughly 30–60 s the two pods answer differently for the apex, grafana, crafty
and seerr; both answers work. Cached answers age out in 60 s (`local-ttl`).
- **Live DNS before the change:** @8.8.8.8 returns Cloudflare (104.21.51.91 / 172.67.178.27) for the
apex, grafana, seerr, crafty and gitea, and nothing for jackett; @192.168.2.23 returns 192.168.2.20
for all of them (the wildcard).
- **Cloudflared image:** `skopeo inspect --config` on the pinned digest (amd64): Entrypoint
`["cloudflared"]`, Cmd `["version"]`, User `65532:65532`.
## What I did NOT verify
- **The post-merge behaviour.** Nothing here ran against a live PiHole with the new list; the
expected answers are from dnsmasq semantics and the tree. After reconcile, check
`dig @192.168.2.23` for one name per row, and grafana returning Cloudflare.
- **Whether every LAN client path works via Cloudflare.** Measured 2xx/3xx through Cloudflare by the
brief, not re-measured from a LAN client here.
- **The FTL DNS-listening moment.** "FTL started" is a log line, not a successful query; the rollout
timing above assumes DNS is up shortly after it.
**Accepted consequences, stated plainly:**
- The apex, grafana, crafty and seerr now depend on the internet and the tunnel from the LAN too. An
ISP or Cloudflare outage, or stopping the connectors, makes them unreachable from the LAN where they
previously kept working. If grafana in particular should survive an internet outage, add it to the
list (one entry; the check permits it).
- The tunnel rollback (revert to `wan.`) now also requires restoring the wildcard; recorded in
`runbooks/cloudflare-tunnel.md`.
**Flagged, not changed:**
- The `pihole` container has no readiness probe; only the `oauth2-proxy` sidecar does, so "Ready" says
nothing about DNS. The measured margin (FTL ~1 s, Ready ~16 s) makes this benign in a normal rollout
but a slow FTL start would not hold the rollout back. A DNS readiness probe needs a schema/renderer
decision.
**Stale statements left alone:** `platform/coredns/coredns-custom.yaml` header ("both resolve the whole
zone ... by wildcard", brief said not to touch CoreDNS); `clusters/base/cluster-vars.yaml` comments
(sensitive path); `schemas/renderer-contract.md` §`exposure` first bullets (manual edge port-forward
wording); historical `infra/PROD-PROVISIONING.md`, `platform/externaldns/README.md` TODO list.
+84
View File
@@ -0,0 +1,84 @@
---
date: 2026-11-09
branch: feat/vuln-visibility
title: Automated CVE visibility for production (R-VULN-01..09, ADR-0027)
---
## What changed
Implements `docs/17-vulnerability-visibility.md` from spec PR Home-Network/home-network-k3s#4
(commit `0fb44bb`, open, not merged). `ci/SPEC_PIN` is deliberately not bumped: the merge SHA does
not exist yet, and open PR #160 owns the pin backlog.
- `platform/vuln-scanner/`: stdlib Python scanner in a ConfigMap on a digest-pinned
`python:3.14.9-alpine`, Trivy 0.76.1 by digest copied in by an initContainer, a per-app
oauth2-proxy sidecar for `cve.${BASE_DOMAIN}`, read-only ClusterRole, RWO Longhorn PVC for the
report and state, size-limited emptyDir for the DB and scan cache, pinned Service ClusterIP
`10.43.210.10` as the bot's DNS fallback, five alerts with promtool tests and a runbook.
- `clusters/base/vuln-scanner.yaml`: its own Flux tier, `wait: false`, `dependsOn: platform-config`,
nothing depends on it.
- `.gitea/workflows/cve-triage.yaml` and `scripts/cve_triage.py`: the bot, cron `15 7 * * *`,
counts-only `CVE Dashboard` issue with its own `cve-dashboard` label, approve-row ticks only.
- `renovate.json5`: Trivy on the blast-radius list, 7-day minimum release age, and everything under
`platform/vuln-scanner/**` non-automerging.
- `conformance/`: R-VULN-01..09 in the registry; L0 checks and L1 fixture tests (scanner, page,
bot) wired by scope, L2/L3 authored as scaffolds.
- PiHole LAN names and the Homepage link gained the CVE page.
### Decisions a future reader will want
- Page is server-rendered with **no JavaScript**, not inline JS: the CSP has no `script-src`,
escaping is in one place, and the L1 test exercises the real rendered page, which a JS page could
not do without a browser engine. Filters, sort and pagination are URL parameters.
- Three pod ports, not two. Prometheus gets 9090 only, so it cannot read `report.json`.
- A killed image (OOM, eviction) is recorded as failed from an in-flight marker and sits out full
runs for 20h, so it cannot wedge the scan loop.
- Private registries are named in `VULN_PRIVATE_REGISTRIES` and short-circuit to
`credential-required` before Trivy runs (see escalation 1).
- Bot's job container is the repo's toolbox image by digest: `actions/checkout` needs node and the
pull is already proven by the merge gate.
### Spec escalations
1. Coverage state `credential-required` is defined as "the registry refused anonymous access", but
the egress contract excludes LAN ranges, so the scanner cannot reach the private registry to be
refused. Implemented by naming private registries in configuration. The spec should define the
state by configuration or admit the registry to egress.
2. The egress contract lists only the Kubernetes API, DNS and public TCP/443, but the SSO sidecar
must reach Keycloak. Egress additionally allows the Gateway on 443 and Keycloak. The spec should
name this path.
## What I verified
- Toolbox gate on the integrated tree: **rc 3 (UNDETERMINED, not green)**. Stages that did not run:
`spec-immutability` and `gitea-settings`; `conformance` (L2/L3) logged SKIPPED, no cluster.
`approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` reported green.
`approval-policy` is N/A offline and proves nothing locally.
- All `vuln-*` L0 and L1 checks pass in the toolbox, each with a mutated-fixture non-vacuity case.
- `renovate-config-validator` from `ghcr.io/renovatebot/renovate:44.167.0`, the tag `renovate.yaml`
runs: validated successfully. This is a read-only container run of a public image.
- Trivy 0.76.1 digest: registry `created` 2026-10-21, 19 days old at authoring; cosign keyless
verification passed (cosign 3.1.3); `cp` and the CA bundle confirmed from a layer listing.
- ClusterIP `10.43.210.10` confirmed unused against the live Service list, read-only.
- An independent reviewer checked the diff against docs/17 and found no anonymous leak path, no way
for an unscanned image to read as clean, no way for the bot to tick a `rebase-branch` row, and no
platform stall path. One BLOCKER and five other code findings were fixed with tests.
## What I did NOT verify
Nothing here has been deployed. No runtime behaviour has been observed.
- **R-VULN-04 is NOT delivered until the operator adds the Keycloak client.** It needs a new key in
`platform/keycloak/keycloak-realm-clients.sops.yaml`, which needs the age private key, and a
one-time `kcadm` against the live realm. Exact steps: `platform/vuln-scanner/README.md`, "Operator
action required". Until then login fails closed with `invalid_client`. Those steps are UNVERIFIED.
- A real Trivy run: every scanner test uses a recorded fixture and a fake runner.
- The real Kubernetes API, real browser rendering, OIDC login and the `platform-admins` restriction.
- Whether in-cluster DNS resolves from a DinD job container, and whether the pinned ClusterIP
fallback is reachable from one. The workflow has never run; Gitea behaviour is tested against a
fake only.
- The `## Pending Approval` row format is from Renovate's source as recalled, because issue #91 has
no live approve row today. The `approve-branch` marker itself is documented.
- L2 and L3 are scaffolds and do not run in CI.
- Renovate `minimumReleaseAge` behaviour on a real update, as opposed to the config validating.
- Cgroup v2 memory behaviour under the scanner's limits is inferred, not measured.
+9 -1
View File
@@ -53,6 +53,7 @@
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
# WG_MTU default 1280 (client tunnel MTU; see the comment on the default below)
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
#
# REQUIRES: `wg`, `qrencode`, `sops` — all three in the dev container.
@@ -80,7 +81,14 @@ PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.conf"
# cannot resolve until it is already connected.
: "${WG_ENDPOINT_HOST:=wan.home.keithjackson.dev}"
: "${WG_LISTEN_PORT:=51820}"
: "${WG_MTU:=1420}"
# The CLIENT's tunnel MTU. Deliberately lower than the server's 1420 (daemonset.yaml), which only
# describes the 1500-byte path at the node's end. A phone's path is the carrier's, often behind
# carrier NAT, and is not known to carry 1500. Measured 2026-11-09, one Android phone on one
# carrier: at 1420 the handshake was healthy but names and pages failed; clearing the app's MTU
# field (its userspace backend then uses 1280) fixed it. 1280 is also the IPv6 minimum link MTU, so
# it stays valid if the tunnel ever gains IPv6. For a device only ever on a wired or known-1500
# path, WG_MTU=1420 recovers about 10% of per-packet payload.
: "${WG_MTU:=1280}"
SPLIT=0
OUT_DIR=""
+5 -3
View File
@@ -17,6 +17,8 @@ outbound-only from the cluster (no router port forward, no hairpin, no split-DNS
| `networkpolicy.yaml` | default-deny (single owner — not an app namespace) + Prometheus scrape allow + egress CNP: world 7844 tcp/udp and 443, the Gateway (`ingress` entity), and Gateway backends by `gateway-access=shared` minus the LAN-only namespaces. |
| `servicemonitor.yaml`, `alerts.yaml` | Headless metrics Service + ServiceMonitor scraping :2000, and the two alerts, both written to fire (not go quiet) when their series vanish. |
**Phase 1 only.** This component changes no live traffic: Cloudflare sends a hostname through the
tunnel only when its DNS record is a CNAME to `<UUID>.cfargotunnel.com`, and nothing here creates
one. Moving DNS (external-dns, Gateway annotations, Pi-hole/CoreDNS split horizon) is a later change.
Cloudflare sends a hostname through the tunnel only when its DNS record is a proxied CNAME to
`<UUID>.cfargotunnel.com`; nothing in this directory creates one. Those records are published by
external-dns from the shared Gateway's target annotation (Phase 2, PR #156; see
`platform/externaldns/README.md`). LAN resolution: `catalog/pihole.yaml` (explicit names only since
2026-11-09), so public names reach Cloudflare from the LAN too.
+4
View File
@@ -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}
+3 -2
View File
@@ -70,8 +70,9 @@ spec:
- name: cloudflared
# Tag + digest: Renovate's `kubernetes` manager (renovate.json5, platform/**) bumps both.
image: docker.io/cloudflare/cloudflared:2026.10.7@sha256:4955524aeaa379e154023d5a027c250f7b35041a2e7e950cf13f144bc9b9c5ba
# The image ENTRYPOINT is `cloudflared --no-autoupdate`; these replace its CMD. The tunnel
# UUID, credentials path, metrics address and ingress rules all come from the file.
# The image ENTRYPOINT is `cloudflared` and its CMD is `version` (read from the pinned
# digest's image config, 2026-11-09); these args replace that CMD. `no-autoupdate`, the
# tunnel UUID, credentials path, metrics address and ingress rules all come from the file.
args:
- tunnel
- --config
+1
View File
@@ -147,3 +147,4 @@ spec:
- radarr
- sonarr
- headlamp
- vuln-scanner
+7 -2
View File
@@ -44,8 +44,13 @@ wan.home.keithjackson.dev A (DNS-only) <current WAN IP> <- cloudflare-ddn
- `internal` routes (`controller: none`) still publish nothing. Plex's web UI and Headlamp joined that
set on 2026-11-08 (LAN/VPN-only); the tunnel also 404s every such name (9c/9d).
- Public answers are Cloudflare anycast **A and AAAA** — Cloudflare's edge, not an origin address.
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
(split-horizon) and never use the public path.
- **Pods** resolve the whole zone to the Gateway VIP via CoreDNS (split-horizon) and never use the
public path. **LAN clients** use PiHole, which since 2026-11-09 answers an EXPLICIT list
(`catalog/pihole.yaml`): the LAN-only names, plus gitea/nextcloud/keycloak/auth (kept off
Cloudflare's 100 MB / 100 s limits and on one OIDC path), go to the Gateway VIP and the L4 names to
their own VIPs; every other public name (apex, grafana, crafty, seerr) resolves to Cloudflare from
the LAN exactly as from anywhere else, so no split-DNS is needed for them. Guard:
`conformance/tests/l0-static/pihole-lan-names`.
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node the apiserver's
OIDC issuer `keycloak.home.keithjackson.dev` now resolves to Cloudflare and its discovery/JWKS
fetches go through the tunnel (publicly trusted edge cert; no `oidc-ca-file` is configured). The
+4 -2
View File
@@ -30,8 +30,10 @@ metadata:
# applies BEFORE it reads this target. Those same names map to `http_status:404` in the tunnel's
# ingress rules, so even a hand-made record cannot reach them through the tunnel.
#
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
# In-cluster clients are unaffected: CoreDNS answers ${BASE_DOMAIN} with ${GATEWAY_VIP} directly
# (split-horizon). LAN clients use PiHole's EXPLICIT list (catalog/pihole.yaml, since 2026-11-09):
# LAN-only names and gitea/nextcloud/keycloak/auth get ${GATEWAY_VIP}; every other published
# name takes this public path through Cloudflare, from the LAN as from anywhere.
#
# `wan.${BASE_DOMAIN}` (platform/externaldns/ddns.yaml) is NO LONGER a target of anything here;
# it stays only as the WireGuard udp/51820 endpoint.
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
homelab.keithjackson.dev/auth-groups: platform-admins
# LAN/VPN-ONLY (operator decision 2026-11-08). Disowned from external-dns, so the public
# headlamp record and its TXT are DELETED and nothing republishes them; LAN/VPN clients keep
# resolving the name via PiHole's split-horizon wildcard (-> the Gateway VIP). The Cloudflare
# resolving the name via PiHole's explicit entry for it (-> the Gateway VIP). The Cloudflare
# Tunnel also maps headlamp.${BASE_DOMAIN} to http_status:404 (platform/cloudflared/config.yaml).
# Same mechanism the renderer emits for `exposure: internal` services; asserted by
# conformance/tests/l0-static/internal-services-publish-no-public-dns (9c).
+7
View File
@@ -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
+7 -4
View File
@@ -72,11 +72,14 @@ Both are deliberate, and both fail **visibly** rather than silently:
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
*node* process, so it resolves through the node's public upstreams
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
IP — which this router does not hairpin; it answers :443 with its own placeholder
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. At the time,
the public answer for every name in this zone was a CNAME to `wan.home.keithjackson.dev`
— the WAN IP — which this router does not hairpin; it answered :443 with its own placeholder
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
`x509: certificate is not valid for any names`. (Since 2026-11-08 the public answer is
Cloudflare's edge via the Tunnel, not the WAN IP — see `platform/externaldns/README.md`.
The pin is still the right path: it keeps registry pulls on the LAN and off Cloudflare's
proxy.) The Gateway, the wildcard certificate
and the digest were never implicated. The fix is the pin this repository already uses
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
+54 -12
View File
@@ -42,8 +42,9 @@ no version string to match against a CVE, and no amplification to abuse.
That is the whole security argument, and it is why the exposed port carries no IP allowlist (which
would also have made roaming clients unusable — a phone's address changes with every cell tower).
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN: a
resolver answers anybody, so it must be scoped by address. WireGuard answers nobody.
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN and
this tunnel's client subnet: a resolver answers anybody, so it must be scoped by address. WireGuard
answers nobody.
What it does **not** protect against is a stolen device — anyone holding a device's private key
*is* that device. That is what revocation is for: delete the stanza, merge, done (~1 min).
@@ -55,9 +56,9 @@ What it does **not** protect against is a stolen device — anyone holding a dev
┌──────────────┐ udp/51820 ┌───────────────┐ forward ┌────────────────────────────┐
│ WireGuard app│─────────────▶│ WAN 51820 ──▶ │───────────▶│ wg0 10.90.0.1/24 │
│ 10.90.0.2/32 │ over the │ node IP:51820 │ │ decrypt, then FORWARD │
└──────────────┘ internet └───────────────┘ │ SNAT to the node's addr │
└──────────────┘ internet └───────────────┘ │ SNAT (LAN), eBPF LB (VIPs)│
└─────────────┬──────────────┘
│ as `host`/`remote-node`
│ translated per destination (below)
┌─────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
LAN 192.168.2.0/24 Gateway VIP .20 (16 web UIs) WAN / internet
@@ -86,9 +87,21 @@ resolved, because `apps/wave-0/pihole-dns.yaml`'s ingress policy deliberately om
entity. A VPN that looks up and is useless is the worst available outcome, and nothing in the
manifests would have hinted at it.
In the **host** netns there is no pod identity to carry. Forwarded client traffic is SNATed to the
node's own address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
byte-for-byte what a LAN client looks like. Three further problems vanish with it:
In the **host** netns there is no pod identity to carry, which is what avoids the 403s. Clients do
**not**, however, all arrive SNATed to the node. Measured 2026-11-08: Cilium counts `wg0` among its
kube-proxy-replacement devices (`cilium-dbg status`: `eno1`, `wg0`, `tun0`), so what a packet looks
like depends on where it is going:
| destination | handled by | source the destination sees |
|---|---|---|
| LAN hosts, the internet | kernel FORWARD, then the iptables MASQUERADE in [`daemonset.yaml`](./daemonset.yaml) | the node's address |
| LoadBalancer VIPs (Gateway `.20`, PiHole `.23`, ...) | Cilium's eBPF load balancer, as the packet enters from `wg0`, **before** MASQUERADE | `10.90.0.x` at the VIP's source-range check, identity `world` |
The second row is why PiHole's `loadBalancerSourceRanges` has to list `${WG_CLIENT_CIDR}` as well as
the LAN: left LAN-only, tunnel DNS is predicted to die with `DROP_NOT_IN_SRC_RANGE`. That prediction
and the row's source column come from reading Cilium's datapath source; **no peer has ever completed
a handshake, so neither has been observed live.** The Gateway VIP carries no source ranges and needs
no change. Three further problems vanish with the host netns:
* `net.ipv4.ip_forward` is **already 1** on these nodes (`node_baseline` writes
`/etc/sysctl.d/99-k3s-cilium.conf`). It is *not* in the kubelet's safe-sysctl allowlist, so a
@@ -99,7 +112,9 @@ byte-for-byte what a LAN client looks like. Three further problems vanish with i
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node`) and no
`externalTrafficPolicy` trade-off to make at all.
* **No double encapsulation.** Client UDP lands on the node's own NIC, so the underlay is a flat
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` simply correct.
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` correct on the
server's `wg0`. It says nothing about a client's own path: `new-peer.sh` defaults client profiles
to `1280` (see "Troubleshooting" in `runbooks/vpn-remote-access.md`).
`hostNetwork` is *permitted* here, and not by oversight:
`platform/kyverno/policies/restrict-host-namespaces.yaml` matches `namespaceSelector:
@@ -163,6 +178,30 @@ internet path. To enforce less *server-side*, add filtering to the `PostUp` chai
before the accept rules) — the FORWARD chain there is the only enforcement point, since no
NetworkPolicy applies.
## Phone profiles: one tunnel for home, one for away
A phone that needs the LAN-only apps runs full tunnel (`AllowedIPs = 0.0.0.0/0`, `DNS = 192.168.2.23`)
everywhere, so it needs **two tunnels**, each its own peer with its own key and address:
| tunnel | peer | address | Endpoint | use when |
|---|---|---|---|---|
| `away` | `keith-phone` | `10.90.0.2` | `wan.home.keithjackson.dev:51820` | off the home network |
| `home` | `keith-phone-home` | `10.90.0.3` | `192.168.2.10:51820` | on the home Wi-Fi |
**`wan.` cannot work from inside the house.** It is an unproxied A record holding the house's WAN
address (`platform/externaldns/README.md`), and the router has no hairpin NAT, so a LAN client that
sends to it never comes back in through the port-forward. The Cloudflare Tunnel is no way round: it
carries HTTP(S) to the Gateway and WireGuard is UDP to a node, so `wan.` has to stay DNS-only and
`away` has to keep using it.
**`home` depends on `k3s-prod-1`.** Its Endpoint is that node's address, with no failover: if
prod-1 is down, `home` cannot handshake even though the other two listeners are healthy. The fix
is client-side: edit the tunnel's Endpoint to `192.168.2.11` or `.12`, since every node answers the
same keys. No server change and no new key is needed. The same dependency applies to `away` if the
WAN forward points at prod-1.
Android allows one active VPN at a time, so switch tunnels by hand when leaving or arriving.
## Operating it
* **Enrol a device:** `./infra/vpn-ingress/new-peer.sh <name> <octet>` → paste the printed `[Peer]`
@@ -215,10 +254,13 @@ right way; one was a defect in my own manifest.
handshake — and would be a real secret, forcing `peers.conf` into SOPS and costing the
reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in the
`wg-server-key` Secret.
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
still reaches IPv6 sites *outside* the tunnel. Functionally harmless, a privacy leak if the point
was to hide the client's network. Fix is an `fd..` ULA on `wg0` plus `::/0` in the client's
`AllowedIPs`; not done because nothing here needs it.
* **No IPv6.** The server offers no IPv6 tunnel address and `new-peer.sh` emits only `0.0.0.0/0`.
What happens to a client's IPv6 then depends on the client. The **Android app does not leak it**:
its `GoBackend` skips `allowFamily()` when the config is one peer with a default route, so Android
blocks IPv6 for the VPN's duration and apps fall back to IPv4. Other clients are not covered by
that code; expect `wg-quick` on a dual-stack Linux host to leave IPv6 outside the tunnel. Fix is
an `fd..` ULA on `wg0` plus `::/0` in the client's `AllowedIPs`; not done because nothing here
needs it.
* **No in-cluster-service reach beyond what the LAN has.** Clients reach cluster apps the way a LAN
browser does — through the Gateway VIP. Pod IPs and `10.43.0.0/16` service IPs are not routed
into the tunnel.
+6 -3
View File
@@ -30,9 +30,12 @@
# `cluster` entity, so VPN clients would not have been able to resolve `*.${BASE_DOMAIN}` either.
# The VPN would have looked up and been useless, which is the worst failure shape available.
#
# In the host netns there is no pod identity to carry: forwarded client traffic is SNATed to the
# NODE's address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
# exactly what a LAN client looks like. Three further problems disappear with it:
# In the host netns there is no pod identity to carry, so a client never looks like a pod. What a
# destination sees depends on how the packet is handled. LAN hosts and the internet: forwarded,
# then MASQUERADEd to the NODE's address by the PostUp rule below. LoadBalancer VIPs: Cilium's load
# balancer takes the packet as it enters from wg0, before that rule, so the source is still
# 10.90.0.x with identity `world`, like a LAN browser (measured 2026-11-09 for PiHole .23 and Plex
# .24; see README). Three further problems disappear with it:
# * net.ipv4.ip_forward is ALREADY 1 on these nodes (node_baseline writes it to
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
+4
View File
@@ -90,3 +90,7 @@
[Peer]
PublicKey = pBRSHrRyG2hnsuZ6FvHfEzF8hOVTF5stHUb/7fW2Km8=
AllowedIPs = 10.90.0.2/32
# keith-phone-home — <owner>, enrolled 2026-11-08 (at-home profile, LAN Endpoint; no hairpin NAT)
[Peer]
PublicKey = AT8lBJHqrVkv8RL9n0qWDsh5nyl0e3qgrfTAaYKgBz0=
AllowedIPs = 10.90.0.3/32
+113
View File
@@ -0,0 +1,113 @@
# 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 action required: the Keycloak client (R-VULN-04 is NOT delivered until this is done)
Until these steps are done the page cannot complete a login (`invalid_client`, fail-closed). The
scanner, its metrics and its alerts are unaffected.
An agent cannot do this, because every route runs through one encrypted file. The client's secret
must be the key `vuln-scanner-client-secret` in `platform/keycloak/keycloak-realm-clients.sops.yaml`:
the reconciler mounts only that Secret, and the L0 check `keycloak-client-secret-reconciler` rejects a
placeholder that reads any other (a second source would be declared but never healed). That check
also requires the client and the key to land in the same change. Adding a key needs the age private
key. Separately, a completed realm import never re-runs, so a new client has to be created in the
live realm once whatever git says. `*.sops.yaml` is a sensitive path, so the PR needs an approval or
an operator-posted `/approve-override` (`infra/gitea/approval-policy.json`).
**Order matters.** Create the live client first: the reconciler treats every `*-client-secret` key as
a client that must exist, and fails every 15-minute run with `NOT FOUND` otherwise
(`KeycloakClientSecretReconcilerFailing`).
1. Create the client in the live realm. No secret is passed: the reconciler sets it from git after
the merge, and it reads the admin credential from the pod's own environment, so nothing is echoed.
```bash
kubectl --context k3s-prod -n keycloak exec keycloak-0 -- bash -c '
K="/opt/keycloak/bin/kcadm.sh"; C="--config /tmp/kcadm-vuln.config"
$K config credentials $C --server http://localhost:8080 --realm master \
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
D=home.keithjackson.dev
ID=$($K create clients $C -r homelab -i -s clientId=vuln-scanner -s "name=CVE report (oauth2-proxy)" \
-s protocol=openid-connect -s enabled=true -s publicClient=false -s standardFlowEnabled=true \
-s directAccessGrantsEnabled=false -s "redirectUris=[\"https://cve.$D/oauth2/callback\"]" \
-s "webOrigins=[\"https://cve.$D\"]")
$K create clients/$ID/protocol-mappers/models $C -r homelab -s name=groups -s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper -s "config.\"full.path\"=false" \
-s "config.\"id.token.claim\"=true" -s "config.\"access.token.claim\"=true" \
-s "config.\"userinfo.token.claim\"=true" -s "config.\"claim.name\"=groups"
rm -f /tmp/kcadm-vuln.config'
```
`D` is the prod `BASE_DOMAIN`; check it against the `cluster-vars` ConfigMap first.
2. On a branch, generate one value and set it in both encrypted files, through pipes so it is never
echoed. `client-secret` in the scanner's file is replaced so that the two match:
```bash
./infra/devcontainer/dev.sh run bash -c '
cs=$(python3 -c "import secrets; print(secrets.token_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\""'
```
3. 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
```
4. Regenerate the derived URL file with `python3 ci/render-keycloak-client-urls.py --write`, and add
`vuln-scanner-client-secret: REPLACE-ME` to `keycloak-realm-clients.sops.yaml.example`. Commit all of
it in one PR, gate, merge.
5. After Flux applies it, the next reconciler run sets the client's secret and URLs from git. Prove it
with the L2 test `keycloak-client-secrets-match-git`, then log in at `https://cve.<domain>`. The
`platform-admins` restriction is the sidecar's `--allowed-group`; the `groups` mapper above is what
supplies the claim it reads, so a user outside that group gets a 403 from oauth2-proxy.
+15
View File
@@ -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
+96
View File
@@ -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"
+285
View File
@@ -0,0 +1,285 @@
---
# 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
# Registries that need a credential this scanner deliberately does not hold (R-VULN-09).
# Comma-separated hostnames. They report credential-required WITHOUT invoking Trivy:
# the egress policy excludes the LAN, so a real attempt ends in a connection error and
# would read as `failed`, firing VulnCoverageGap permanently. Flux substitutes the domain.
- name: VULN_PRIVATE_REGISTRIES
value: gitea.${BASE_DOMAIN}
- 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 action required). 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
+34
View File
@@ -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
+50
View File
@@ -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
+23
View File
@@ -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
+119
View File
@@ -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
+19
View File
@@ -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
+50
View File
@@ -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
+33
View File
@@ -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
+20
View File
@@ -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
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more