Compare commits

..
Author SHA1 Message Date
renovate-bot 2c33df0bb9 chore(deps): Update docker Docker tag to v29
gate / gate (pull_request) Successful in 2m58s
gate / conformance (pull_request) Has been skipped
2026-11-05 08:17:25 +00:00
212 changed files with 381 additions and 16611 deletions

No files matched your search

+1 -5
View File
@@ -164,14 +164,10 @@ 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
install-tools.sh installs the pinned tools per stage (from ci/versions.env)
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
@@ -1,159 +0,0 @@
# 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"
+10 -99
View File
@@ -3,10 +3,8 @@
# This is the LIVE gate wired to the in-cluster Gitea Actions runner (ns gitea-actions,
# Docker executor + DinD). It invokes the AGGREGATE merge gate — `./ci/gate.sh`, which is
# exactly what `make -C ci gate` runs, so CI and the devcontainer gate the same way
# (R-TEST-02) — and since 2026-11-08 it runs INSIDE the same image: the job `container:` is the
# CI-built devcontainer toolbox (see "THE JOB IMAGE" below), not a generic runner image that
# downloads the pinned tools on every run. (See further below for why the script, not `make`,
# is invoked here.) It runs every stage in order —
# (R-TEST-02): the runner just installs the pinned tools from ci/versions.env first (and see
# below for why the script, not `make`, is invoked here). It runs every stage in order —
# spec-immutability, lint, Kyverno L0, render==golden, L1 offline render — and then applies the
# R-TEST-03 decision, printing
# "MERGE GATE: PASS" (0) / "BLOCKED" (1) / "UNDETERMINED" (3) / "NOT APPLICABLE" (0). It is the
@@ -19,8 +17,7 @@
# kinds of non-run must be told apart HERE, in the caller, because only the caller knows which
# environment it is:
# * a pinned tool missing or a seam unwired ON THIS RUNNER is a DEFECT — the toolchain is
# baked into the job image and checked against ci/versions.env two steps up.
# GATE_STRICT_SKIPS=true DECLARES that (no env sniffing, no
# installed for us two steps up. GATE_STRICT_SKIPS=true DECLARES that (no env sniffing, no
# "does this look like CI?" heuristic) and makes those skips exit 1: untolerated, job fails.
# * spec-immutability CANNOT be verified here at all: the spec repo is a separate repo and is
# not checked out into this runner (SPEC_REPO_SLUG unwired; blueprint in merge-gate.yaml). It
@@ -44,32 +41,6 @@
# same script the laptop runs; the extra pre-step is a read-only git-diff check and is
# purely additive. The full pipeline blueprint (spec-checkout, per-layer jobs, decision job)
# is retained dormant in merge-gate.yaml.
#
# THE JOB IMAGE (2026-11-08, feat/gate-toolbox-container). The gate job runs in
# gitea.home.keithjackson.dev/home-network/toolbox, pinned BY DIGEST — the image
# .gitea/workflows/images.yaml builds from infra/devcontainer/Containerfile, verifies, and
# publishes. It is the SAME image `infra/devcontainer/dev.sh` runs, so a local gate and the CI
# gate now execute the identical toolchain (R-TEST-02), not two installs that agree by intent.
# Before this, the job ran in ghcr.io/catthehacker/ubuntu:act-22.04 and spent ~11s of every run
# re-downloading yamllint, check-jsonschema, kyverno, promtool and chainsaw
# (.gitea/workflows/scripts/install-tools.sh l0 + l1; measured on runs 494/495).
# * PINNED BY DIGEST, MOVED BY RENOVATE. The `# renovate-digest:` marker above the image line
# is the repo-wide convention (renovate.json5, "This repo's own CI-published images"):
# Renovate follows the `main` tag and opens a digest-bump PR, whose OWN gate run then
# executes on the new image — the bump tests itself. infra/gitea/registry-retention.sh reads
# the same marker, so the pinned digest is never pruned. Never hand-edit it to a tag.
# * PULL AUTH: none needed. The Home-Network org is public and the registry issues an
# anonymous pull token (measured 2026-11-08: anonymous /v2/token -> manifest 200), so the
# runner's DinD pulls it like any public image. If the org is ever made private, add
# `credentials:` here with a read:package token secret — the automatic Actions token has no
# package scope at /v2/token (see images.yaml's header).
# * COLD PULL: ~467 MB compressed (1.54 GB unpacked). The DinD store is an emptyDir, so the
# first job after a runner-pod restart or a digest bump pays one pull; every other run finds
# it cached (forcePull=false), exactly as it already did for the catthehacker image.
# * NO NODE in the toolbox, so `actions/checkout` (a Node action) cannot run here. The checkout
# step is plain git instead — see its comment for how it keeps fetch-depth-0 semantics.
# * install-tools.sh is NOT deleted: merge-gate.yaml (the dormant blueprint) still calls it,
# and conformance/tests/l0-static/alert-rules-unit-tested asserts its promtool wiring.
---
name: gate
@@ -107,76 +78,16 @@ jobs:
# Single required status check: the static merge gate.
gate:
runs-on: ubuntu-latest
# The CI-built toolbox — the same image dev.sh runs. See "THE JOB IMAGE" in the header.
container:
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
steps:
# Plain git, because actions/checkout is a Node action and the toolbox has no Node. It
# reproduces what actions/checkout@v7 with `fetch-depth: 0` did here (read from its logged
# commands on runs 490/495): full history of every branch + tags, so
# ci/touched-requirements.sh can diff against origin/<base>; the exact event commit
# checked out (detached for a PR, as `-B <branch>` for a push). One difference, on
# purpose: the job token is passed to the single fetch through GIT_CONFIG_* environment
# variables and is NOT persisted into .git/config — nothing after this step needs
# authenticated git (ci/ and conformance/ make no network git calls), so there is no
# reason to leave a credential in the workspace for the gate's scripts to read.
- name: Checkout (git, full history)
env:
GIT_TOKEN: ${{ github.token }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
# act runs steps with umask 0000, so without this every checked-out directory is
# world-writable — and Ansible then IGNORES infra/ansible/ansible.cfg as a source
# ("Ansible is being run in a world writable directory"), which made the L0 check
# prod-baremetal-not-dev-surface fail on this PR's first CI run (run 500). The
# toolbox has Ansible, so that check now drives it for real. 0022 = what a
# workstation clone gets (reproduced locally: 0000 fails the check, 0022 passes).
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 --no-tags --prune --no-recurse-submodules origin \
'+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' \
"+${SHA}:refs/remotes/checkout/head"
unset auth GIT_TOKEN
case "$REF" in
refs/heads/*) git checkout -q --force -B "${REF#refs/heads/}" "$SHA" ;;
*) git checkout -q --force --detach "$SHA" ;;
esac
[ "$(git rev-parse HEAD)" = "$SHA" ] || { echo "::error::HEAD is not ${SHA}" >&2; exit 1; }
echo "checked out $(git log -1 --format='%H %s') (ref ${REF}); $(git rev-list --count HEAD) commits of history"
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main
# The image is the toolchain, so prove it is the PINNED one before trusting it: every
# *_VERSION in ci/versions.env must equal what the image recorded at build time
# (/etc/dev-toolchain-versions, written by infra/devcontainer/Containerfile; its `verify`
# stage already asserted the version-sensitive binaries report those pins). This replaces
# the per-run install: a drifted toolbox fails HERE, naming the tool, instead of gating with
# a different engine. ci/versions.env and infra/devcontainer/versions.env must move together.
- name: Toolchain is the one ci/versions.env pins
- name: Install pinned CI toolchain (ci/versions.env)
run: |
set -euo pipefail
. ci/versions.env
drift=0
for var in $(grep -oE '^[A-Z_]+_VERSION' ci/versions.env); do
tool="$(printf '%s' "${var%_VERSION}" | tr 'A-Z_' 'a-z-')"
want="${!var}"
got="$(sed -n "s/^${tool}=//p" /etc/dev-toolchain-versions)"
if [ "$got" = "$want" ]; then
printf 'ok %-18s %s\n' "$tool" "$want"
else
printf '::error::DRIFT %s: ci/versions.env pins %s, the job image has %s\n' "$tool" "$want" "${got:-nothing}"
drift=1
fi
done
[ "$drift" -eq 0 ]
./.gitea/workflows/scripts/install-tools.sh l0
./.gitea/workflows/scripts/install-tools.sh l1
# R-TEST-06: the spec is read-only to agents. An EXPLICIT early surfacing of the
# spec-immutability check, so its result is a distinct, tolerant status line in the
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0 # the per-image change detection diffs against the base commit
+10 -10
View File
@@ -60,9 +60,9 @@ jobs:
spec-immutability:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # the implementation repo
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # the implementation repo
- name: Check out the read-only spec (sibling path)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ vars.SPEC_REPO_SLUG }} # e.g. Home-Network/home-network-k3s (set in Gitea)
path: ../home-network-k3s
@@ -74,7 +74,7 @@ jobs:
runs-on: ubuntu-latest
needs: [spec-immutability]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { fetch-depth: 0 } # full history so touched-requirement diff works
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh lint
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
needs: [lint]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh l0
@@ -96,7 +96,7 @@ jobs:
runs-on: ubuntu-latest
needs: [l0]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh l1
@@ -107,7 +107,7 @@ jobs:
runs-on: ubuntu-latest
needs: [render-golden]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh l1
@@ -115,7 +115,7 @@ jobs:
run: make -C ci l1
- name: Upload L1 artifacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: l1-junit-coverage
path: |
@@ -131,7 +131,7 @@ jobs:
needs: [l1]
if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { fetch-depth: 0 }
- name: Install pinned tools
run: ./.gitea/workflows/scripts/install-tools.sh conformance
@@ -142,7 +142,7 @@ jobs:
run: make -C ci conformance
- name: Upload L2/L3 artifacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: conformance-junit-coverage
path: |
@@ -160,7 +160,7 @@ jobs:
needs: [lint, l0, render-golden, l1, conformance]
if: always()
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Decide
env:
R_LINT: ${{ needs.lint.result }}
-820
View File
@@ -1,820 +0,0 @@
#!/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 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 |
| 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 |
| 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** |
-5
View File
@@ -100,11 +100,6 @@ metadata:
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP.
# This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443.
external-dns.alpha.kubernetes.io/hostname: "unifi.${BASE_DOMAIN}"
# DNS-ONLY, NEVER PROXIED (2026-11-08). external-dns runs with --cloudflare-proxied globally (the
# gateway names point at the Cloudflare Tunnel); Cloudflare's proxy carries HTTP(S) only, so an
# orange-clouded A for these L4 ports would be broken. Required by the L0 check
# internal-services-publish-no-public-dns (9b) on every `service`-source publisher.
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
+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, 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
`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
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.
+3 -7
View File
@@ -78,7 +78,7 @@ spec:
spec:
containers:
- name: gitea
image: gitea/gitea:28.3.0
image: gitea/gitea:1.24.3
ports:
- containerPort: 3000
env:
@@ -89,18 +89,14 @@ spec:
secretKeyRef:
name: gitea-bootstrap
key: secret-key
- name: GITEA__server__DOMAIN
value: gitea.${BASE_DOMAIN}
- name: GITEA__server__ROOT_URL
value: https://gitea.${BASE_DOMAIN}/
- name: GITEA__actions__ENABLED
value: 'true'
- name: GITEA__actions__DEFAULT_ACTIONS_URL
value: github
- name: GITEA__actions__RUN_RETENTION_DAYS
value: '400'
- name: GITEA__security__EGRESS_MODE
value: lax
- name: GITEA__service__DISABLE_REGISTRATION
value: 'true'
- name: GITEA_ADMIN_USERNAME
valueFrom:
secretKeyRef:
+2 -2
View File
@@ -78,14 +78,14 @@ spec:
spec:
containers:
- name: jackett
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
image: lscr.io/linuxserver/jackett:v0.24.2865-ls7
ports:
- containerPort: 9117
volumeMounts:
- name: config
mountPath: /config
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
+2 -2
View File
@@ -48,7 +48,7 @@ spec:
# Scaling out is non-disruptive: CNPG creates -2 then -3 sequentially, each seeded by
# pg_basebackup from the running primary, which is neither restarted nor disconnected.
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.15
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# Stamp app.kubernetes.io/name=nextcloud-db onto every generated pod/service so the rendered
# nextcloud egress CiliumNetworkPolicy's toEndpoints selector matches the primary pod (R-NET-02).
inheritedMetadata:
@@ -300,7 +300,7 @@ spec:
# is already in it and it is already present on every node, so this
# adds no new image and no new supply-chain surface. Keep the tag in
# step with spec.imageName above when Renovate bumps it.
image: ghcr.io/cloudnative-pg/postgresql:16.15
image: ghcr.io/cloudnative-pg/postgresql:16.4
imagePullPolicy: IfNotPresent
command:
- psql
+5 -19
View File
@@ -83,25 +83,15 @@
# and would also fix the same latent defect in unifi and crafty; it belongs in its own change.
#
# -------------------------------------------------------------------------------------------------
# loadBalancerSourceRanges — THE LAN + VPN RESTRICTION, ENFORCED IN THE DATAPATH.
# loadBalancerSourceRanges — THE LAN 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} and ${WG_CLIENT_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} 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
@@ -123,7 +113,6 @@ spec:
externalTrafficPolicy: Cluster
loadBalancerSourceRanges:
- ${LAN_CIDR}
- ${WG_CLIENT_CIDR}
selector:
app.kubernetes.io/name: pihole
ports:
@@ -144,7 +133,6 @@ spec:
externalTrafficPolicy: Cluster
loadBalancerSourceRanges:
- ${LAN_CIDR}
- ${WG_CLIENT_CIDR}
selector:
app.kubernetes.io/name: pihole
ports:
@@ -170,9 +158,7 @@ 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} + ${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.
# policy is consulted. Together: ${LAN_CIDR} in the datapath, udp/53 + tcp/53 here.
#
# 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;
@@ -201,7 +187,7 @@ spec:
app.kubernetes.io/name: pihole
ingress:
- fromEntities:
- world # LAN + VPN clients, post-LoadBalancer: see the block above for why not fromCIDR
- world # LAN 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:
+2 -2
View File
@@ -73,7 +73,7 @@ spec:
- name: FTLCONF_dns_rateLimit_count
value: '0'
- name: FTLCONF_misc_dnsmasq_lines
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}
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}
- name: FTLCONF_webserver_api_password
valueFrom:
secretKeyRef:
@@ -90,7 +90,7 @@ spec:
- -c
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
+1 -3
View File
@@ -120,7 +120,7 @@ spec:
spec:
containers:
- name: plex
image: plexinc/pms-docker:1.43.6.11085-d1e52e169
image: plexinc/pms-docker:1.43.4.10903-e5521bd8c
ports:
- containerPort: 32400
env:
@@ -302,8 +302,6 @@ metadata:
namespace: plex
labels:
app.kubernetes.io/name: plex
annotations:
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+2 -2
View File
@@ -122,7 +122,7 @@ spec:
spec:
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent:5.3.0
image: lscr.io/linuxserver/qbittorrent:5.1.2
ports:
- containerPort: 8080
env:
@@ -156,7 +156,7 @@ spec:
, \"[Preferences]\"\n\ndef matches(raw):\n \"\"\"True iff the stored digest is one OUR password produces (salt is read from it).\"\"\"\n v = raw.strip().strip('\"')\n if v.startswith(\"@ByteArray(\") and v.endswith(\")\"):\n v = v[len(\"@ByteArray(\"):-1]\n try:\n salt_b64, digest_b64 = v.split(\":\", 1)\n salt = base64.b64decode(salt_b64, validate=True)\n digest = base64.b64decode(digest_b64, validate=True)\n except Exception:\n return False\n if not salt or not digest:\n return False\n return hmac.compare_digest(\n hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, len(digest)), digest)\n\ntry:\n lines = open(conf, encoding=\"utf-8\", errors=\"replace\").read().splitlines()\nexcept FileNotFoundError:\n lines = []\n\ndef value_of(key):\n for i, line in enumerate(lines):\n if line.startswith(key + \"=\"):\n return i, line.split(\"=\", 1)[1]\n return -1, None\n\nui, uval = value_of(UKEY)\npi, pval = value_of(PKEY)\nif uval == user and pval is not None and matches(pval):\n raise SystemExit(0) # converged - leave the file (and its salt) alone\n\nsalt = os.urandom(SALT_LEN)\ndigest = hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, DIGEST_LEN)\nstored = base64.b64encode(salt).decode() + \":\" + base64.b64encode(digest).decode()\ndesired = [UKEY + \"=\" + user, PKEY + '=\"@ByteArray(' + stored + ')\"']\n\nout = [line for i, line in enumerate(lines) if i not in (ui, pi)]\nif SECTION in out:\n at = out.index(SECTION) + 1\nelse:\n if out and out[-1].strip():\n out.append(\"\")\n out.append(SECTION)\n at = len(out)\nout[at:at] = desired\n\ntry:\n os.makedirs(os.path.dirname(conf) or \".\", exist_ok=True)\n tmp = conf + \".webui-cred.new\"\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n fh.write(\"\\n\".join(out) + \"\\n\")\n fh.flush()\n os.fsync(fh.fileno())\n # This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would\n # cost the app the ability to save its own settings, so carry the ownership over.\n try:\n st = os.stat(conf)\n os.chown(tmp, st.st_uid, st.st_gid)\n os.chmod(tmp, stat.S_IMODE(st.st_mode))\n except FileNotFoundError:\n os.chown(tmp, int(os.environ.get(\"PUID\", \"1000\")),\n int(os.environ.get(\"PGID\", \"1000\")))\n os.chmod(tmp, 0o664)\n os.replace(tmp, conf) # atomic: the app never reads a half-written conf\nexcept OSError as exc:\n sys.stderr.write(\"[qbt-webui-cred] cannot write %s: %s\\n\" % (conf, exc))\n raise SystemExit(1)\nraise SystemExit(10)\nPY\n rc=$?\n\n if [ \"$rc\" = 0 ]; then\n log \"conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)\"\n elif [ \"$rc\" = 10 ]; then\n log \"asserted WebUI Username + Password_PBKDF2 into $CONF\"\n # postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY\n # have read the old, credential-less conf and be serving a random temporary password.\n # Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.\n # Nothing to do when the service has not started yet - it will read the new file.\n # Its own counter, NOT the shared `waited` API budget: sharing one counter is how the\n # 2026-09-13 failure spent the entire verification budget before the first login and\n # then fired attempts 2 and 3 with no wait left at all.\n sv=0\n while [ \"$sv\" -lt 60 ] && [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" != \"true\" ]; do\n sv=$((sv + 1)); sleep 1\n done\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"qbittorrent-nox was already running - restarting it to load the credential\"\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\"\n sleep 3\n fi\n else\n log \"FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep\" >&2\n log \"minting a random password\
\ every start and BOTH arrs will break. Fix and restart.\" >&2\n exit 0\n fi\n\n # (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at ALL\n # (shared ~120s budget across attempts), then spend exactly ONE login per attempt -\n # at most 3 rejected logins, which stays under qBittorrent's default ban threshold.\n #\n # \"THE API NEVER ANSWERED\" IS A DIFFERENT FAULT FROM \"THE PASSWORD WAS REJECTED\", and\n # conflating them is what made this hook lie once already (see the PORT comment). An\n # unreachable WebUI is reported as unreachable, and the hook stops rather than spending\n # its remaining login attempts to produce two more identical wrong diagnoses.\n api_ok=0\n while [ \"$waited\" -lt 120 ]; do\n code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' \"http://127.0.0.1:$PORT/\" 2>/dev/null)\n if [ -n \"$code\" ] && [ \"$code\" != \"000\" ]; then api_ok=1; break; fi\n waited=$((waited + 1)); sleep 1\n done\n if [ \"$api_ok\" != 1 ]; then\n log \"the WebUI never answered on 127.0.0.1:$PORT within the wait budget, so the\" >&2\n log \"credential in $CONF is UNVERIFIED. This is NOT a rejected password - it was\" >&2\n log \"never submitted. Do not rotate the credential on the strength of this line.\" >&2\n exit 0\n fi\n body=$(curl -sS -m 10 \\\n --data-urlencode \"username=$QBT_WEBUI_USERNAME\" \\\n --data-urlencode \"password=$QBT_WEBUI_PASSWORD\" \\\n \"http://127.0.0.1:$PORT/api/v2/auth/login\" 2>/dev/null)\n if [ \"$body\" = \"Ok.\" ]; then\n log \"VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'\"\n exit 0\n fi\n log \"WebUI rejected the durable credential (login said '$body')\" >&2\n # A rejection when the conf was ALREADY converged (rc=0, nothing written) can only mean\n # the running qbittorrent-nox is serving something it read from an EARLIER conf, so\n # rewriting the same bytes on the next attempt would change nothing. The remedy is to\n # make the process RE-READ the file, so restart the supervised service and let the next\n # attempt re-verify. Without this the retry loop could never change its own outcome.\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"restarting qbittorrent-nox so it re-reads $CONF, then re-verifying\" >&2\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\" >&2\n sleep 3\n fi\ndone\n\nlog \"GAVE UP after $attempt attempts: the WebUI does not accept the credential from git.\" >&2\n# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.\n# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the\n# app, and the arrs' own download-client health check surfaces a broken credential.\nexit 0\n"
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
+1 -1
View File
@@ -49,7 +49,7 @@ spec:
# Scaling out is non-disruptive: CNPG creates -2 then -3 sequentially, each seeded by
# pg_basebackup from the running primary, which is neither restarted nor disconnected.
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.15
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# Stamp app.kubernetes.io/name=semaphore-db onto every generated pod/service so the rendered
# semaphore egress CiliumNetworkPolicy's toEndpoints selector matches the primary pod (R-NET-02).
inheritedMetadata:
+6 -70
View File
@@ -77,42 +77,6 @@ spec:
requests:
storage: 1Gi
---
# act_runner config file — DELTAS ONLY. act_runner v0.6.1 LoadDefault()
# (internal/pkg/config/config.go) unmarshals this file onto a ZERO Config and then fills every
# unset field with the same default it uses when there is no file at all, so a file holding
# only `runner.capacity` changes exactly that and nothing else. Do NOT paste in the output of
# `act_runner generate-config`: that example is NOT the defaults. Its `runner.labels` would
# OVERRIDE the registered labels in /data/.runner (daemon.go: config labels win when non-empty)
# and repoint ubuntu-latest at a different job image; its `force_pull: true` differs from the
# no-file default (false); and its sample `envs` would be injected into every job.
#
# Using a file also makes act_runner IGNORE the legacy GITEA_RUNNER_CAPACITY / _FILE /
# _ENVIRON / _ENV_FILE / GITEA_DEBUG / GITEA_TRACE env vars (deprecated.go). None of them is
# set on this Deployment; the GITEA_INSTANCE_URL / _REGISTRATION_TOKEN / _NAME / _LABELS env
# below is consumed by the image's run.sh for registration, not by that legacy path.
#
# A ConfigMap edit alone does NOT restart the runner (it reads the file once at start). If a
# future change touches ONLY this ConfigMap, also bump the Deployment's pod template (e.g. the
# config-revision annotation below) or the new value stays inert until the next restart.
apiVersion: v1
kind: ConfigMap
metadata:
name: act-runner-config
namespace: gitea-actions
labels:
app.kubernetes.io/name: act-runner
data:
config.yaml: |
runner:
# Two concurrent jobs (default 1). Measured 2026-11-08: queue wait ~1s normally, but
# 29-759s whenever gate / images / renovate runs collide (run 472 waited 759s behind a
# renovate burst). Both jobs share the ONE dind daemon below; act_runner names each job's
# containers GITEA-ACTIONS-TASK-<task id> and creates a network per job, so they do not
# collide. Workflow `concurrency:` groups are enforced by Gitea server-side and are
# unaffected: gate.yml's gate-<ref> group still cancels a superseded run on the SAME ref,
# and renovate's group still serialises renovate. The dind resources are sized for TWO jobs.
capacity: 2
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -131,10 +95,6 @@ spec:
metadata:
labels:
app.kubernetes.io/name: act-runner
annotations:
# Bump when act-runner-config changes without any other pod-template change, so the
# Deployment rolls and act_runner re-reads its config (see the ConfigMap header).
homelab.keithjackson.dev/act-runner-config-revision: "1" # capacity 2
spec:
# CI holds no cluster credentials — do not mount a SA token into the runner or
# (via the shared netns) the job containers (R-SEC-02, docs/14).
@@ -186,23 +146,12 @@ spec:
mountPath: /certs
- name: dind-storage
mountPath: /var/lib/docker
# Sized for runner.capacity: 2. Every job's containers run UNDER this daemon, so this
# container's cgroup carries the whole CI workload. Measured 2026-11-08 (Prometheus,
# capacity 1): peak CPU 0.69 core (1m rate, 30d), peak working set 447Mi (30d), 0 OOMs.
# cpu request 2: two jobs at today's peak is ~1.4 core, and the gate is being
# parallelised, so per-job demand rises. The request is what the CFS share is
# weighted by under contention (100m was a 1/20 share); NO cpu limit, deliberately,
# so an idle node's spare cores are still usable. k3s-prod-2 (8 CPU) had 2945m
# requested; +1.9 core = ~4.85 (61%), and prod-1/prod-3 fit too if it reschedules.
# memory request 1Gi / limit 4Gi: two jobs at today's peak is ~0.9Gi. An OOM kill here
# takes down dockerd and with it BOTH running jobs, so the limit carries ~4x headroom
# over that for the parallelised gate. Nodes have ~31Gi allocatable.
resources:
requests:
cpu: "2"
memory: 1Gi
cpu: 100m
memory: 256Mi
limits:
memory: 4Gi
memory: 2Gi
startupProbe:
exec:
command:
@@ -218,7 +167,7 @@ spec:
containers:
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
- name: runner
image: gitea/act_runner:0.6.1
image: gitea/act_runner:0.2.11
# No `command:` override. The image's own entrypoint (/sbin/tini -- /opt/act/run.sh)
# registers from env on first boot and runs `act_runner daemon`. The two wait-loops
# that used to live here are gone: kubelet now holds this container until the dind
@@ -246,36 +195,23 @@ spec:
value: /certs/client
- name: DOCKER_TLS_VERIFY
value: "1"
# The image's run.sh passes `--config <this>` to BOTH `act_runner register` and
# `act_runner daemon` when CONFIG_FILE is set (act_runner v0.6.1 scripts/run.sh).
- name: CONFIG_FILE
value: /etc/act_runner/config.yaml
volumeMounts:
- name: data
mountPath: /data
- name: certs
mountPath: /certs
readOnly: true
- name: config
mountPath: /etc/act_runner
readOnly: true
# Measured 2026-11-08 at capacity 1: peak CPU 0.24 core, peak working set 314Mi (30d)
# against the old 512Mi limit. It holds per-task state, log buffers and the embedded
# actions/cache server for every concurrent task, so the limit is doubled for capacity 2.
resources:
requests:
cpu: 100m
memory: 256Mi
memory: 128Mi
limits:
memory: 1Gi
memory: 512Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: act-runner-data
- name: certs
emptyDir: {}
- name: config
configMap:
name: act-runner-config
- name: dind-storage
emptyDir: {}
+2 -2
View File
@@ -122,7 +122,7 @@ spec:
spec:
containers:
- name: radarr
image: lscr.io/linuxserver/radarr:5.28.0
image: lscr.io/linuxserver/radarr:5.21.1
ports:
- containerPort: 7878
env:
@@ -138,7 +138,7 @@ spec:
- name: media
mountPath: /media
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
+1 -1
View File
@@ -78,7 +78,7 @@ spec:
spec:
containers:
- name: semaphore
image: semaphoreui/semaphore:v2.19.17
image: semaphoreui/semaphore:v2.19.10
ports:
- containerPort: 3000
env:
+1 -1
View File
@@ -138,7 +138,7 @@ spec:
- name: media
mountPath: /media
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
+1 -1
View File
@@ -75,7 +75,7 @@ spec:
spec:
containers:
- name: seerr
image: ghcr.io/seerr-team/seerr:v3.5.1
image: ghcr.io/seerr-team/seerr:v3.4.1
ports:
- containerPort: 5055
volumeMounts:
+5 -39
View File
@@ -10,7 +10,7 @@
name: gitea
kind: container
source:
image: gitea/gitea:28.3.0 # pinned recent stable tag (R-UPD-01); update.range governs advancement
image: gitea/gitea:1.24.3 # pinned recent stable tag (R-UPD-01); update.range governs advancement
# Headless-install bootstrap (formerly apps/gitea-bootstrap-patch.yaml; now first-class via the
# ServiceDefinition env/envFrom/lifecycle fields, schema >=1.2.0). Gitea must come up INSTALLED
# without the interactive web wizard and with a FIXED, persistent SECRET_KEY, so all of this is
@@ -23,43 +23,14 @@ env:
secretKeyRef:
name: gitea-bootstrap
key: secret-key
# ROOT_URL is the ONLY source of the instance domain since Gitea 28 (go-gitea/gitea#39400): [server]
# DOMAIN is no longer read, and the SSH domain defaults from ROOT_URL too. GITEA__server__DOMAIN was
# removed in the 28 upgrade; the stale DOMAIN line environment-to-ini already wrote into app.ini is
# inert. Correct public clone/redirect URLs for the TLS-terminated host come from this one value.
- name: GITEA__server__DOMAIN # correct public clone/redirect URLs for the TLS-terminated host
value: gitea.${BASE_DOMAIN}
- name: GITEA__server__ROOT_URL
value: https://gitea.${BASE_DOMAIN}/
- name: GITEA__actions__ENABLED # Phase 6 CI/CD: declared (not implicit upstream default) so the
value: "true" # in-cluster act_runner (ns gitea-actions) registers against it
- name: GITEA__actions__DEFAULT_ACTIONS_URL # resolve `uses: <org>/<action>@ref` from github.com (cluster
value: github # has internet egress; not air-gapped)
# Gitea 28 added run retention (go-gitea/gitea#38855): completed runs, with their jobs, logs and
# artifacts, are deleted by the cleanup_action_runs cron after RUN_RETENTION_DAYS. Upstream's default
# (400) is kept but DECLARED, so the deletion horizon is visible here rather than implicit. The spec
# repo states no CI-history retention requirement; 0 would mean keep forever.
- name: GITEA__actions__RUN_RETENTION_DAYS
value: "400"
# Gitea 28 (go-gitea/gitea#39426) puts webhooks, the OAuth2 avatar fetch and OpenID 2.0 behind an
# egress policy: in the default lax mode, PUBLIC targets are allowed and private/loopback/CGNAT
# targets need an IP or CIDR entry in [security] ALLOWED_HOST_LIST (a hostname entry is NOT enough
# for a private address). DECLARED lax with an EMPTY allow list, deliberately:
# * the Keycloak OIDC login source is NOT gated by it. In 28.3.0 the openidConnect provider is
# goth's, built with its default HTTP client (services/auth/source/oauth2/providers_openid.go);
# egress.NewSecurityPolicy is used only by routers/web/auth/oauth.go (avatar fetch, and
# [oauth2_client] UPDATE_AVATAR is off here) and modules/auth/openid (OpenID 2.0, unused).
# So keycloak.<domain> resolving to the private Gateway address in-cluster does not matter.
# * this instance has no webhooks and no mirrors (measured 2026-11-07), and 1.27 already refused
# private webhook targets by default, so nothing that worked before is newly denied.
# Adding an entry here would only WIDEN the webhook SSRF surface. Add the narrowest CIDR (e.g. the
# Gateway address /32) only if a real private webhook target appears.
- name: GITEA__security__EGRESS_MODE
value: lax
# Gitea 28 (go-gitea/gitea#39400) disables LOCAL self-registration unless DISABLE_REGISTRATION is
# explicitly false; 1.27 defaulted it open. Declared true: accounts arrive via Keycloak only.
# OAuth2 auto-registration below is unaffected — routers/web/auth/oauth.go gates it on
# ALLOW_ONLY_INTERNAL_REGISTRATION and ENABLE_AUTO_REGISTRATION, not on DISABLE_REGISTRATION.
- name: GITEA__service__DISABLE_REGISTRATION
value: "true"
- name: GITEA_ADMIN_USERNAME # break-glass local admin creds (consumed by the postStart hook)
valueFrom:
secretKeyRef:
@@ -262,14 +233,9 @@ auth:
# recovery tools (docs/15 "Availability caveat"). No secret is embedded here.
update:
channel: stable # R-UPD-01
range: ">=28.0.0 <29.0.0" # Gitea dropped the "1." prefix at 28.0.0 (successor of 1.27); stay on 28.x
range: ">=1.22.0 <2.0.0" # stay on the 1.x stable line
cadence: "0 4 * * *"
# R-UPD-03. INERT FOR THIS SERVICE, and it must stay that way: this flag renders only an annotation
# on a Flux ImagePolicy, and apps/ is rendered --no-image-automation (ci/render-golden.sh), so no
# ImagePolicy exists and the Deployment (strategy Recreate) is never auto-reverted. That matters
# because a Gitea upgrade (1.27 -> 28 included) MIGRATES THE SQLITE DB ONE-WAY: an image revert onto
# a migrated DB is not a rollback. Roll Gitea back by RESTORING THE DATA PVC from its pre-upgrade backup.
autoRollbackOnFailedHealth: true
autoRollbackOnFailedHealth: true # R-UPD-03
observability:
metrics: true
slis:
+1 -1
View File
@@ -16,7 +16,7 @@
name: jackett
kind: container
source:
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
image: lscr.io/linuxserver/jackett:v0.24.2865-ls7
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:
+31 -58
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}, ${WG_CLIENT_CIDR}]` on the DNS Services,
# the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_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,61 +108,41 @@ env:
# `loadBalancer.mode: dsr`, which preserves the client address.
- name: FTLCONF_dns_rateLimit_count
value: "0"
# 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.
# 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.
#
# 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).
# 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.
#
# 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.
# 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.
#
# 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 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.
# 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.
#
# ── 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":
@@ -192,17 +172,10 @@ 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 every listed name.
# configuration, /etc/hosts and DHCP leases), so one directive covers all five names.
- name: FTLCONF_misc_dnsmasq_lines
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}"
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}"
# 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.
+2 -9
View File
@@ -12,7 +12,7 @@
name: plex
kind: container
source:
image: plexinc/pms-docker:1.43.6.11085-d1e52e169
image: plexinc/pms-docker:1.43.4.10903-e5521bd8c
# plexinc/pms-docker env. Rendered VERBATIM onto the app container by render.py (schema env list).
env:
# PLEX_UID/PLEX_GID: the image boots as root (s6 PID1), chowns /config to this uid/gid, then drops
@@ -347,14 +347,7 @@ lifecycle:
# `runbooks/plex-quicksync.md` is that procedure, plus how to verify it took.
hardware:
gpu: intel-quicksync
# EXPOSURE: internal (operator decision 2026-11-08, Cloudflare Tunnel cut-over). The plex.${BASE_DOMAIN}
# web UI is LAN/VPN-only: `internal` renders `external-dns ... controller: none` on the HTTPRoute, so
# external-dns DELETES the public plex record and its TXT, and platform/cloudflared/config.yaml maps the
# name to http_status:404 so it can never be served through the tunnel either. REMOTE STREAMING IS NOT
# AFFECTED: it never used this name. It rides plex.tv + the advertised plex.direct URL (ADVERTISE_IP
# above) to the raw tcp/32400 L4 VIP (${PLEX_L4_VIP}, apps/wave-0/plex-remote.yaml) via the WAN
# tcp/32400 forward — none of which this field touches. See runbooks/plex-remote-access.md.
exposure: internal # web UI LAN/VPN-only; remote streaming = plex.tv + WAN tcp/32400 (docs/09; R-DNS/R-NET)
exposure: public # edge port-forward + public DNS; clients + Seerr reach it (docs/09; R-DNS/R-NET)
egress: internet # normal WAN egress (docs/03; R-NET-05)
storage:
- name: config
+1 -1
View File
@@ -30,7 +30,7 @@
name: qbittorrent
kind: container
source:
image: lscr.io/linuxserver/qbittorrent:5.3.0 # pinned stable tag (R-UPD-01); update.range governs advancement
image: lscr.io/linuxserver/qbittorrent:5.1.2 # pinned stable tag (R-UPD-01); update.range governs advancement
# LinuxServer images run their service as the user `abc`, whose uid/gid these two set. They are
# LOAD-BEARING FOR THE BULK MOUNT, not cosmetic — exactly as on sonarr/radarr. Measured on prod
# 2026-09-12 BEFORE this change: `id` inside the container reported the qbittorrent-nox process as
+1 -1
View File
@@ -8,7 +8,7 @@
name: radarr
kind: container
source:
image: lscr.io/linuxserver/radarr:5.28.0 # pinned stable v5 tag (R-UPD-01); update.range governs advancement
image: lscr.io/linuxserver/radarr:5.21.1 # pinned stable v5 tag (R-UPD-01); update.range governs advancement
# LinuxServer images run their service as the user `abc`, whose uid/gid these two set. They are
# LOAD-BEARING for the bulk mount, not cosmetic: see the storage note below. The default is 911,
# which cannot write a 1000:3000-owned 0755 tree.
+1 -1
View File
@@ -129,7 +129,7 @@ AUTH_MODES = ("oidc", "forward-auth", "native", "none")
# the sidecar authenticates the browser against Keycloak (homelab realm, the app's auth.groups)
# and only THEN proxies to the co-located app UI at http://127.0.0.1:<uiport>. The gate is IN
# the data path (fail-closed: no valid session in an authorized group => no upstream; R-IDP-02).
OAUTH2_PROXY_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.15.6"
OAUTH2_PROXY_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.8.1"
OAUTH2_PROXY_PORT = 4180
KEYCLOAK_ISSUER = f"https://keycloak.{ZONE}/realms/homelab"
+1 -1
View File
@@ -24,7 +24,7 @@
name: seerr
kind: container
source:
image: ghcr.io/seerr-team/seerr:v3.5.1 # Seerr: maintained Overseerr/Jellyseerr successor, Plex-native request UI (docs/13 row 5); served on 5055
image: ghcr.io/seerr-team/seerr:v3.4.1 # Seerr: maintained Overseerr/Jellyseerr successor, Plex-native request UI (docs/13 row 5); served on 5055
# The ghcr.io/seerr-team/seerr image runs as the non-root user `node:node` (UID 1000 / GID 1000 —
# the standard node base-image user; confirmed from the image config's User field). On boot the app
# creates /app/config/logs and writes its settings/DB under the mounted /app/config PVC. A fresh
+1 -1
View File
@@ -22,7 +22,7 @@
name: semaphore
kind: container
source:
image: semaphoreui/semaphore:v2.19.17 # pinned recent stable tag (R-UPD-01); update.range governs advancement
image: semaphoreui/semaphore:v2.19.10 # pinned recent stable tag (R-UPD-01); update.range governs advancement
env:
# --- PostgreSQL connection (the CNPG-DB pattern). DIALECT/HOST/PORT/DB/USER are literals; PASS is
# sourced from the in-namespace SOPS secret (the CNPG -app secret lives in ns semaphore-db,
+1 -1
View File
@@ -99,7 +99,7 @@ caller that must distinguish them invokes `./ci/gate.sh` directly.
| stage | a skip there means | verdict |
|---|---|---|
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is the job image — the CI-built toolbox, `.gitea/workflows/gate.yml` `container:` — so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
| `decisions-log` | **nothing** — this stage has no skip path. It reads only files in the tree under test and needs no tool and no cluster, so there is no environment in which it "could not run" | a reported skip is a **defect in the stage** and BLOCKS. The stage fails when the frozen `DECISIONS-LOG.md` is modified (which silently re-creates, for every *other* open branch, the tail-hunk conflict that made four of five PRs unmergeable on 2026-09-29) or when a `decisions/` entry is malformed. There is deliberately **no** `REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard. See `decisions/README.md`. |
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
+3 -3
View File
@@ -46,8 +46,8 @@
# WHICH SKIPS BLOCK, AND WHY (per stage — a skip is not one thing):
#
# lint / l0 / render-golden / l1 A skip here means a PINNED tool is absent or a wired
# seam went unwired. On a CI runner the pinned toolchain is the job image itself (the
# CI-built toolbox, .gitea/workflows/gate.yml), so a skip there is a DEFECT, not an
# seam went unwired. On a CI runner the pinned toolchain is installed for us by
# .gitea/workflows/scripts/install-tools.sh, so a skip there is a DEFECT, not an
# environment: it BLOCKS. On a workstation a missing tool is a real local condition, so
# it degrades to UNDETERMINED (still non-zero, still never a pass). See strict_skips()
# in ci/lib.sh for how CI is detected and why misdetecting it cannot fail open.
@@ -257,7 +257,7 @@ for s in lint l0 render-golden l1; do
skip)
if strict_skips; then
err "${s}: skipped (unwired seam / missing tool) under STRICT skips, where the pinned"
err " toolchain is baked into the job image (.gitea/workflows/gate.yml container:) and every"
err " toolchain is installed for us (.gitea/workflows/scripts/install-tools.sh) and every"
err " seam is on main — so this is a DEFECT, not an environment -> blocks merge"
summary "- ${s}: **SKIPPED under strict skips** (unwired seam / missing tool) — blocks merge"
BLOCK=1
+3 -3
View File
@@ -97,9 +97,9 @@ cluster_available() { truthy "${CLUSTER_AVAILABLE:-false}"; }
# --- Skip strictness: is this a CI runner, or a workstation? -------------------
# A SKIPPED stage is never green anywhere (HARD RULE 4), but its SEVERITY differs:
#
# CI the pinned toolchain IS the job image (the CI-built toolbox; gate.yml `container:`),
# and every seam is on `main`. A stage that skips here is a
# DEFECT (a tool dropped from the image, a seam that went unwired) -> BLOCK.
# CI the pinned toolchain is installed by .gitea/workflows/scripts/install-tools.sh
# before the gate runs, and every seam is on `main`. A stage that skips here is a
# DEFECT (a tool dropped from the installer, a seam that went unwired) -> BLOCK.
# local a missing pinned tool is a real, ordinary local condition -> the gate reports
# UNDETERMINED (exit 3): not green, not a pass, but distinguishable from a red.
#
+2 -38
View File
@@ -25,44 +25,8 @@ if [ "${#files[@]}" -eq 0 ]; then
exit 0
fi
# yamllint is single-threaded, so the file list is split into LINT_JOBS CONTIGUOUS slices of the
# sorted list, one yamllint per slice, run concurrently. Each slice's output goes to its own file
# and the files are printed in slice order afterwards: the log is the same text, in the same
# file order, as one serial `yamllint "${files[@]}"`, and a finding is never interleaved with
# another process's output (yamllint's standard format puts the FILE NAME on its own line above
# that file's `line:col` findings, so interleaving would misattribute them). The stage's exit
# code is the highest any slice returned (yamllint: 1 = error, 2 = warning under strict), so a
# single failing slice fails the stage exactly as the serial call did. LINT_JOBS=1 is serial.
lint_jobs="${LINT_JOBS:-$(n="$(nproc 2>/dev/null || echo 1)"; [ "$n" -gt 8 ] && n=8; echo "$n")}"
case "$lint_jobs" in ''|*[!0-9]*|0) err "LINT_JOBS must be a positive integer, got '${lint_jobs}'"; exit 2 ;; esac
[ "$lint_jobs" -gt "${#files[@]}" ] && lint_jobs="${#files[@]}"
lint_tmp="$(mktemp -d)"
trap 'rm -rf "$lint_tmp"' EXIT
per=$(( (${#files[@]} + lint_jobs - 1) / lint_jobs ))
pids=()
for (( i = 0; i < lint_jobs; i++ )); do
slice=("${files[@]:$(( i * per )):${per}}")
[ "${#slice[@]}" -gt 0 ] || break
( rc=0
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${slice[@]}" \
>"${lint_tmp}/${i}.out" 2>&1 || rc=$?
echo "$rc" >"${lint_tmp}/${i}.rc" ) &
pids+=("$!")
done
wait "${pids[@]}" || true
lint_rc=0
for (( i = 0; i < ${#pids[@]}; i++ )); do
cat "${lint_tmp}/${i}.out"
# A slice that left no rc file died before yamllint returned: that is a failure, not a pass.
slice_rc="$(cat "${lint_tmp}/${i}.rc" 2>/dev/null || true)"
case "$slice_rc" in ''|*[!0-9]*) slice_rc=1 ;; esac
[ "$slice_rc" -gt "$lint_rc" ] && lint_rc="$slice_rc"
done
if [ "$lint_rc" -ne 0 ]; then
err "yaml/lint failed (yamllint exit ${lint_rc}; ${#files[@]} files in ${#pids[@]} parallel slice(s))"
exit "$lint_rc"
fi
ok "yaml/lint passed (${#files[@]} files, ${#pids[@]} parallel slice(s))"
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}"
ok "yaml/lint passed (${#files[@]} files)"
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
-61
View File
@@ -1,61 +0,0 @@
---
# Flux Kustomization: Cloudflare Tunnel connector — reconciles ./platform/cloudflared.
#
# ISOLATION IS THE POINT OF THIS FILE. The Deployment it applies CANNOT become Ready until the
# operator enrols the tunnel (runbooks/cloudflare-tunnel.md): it ships with REPLACE-ME credentials
# and a placeholder UUID, and a wrong or revoked credential later produces the same state. So:
#
# * ITS OWN Kustomization, not a line in platform/kustomization.yaml. `platform` is wait:true and
# gates platform-config, which gates every apps wave; an unenrolled tunnel there would be a
# platform-wide stall ON MERGE, with the cause (a missing manual step) invisible from any Flux
# condition. Same reasoning, and same failure shape, as vpn-ingress's unfilled server key.
# * wait: false. Its pods are legitimately not-Ready whenever the credentials are absent or the
# edge is unreachable. Health is reported by the CloudflaredTunnelDown / ReplicasDegraded
# alerts (platform/cloudflared/alerts.yaml), not by this Kustomization's Ready condition —
# which reaches Ready on APPLY success.
# * NOTHING dependsOn THIS, and nothing should. A missing or invalid credentials Secret can then
# only ever affect the tunnel itself. conformance/tests/l0-static/cloudflared-ingress-rules
# fails the gate if any Kustomization gains `dependsOn: cloudflared`, or if this one stops
# being wait:false.
# * No Certificate, no HelmRelease, no CRD: nothing in the path for Flux's health checks to wait
# on even if wait were flipped (the Certificate-stall lesson).
#
# dependsOn: [platform-config] — A REAL EDGE.
# 1. CRD ORDERING: PodMonitor and PrometheusRule are monitoring.coreos.com CRs whose CRDs are
# installed by kube-prometheus-stack in `platform`; platform-config dependsOn platform, so this
# edge carries that guarantee transitively (Flux aborts a whole pass on an unknown kind).
# 2. THE FAR SIDE OF THE TUNNEL: the Gateway (and so the cilium-gateway-shared Service the ingress
# rules point at) is applied by platform-config. A tunnel that comes up before its origin is a
# support call.
#
# ZERO EFFECT ON LIVE TRAFFIC UNTIL DNS MOVES. Cloudflare only sends a hostname through a tunnel
# when that hostname's DNS record is a CNAME to <uuid>.cfargotunnel.com. This change creates no
# such record and leaves external-dns, the Gateway annotations, Pi-hole and CoreDNS untouched.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cloudflared
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/cloudflared
# REQUIRED, not boilerplate: platform/cloudflared/config.yaml renders `${BASE_DOMAIN}` into every
# ingress hostname, and alerts.yaml into both runbook_url annotations. Without this block the
# token becomes the EMPTY STRING and the Kustomization still reports Ready (substitution fails
# OPEN), leaving ingress rules for `plex.` and `*.` — a tunnel that 404s everything at best.
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 cloudflared-credentials.sops.yaml.
# Without it the Secret applies cleanly full of ENC[...] and cloudflared cannot authenticate.
-11
View File
@@ -78,17 +78,6 @@ 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
# (runbooks/cloudflare-tunnel.md), so in a gating tier an unfinished manual step — or a revoked
# credential later — would stall every apps wave. Carries cloudflared-credentials.sops.yaml, so
# both overlays add a SOPS decryption patch for it (by name). See clusters/base/cloudflared.yaml.
- cloudflared.yaml # Flux Kustomization -> ./platform/cloudflared (dependsOn platform-config)
# PM agent — reads Gitea issues and runs one worker agent per assignment that authors a
# change, gates it and opens a pull request. POST-APP tier (dependsOn apps-wave-0,
# wait:false): what it needs is GITEA, not Keycloak or the Gateway, and it must never gate
-41
View File
@@ -1,41 +0,0 @@
---
# 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.
-32
View File
@@ -140,38 +140,6 @@ 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
# SUCCEEDS, the Kustomization stays Ready, and the only symptom is a connector that cannot
# authenticate to the edge (CloudflaredTunnelDown fires). The `sops-age` Secret is out-of-band
# (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cloudflared
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
-32
View File
@@ -131,38 +131,6 @@ 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
# SUCCEEDS, the Kustomization stays Ready, and the only symptom is a connector that cannot
# authenticate to the edge (CloudflaredTunnelDown fires). The `sops-age` Secret is out-of-band
# (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cloudflared
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
+14 -105
View File
@@ -14,7 +14,7 @@ Pure standard library (matches the harness's no-pip-dependency posture); the che
themselves shell out (render checks to catalog/render.py, whose PyYAML dependency lives
there; L0 checks to git + PyYAML).
Usage: run_l1_offline.py --junit <out.xml> [--label-prefix P] [--jobs N] <test-dir> [<test-dir> ...]
Usage: run_l1_offline.py --junit <out.xml> [--label-prefix P] <test-dir> [<test-dir> ...]
Exit 0 iff every selected offline check passes.
The per-dir check script is whichever of `render-check.sh` / `check.sh` the directory
@@ -43,35 +43,13 @@ Exit codes: 0 all selected checks passed / 1 at least one FAILED / 3 nothing fai
scope-declared check was UNWIRED. conformance/run.sh propagates 3 verbatim; ci/l0.sh turns it
into the gate's own rc 3. This is the ONE place the "unwired, not green" rule lives now,
instead of once per hand-written stage.
PARALLELISM (--jobs, env CONF_OFFLINE_JOBS)
-------------------------------------------
The checks are independent processes, so they run in a bounded thread pool (one `bash` child
per worker). Default: min(8, usable CPUs); `--jobs 1` is the old serial runner exactly. Each
check's stdout+stderr is CAPTURED and only reported after every check has finished, in the
ORDER THE DIRS WERE GIVEN, so the log, the JUnit and the step summary are byte-for-byte
independent of which check finished first (only the trailing "[N.Ns]" wall-time on each
status line varies). The 0/1/3 aggregation runs over the same ordered case list as before.
Parallel-safety is a property of the CHECKS, not of this runner: every check must keep its
scratch state in a private mktemp/mkdtemp dir and must not write into the tree or a fixed
path. The audit that established that for the current set is recorded in
decisions/2026-11-08-parallel-l0-checks.md; a new check that needs shared state must say so.
Nested pools: the keycloak reconciler checks run their own mutation suites in a pool
(mutations.py, env MUTATION_JOBS). When this runner is itself parallel it exports a SMALLER
MUTATION_JOBS (unless the caller already set one), so the two suites share the CPUs the
other jobs-2 checks are not using instead of each taking min(8, CPUs). See inner_jobs().
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import xml.etree.ElementTree as ET
from pathlib import Path
from xml.dom import minidom
@@ -119,46 +97,6 @@ def collect_meta(test_yaml: Path, prefix: str):
return name, reqs
def usable_cpus() -> int:
"""CPUs this process may run on (the cgroup/affinity view, not the host's count)."""
try:
return max(1, len(os.sched_getaffinity(0)))
except (AttributeError, OSError):
return max(1, os.cpu_count() or 1)
def positive_int(text: str) -> int:
n = int(text)
if n < 1:
raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
return n
def inner_jobs(cpus: int, outer: int) -> int:
"""MUTATION_JOBS to hand the checks when this runner runs `outer` of them at once.
Two checks (the keycloak client-secret and passkey reconcilers) fan out internally; the
other outer-2 workers are single processes. Give the two suites an equal share of the CPUs
those others leave free, never fewer than 2 each (a 1-wide pool would make the 40-mutation
passkey suite the critical path of the whole stage), never more than 8. Peak process count
is then about (outer-2) + 2*inner, i.e. <= cpus on any machine with cpus >= outer + 2, and
at worst ~1.25-1.5x cpus on a small runner — CPU-bound oversubscription of that size costs
latency per process, not throughput.
"""
if outer <= 1:
return min(8, cpus)
return max(2, min(8, (cpus - outer + 2) // 2))
def run_check(check: Path, env: dict) -> tuple[int, str, float]:
t0 = time.monotonic()
proc = subprocess.run(
["bash", str(check)], capture_output=True, text=True,
stdin=subprocess.DEVNULL, env=env,
)
return proc.returncode, (proc.stdout + proc.stderr).strip(), time.monotonic() - t0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--junit", type=Path, required=True)
@@ -170,54 +108,26 @@ def main() -> int:
"(makes this runner exit 3); repeatable")
ap.add_argument("--summary-out", type=Path, default=None,
help="write one Markdown line per case here, for the CI step summary")
ap.add_argument("--jobs", type=positive_int, default=None,
help="checks to run at once (default: env CONF_OFFLINE_JOBS, else "
"min(8, usable CPUs)); 1 = serial")
ap.add_argument("dirs", nargs="+", type=Path)
args = ap.parse_args()
strict_scopes = set(args.unwired_scope)
cpus = usable_cpus()
if args.jobs is not None:
jobs = args.jobs
elif os.environ.get("CONF_OFFLINE_JOBS", "").strip():
try:
jobs = positive_int(os.environ["CONF_OFFLINE_JOBS"].strip())
except (ValueError, argparse.ArgumentTypeError) as e:
ap.error(f"CONF_OFFLINE_JOBS={os.environ['CONF_OFFLINE_JOBS']!r}: {e}")
else:
jobs = min(8, cpus)
child_env = dict(os.environ)
if not child_env.get("MUTATION_JOBS", "").strip():
child_env["MUTATION_JOBS"] = str(inner_jobs(cpus, jobs))
print(f"[offline] running {len(args.dirs)} check(s), jobs={jobs} "
f"(cpus={cpus}, MUTATION_JOBS={child_env['MUTATION_JOBS']})", file=sys.stderr)
# Metadata first, in the given order; then the checks themselves through the pool. The
# results are consumed in SUBMISSION order, so `cases` is identical to the serial runner's.
planned = [] # (name, reqs, dir, scope, check-or-None)
cases = [] # (name, reqs, dir, rc, output, scope)
for d in args.dirs:
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
scope = scope_of(d / "chainsaw-test.yaml", args.scope_annotation)
planned.append((name, reqs, d, scope, check))
cases = [] # (name, reqs, dir, rc, output, scope)
elapsed = [] # wall seconds per case, same order (None: nothing ran)
with ThreadPoolExecutor(max_workers=jobs) as pool:
futures = [pool.submit(run_check, check, child_env) if check is not None else None
for (_n, _r, _d, _s, check) in planned]
for (name, reqs, d, scope, check), fut in zip(planned, futures):
if fut is None:
cases.append((name, reqs, d, 3,
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
scope))
elapsed.append(None)
continue
rc, out, secs = fut.result()
cases.append((name, reqs, d, rc, out, scope))
elapsed.append(secs)
if check is None:
cases.append((name, reqs, d, 3,
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}",
scope))
continue
proc = subprocess.run(
["bash", str(check)], capture_output=True, text=True
)
out = (proc.stdout + proc.stderr).strip()
cases.append((name, reqs, d, proc.returncode, out, scope))
failures = sum(1 for c in cases if c[3] not in (0, 3))
skipped = sum(1 for c in cases if c[3] == 3)
@@ -235,7 +145,7 @@ def main() -> int:
},
)
summary_lines = []
for (name, reqs, d, rc, out, scope), secs in zip(cases, elapsed):
for name, reqs, d, rc, out, scope in cases:
tc = ET.SubElement(
suite,
"testcase",
@@ -254,9 +164,8 @@ def main() -> int:
fail.text = out
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
mark = f" scope={scope}" if scope else ""
took = f" [{secs:.1f}s]" if secs is not None else ""
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
f"({','.join(reqs) or 'untagged'}){mark}{took}",
f"({','.join(reqs) or 'untagged'}){mark}",
file=sys.stderr)
if rc not in (0, 3):
print(out, file=sys.stderr)
-38
View File
@@ -135,44 +135,6 @@ 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,54 +411,6 @@ 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.
{
"owner": "cloudflared: headless metrics Service + ServiceMonitor on port `metrics` (:2000)",
"prefixes": ("cloudflared_",),
"file": "platform/cloudflared/servicemonitor.yaml",
"require": [
r"^kind:\s*ServiceMonitor\s*$",
r"^kind:\s*Service\s*$",
r"^\s*-\s*port:\s*metrics\s*$",
],
"forbid": [],
"why": "cloudflared_tunnel_ha_connections powers CloudflaredTunnelDown, the only signal "
"that the tunnel has lost the Cloudflare edge. That rule is written to FIRE on an "
"absent series, so a lost scrape is loud rather than inert — but it would then be "
"permanently firing for a wiring reason, which teaches the operator to ignore it",
"companion": {
"file": "platform/cloudflared/kustomization.yaml",
"require": [r"^\s*-\s*networkpolicy\.yaml\s*$"],
"why": "platform/cloudflared/networkpolicy.yaml ships the namespace's default-deny "
"(a platform namespace, outside Kyverno's generator) together with the ingress "
"allow for Prometheus on :2000. Keeping them in one file, applied together, is "
"what stops a baseline-without-allow from emptying the TSDB",
},
},
]
# `up` is produced by EVERY scrape target, so no owner can meaningfully claim it and assertion (1)
@@ -1,47 +0,0 @@
# =============================================================================
# L0 STATIC — the Cloudflare Tunnel exposes only what the operator decided, and cannot stall Flux
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# platform/cloudflared/config.yaml is the entire internet-facing surface of the tunnel. This test
# asserts the operator decisions of 2026-11-08 against it on every gate run: no rule may target
# anything but a 404 or the shared Gateway (so never the Kubernetes API); the LAN/VPN-only
# hostnames (plex, jackett, pihole, semaphore, qbittorrent, radarr, sonarr, headlamp,
# unifi, crafty-mc) 404 before the wildcard; the
# egress policy admits no API/node entity; and the Flux tier is isolated (wait:false, nothing
# dependsOn it, both overlays decrypt it). It proves itself non-vacuous by mutation. Rationale and
# the honest NOT-proven list: check.sh.
#
# WHY SCOPE, NOT REQUIREMENTS: the property spans platform/cloudflared, platform/gateway (the
# derived Service name), clusters/base and both overlays. A requirement-scoped selection would skip
# it on, e.g., a Gateway rename — precisely a change that breaks it.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: cloudflared-ingress-rules
labels:
conformance.home.k3s/R-NET-01: "true"
conformance.home.k3s/R-NET-02: "true"
annotations:
conformance.home.k3s/requirements: "R-NET-01, R-NET-02"
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: >-
The Cloudflare Tunnel routes only to the shared Gateway or a 404, never to the Kubernetes
API; the LAN/VPN-only hostnames 404 ahead of the wildcard; the connector's egress admits no
API or node entity; and its Flux tier cannot stall any other tier.
spec:
steps:
- name: assert-tunnel-ingress-rules-and-isolation
description: >-
Parse platform/cloudflared/config.yaml with BASE_DOMAIN substituted, assert every rule's
target against the Gateway Service derived from platform/gateway/gateway.yaml, assert the
excluded hostnames 404 first, check the egress CiliumNetworkPolicy and the Flux wiring, and
run the evaluator on mutated configs to prove it is not vacuous; and assert the
credentials Secret volume is readable by the container's run-as uid/groups (fsGroup +
mode), self-tested by mutation.
try:
- script:
content: bash ./check.sh
@@ -1,321 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — cloudflared-ingress-rules
# Serves: R-NET-01 / R-NET-02 (default-deny; allow rules declarative and scoped), and the four
# operator decisions of 2026-11-08 recorded in decisions/2026-11-08-cloudflare-tunnel.md.
#
# WHY THIS EXISTS. platform/cloudflared/config.yaml is the whole of what the internet can reach
# through the Cloudflare Tunnel. A one-line edit there — a rule pointing at the Kubernetes API, a
# LAN-only hostname moved below the wildcard, a dropped catch-all — publishes something the operator
# decided must never be public, and nothing in the cluster turns red: the tunnel stays connected and
# the Kustomization stays Ready. So the decisions are asserted here, on every gate run.
#
# GIVEN the repository as authored,
# THEN (1) every ingress rule's `service` is `http_status:404` or EXACTLY the shared Gateway's
# in-cluster Service, derived from platform/gateway/gateway.yaml (so a Gateway rename
# fails here rather than in production) — which by construction excludes the
# Kubernetes API, and a named deny-list says so explicitly when it is violated;
# (2) each LAN/VPN-only hostname (plex, plex-vip, jackett, pihole, pihole-dns, semaphore,
# qbittorrent, radarr, sonarr, headlamp, unifi, crafty-mc) has a rule mapping it to http_status:404 that comes BEFORE any
# Gateway-bound rule (cloudflared matches first-rule-wins);
# (3) the apex and the wildcard are the Gateway rules, with matchSNItoHost, and nothing
# disables origin TLS verification; the last rule is a hostname-less 404 catch-all;
# (4) the egress CiliumNetworkPolicy admits none of kube-apiserver/host/remote-node/
# cluster/all and no CIDR rule, and its backend selector excludes the LAN-only
# namespaces (the second, independent layer behind (2));
# (5) the Flux tier is ISOLATED: its own Kustomization, wait:false, not listed in
# platform/kustomization.yaml, nothing anywhere dependsOn it, and both overlays carry its
# SOPS decryption patch — so a missing or invalid credential can stall nothing else;
# (6) the rule evaluator is not vacuous: it is run against mutated copies of the real config
# (an API rule added, plex moved below the wildcard, the catch-all removed) and must
# flag each one;
# AND (7) every Secret volume the container mounts — the tunnel credentials above all — is
# READABLE by the container's run-as identity, modelled the way the kubelet lays it out:
# files owned by uid 0, group-owned by fsGroup if set (else gid 0), with the item mode or
# defaultMode (API default 0644). The identity is the container securityContext over
# the pod's; the process's groups are runAsGroup + fsGroup + supplementalGroups. An
# unset runAsUser is a FAIL, not a pass: the image default cannot be read offline. The
# model is deliberately conservative — it does not credit the kubelet's habit of
# OR-ing group-read into fsGroup-owned volumes, so 0400+fsGroup fails here though it
# would work. Self-tested by mutation (fsGroup removed; mode 0400; runAsUser unset).
# This is the 2026-11-08 CrashLoop: runAsUser 65532, no fsGroup, 0440 root:root.
#
# NOT proven (static): that Cloudflare routes only the hostnames whose DNS points at the tunnel,
# that the Gateway's certificate validates from cloudflared, or that Envoy enforces (4) — those are
# runtime properties, verified by the operator per runbooks/cloudflare-tunnel.md.
# =============================================================================
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 (cloudflared-ingress-rules): PyYAML is required", file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
FAILURES, CHECKS = [], 0
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)]
COMP = ROOT / "platform" / "cloudflared"
CONFIG = COMP / "config.yaml"
if not CONFIG.is_file():
print(f"CHECK FAIL (cloudflared-ingress-rules): {CONFIG.relative_to(ROOT)} is absent — the "
"component this check guards was moved or deleted; re-point or delete this check in the "
"SAME change", file=sys.stderr)
raise SystemExit(1)
# The hostnames the operator decided never go through the tunnel (decisions 2026-11-08).
# 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", "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", "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://",
"ssh://", "unix:", "bastion")
# ── substitution: ${BASE_DOMAIN} from cluster-vars, the ONLY token allowed ──────────────────────
cv = next(d for d in docs(ROOT / "clusters" / "base" / "cluster-vars.yaml")
if d.get("kind") == "ConfigMap")
base_domain = cv["data"]["BASE_DOMAIN"]
raw = CONFIG.read_text()
tokens = set(re.findall(r"\$\{([A-Za-z0-9_]+)\}", raw))
check(tokens == {"BASE_DOMAIN"},
f"(0) config.yaml uses exactly the ${{BASE_DOMAIN}} token (found {sorted(tokens)}); any other "
f"token is substituted from cluster-vars unseen by this check")
check(re.search(r"\$(?!\{BASE_DOMAIN\})", raw) is None,
"(0) config.yaml contains a bare `$` — Flux envsubst would rewrite or empty it")
cfg = yaml.safe_load(raw.replace("${BASE_DOMAIN}", base_domain))
# ── the Gateway Service, derived rather than hard-coded ─────────────────────────────────────────
gw = next(d for d in docs(ROOT / "platform" / "gateway" / "gateway.yaml") if d.get("kind") == "Gateway")
GATEWAY_SVC = (f"https://cilium-gateway-{gw['metadata']['name']}."
f"{gw['metadata']['namespace']}.svc.cluster.local:443")
def evaluate(cfg):
"""Return the list of rule violations for a parsed cloudflared config."""
bad = []
rules = cfg.get("ingress") or []
if len(rules) < len(EXCLUDED_HOSTS) + 3:
bad.append(f"only {len(rules)} ingress rules — the scan is vacuous")
return bad
for i, r in enumerate(rules):
svc = str(r.get("service", ""))
if any(m in svc.lower() for m in API_MARKERS):
bad.append(f"rule {i} ({r.get('hostname', '<catch-all>')}) targets {svc!r}, which "
f"is a route toward the Kubernetes API / a raw bastion — the API stays "
f"local-only (operator decision 2026-11-08)")
if svc not in ("http_status:404", GATEWAY_SVC):
bad.append(f"rule {i} ({r.get('hostname', '<catch-all>')}) targets {svc!r}; only "
f"http_status:404 or {GATEWAY_SVC} are permitted")
if (r.get("originRequest") or {}).get("noTLSVerify"):
bad.append(f"rule {i} disables origin TLS verification (noTLSVerify)")
if (cfg.get("originRequest") or {}).get("noTLSVerify"):
bad.append("top-level originRequest disables origin TLS verification (noTLSVerify)")
gw_idx = [i for i, r in enumerate(rules) if r.get("service") == GATEWAY_SVC]
first_gw = min(gw_idx) if gw_idx else len(rules)
for h in EXCLUDED_HOSTS:
fqdn = f"{h}.{base_domain}"
hits = [i for i, r in enumerate(rules) if r.get("hostname") == fqdn]
if not hits:
bad.append(f"no rule for LAN/VPN-only host {fqdn}; it would fall through to the "
f"wildcard and reach the Gateway")
continue
i = hits[0]
if rules[i].get("service") != "http_status:404":
bad.append(f"{fqdn} maps to {rules[i].get('service')!r}, not http_status:404")
if i > first_gw:
bad.append(f"{fqdn}'s 404 rule (#{i}) comes AFTER the first Gateway rule (#{first_gw}); "
f"cloudflared is first-match, so the Gateway rule wins")
gw_hosts = {rules[i].get("hostname") for i in gw_idx}
if gw_hosts != {base_domain, f"*.{base_domain}"}:
bad.append(f"the Gateway-bound hostnames are {sorted(map(str, gw_hosts))}; expected "
f"exactly the apex and the wildcard")
for i in gw_idx:
if (rules[i].get("originRequest") or {}).get("matchSNItoHost") is not True:
bad.append(f"Gateway rule #{i} lacks originRequest.matchSNItoHost: true — the Gateway "
f"would not see the SNI that selects its wildcard certificate")
last = rules[-1]
if "hostname" in last or last.get("service") != "http_status:404":
bad.append("the last rule is not a hostname-less http_status:404 catch-all")
return bad
# (1)-(3) the real config
for v in evaluate(cfg):
check(False, f"(1-3) {v}")
check(str(cfg.get("credentials-file", "")).startswith("/etc/cloudflared/creds/"),
"(3) credentials-file points into the mounted Secret volume")
# (6) mutation self-test — the evaluator must flag each broken variant
mutants = {}
m = copy.deepcopy(cfg)
m["ingress"].insert(0, {"hostname": f"k8s.{base_domain}",
"service": "https://kubernetes.default.svc:443"})
mutants["API rule added"] = m
m = copy.deepcopy(cfg)
plex = next(r for r in m["ingress"] if r.get("hostname") == f"plex.{base_domain}")
m["ingress"].remove(plex)
m["ingress"].insert(len(m["ingress"]) - 1, plex)
mutants["plex moved below the wildcard"] = m
m = copy.deepcopy(cfg)
m["ingress"] = m["ingress"][:-1]
mutants["catch-all removed"] = m
for name, mut in mutants.items():
check(evaluate(mut), f"(6) self-test: the evaluator did NOT flag the mutant '{name}' — the "
f"check is vacuous")
# (4) the egress policy
np_docs = docs(COMP / "networkpolicy.yaml")
cnps = [d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]
check(len(cnps) == 1, f"(4) exactly one CiliumNetworkPolicy in networkpolicy.yaml (found {len(cnps)})")
for cnp in cnps:
for i, rule in enumerate(cnp["spec"].get("egress") or []):
ents = set(rule.get("toEntities") or [])
check(not (ents & FORBIDDEN_ENTITIES),
f"(4) egress rule {i} admits {sorted(ents & FORBIDDEN_ENTITIES)} — that reaches the "
f"Kubernetes API or the nodes; the API stays local-only")
check(not (rule.get("toCIDR") or rule.get("toCIDRSet")),
f"(4) egress rule {i} uses a CIDR — a CIDR can name the API VIP; use entities")
for ep in rule.get("toEndpoints") or []:
notin = [e for e in ep.get("matchExpressions") or []
if e.get("key") == "io.kubernetes.pod.namespace" and e.get("operator") == "NotIn"]
missing = set(EXCLUDED_NAMESPACES) - set(notin[0]["values"] if notin else [])
check(not missing,
f"(4) backend selector in egress rule {i} does not exclude {sorted(missing)}")
for d in np_docs:
if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny":
check(set(d["spec"].get("policyTypes", [])) == {"Ingress", "Egress"},
"(4) default-deny covers both directions")
break
else:
check(False, "(4) networkpolicy.yaml ships no default-deny baseline for the namespace")
# (5) Flux isolation
fk = next(d for d in docs(ROOT / "clusters" / "base" / "cloudflared.yaml")
if d.get("kind") == "Kustomization")
check(fk["spec"].get("wait") is False,
"(5) clusters/base/cloudflared.yaml must be wait: false — its pods are legitimately not-Ready "
"until the tunnel is enrolled")
check(fk["spec"].get("path") == "./platform/cloudflared", "(5) the Kustomization reconciles ./platform/cloudflared")
check("cloudflared.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(),
"(5) clusters/base/kustomization.yaml lists cloudflared.yaml")
check("cloudflared" not in (ROOT / "platform" / "kustomization.yaml").read_text(),
"(5) platform/kustomization.yaml must not include cloudflared — that is 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") == "cloudflared"),
f"(5) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn "
f"cloudflared — that hands an unenrolled tunnel the power to stall it")
for env in ("dev", "prod"):
text = (ROOT / "clusters" / env / "kustomization.yaml").read_text()
check(re.search(r"name:\s*cloudflared\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n"
r"\s*decryption:", text) is not None,
f"(5) clusters/{env}/kustomization.yaml carries the cloudflared SOPS decryption patch")
dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment")
check(dep["spec"].get("replicas", 1) >= 2, "(5) the Deployment runs at least 2 replicas")
check(dep["spec"]["template"]["metadata"]["labels"].get("homelab.keithjackson.dev/egress-class")
== "internet",
"(4) the pods are egress-class=internet — vpn would put the edge connections behind PIA")
check(dep["spec"]["template"]["spec"].get("automountServiceAccountToken") is False,
"(4) the pods mount no ServiceAccount token")
# (7) the credentials Secret is readable by the run-as identity
def unreadable_secret_volumes(pod_spec):
"""Return a description per Secret volume the containers mount but cannot read."""
bad = []
psc = pod_spec.get("securityContext") or {}
vols = {v["name"]: v for v in pod_spec.get("volumes") or [] if "secret" in v}
for c in (pod_spec.get("initContainers") or []) + (pod_spec.get("containers") or []):
csc = c.get("securityContext") or {}
uid = csc.get("runAsUser", psc.get("runAsUser"))
gid = csc.get("runAsGroup", psc.get("runAsGroup"))
fs_group = psc.get("fsGroup")
groups = {g for g in [gid, fs_group, *(psc.get("supplementalGroups") or [])]
if g is not None}
for vm in c.get("volumeMounts") or []:
v = vols.get(vm["name"])
if v is None:
continue
sec = v["secret"]
if uid is None:
bad.append(f"container {c['name']} mounts Secret volume {vm['name']} but sets no "
f"runAsUser (pod or container) — readability cannot be proven offline")
continue
default = sec.get("defaultMode", 0o644)
modes = [it.get("mode", default) for it in sec.get("items") or []] or [default]
owner_gid = fs_group if fs_group is not None else 0
for mode in modes:
readable = ((uid == 0 and mode & 0o400) or (owner_gid in groups and mode & 0o040)
or mode & 0o004)
if not readable:
bad.append(f"container {c['name']} (uid {uid}, groups {sorted(groups)}) cannot "
f"read Secret volume {vm['name']} ({sec.get('secretName')}): files "
f"are 0:{owner_gid} mode {mode:04o} — set pod fsGroup to a group "
f"the container runs with, keeping 0440")
return bad
pod_spec = dep["spec"]["template"]["spec"]
creds_vol = [v for v in pod_spec.get("volumes") or []
if (v.get("secret") or {}).get("secretName") == "cloudflared-credentials"]
check(len(creds_vol) == 1, "(7) the Deployment mounts Secret cloudflared-credentials as a volume")
for v in unreadable_secret_volumes(pod_spec):
check(False, f"(7) {v}")
sec_mutants = {}
m = copy.deepcopy(pod_spec)
m["securityContext"].pop("fsGroup", None)
m["securityContext"].pop("supplementalGroups", None)
sec_mutants["fsGroup removed"] = m
m = copy.deepcopy(pod_spec)
for v in m["volumes"]:
if "secret" in v:
v["secret"]["defaultMode"] = 0o400
for it in v["secret"].get("items") or []:
it["mode"] = 0o400
sec_mutants["mode 0400"] = m
m = copy.deepcopy(pod_spec)
m["securityContext"].pop("runAsUser", None)
for c in m["containers"]:
(c.get("securityContext") or {}).pop("runAsUser", None)
sec_mutants["runAsUser unset"] = m
for name, mut in sec_mutants.items():
check(unreadable_secret_volumes(mut),
f"(7) self-test: the readability model did NOT flag the mutant '{name}' — vacuous")
if CHECKS < 25:
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
if FAILURES:
print(f"CHECK FAIL (cloudflared-ingress-rules): {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 (cloudflared-ingress-rules): {CHECKS} assertions")
PY
@@ -92,10 +92,9 @@
# the NAME axis only. R-NET-04 / the edge port-forward posture owns the other.
# - Anything about the seven non-catalog publishers' own correctness. The register records WHAT
# each publishes so the claim is reviewable, which is all a static check can offer.
# - That the public names RESOLVE to Cloudflare at runtime. check.sh (9) pins the TREE: the
# Gateway target is the tunnel UUID in platform/cloudflared/config.yaml, proxying is on, the L4
# `service` publishers opt out, and every name the tunnel 404s is unpublished. Whether Cloudflare
# then answers anycast for them is a live read (`dig @1.1.1.1`), not a static one.
# - Anything about the five `public` services' TARGET. They still resolve to the Gateway VIP
# rather than a routable address; that is an open operator decision recorded in
# platform/externaldns/helmrelease.yaml, and asserting a target here would hard-code a guess.
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
@@ -26,20 +26,7 @@
# KNOWN_NON_CATALOG_PUBLISHERS — shrink-only, stale entries FAIL;
# (7) the check is LOAD-BEARING: whole-repo scope declared in chainsaw-test.yaml, honoured by
# ci/l0.sh's --include-scope union, and ci/gate.sh blocks on an l0 failure. NO hand-written
# ci/l0.sh stage (whole-repo-invariants-wired fails the gate if one appears);
# (8) the external-dns HelmRelease declares `annotationPrefix` explicitly and it matches the
# prefix of EVERY external-dns annotation in the tree and in catalog/render.py — added after
# the 2026-11-07 v0.23.0 default-prefix change silently voided (1)-(3) at runtime;
# (9) THE CLOUDFLARE TUNNEL WIRING (2026-11-08) — every public name is a PROXIED CNAME to the tunnel:
# (a) the shared Gateway's `target` is EXACTLY `<tunnel:>.cfargotunnel.com`, the UUID read from
# platform/cloudflared/config.yaml, and the HelmRelease sets `--cloudflare-proxied`;
# (b) every `service`-source publisher carries `cloudflare-proxied: "false"` (L4 A records stay
# DNS-only), and no HTTPRoute opts out of proxying (a DNS-only tunnel CNAME is dead);
# (c) no hostname the tunnel maps to `http_status:404` has a public record — no undisowned route
# names it, and the only `service`-source publishers of one are the declared L4 DNS-only
# names (unifi, crafty-mc); conversely every disowned route's hostname IS 404'd by the
# tunnel, so the "unpublished" and "refused at the tunnel" lists cannot drift apart;
# (d) the operator's LAN/VPN-only names (plex, headlamp) are disowned everywhere they appear.
# ci/l0.sh stage (whole-repo-invariants-wired fails the gate if one appears).
#
# WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free):
# - That the record is gone from Cloudflare. Live read; belongs beside
@@ -49,11 +36,8 @@
# both the A and its `a-` TXT within ~60s) — but a manual proof is not a gate.
# - That an internal name is UNREACHABLE. A port-forward plus a literal IP needs no DNS record.
# This check owns the NAME axis only.
# - That the tunnel actually SERVES the public names, or that Cloudflare honoured `proxied`. (9)
# proves the tree asks for a proxied CNAME to the right tunnel; the live answer (`dig @1.1.1.1`
# returning Cloudflare anycast) is a runtime read. Nor does (8)/(9) prove the RUNNING controller
# honours the prefix — a future chart bump that drops it would publish PROXIED A records at the
# Gateway VIP; (8) only keeps the tree and the HelmRelease value consistent.
# - Anything about the five public services' TARGET. They still answer with the Gateway VIP; the
# routable-target decision is open and recorded in platform/externaldns/helmrelease.yaml.
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
#
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
@@ -106,18 +90,9 @@ CHECKS = 0
# ControllerValue = "dns-controller" -> the value that CLAIMS an object
# source/annotations/processors.go IsControllerMismatch skips a source object when the key is
# PRESENT and the value is NOT ControllerValue. So "disowned" == key present, value != claimed.
#
# THE PREFIX IS NOT A CONSTANT OF NATURE. Every key external-dns reads is `<annotationPrefix><name>`,
# and the prefix is a runtime flag whose DEFAULT moved in v0.22.0 (#6424: alpha -> GA, "with no
# fallback"). On 2026-11-07 the chart bump to v0.23.0 made every key below invisible to the running
# controller while this check — which only asserted the keys were PRESENT — stayed green. (8) now
# ties ANNOTATION_PREFIX to the HelmRelease's explicit `annotationPrefix`.
ANNOTATION_PREFIX = "external-dns.alpha.kubernetes.io/"
CONTROLLER_KEY = ANNOTATION_PREFIX + "controller"
CONTROLLER_KEY = "external-dns.alpha.kubernetes.io/controller"
CONTROLLER_CLAIMED = "dns-controller"
HOSTNAME_KEY = ANNOTATION_PREFIX + "hostname"
# Any key that looks like an external-dns annotation, under ANY prefix variant (alpha, GA, typos).
EXTERNAL_DNS_KEY_RE = re.compile(r"^external-dns(\.[a-z0-9-]+)*\.kubernetes\.io/")
HOSTNAME_KEY = "external-dns.alpha.kubernetes.io/hostname"
def check(cond, msg):
@@ -154,12 +129,9 @@ def docs_of(p: Path):
# unadjudicated set cannot grow without a review event. Same ratchet reasoning as
# KNOWN_EXTERNAL_MONITORS in servicemonitor-ports-resolve.
#
# WHAT THE ENTRIES PUBLISH NOW (2026-11-08). Every HTTPRoute entry that is not disowned publishes a
# PROXIED CNAME to the Cloudflare Tunnel (the shared Gateway's `target`, assertion 9a), so no origin
# address of ours is in the public zone for it. Headlamp is disowned (operator: LAN/VPN-only, 9d).
# The two `service`-source entries still publish their RFC1918 LB VIP as a DNS-only A record —
# deliberate (L4 ports cannot ride Cloudflare's HTTP proxy), and pinned DNS-only by 9b. Whether each
# remaining name should be public at all stays an operator decision per name, not a renderer change,
# EVERY ENTRY HERE IS A NAME THAT STILL ANSWERS WITH AN RFC1918 ADDRESS IN A PUBLIC ZONE. That is
# the honest state after this change, and it is deliberate: converting them is an operator
# decision per name (is grafana meant to be publicly resolvable at all?), not a renderer change,
# and a cluster-wide ExternalDNS opt-in filter — the available hardening, see
# platform/externaldns/helmrelease.yaml — would DELETE all of them at once the moment it is set.
#
@@ -167,32 +139,27 @@ def docs_of(p: Path):
# =============================================================================================
KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = {
"platform/oauth2-proxy/httproute.yaml:oauth2-proxy/oauth2-proxy":
"publishes auth.${BASE_DOMAIN} -> the tunnel (proxied CNAME). The oauth2-proxy login endpoint is a "
"publishes auth.${BASE_DOMAIN} -> the Gateway VIP. The oauth2-proxy login endpoint is a "
"platform component, not a catalog service; it must be reachable by every browser that "
"any forward-auth app redirects, so whether it is PUBLICLY resolvable is coupled to "
"whether the five public services are — an operator decision, not a renderer one.",
"platform/keycloak/httproute.yaml:keycloak/keycloak":
"publishes keycloak.${BASE_DOMAIN} -> the tunnel (proxied CNAME), and it is the route that actually "
"publishes keycloak.${BASE_DOMAIN} -> the Gateway VIP, and it is the route that actually "
"OWNS that record (the TXT registry entry reads resource=httproute/keycloak/keycloak, not "
"the catalog-rendered keycloak-8080 route, because external-dns merges duplicate "
"hostnames and this one won). Keycloak is `exposure: public` in the catalog, so the name "
"is meant to be public either way; it is listed because the PUBLISHING object is the "
"hand-authored platform route, which carries no exposure field.",
"platform/monitoring/grafana-httproute.yaml:monitoring/grafana":
"publishes grafana.${BASE_DOMAIN} -> the tunnel (proxied CNAME). An admin dashboard "
"behind Keycloak; it is not a catalog service, so withdrawing it is an operator call per "
"name (it stayed public in the 2026-11-08 tunnel decision).",
"publishes grafana.${BASE_DOMAIN} -> the Gateway VIP. An admin dashboard; almost "
"certainly should not be publicly resolvable, but it is not a catalog service and "
"withdrawing it is an operator call with the same LAN-resolution prerequisite as the six "
"internal services (Pi-hole must answer first).",
"platform/headlamp/httproute.yaml:headlamp/headlamp":
"publishes NOTHING since 2026-11-08: the route carries `controller: none` (operator "
"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).",
"publishes headlamp.${BASE_DOMAIN} -> the Gateway VIP. A Kubernetes web UI with native "
"Keycloak OIDC; same adjudication as grafana.",
"platform/homepage/httproute.yaml:homepage/homepage":
"publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex "
"publishes the APEX ${BASE_DOMAIN} itself -> the Gateway VIP, via the https-apex "
"listener. The apex is not a per-service name and cannot be withdrawn by an exposure "
"declaration.",
"runbooks/serving/httproute.yaml:runbooks/runbooks":
@@ -201,16 +168,16 @@ KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = {
"entry names the homepage route). Deliberately un-authenticated — see the manifest's own "
"header for why gating runbooks on Keycloak is a loop with no exit.",
"platform/crafty/httproute.yaml:crafty/crafty":
"publishes crafty.${BASE_DOMAIN} -> the tunnel (proxied CNAME) (the Minecraft admin UI, gated by an "
"publishes crafty.${BASE_DOMAIN} -> the Gateway VIP (the Minecraft admin UI, gated by an "
"oauth2-proxy sidecar). Platform component, no exposure field.",
"platform/crafty/service-l4.yaml:crafty/crafty-l4-tcp":
"the `service` SOURCE, not a route: publishes crafty-mc.${BASE_DOMAIN} -> "
"${CRAFTY_L4_VIP} (192.168.2.22) for the raw Minecraft TCP/UDP game ports the HTTPS "
"Gateway cannot express. A game server is the one case where a public name may be "
"genuinely wanted; the address it answers with is still RFC1918, DNS-only (9b).",
"genuinely wanted; the address it answers with is still RFC1918.",
"apps/vms/unifi/50-service-l4.yaml:unifi/unifi-l4-tcp":
"the `service` source: publishes unifi.${BASE_DOMAIN} -> ${UNIFI_L4_VIP} "
"(192.168.2.21), DNS-only (9b), for the UniFi controller VM's inform/STUN/adoption ports. Hand-authored "
"(192.168.2.21) for the UniFi controller VM's inform/STUN/adoption ports. Hand-authored "
"under apps/vms (a VM, not a rendered container service); the catalog's only VM entry is "
"vm-demo, which declares no ingressRoutes and publishes nothing.",
}
@@ -264,7 +231,9 @@ for sub in SCAN_DIRS:
ann = md.get("annotations") or {}
if kind == "HTTPRoute":
routes.append((rel, d))
elif kind == "Service" and any(EXTERNAL_DNS_KEY_RE.match(a) for a in ann):
elif kind == "Service" and any(
a.startswith("external-dns.alpha.kubernetes.io/") for a in ann
):
dns_services.append((rel, d))
check(len(routes) > 0, "inventory: no HTTPRoute found under apps/ platform/ runbooks/ — parse "
@@ -475,174 +444,6 @@ check(
"and the whole-repo-invariants-wired meta-test fails the gate on a hand-written stage.",
)
# =============================================================================================
# (8) THE RUNNING CONTROLLER READS THE PREFIX THIS TREE WRITES.
# (a) the external-dns HelmRelease sets `values.annotationPrefix` EXPLICITLY (an upstream
# default is exactly what moved under us) and it equals ANNOTATION_PREFIX;
# (b) it does not ALSO enable `--enable-legacy-annotation-prefix` (dual-read hides a
# half-migration; upstream says the flag will be removed);
# (c) every external-dns-looking annotation key on any object under apps/ platform/ runbooks/
# uses ANNOTATION_PREFIX — a key under another prefix is silently ignored at runtime;
# (d) catalog/render.py emits no external-dns key under another prefix.
# Migrating to the GA prefix = change ANNOTATION_PREFIX, the HelmRelease value, the renderer
# and every manifest in ONE commit; this assertion fails on any partial state.
# =============================================================================================
HR_PATH = ROOT / "platform/externaldns/helmrelease.yaml"
hr = next((d for d in docs_of(HR_PATH) if d.get("kind") == "HelmRelease"
and (d.get("metadata") or {}).get("name") == "external-dns"), None)
check(hr is not None, f"(8) {HR_PATH.relative_to(ROOT)} holds no HelmRelease `external-dns` — "
f"cannot prove which annotation prefix the controller reads")
values = ((hr or {}).get("spec") or {}).get("values") or {}
hr_prefix = values.get("annotationPrefix")
check(
hr_prefix == ANNOTATION_PREFIX,
f"(8a) external-dns HelmRelease `values.annotationPrefix` is {hr_prefix!r}, but every "
f"external-dns annotation in this tree uses {ANNOTATION_PREFIX!r}. A mismatch (or relying on "
f"the upstream default, which changed in v0.22.0) makes the controller IGNORE `controller: "
f"none`, the Gateway `target` and Service `hostname` — internal names go public, public names "
f"answer RFC1918 (incident 2026-11-07).",
)
extra = values.get("extraArgs") or []
extra_s = " ".join(f"{k}={v}" for k, v in extra.items()) if isinstance(extra, dict) \
else " ".join(map(str, extra))
check("enable-legacy-annotation-prefix" not in extra_s and "annotation-prefix" not in extra_s,
"(8b) external-dns extraArgs must not set --annotation-prefix or "
"--enable-legacy-annotation-prefix; the prefix is declared ONLY via values.annotationPrefix "
"so this check can see it")
foreign: list[str] = []
seen_keys = 0
for sub in SCAN_DIRS:
base = ROOT / sub
if not base.is_dir():
continue
for p in sorted(base.rglob("*.yaml")):
rel = p.relative_to(ROOT).as_posix()
for d in docs_of(p):
for k in annotations_of(d):
if isinstance(k, str) and EXTERNAL_DNS_KEY_RE.match(k):
seen_keys += 1
if not k.startswith(ANNOTATION_PREFIX):
foreign.append(f"{rel}:{'/'.join(ident(d))} {k}")
check(seen_keys > 0, "(8c) no external-dns annotation found anywhere — parse contract broken "
"(the Gateway target alone guarantees at least one)")
check(not foreign,
f"(8c) external-dns annotations under a prefix the controller does NOT read "
f"({ANNOTATION_PREFIX!r} is configured): {foreign}")
render_src = read(CATALOG / "render.py")
bad_render = sorted({m for m in re.findall(r"external-dns(?:\.[a-z0-9-]+)*\.kubernetes\.io/",
render_src) if m != ANNOTATION_PREFIX})
check(not bad_render,
f"(8d) catalog/render.py emits external-dns keys under {bad_render}, but the controller "
f"reads {ANNOTATION_PREFIX!r}")
# =============================================================================================
# (9) THE CLOUDFLARE TUNNEL WIRING (2026-11-08). The public names are PROXIED CNAMEs to the tunnel.
# =============================================================================================
CF_CONFIG = ROOT / "platform/cloudflared/config.yaml"
GATEWAY_FILE = ROOT / "platform/gateway/gateway.yaml"
TARGET_KEY = ANNOTATION_PREFIX + "target"
PROXIED_KEY = ANNOTATION_PREFIX + "cloudflare-proxied"
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
# The L4 names the tunnel 404s that are NONETHELESS published — as DNS-only A records from the
# `service` source, at their own LB VIP, because they are not HTTP and never touch the tunnel
# (operator decision 2026-11-08). Shrink-only, and must agree with the tunnel's 404 list (9c).
L4_DNS_ONLY_NAMES = {"unifi.${BASE_DOMAIN}", "crafty-mc.${BASE_DOMAIN}"}
# The operator's LAN/VPN-only HTTP names (2026-11-08). Pinned here as well as in the tunnel config, so
# deleting one from config.yaml cannot quietly also release it from (9c).
OPERATOR_LAN_ONLY_NAMES = {"plex.${BASE_DOMAIN}", "headlamp.${BASE_DOMAIN}"}
cf = next(iter(docs_of(CF_CONFIG)), None)
check(cf is not None and isinstance(cf.get("ingress"), list),
f"(9) {CF_CONFIG.relative_to(ROOT)} unreadable or has no `ingress` list — cannot prove what "
f"the tunnel refuses or which tunnel the Gateway must target")
cf = cf or {}
tunnel = str(cf.get("tunnel") or "")
check(bool(UUID_RE.match(tunnel)),
f"(9a) platform/cloudflared/config.yaml `tunnel:` is {tunnel!r}, not a literal tunnel UUID — "
f"the Gateway target is derived from it")
gw = next((d for d in docs_of(GATEWAY_FILE) if d.get("kind") == "Gateway"
and ident(d) == ("gateway", "shared")), None)
check(gw is not None, f"(9a) {GATEWAY_FILE.relative_to(ROOT)} holds no Gateway gateway/shared")
want_target = f"{tunnel}.cfargotunnel.com"
got_target = annotations_of(gw or {}).get(TARGET_KEY)
check(got_target == want_target,
f"(9a) Gateway gateway/shared `{TARGET_KEY}` is {got_target!r}; it must be EXACTLY "
f"{want_target!r} (the tunnel in platform/cloudflared/config.yaml). Any other value publishes "
f"every public name at the wrong place — a dead cfargotunnel.com name (Cloudflare 1016), or "
f"an origin address of ours.")
check("--cloudflare-proxied" in [str(a) for a in (extra if isinstance(extra, list) else [])]
or (isinstance(extra, dict) and str(extra.get("cloudflare-proxied", "")).lower() == "true"),
"(9a) external-dns HelmRelease extraArgs must include `--cloudflare-proxied`: a DNS-only "
"CNAME to <uuid>.cfargotunnel.com resolves to nothing routable, so every public name is dead")
for rel, d in dns_services:
if is_disowned(d):
continue
if HOSTNAME_KEY not in annotations_of(d):
continue
v = annotations_of(d).get(PROXIED_KEY)
check(str(v).lower() == "false",
f"(9b) {rel} Service {'/'.join(ident(d))} publishes via the `service` source but carries "
f"`{PROXIED_KEY}: {v!r}`, not \"false\". With --cloudflare-proxied global it would become "
f"an orange-clouded A record, which cannot carry L4 traffic and hides nothing (the LB VIP "
f"is RFC1918). Every `service`-source publisher must opt out explicitly.")
for rel, d in routes:
v = annotations_of(d).get(PROXIED_KEY)
check(v is None or str(v).lower() == "true",
f"(9b) {rel} HTTPRoute {'/'.join(ident(d))} sets `{PROXIED_KEY}: {v!r}`. A gateway-sourced "
f"record is a CNAME to the tunnel; DNS-only, it is dead. Disown the route instead "
f"(`{CONTROLLER_KEY}: none`) if the name should not be public.")
refused = {str(r.get("hostname")) for r in cf.get("ingress") or []
if isinstance(r, dict) and r.get("hostname") and r.get("service") == "http_status:404"}
check(len(refused) > 0, "(9c) the tunnel config 404s no hostname — parse contract broken (the "
"operator's LAN/VPN-only list is known to be non-empty)")
published_l4: set[str] = set()
for rel, d in routes:
hosts = {str(h) for h in (d.get("spec") or {}).get("hostnames") or []}
leaked = sorted(hosts & refused)
if is_disowned(d):
unrefused = sorted(hosts - refused)
check(not unrefused,
f"(9c) {rel} HTTPRoute {'/'.join(ident(d))} is disowned (no public record) but the "
f"tunnel does not 404 {unrefused}: add them to platform/cloudflared/config.yaml above "
f"the wildcard, or the two halves of \"LAN/VPN-only\" have drifted apart.")
continue
check(not leaked,
f"(9c) {rel} HTTPRoute {'/'.join(ident(d))} PUBLISHES {leaked}, which the tunnel maps to "
f"http_status:404 (LAN/VPN-only). The name would be a public proxied record that serves "
f"only 404s — disown the route (`{CONTROLLER_KEY}: none`) or adjudicate the name.")
for rel, d in dns_services:
if is_disowned(d):
continue
hosts = {h.strip() for h in str(annotations_of(d).get(HOSTNAME_KEY) or "").split(",")
if h.strip()}
for h in sorted(hosts & refused):
published_l4.add(h)
check(h in L4_DNS_ONLY_NAMES,
f"(9c) {rel} Service {'/'.join(ident(d))} publishes {h}, which the tunnel 404s, and it "
f"is not a declared L4 DNS-only name {sorted(L4_DNS_ONLY_NAMES)}")
check(L4_DNS_ONLY_NAMES <= refused,
f"(9c) L4 DNS-only names {sorted(L4_DNS_ONLY_NAMES - refused)} are not 404'd by the tunnel; a "
f"stray CNAME to the tunnel for them would hit the Gateway's catch-all instead of a 404")
check(published_l4 == L4_DNS_ONLY_NAMES,
f"(9c) L4_DNS_ONLY_NAMES is shrink-only: {sorted(L4_DNS_ONLY_NAMES - published_l4)} no longer "
f"published by any `service`-source publisher — delete them from the list")
check(OPERATOR_LAN_ONLY_NAMES <= refused,
f"(9d) operator LAN/VPN-only names {sorted(OPERATOR_LAN_ONLY_NAMES - refused)} are missing "
f"from the tunnel's http_status:404 rules (platform/cloudflared/config.yaml)")
for name in sorted(OPERATOR_LAN_ONLY_NAMES):
naming = [(rel, d) for rel, d in routes
if name in {str(h) for h in (d.get("spec") or {}).get("hostnames") or []}]
check(len(naming) > 0, f"(9d) no HTTPRoute names {name} — stale OPERATOR_LAN_ONLY_NAMES entry")
for rel, d in naming:
check(is_disowned(d),
f"(9d) {rel} HTTPRoute {'/'.join(ident(d))} names LAN/VPN-only {name} but is not "
f"disowned, so external-dns publishes it publicly (operator decision 2026-11-08)")
# =============================================================================================
if FAILURES:
for f in FAILURES:
@@ -25,19 +25,15 @@
#
# python3 mutations.py <path-to-extracted-reconciler.py>
#
# Runtime is ~11s serial (one subprocess per mutation, each running the full selftest
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
# verdicts are independent of the pool size.
# Runtime is ~20s (one subprocess per mutation, each running the full selftest
# against an in-process stub Keycloak; no network, no cluster).
# =============================================================================
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -140,99 +136,43 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
]
def mutation_jobs() -> int:
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
running several checks in parallel, so the nested pools do not oversubscribe the host.
MUTATION_JOBS=1 reproduces the old serial loop exactly.
"""
raw = os.environ.get("MUTATION_JOBS", "").strip()
if raw:
try:
n = int(raw)
except ValueError:
n = 0
if n < 1:
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
return n
try:
cpus = len(os.sched_getaffinity(0))
except (AttributeError, OSError):
cpus = os.cpu_count() or 1
return max(1, min(8, cpus))
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(mutated_src)
path = fh.name
try:
return subprocess.run([sys.executable, path, "--selftest"],
capture_output=True, text=True, timeout=240,
stdin=subprocess.DEVNULL)
finally:
Path(path).unlink(missing_ok=True)
def main() -> int:
if len(sys.argv) != 2:
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
return 2
src = Path(sys.argv[1]).read_text()
try:
jobs = mutation_jobs()
except ValueError as e:
print("mutations.py: %s" % e, file=sys.stderr)
return 2
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
# serial loop did, and each mutation's source is built here. None = anchor failure.
planned: list[tuple[str, str, str | None, str | None]] = []
problems: list[str] = []
killed = 0
for name, why, old, new in MUTATIONS:
old, new = dedent4(old), dedent4(new)
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
# moved and this mutation silently stopped proving anything.
n = src.count(old)
if n != 1:
planned.append((name, why,
"%s: anchor matches %d times (expected exactly 1) -- the "
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
"code moved and this mutation no longer proves anything. "
"Re-anchor it against the current source." % (name, n), None))
"Re-anchor it against the current source." % (name, n))
continue
planned.append((name, why, None, src.replace(old, new, 1)))
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
# same verdict, as the serial loop produced -- whichever child finishes first.
problems: list[str] = []
killed = 0
pool = ThreadPoolExecutor(max_workers=jobs)
try:
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
for (_n, _w, _a, mutated) in planned]
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
if anchor_problem is not None:
problems.append(anchor_problem)
continue
proc = fut.result()
if proc.returncode == 0:
problems.append("%s SURVIVED -- --selftest still passed with this regression "
"applied, so nothing in the suite actually checks it. The "
"mistake it models: %s" % (name, why))
continue
killed += 1
out = proc.stdout + proc.stderr
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
how = ("%s assertion(s) caught it" % m.group(1) if m
else "the selftest aborted (caught, less cleanly)")
print(" killed %-42s %s" % (name, how))
finally:
# A timeout (or any other exception) still propagates and fails the check, as it did
# serially; queued mutations are cancelled rather than run for nothing.
pool.shutdown(wait=True, cancel_futures=True)
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(src.replace(old, new, 1))
path = fh.name
try:
proc = subprocess.run([sys.executable, path, "--selftest"],
capture_output=True, text=True, timeout=240)
finally:
Path(path).unlink(missing_ok=True)
if proc.returncode == 0:
problems.append("%s SURVIVED -- --selftest still passed with this regression "
"applied, so nothing in the suite actually checks it. The "
"mistake it models: %s" % (name, why))
continue
killed += 1
out = proc.stdout + proc.stderr
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
how = ("%s assertion(s) caught it" % m.group(1) if m
else "the selftest aborted (caught, less cleanly)")
print(" killed %-42s %s" % (name, how))
print()
if problems:
@@ -40,19 +40,15 @@
#
# python3 mutations.py <path-to-extracted-reconciler.py>
#
# Runtime is ~85s serial (one subprocess per mutation, each running the full selftest
# against an in-process stub Keycloak; no network, no cluster). The selftests run in a
# bounded pool (env MUTATION_JOBS, default min(8, CPUs); 1 = serial); output order and
# verdicts are independent of the pool size.
# Runtime is ~60s (one subprocess per mutation, each running the full selftest
# against an in-process stub Keycloak; no network, no cluster).
# =============================================================================
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
# (name, the mistake being modelled, anchor, replacement)
@@ -407,98 +403,42 @@ MUTATIONS: list[tuple[str, str, str, str]] = [
]
def mutation_jobs() -> int:
"""Selftests to run at once: env MUTATION_JOBS, else min(8, usable CPUs).
conformance/lib/run_l1_offline.py exports a smaller MUTATION_JOBS when it is itself
running several checks in parallel, so the nested pools do not oversubscribe the host.
MUTATION_JOBS=1 reproduces the old serial loop exactly.
"""
raw = os.environ.get("MUTATION_JOBS", "").strip()
if raw:
try:
n = int(raw)
except ValueError:
n = 0
if n < 1:
raise ValueError("MUTATION_JOBS must be a positive integer, got %r" % raw)
return n
try:
cpus = len(os.sched_getaffinity(0))
except (AttributeError, OSError):
cpus = os.cpu_count() or 1
return max(1, min(8, cpus))
def run_selftest(mutated_src: str) -> subprocess.CompletedProcess:
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(mutated_src)
path = fh.name
try:
return subprocess.run([sys.executable, path, "--selftest"],
capture_output=True, text=True, timeout=300,
stdin=subprocess.DEVNULL)
finally:
Path(path).unlink(missing_ok=True)
def main() -> int:
if len(sys.argv) != 2:
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
return 2
src = Path(sys.argv[1]).read_text()
try:
jobs = mutation_jobs()
except ValueError as e:
print("mutations.py: %s" % e, file=sys.stderr)
return 2
# Pass 1, serial and cheap: every anchor is checked BEFORE anything runs, exactly as the
# serial loop did, and each mutation's source is built here. None = anchor failure.
planned: list[tuple[str, str, str | None, str | None]] = []
problems: list[str] = []
killed = 0
for name, why, old, new in MUTATIONS:
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
# moved and this mutation silently stopped proving anything.
n = src.count(old)
if n != 1:
planned.append((name, why,
"%s: anchor matches %d times (expected exactly 1) -- the "
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
"code moved and this mutation no longer proves anything. "
"Re-anchor it against the current source." % (name, n), None))
"Re-anchor it against the current source." % (name, n))
continue
planned.append((name, why, None, src.replace(old, new, 1)))
# Pass 2: the selftests, `jobs` at a time. Each child gets its own temp file, its own
# mkdtemp dirs and its own 127.0.0.1:0 stub port, so they share nothing. Results are
# consumed in MUTATIONS order, so every line below is printed in the same order, with the
# same verdict, as the serial loop produced -- whichever child finishes first.
problems: list[str] = []
killed = 0
pool = ThreadPoolExecutor(max_workers=jobs)
try:
futures = [pool.submit(run_selftest, mutated) if mutated is not None else None
for (_n, _w, _a, mutated) in planned]
for (name, why, anchor_problem, _m), fut in zip(planned, futures):
if anchor_problem is not None:
problems.append(anchor_problem)
continue
proc = fut.result()
if proc.returncode == 0:
problems.append("%s SURVIVED -- --selftest still passed with this regression "
"applied, so nothing in the suite actually checks it. The "
"mistake it models: %s" % (name, why))
continue
killed += 1
out = proc.stdout + proc.stderr
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
how = ("%s assertion(s) caught it" % m.group(1) if m
else "the selftest aborted (caught, less cleanly)")
print(" killed %-54s %s" % (name, how))
finally:
# A timeout (or any other exception) still propagates and fails the check, as it did
# serially; queued mutations are cancelled rather than run for nothing.
pool.shutdown(wait=True, cancel_futures=True)
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(src.replace(old, new, 1))
path = fh.name
try:
proc = subprocess.run([sys.executable, path, "--selftest"],
capture_output=True, text=True, timeout=300)
finally:
Path(path).unlink(missing_ok=True)
if proc.returncode == 0:
problems.append("%s SURVIVED -- --selftest still passed with this regression "
"applied, so nothing in the suite actually checks it. The "
"mistake it models: %s" % (name, why))
continue
killed += 1
out = proc.stdout + proc.stderr
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
how = ("%s assertion(s) caught it" % m.group(1) if m
else "the selftest aborted (caught, less cleanly)")
print(" killed %-54s %s" % (name, how))
print()
if problems:
@@ -1,39 +0,0 @@
# =============================================================================
# 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
@@ -1,146 +0,0 @@
#!/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
@@ -1,45 +0,0 @@
# =============================================================================
# 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
@@ -1,269 +0,0 @@
#!/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
@@ -1,28 +0,0 @@
# =============================================================================
# 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
@@ -1,105 +0,0 @@
#!/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
@@ -1,29 +0,0 @@
# =============================================================================
# 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
@@ -1,153 +0,0 @@
#!/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
@@ -1,28 +0,0 @@
# =============================================================================
# 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
@@ -1,105 +0,0 @@
#!/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
@@ -1,30 +0,0 @@
# =============================================================================
# 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
@@ -1,210 +0,0 @@
#!/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
@@ -1,34 +0,0 @@
# =============================================================================
# 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
@@ -1,351 +0,0 @@
#!/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
@@ -1,31 +0,0 @@
# =============================================================================
# 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
@@ -1,102 +0,0 @@
#!/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
@@ -62,7 +62,7 @@ spec:
ports:
- containerPort: 8080
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
@@ -85,7 +85,7 @@ spec:
- name: config
mountPath: /data/config
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
@@ -85,7 +85,7 @@ spec:
- name: config
mountPath: /data/config
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.6
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
args:
- --http-address=0.0.0.0:4180
@@ -1,25 +0,0 @@
# 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
@@ -1,120 +0,0 @@
#!/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)
@@ -1,7 +0,0 @@
#!/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"
@@ -1,28 +0,0 @@
# 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
@@ -1,230 +0,0 @@
#!/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)
@@ -1,7 +0,0 @@
#!/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"
@@ -1,25 +0,0 @@
# 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
@@ -1,155 +0,0 @@
#!/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)
@@ -1,7 +0,0 @@
#!/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"
@@ -1,395 +0,0 @@
{
"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"
}
}
]
}
@@ -1,26 +0,0 @@
# 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
@@ -1,214 +0,0 @@
#!/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)
@@ -1,7 +0,0 @@
#!/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"
@@ -1,199 +0,0 @@
{
"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": ""
}
]
}
]
}
@@ -1,27 +0,0 @@
# 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
@@ -1,444 +0,0 @@
#!/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)
@@ -1,7 +0,0 @@
#!/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"
@@ -1,198 +0,0 @@
{
"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": []
}
@@ -1,4 +0,0 @@
{
"ArtifactName": "x",
"SchemaVersion": 2
}
@@ -1,33 +0,0 @@
{
"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
}
@@ -1,19 +0,0 @@
{
"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
}
@@ -1,25 +0,0 @@
{
"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
}
@@ -1,32 +0,0 @@
# =============================================================================
# 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
@@ -1,363 +0,0 @@
#!/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)
@@ -1,5 +0,0 @@
[
{"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"}
]
@@ -1,71 +0,0 @@
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>
@@ -1,425 +0,0 @@
{
"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": []
}
@@ -1,6 +0,0 @@
#!/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"
@@ -1,31 +0,0 @@
# =============================================================================
# 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
@@ -1,162 +0,0 @@
#!/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)
@@ -1,20 +0,0 @@
{
"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": []
}
@@ -1,6 +0,0 @@
#!/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"
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more