Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1adc3963c | ||
|
|
4ca0ebfac6 | ||
|
|
1ff527f546 | ||
|
|
d8b25a67fe | ||
|
|
1763e27859 | ||
|
|
4c82c64b2f | ||
|
|
d06dff9a19 | ||
|
|
b1a6fe6503 | ||
|
|
b265d08278 | ||
|
|
2b9dcef6c7 | ||
|
|
d4bea8a78c | ||
|
|
b85d1e3ed7 | ||
|
|
50ec2297ee | ||
|
|
ec6b39bb91 | ||
|
|
c3c72c47bb | ||
|
|
4c3a284cad | ||
|
|
4a89aacfbc | ||
|
|
50c8719b12 | ||
|
|
8f60e6103a | ||
|
|
da0515c8c8 | ||
|
|
3a2ec5a12c | ||
|
|
f13d336630 | ||
|
|
8fe8d5df8a | ||
|
|
020e736541 | ||
|
|
92944486d1 | ||
|
|
2e777e13ca | ||
|
|
16222606ae | ||
|
|
d9ab57f2d1 | ||
|
|
9724445a08 |
No files matched your search
@@ -164,11 +164,14 @@ Cost: the job shares the single act_runner slot with the gate, but the build its
|
||||
.gitea/workflows/
|
||||
merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision)
|
||||
images.yaml build/verify/publish this repo's container images (see above)
|
||||
cve-triage.yaml scheduled CVE triage bot (R-VULN-05/07, ADR-0027; runs before Renovate)
|
||||
scripts/
|
||||
install-tools.sh installs the pinned tools per stage (from ci/versions.env) — used by the
|
||||
dormant merge-gate.yaml blueprint only; gate.yml runs in the toolbox image
|
||||
decide.sh the R-TEST-03 merge decision over job results
|
||||
images.sh the image pipeline's driver (what to build, base, push, digest)
|
||||
cve_triage.py the CVE triage bot: reads the scanner report, maintains the counts-only
|
||||
CVE Dashboard issue, ticks Renovate approval rows (cve-triage.yaml)
|
||||
README.md this file
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as
|
||||
# renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it
|
||||
# records what it did in the job summary. The logic and its reasoning live in
|
||||
# scripts/cve_triage.py; this file only supplies the image, the code, the credential and the
|
||||
# schedule.
|
||||
#
|
||||
# WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md §2), so the
|
||||
# token preflight exits 1 rather than skipping, and an unreachable scanner fails the script
|
||||
# rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent
|
||||
# fires for every run that did not get all the way through, and when the schedule stops firing.
|
||||
#
|
||||
# THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which
|
||||
# is also what authored the dashboard this edits. It needs read:repository and write:issue.
|
||||
name: cve-triage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same
|
||||
# morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body.
|
||||
- cron: "15 7 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run: read everything, write nothing, print counts."
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
concurrency:
|
||||
group: cve-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
# THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker
|
||||
# daemon, so a job container is the only way to choose an image. The toolbox provides
|
||||
# python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in
|
||||
# the DinD store. It has no Node, so actions/checkout cannot run; a public python image has
|
||||
# neither Node nor git and would add a second image to keep pinned. The cost is a cold pull
|
||||
# after a runner restart, which the gate pays anyway.
|
||||
container:
|
||||
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
|
||||
image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81
|
||||
# Without a bound, a wedged run would hold the concurrency group and block every later run.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Preflight - the token must exist, and its absence must be LOUD
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RENOVATE_TOKEN:-}" ]; then
|
||||
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
|
||||
echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RENOVATE_TOKEN is present (value not printed)."
|
||||
|
||||
- name: Preflight - Gitea is reachable and the token can do this job
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403
|
||||
# its scope; the script's own errors would only show a bare status later.
|
||||
run: |
|
||||
set -uo pipefail
|
||||
GITEA=https://gitea.home.keithjackson.dev
|
||||
REPO=Home-Network/home-network-k3s-impl
|
||||
fail=0
|
||||
body=$(mktemp)
|
||||
fetch() {
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
|
||||
| curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true
|
||||
}
|
||||
probe() { # <what it proves> <path>
|
||||
local code
|
||||
code=$(fetch "$2")
|
||||
if [ "$code" = 200 ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
fail=1
|
||||
echo "::error::$1: GET ${2%%\?*} -> $code"
|
||||
fi
|
||||
}
|
||||
probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO"
|
||||
if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then
|
||||
fail=1
|
||||
echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues."
|
||||
fi
|
||||
probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1"
|
||||
probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91"
|
||||
# A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes
|
||||
# (names only). Same technique as renovate.yaml, advisory if the message format changes.
|
||||
fetch "/api/v1/admin/orgs?limit=1" > /dev/null
|
||||
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
|
||||
if [ -n "$scopes" ]; then
|
||||
case ",$scopes," in
|
||||
*",write:issue,"*|*",all,"*) echo "ok write:issue" ;;
|
||||
*) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;;
|
||||
esac
|
||||
else
|
||||
echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED"
|
||||
fi
|
||||
rm -f "$body"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
# Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough,
|
||||
# and the job token goes to the single fetch only, never into .git/config.
|
||||
- name: Checkout the script (git, one commit)
|
||||
shell: bash
|
||||
env:
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 0022
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git init -q .
|
||||
git remote add origin "${SERVER_URL}/${REPOSITORY}"
|
||||
auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \
|
||||
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \
|
||||
git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \
|
||||
"+${SHA}:refs/remotes/checkout/head"
|
||||
unset auth GIT_TOKEN
|
||||
git checkout -q --force --detach "$SHA"
|
||||
[ "$(git rev-parse HEAD)" = "$SHA" ]
|
||||
|
||||
- name: Triage
|
||||
shell: bash
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
GITEA_URL: https://gitea.home.keithjackson.dev
|
||||
GITEA_REPOSITORY: Home-Network/home-network-k3s-impl
|
||||
RENOVATE_DASHBOARD_ISSUE: "91"
|
||||
VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081
|
||||
# The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only
|
||||
# when the name above fails to resolve. Keep the two in step.
|
||||
VULN_API_FALLBACK_URL: http://10.43.210.10:8081
|
||||
VULN_PAGE_URL: https://cve.home.keithjackson.dev
|
||||
# Anything other than an explicit false on a dispatch is a dry run; a schedule is live.
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}
|
||||
run: python3 .gitea/workflows/scripts/cve_triage.py
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## CVE triage"
|
||||
echo
|
||||
echo "- trigger: \`${{ github.event_name }}\`"
|
||||
echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`"
|
||||
echo "- outcome: \`${{ job.status }}\`"
|
||||
echo
|
||||
echo "Counts only: which CVE lands where is on the CVE page, never here."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
Executable
+820
@@ -0,0 +1,820 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CVE triage bot (docs/17 "The triage bot", ADR-0027; R-VULN-05, R-VULN-07).
|
||||
|
||||
Runs in .gitea/workflows/cve-triage.yaml, once a day, before Renovate. Each run:
|
||||
1. reads report.json from the scanner's internal API;
|
||||
2. creates or updates the single `CVE Dashboard` issue (aggregates only);
|
||||
3. reads Renovate's Dependency Dashboard and open PRs, derives each image's remediation state;
|
||||
4. ticks `approve-branch` boxes on that dashboard, for packages matching an image with an
|
||||
unaccepted CRITICAL that has a fix;
|
||||
5. POSTs the heartbeat and the remediation states to the scanner.
|
||||
|
||||
WHAT IT MUST NEVER DO. Renovate is the only author of version changes (R-UPD-02): this script
|
||||
never edits a version, opens a bump PR, merges anything, or posts `/approve-override`. It never
|
||||
ticks a `rebase-branch` row. The dashboard issue carries no CVE id, package, image or workload,
|
||||
because the repository is readable without authentication, and no label the in-cluster PM acts
|
||||
on (platform/pm-agent/configmap.yaml PM_REQUIRE_LABEL = `agent`).
|
||||
|
||||
OUTPUT IS COUNTS ONLY. Job logs and the summary are as visible as the issue, so nothing here
|
||||
prints a report string. Every report value is untrusted: strings are validated, and only counts
|
||||
and timestamps that passed RFC 3339 validation ever reach markdown.
|
||||
|
||||
HOW A JOB CONTAINER REACHES THE SCANNER, AND WHAT IS NOT PROVEN
|
||||
---------------------------------------------------------------
|
||||
Jobs are containers on a per-job docker bridge network inside the DinD sidecar of the act-runner
|
||||
pod (apps/wave-1/gitea-actions.yaml). The runner config sets no `container.network` and dockerd no
|
||||
`--dns`, so name resolution is docker's embedded resolver forwarding to the resolvers of the
|
||||
dockerd container, which are that pod's: cluster DNS. Egress is masqueraded to the pod IP
|
||||
(measured 2026-10-29, platform/buildkit/networkpolicy.yaml header), so a ClusterIP is routed and
|
||||
policy-matched as the act-runner pod in gitea-actions. images.yaml relies on the same path to
|
||||
reach buildkitd.buildkit.svc.cluster.local, which is indirect evidence only.
|
||||
UNVERIFIED: in-cluster DNS from a DinD job container.
|
||||
UNVERIFIED: ClusterIP reachability from a DinD job container to vuln-scanner:8081.
|
||||
Hence VULN_API_FALLBACK_URL (the Service's pinned ClusterIP), used only when the name fails to
|
||||
resolve. Anything else, including refused and timed-out connections, fails the job: an
|
||||
unreachable API is a red run, never a skip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Iterable, NamedTuple
|
||||
|
||||
DASHBOARD_TITLE = "CVE Dashboard"
|
||||
DASHBOARD_LABEL = "cve-dashboard"
|
||||
DASHBOARD_LABEL_COLOR = "b60205"
|
||||
# Labels the in-cluster PM dispatcher selects work by. Never applied to anything here.
|
||||
FORBIDDEN_LABELS = frozenset({"agent"})
|
||||
RENOVATE_DASHBOARD_TITLE = "Dependency Dashboard"
|
||||
|
||||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
FINDING_STATUSES = ("open", "fix-available", "accepted")
|
||||
TARGET_STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
|
||||
TARGET_TYPES = ("image", "vm", "node")
|
||||
RUN_STATUSES = ("complete", "degraded")
|
||||
REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
|
||||
|
||||
MAX_REPORT_BYTES = 128 * 1024 * 1024
|
||||
MAX_API_BYTES = 8 * 1024 * 1024
|
||||
MAX_FIELD_LEN = 4096
|
||||
MAX_IMAGE_LEN = 512
|
||||
MAX_ISSUE_BODY = 2 * 1024 * 1024
|
||||
MAX_DASHBOARD_ROWS = 2000
|
||||
MAX_DETECTED_DEPS = 20000
|
||||
MAX_REMEDIATION = 5000
|
||||
MAX_PAGES = 200
|
||||
PAGE_SIZE = 50
|
||||
REPORT_STALE_HOURS = 26
|
||||
DATABASE_STALE_HOURS = 48
|
||||
|
||||
HTTP_TIMEOUT = 60
|
||||
|
||||
RFC3339_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})")
|
||||
IMAGE_RE = re.compile(r"[a-z0-9]([a-z0-9._:/-]*[a-z0-9])?")
|
||||
HTTP_URL_RE = re.compile(r"https?://[A-Za-z0-9.-]+(:\d{1,5})?(/[A-Za-z0-9._~%/-]*)?")
|
||||
REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
|
||||
|
||||
# Renovate's own row shape (lib/workers/repository/dependency-dashboard.ts, getListItem). The kind
|
||||
# is captured whole so `approvePr-branch` can never read as `approve-branch`.
|
||||
ROW_RE = re.compile(r" - \[(?P<mark>[ xX])\] <!-- (?P<kind>[A-Za-z]+)-branch=(?P<branch>\S+) -->(?P<rest>.*)")
|
||||
LINKED_TITLE_RE = re.compile(r"\[(?P<title>.*)\]\((?P<target>[^()\s]*)\)(?P<tail>.*)")
|
||||
PR_LINK_RE = re.compile(r"(?:^|/)pulls?/(\d{1,9})$")
|
||||
PACKAGE_LIST_RE = re.compile(r"\s\(((?:`[^`\s]+`(?:, )?)+)\)\s*$")
|
||||
TITLE_PACKAGE_RE = re.compile(r"(?:^|\s)(?:Update|Pin)\s+(\S+)\s+Docker\s+(?:tag|digest)\b")
|
||||
SUMMARY_RE = re.compile(r"<details><summary>([^<]*?)(?: \(\d+\))?</summary>")
|
||||
DETECTED_RE = re.compile(r" - `(?P<name>[^\s`]+)(?: [^`]*)?`(?P<updates>\s*→ \[Updates:.*\])?\s*")
|
||||
|
||||
# Detected-dependency managers whose names are charts or actions, never container images.
|
||||
NON_IMAGE_MANAGERS = frozenset({"flux", "helmv3", "github-actions"})
|
||||
|
||||
|
||||
class BotError(Exception):
|
||||
"""A failure that ends the run. The message must be safe to print: no report text, no secret."""
|
||||
|
||||
|
||||
class DnsFailure(Exception):
|
||||
"""The hostname did not resolve. The only condition that selects the fallback address."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: normalisation + report
|
||||
|
||||
|
||||
def normalise_image(value: object) -> str:
|
||||
"""Canonical form for exact comparison, or "" when the value is not an image reference.
|
||||
|
||||
Lowercase; drop digest and tag; drop the Docker Hub registry and the `library/` namespace.
|
||||
`library/` is only Hub's implicit namespace, so ghcr.io/library/x keeps it. Comparison of the
|
||||
result is always string equality, never a substring test.
|
||||
"""
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_IMAGE_LEN:
|
||||
return ""
|
||||
name = value.strip().lower().split("@", 1)[0]
|
||||
head, sep, last = name.rpartition("/")
|
||||
name = f"{head}{sep}{last.split(':', 1)[0]}"
|
||||
for hub in ("docker.io/", "index.docker.io/", "registry-1.docker.io/"):
|
||||
if name.startswith(hub):
|
||||
name = name[len(hub):]
|
||||
break
|
||||
if name.startswith("library/"):
|
||||
name = name[len("library/"):]
|
||||
return name if IMAGE_RE.fullmatch(name) else ""
|
||||
|
||||
|
||||
def parse_timestamp(value: object, field: str, nullable: bool = False) -> datetime | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or not RFC3339_RE.fullmatch(value):
|
||||
raise BotError(f"report: {field} is not an RFC 3339 timestamp")
|
||||
text = re.sub(r"\.\d+", "", value).replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"report: {field} is not a valid timestamp") from exc
|
||||
|
||||
|
||||
def _object(value: object, field: str) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise BotError(f"report: {field} is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(value: object, field: str) -> list:
|
||||
if not isinstance(value, list):
|
||||
raise BotError(f"report: {field} is not an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(value: object, field: str, nullable: bool = False) -> str | None:
|
||||
if value is None and nullable:
|
||||
return None
|
||||
if not isinstance(value, str) or len(value) > MAX_FIELD_LEN:
|
||||
raise BotError(f"report: {field} is not a bounded string")
|
||||
return value
|
||||
|
||||
|
||||
def _member(value: object, allowed: Iterable[str], field: str) -> str:
|
||||
if not isinstance(value, str) or value not in allowed:
|
||||
raise BotError(f"report: {field} has a value outside its enumeration")
|
||||
return value
|
||||
|
||||
|
||||
def parse_report(raw: bytes) -> dict:
|
||||
"""Decode and validate report.json into the only shape the rest of this file reads."""
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except (ValueError, RecursionError) as exc:
|
||||
raise BotError("report: body is not valid JSON") from exc
|
||||
doc = _object(doc, "document")
|
||||
if doc.get("schemaVersion") != 1 or isinstance(doc.get("schemaVersion"), bool):
|
||||
raise BotError("report: unsupported schemaVersion")
|
||||
parse_timestamp(doc.get("generatedAt"), "generatedAt")
|
||||
database = _object(doc.get("database"), "database")
|
||||
run = _object(doc.get("run"), "run")
|
||||
parse_timestamp(database.get("updatedAt"), "database.updatedAt", nullable=True)
|
||||
parse_timestamp(run.get("startedAt"), "run.startedAt", nullable=True)
|
||||
parse_timestamp(run.get("finishedAt"), "run.finishedAt", nullable=True)
|
||||
if run.get("status") is not None:
|
||||
_member(run.get("status"), RUN_STATUSES, "run.status")
|
||||
|
||||
targets = []
|
||||
known_ids = {}
|
||||
for entry in _array(doc.get("targets"), "targets"):
|
||||
entry = _object(entry, "target")
|
||||
target_id = _string(entry.get("id"), "target.id")
|
||||
kind = _member(entry.get("type"), TARGET_TYPES, "target.type")
|
||||
state = _member(entry.get("state"), TARGET_STATES, "target.state")
|
||||
image = _string(entry.get("image"), "target.image", nullable=True)
|
||||
if kind == "image" and not normalise_image(image):
|
||||
raise BotError("report: an image target has an invalid image reference")
|
||||
known_ids[target_id] = image if kind == "image" else None
|
||||
targets.append({"id": target_id, "type": kind, "state": state, "image": image})
|
||||
|
||||
findings = []
|
||||
for entry in _array(doc.get("findings"), "findings"):
|
||||
entry = _object(entry, "finding")
|
||||
target_id = _string(entry.get("targetId"), "finding.targetId")
|
||||
if target_id not in known_ids:
|
||||
raise BotError("report: a finding references an unknown target")
|
||||
findings.append({
|
||||
"targetId": target_id,
|
||||
"image": known_ids[target_id],
|
||||
"severity": _member(entry.get("severity"), SEVERITIES, "finding.severity"),
|
||||
"status": _member(entry.get("status"), FINDING_STATUSES, "finding.status"),
|
||||
"fixedVersion": _string(entry.get("fixedVersion"), "finding.fixedVersion", nullable=True),
|
||||
})
|
||||
return {
|
||||
"generatedAt": doc["generatedAt"],
|
||||
"database": {"updatedAt": database.get("updatedAt")},
|
||||
"run": {"finishedAt": run.get("finishedAt"), "status": run.get("status")},
|
||||
"targets": targets,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def qualifies(finding: dict) -> bool:
|
||||
"""An unaccepted CRITICAL with a fix: the only thing that may cause a tick."""
|
||||
return (
|
||||
finding["severity"] == "CRITICAL"
|
||||
and finding["status"] == "fix-available"
|
||||
and bool(finding["fixedVersion"])
|
||||
)
|
||||
|
||||
|
||||
def qualifying_images(report: dict) -> set[str]:
|
||||
return {
|
||||
norm
|
||||
for finding in report["findings"]
|
||||
if qualifies(finding) and (norm := normalise_image(finding["image"]))
|
||||
}
|
||||
|
||||
|
||||
def aggregate(report: dict) -> dict:
|
||||
severity = {sev: dict.fromkeys(FINDING_STATUSES, 0) for sev in SEVERITIES}
|
||||
for finding in report["findings"]:
|
||||
severity[finding["severity"]][finding["status"]] += 1
|
||||
coverage = dict.fromkeys(TARGET_STATES, 0)
|
||||
for target in report["targets"]:
|
||||
coverage[target["state"]] += 1
|
||||
return {
|
||||
"severity": severity,
|
||||
"coverage": coverage,
|
||||
"targets_total": len(report["targets"]),
|
||||
"critical_with_fix": sum(1 for f in report["findings"] if qualifies(f)),
|
||||
"generatedAt": report["generatedAt"],
|
||||
"databaseUpdatedAt": report["database"]["updatedAt"],
|
||||
"runFinishedAt": report["run"]["finishedAt"],
|
||||
"runStatus": report["run"]["status"],
|
||||
}
|
||||
|
||||
|
||||
def render_dashboard(agg: dict, page_url: str, now: datetime) -> str:
|
||||
"""The CVE Dashboard issue body: a fixed template over counts and validated timestamps."""
|
||||
if not HTTP_URL_RE.fullmatch(page_url):
|
||||
raise BotError("config: VULN_PAGE_URL is not a plain http(s) URL")
|
||||
warnings = []
|
||||
generated = parse_timestamp(agg["generatedAt"], "generatedAt")
|
||||
if now - generated > timedelta(hours=REPORT_STALE_HOURS):
|
||||
warnings.append(f"The report is older than {REPORT_STALE_HOURS} hours.")
|
||||
updated = parse_timestamp(agg["databaseUpdatedAt"], "database.updatedAt", nullable=True)
|
||||
if updated is None or now - updated > timedelta(hours=DATABASE_STALE_HOURS):
|
||||
warnings.append(f"The vulnerability database is older than {DATABASE_STALE_HOURS} hours or unknown.")
|
||||
if agg["runStatus"] != "complete":
|
||||
warnings.append("The latest scan run did not complete cleanly.")
|
||||
|
||||
lines = [
|
||||
"Maintained by the CVE triage bot, once a day. **Counts only**: this repository is readable",
|
||||
"without authentication, so which CVE lands where is only on the",
|
||||
f"[CVE page]({page_url}) (SSO, platform-admins).",
|
||||
"",
|
||||
]
|
||||
lines += [f"> **WARNING:** {text}" for text in warnings]
|
||||
if warnings:
|
||||
lines.append("")
|
||||
lines += [
|
||||
"## Freshness",
|
||||
"",
|
||||
"| | |",
|
||||
"|---|---|",
|
||||
f"| Report generated | {agg['generatedAt']} |",
|
||||
f"| Database updated | {agg['databaseUpdatedAt'] or 'unknown'} |",
|
||||
f"| Last run finished | {agg['runFinishedAt'] or 'none yet'} |",
|
||||
f"| Last run status | {agg['runStatus'] or 'none yet'} |",
|
||||
f"| Dashboard rendered | {now.strftime('%Y-%m-%dT%H:%M:%SZ')} |",
|
||||
"",
|
||||
"## Findings by severity",
|
||||
"",
|
||||
"| Severity | Open | Fix available | Accepted |",
|
||||
"|---|---:|---:|---:|",
|
||||
]
|
||||
for sev in SEVERITIES:
|
||||
row = agg["severity"][sev]
|
||||
lines.append(f"| {sev} | {row['open']} | {row['fix-available']} | {row['accepted']} |")
|
||||
lines += [
|
||||
"",
|
||||
f"Unaccepted CRITICAL with a fix: **{agg['critical_with_fix']}**",
|
||||
"",
|
||||
"## Coverage",
|
||||
"",
|
||||
f"{agg['targets_total']} targets. A count in any state other than `scanned` is something"
|
||||
" that was not freshly examined, and is not a clean result.",
|
||||
"",
|
||||
"| State | Targets |",
|
||||
"|---|---:|",
|
||||
]
|
||||
lines += [f"| {state} | {agg['coverage'][state]} |" for state in TARGET_STATES]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pure: Renovate's dashboard
|
||||
|
||||
|
||||
class Row(NamedTuple):
|
||||
kind: str
|
||||
mark: str
|
||||
branch: str
|
||||
packages: tuple[str, ...]
|
||||
pr_number: int | None
|
||||
offset: int # offset in the body of the first character of the line
|
||||
|
||||
|
||||
def _title_package(title: str) -> str:
|
||||
match = TITLE_PACKAGE_RE.search(title)
|
||||
return normalise_image(match.group(1)) if match else ""
|
||||
|
||||
|
||||
def _row_details(rest: str) -> tuple[tuple[str, ...], int | None]:
|
||||
"""Packages and PR number of a row. No recognisable package means no packages, so no tick."""
|
||||
rest = rest.rstrip("\r")
|
||||
number = None
|
||||
title = rest
|
||||
tail = rest
|
||||
linked = LINKED_TITLE_RE.fullmatch(rest)
|
||||
if linked:
|
||||
title, tail = linked.group("title"), linked.group("tail")
|
||||
link = PR_LINK_RE.search(linked.group("target"))
|
||||
number = int(link.group(1)) if link else None
|
||||
listed = PACKAGE_LIST_RE.search(tail)
|
||||
if listed:
|
||||
names = re.findall(r"`([^`]+)`", listed.group(1))
|
||||
packages = tuple(n for n in map(normalise_image, names) if n)
|
||||
else:
|
||||
package = _title_package(title)
|
||||
packages = (package,) if package else ()
|
||||
return packages, number
|
||||
|
||||
|
||||
def _is_detected_heading(line: str) -> bool:
|
||||
return line.startswith("## ") and line[3:].strip().lower().startswith("detected dependencies")
|
||||
|
||||
|
||||
def parse_rows(body: str) -> list[Row]:
|
||||
"""Checkbox rows above `## Detected Dependencies`. Only a whole-line match counts."""
|
||||
if len(body) > MAX_ISSUE_BODY:
|
||||
raise BotError("renovate dashboard: body exceeds its bound")
|
||||
rows: list[Row] = []
|
||||
offset = 0
|
||||
for line in body.split("\n"):
|
||||
if _is_detected_heading(line):
|
||||
break
|
||||
match = ROW_RE.fullmatch(line.rstrip("\r"))
|
||||
if match:
|
||||
packages, number = _row_details(match.group("rest"))
|
||||
rows.append(Row(match.group("kind"), match.group("mark"), match.group("branch"),
|
||||
packages, number, offset))
|
||||
if len(rows) > MAX_DASHBOARD_ROWS:
|
||||
raise BotError("renovate dashboard: too many rows")
|
||||
offset += len(line) + 1
|
||||
return rows
|
||||
|
||||
|
||||
def parse_detected(body: str) -> dict[str, bool]:
|
||||
"""{normalised depName: has a pending update} from `## Detected Dependencies`."""
|
||||
tracked: dict[str, bool] = {}
|
||||
lines = body.split("\n")
|
||||
in_section = False
|
||||
manager = ""
|
||||
count = 0
|
||||
for index, raw in enumerate(lines):
|
||||
line = raw.rstrip("\r")
|
||||
if line.startswith("## "):
|
||||
in_section = _is_detected_heading(line)
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
summary = SUMMARY_RE.fullmatch(line)
|
||||
if summary:
|
||||
# A manager heading is the summary directly followed by <blockquote>; a file heading
|
||||
# is not.
|
||||
if index + 1 < len(lines) and lines[index + 1].strip() == "<blockquote>":
|
||||
manager = summary.group(1)
|
||||
continue
|
||||
match = DETECTED_RE.fullmatch(line)
|
||||
if not match or manager in NON_IMAGE_MANAGERS:
|
||||
continue
|
||||
count += 1
|
||||
if count > MAX_DETECTED_DEPS:
|
||||
raise BotError("renovate dashboard: too many detected dependencies")
|
||||
norm = normalise_image(match.group("name"))
|
||||
if norm:
|
||||
tracked[norm] = tracked.get(norm, False) or bool(match.group("updates"))
|
||||
return tracked
|
||||
|
||||
|
||||
def row_matches(row: Row, qualifying: set[str]) -> bool:
|
||||
return any(package in qualifying for package in row.packages)
|
||||
|
||||
|
||||
def select_ticks(rows: list[Row], qualifying: set[str]) -> list[Row]:
|
||||
return [
|
||||
row for row in rows
|
||||
if row.kind == "approve" and row.mark == " " and row_matches(row, qualifying)
|
||||
]
|
||||
|
||||
|
||||
def apply_ticks(body: str, ticks: list[Row]) -> str:
|
||||
"""Replace only the `[ ]` of each row, re-verifying the row where it stands.
|
||||
|
||||
Every byte other than the three of each marker is carried over unchanged.
|
||||
"""
|
||||
pieces = []
|
||||
cursor = 0
|
||||
for row in sorted(ticks, key=lambda r: r.offset):
|
||||
expected = f" - [ ] <!-- approve-branch={row.branch} -->"
|
||||
if row.kind != "approve" or not body.startswith(expected, row.offset):
|
||||
raise BotError("renovate dashboard: a row changed between parse and write")
|
||||
marker = row.offset + len(" - ")
|
||||
pieces += [body[cursor:marker], "[x]"]
|
||||
cursor = marker + len("[ ]")
|
||||
pieces.append(body[cursor:])
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def derive_remediation(
|
||||
images: Iterable[str],
|
||||
rows: list[Row],
|
||||
open_prs: list[dict],
|
||||
tracked: dict[str, bool],
|
||||
pr_url: Callable[[int], str],
|
||||
now_text: str,
|
||||
) -> list[dict]:
|
||||
"""One entry per report image.
|
||||
|
||||
pr-open: an open-PR row or an open Renovate PR for that package. approval-requested: an
|
||||
approve row. no-update-identified: Renovate tracks it and lists no update. not-tracked:
|
||||
everything else, which deliberately includes "Renovate knows an update but no PR or approval
|
||||
row exists" (awaiting schedule, rate limited): saying no update was identified would be
|
||||
false, and the enumeration has no closer value, so it lands on the state that draws attention.
|
||||
"""
|
||||
by_branch = {pr["branch"]: pr["number"] for pr in open_prs}
|
||||
result = []
|
||||
for image in sorted(set(images)):
|
||||
norm = normalise_image(image)
|
||||
state = "not-tracked"
|
||||
url = None
|
||||
rebase = next((r for r in rows if r.kind == "rebase" and norm in r.packages), None)
|
||||
pr = next((p for p in open_prs if p["package"] == norm), None)
|
||||
if rebase is not None or pr is not None:
|
||||
state = "pr-open"
|
||||
number = None
|
||||
if rebase is not None:
|
||||
number = rebase.pr_number or by_branch.get(rebase.branch)
|
||||
if number is None and pr is not None:
|
||||
number = pr["number"]
|
||||
url = pr_url(number) if number is not None else None
|
||||
elif any(r.kind == "approve" and norm in r.packages for r in rows):
|
||||
state = "approval-requested"
|
||||
elif norm in tracked and not tracked[norm]:
|
||||
state = "no-update-identified"
|
||||
result.append({"image": image, "state": state, "url": url, "updatedAt": now_text})
|
||||
return result
|
||||
|
||||
|
||||
def count_states(remediation: list[dict]) -> dict[str, int]:
|
||||
counts = dict.fromkeys(REMEDIATION_STATES, 0)
|
||||
for entry in remediation:
|
||||
counts[entry["state"]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- I/O
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""A redirect would carry the Authorization header to wherever it points."""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class UrllibTransport:
|
||||
"""(method, url, headers, body, max_bytes) -> (status, bytes); never uses a proxy."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def __call__(self, method: str, url: str, headers: dict, body: bytes | None, max_bytes: int):
|
||||
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
try:
|
||||
with self._opener.open(request, timeout=HTTP_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
return response.status, data
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, b""
|
||||
except urllib.error.URLError as exc:
|
||||
if isinstance(exc.reason, socket.gaierror):
|
||||
raise DnsFailure() from exc
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc.reason).__name__}") from exc
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise BotError(f"{method} {urllib.parse.urlsplit(url).path}: {type(exc).__name__}") from exc
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, base_url: str, repo: str, token: str, transport) -> None:
|
||||
self._api = f"{base_url.rstrip('/')}/api/v1/repos/{repo}"
|
||||
self._web = f"{base_url.rstrip('/')}/{repo}"
|
||||
self._token = token
|
||||
self._transport = transport
|
||||
|
||||
def _call(self, method: str, path: str, query: dict | None = None, payload=None, expect=(200,)):
|
||||
url = self._api + path + (f"?{urllib.parse.urlencode(query)}" if query else "")
|
||||
headers = {"Authorization": f"token {self._token}", "Accept": "application/json"}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
status, raw = self._transport(method, url, headers, data, MAX_API_BYTES)
|
||||
except DnsFailure as exc:
|
||||
raise BotError(f"gitea: {method} {path}: name resolution failed") from exc
|
||||
if status not in expect:
|
||||
raise BotError(f"gitea: {method} {path} -> HTTP {status}")
|
||||
if len(raw) > MAX_API_BYTES:
|
||||
raise BotError(f"gitea: {method} {path}: response exceeds its bound")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise BotError(f"gitea: {method} {path}: response is not JSON") from exc
|
||||
|
||||
def _list(self, path: str, query: dict | None = None) -> list:
|
||||
items: list = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
batch = self._call("GET", path, {**(query or {}), "limit": PAGE_SIZE, "page": page})
|
||||
if not isinstance(batch, list):
|
||||
raise BotError(f"gitea: GET {path}: expected a list")
|
||||
items += batch
|
||||
if len(batch) < PAGE_SIZE:
|
||||
return items
|
||||
raise BotError(f"gitea: GET {path}: more than {MAX_PAGES} pages")
|
||||
|
||||
def pr_url(self, number: int) -> str:
|
||||
return f"{self._web}/pulls/{int(number)}"
|
||||
|
||||
def labels(self) -> list:
|
||||
return self._list("/labels")
|
||||
|
||||
def create_label(self, name: str, color: str) -> dict:
|
||||
return self._call("POST", "/labels", payload={"name": name, "color": color}, expect=(201,))
|
||||
|
||||
def issues_with_label(self, label: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "labels": label})
|
||||
|
||||
def issues_matching(self, text: str) -> list:
|
||||
return self._list("/issues", {"state": "all", "type": "issues", "q": text})
|
||||
|
||||
def create_issue(self, title: str, body: str, label_id: int) -> dict:
|
||||
return self._call("POST", "/issues", payload={"title": title, "body": body, "labels": [label_id]}, expect=(201,))
|
||||
|
||||
def update_issue(self, number: int, fields: dict) -> None:
|
||||
self._call("PATCH", f"/issues/{int(number)}", payload=fields, expect=(200, 201))
|
||||
|
||||
def add_issue_label(self, number: int, label_id: int) -> None:
|
||||
self._call("POST", f"/issues/{int(number)}/labels", payload={"labels": [label_id]}, expect=(200,))
|
||||
|
||||
def get_issue(self, number: int) -> dict:
|
||||
issue = self._call("GET", f"/issues/{int(number)}")
|
||||
if not isinstance(issue, dict):
|
||||
raise BotError("gitea: issue response is not an object")
|
||||
return issue
|
||||
|
||||
def open_renovate_prs(self) -> list[dict]:
|
||||
result = []
|
||||
for pr in self._list("/pulls", {"state": "open"}):
|
||||
head = pr.get("head") if isinstance(pr, dict) else None
|
||||
ref = head.get("ref") if isinstance(head, dict) else None
|
||||
number = pr.get("number") if isinstance(pr, dict) else None
|
||||
title = pr.get("title") if isinstance(pr, dict) else None
|
||||
if not isinstance(ref, str) or not isinstance(number, int) or isinstance(number, bool):
|
||||
raise BotError("gitea: a pull request has an unexpected shape")
|
||||
if ref.startswith("renovate/"):
|
||||
package = _title_package(title) if isinstance(title, str) else ""
|
||||
result.append({"number": number, "branch": ref, "package": package})
|
||||
return result
|
||||
|
||||
|
||||
class ScannerClient:
|
||||
"""The scanner's internal API. Falls back to the pinned ClusterIP on a DNS failure only."""
|
||||
|
||||
def __init__(self, primary: str, fallback: str, transport) -> None:
|
||||
self._bases = [base.rstrip("/") for base in (primary, fallback) if base]
|
||||
self._transport = transport
|
||||
self.used_fallback = False
|
||||
|
||||
def _call(self, method: str, path: str, body: bytes | None, headers: dict, max_bytes: int):
|
||||
last = len(self._bases) - 1
|
||||
for index, base in enumerate(self._bases):
|
||||
try:
|
||||
result = self._transport(method, base + path, headers, body, max_bytes)
|
||||
except DnsFailure as exc:
|
||||
if index == last:
|
||||
raise BotError(f"scanner: {method} {path}: name resolution failed") from exc
|
||||
continue
|
||||
if index:
|
||||
self.used_fallback = True
|
||||
self._bases = self._bases[index:]
|
||||
return result
|
||||
raise BotError("scanner: no address configured")
|
||||
|
||||
def report(self) -> dict:
|
||||
status, raw = self._call("GET", "/report.json", None, {"Accept": "application/json"}, MAX_REPORT_BYTES)
|
||||
if status != 200:
|
||||
raise BotError(f"scanner: GET /report.json -> HTTP {status}")
|
||||
if len(raw) > MAX_REPORT_BYTES:
|
||||
raise BotError("scanner: report exceeds its bound")
|
||||
return parse_report(raw)
|
||||
|
||||
def triage(self, heartbeat: str, remediation: list[dict]) -> None:
|
||||
if len(remediation) > MAX_REMEDIATION:
|
||||
raise BotError("scanner: more remediation entries than POST /triage accepts")
|
||||
body = json.dumps({"heartbeatAt": heartbeat, "remediation": remediation}).encode()
|
||||
status, _ = self._call("POST", "/triage", body, {"Content-Type": "application/json"}, 4096)
|
||||
if status != 204:
|
||||
raise BotError(f"scanner: POST /triage -> HTTP {status}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- orchestration
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> set:
|
||||
return {label.get("name") for label in (issue.get("labels") or []) if isinstance(label, dict)}
|
||||
|
||||
|
||||
def find_dashboard_issues(gitea: GiteaClient) -> list[dict]:
|
||||
"""Every issue that is the dashboard: by label, and by exact title in case a label was removed."""
|
||||
found: dict[int, dict] = {}
|
||||
for issue in gitea.issues_with_label(DASHBOARD_LABEL) + gitea.issues_matching(DASHBOARD_TITLE):
|
||||
if (
|
||||
isinstance(issue, dict)
|
||||
and issue.get("title") == DASHBOARD_TITLE
|
||||
and not issue.get("pull_request")
|
||||
and isinstance(issue.get("number"), int)
|
||||
):
|
||||
found[issue["number"]] = issue
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def sync_dashboard_issue(gitea: GiteaClient, body: str, dry_run: bool) -> dict:
|
||||
"""Create or update the one dashboard issue. Returns counts of what it did or would do."""
|
||||
if DASHBOARD_LABEL in FORBIDDEN_LABELS:
|
||||
raise BotError("config: the dashboard label is one the PM agent acts on")
|
||||
plan = {"label_created": 0, "issue_created": 0, "issue_updated": 0, "issue_reopened": 0}
|
||||
existing = find_dashboard_issues(gitea)
|
||||
if len(existing) > 1:
|
||||
# Closing an issue on a guess is worse than a red run naming the numbers to look at.
|
||||
numbers = sorted(issue["number"] for issue in existing)
|
||||
raise BotError(f"dashboard: {len(numbers)} issues titled {DASHBOARD_TITLE!r}: {numbers}")
|
||||
|
||||
label = next((x for x in gitea.labels() if isinstance(x, dict) and x.get("name") == DASHBOARD_LABEL), None)
|
||||
if label is None:
|
||||
plan["label_created"] = 1
|
||||
if not dry_run:
|
||||
label = gitea.create_label(DASHBOARD_LABEL, DASHBOARD_LABEL_COLOR)
|
||||
|
||||
if not existing:
|
||||
plan["issue_created"] = 1
|
||||
if not dry_run:
|
||||
created = gitea.create_issue(DASHBOARD_TITLE, body, label["id"])
|
||||
if not isinstance(created, dict) or not isinstance(created.get("number"), int):
|
||||
raise BotError("gitea: issue creation returned an unexpected shape")
|
||||
return plan
|
||||
|
||||
issue = existing[0]
|
||||
fields: dict = {}
|
||||
if issue.get("body") != body:
|
||||
fields["body"] = body
|
||||
if issue.get("state") != "open":
|
||||
fields["state"] = "open"
|
||||
plan["issue_reopened"] = 1
|
||||
if fields:
|
||||
plan["issue_updated"] = 1
|
||||
if not dry_run:
|
||||
if DASHBOARD_LABEL not in _label_names(issue):
|
||||
gitea.add_issue_label(issue["number"], label["id"])
|
||||
if fields:
|
||||
gitea.update_issue(issue["number"], fields)
|
||||
return plan
|
||||
|
||||
|
||||
def _renovate_issue(gitea: GiteaClient, number: int) -> dict:
|
||||
issue = gitea.get_issue(number)
|
||||
body = issue.get("body")
|
||||
if issue.get("title") != RENOVATE_DASHBOARD_TITLE or issue.get("state") != "open" or not isinstance(body, str):
|
||||
raise BotError(f"renovate dashboard: issue {number} is not the open Dependency Dashboard")
|
||||
return issue
|
||||
|
||||
|
||||
def report_images(report: dict) -> list[str]:
|
||||
return [t["image"] for t in report["targets"] if t["type"] == "image"]
|
||||
|
||||
|
||||
def run(cfg: dict, gitea: GiteaClient, scanner: ScannerClient, now: datetime, emit: Callable[[str], None]) -> dict:
|
||||
"""One triage pass. Any failure raises, and the heartbeat is the last thing it writes."""
|
||||
dry_run = cfg["dry_run"]
|
||||
report = scanner.report()
|
||||
agg = aggregate(report)
|
||||
now_text = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
summary: dict = {"dry_run": dry_run, "targets": agg["targets_total"], "critical_with_fix": agg["critical_with_fix"]}
|
||||
summary["dashboard"] = sync_dashboard_issue(gitea, render_dashboard(agg, cfg["page_url"], now), dry_run)
|
||||
|
||||
number = cfg["renovate_issue"]
|
||||
body = _renovate_issue(gitea, number)["body"]
|
||||
remediation = derive_remediation(
|
||||
report_images(report), parse_rows(body), gitea.open_renovate_prs(), parse_detected(body),
|
||||
gitea.pr_url, now_text,
|
||||
)
|
||||
summary["remediation"] = count_states(remediation)
|
||||
|
||||
# Re-read immediately before writing: Renovate rewrites this body, and a tick must apply to
|
||||
# the body as it is now.
|
||||
fresh = _renovate_issue(gitea, number)["body"]
|
||||
ticks = select_ticks(parse_rows(fresh), qualifying_images(report))
|
||||
summary["ticks"] = len(ticks)
|
||||
if ticks and not dry_run:
|
||||
gitea.update_issue(number, {"body": apply_ticks(fresh, ticks)})
|
||||
|
||||
if not dry_run:
|
||||
scanner.triage(now_text, remediation)
|
||||
summary["heartbeat"] = 0 if dry_run else 1
|
||||
summary["fallback_used"] = scanner.used_fallback
|
||||
emit(render_summary(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def render_summary(summary: dict) -> str:
|
||||
verb = "would be" if summary["dry_run"] else "were"
|
||||
dash = summary["dashboard"]
|
||||
states = ", ".join(f"{name} {count}" for name, count in summary["remediation"].items())
|
||||
return "\n".join([
|
||||
f"### CVE triage ({'DRY RUN, no writes' if summary['dry_run'] else 'live'})",
|
||||
"",
|
||||
f"- targets in report: {summary['targets']}",
|
||||
f"- unaccepted CRITICAL with a fix: {summary['critical_with_fix']}",
|
||||
f"- dashboard label created: {dash['label_created']}; issue created: {dash['issue_created']};"
|
||||
f" updated: {dash['issue_updated']}; reopened: {dash['issue_reopened']} ({verb} applied)",
|
||||
f"- remediation states: {states}",
|
||||
f"- approval boxes that {verb} ticked: {summary['ticks']}",
|
||||
f"- heartbeat posted: {summary['heartbeat']}",
|
||||
f"- scanner reached through the ClusterIP fallback: {summary['fallback_used']}",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def load_config(env: dict) -> dict:
|
||||
token = env.get("RENOVATE_TOKEN", "")
|
||||
if not token:
|
||||
raise BotError("RENOVATE_TOKEN is not set")
|
||||
repo = env.get("GITEA_REPOSITORY", "Home-Network/home-network-k3s-impl")
|
||||
if not REPO_RE.fullmatch(repo):
|
||||
raise BotError("config: GITEA_REPOSITORY is malformed")
|
||||
urls = {
|
||||
"gitea_url": env.get("GITEA_URL", "https://gitea.home.keithjackson.dev"),
|
||||
"vuln_url": env.get("VULN_API_URL", "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"),
|
||||
"page_url": env.get("VULN_PAGE_URL", "https://cve.home.keithjackson.dev"),
|
||||
}
|
||||
fallback = env.get("VULN_API_FALLBACK_URL", "")
|
||||
for name, value in {**urls, **({"fallback": fallback} if fallback else {})}.items():
|
||||
if not HTTP_URL_RE.fullmatch(value):
|
||||
raise BotError(f"config: {name} is not a plain http(s) URL")
|
||||
dry_run = env.get("DRY_RUN", "false")
|
||||
if dry_run not in ("true", "false"):
|
||||
raise BotError("config: DRY_RUN must be true or false")
|
||||
issue = env.get("RENOVATE_DASHBOARD_ISSUE", "91")
|
||||
if not issue.isdigit():
|
||||
raise BotError("config: RENOVATE_DASHBOARD_ISSUE is not a number")
|
||||
return {**urls, "token": token, "repo": repo, "fallback": fallback,
|
||||
"dry_run": dry_run == "true", "renovate_issue": int(issue)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def emit(text: str) -> None:
|
||||
print(text)
|
||||
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(text + "\n")
|
||||
|
||||
try:
|
||||
cfg = load_config(dict(os.environ))
|
||||
transport = UrllibTransport()
|
||||
gitea = GiteaClient(cfg["gitea_url"], cfg["repo"], cfg["token"], transport)
|
||||
scanner = ScannerClient(cfg["vuln_url"], cfg["fallback"], transport)
|
||||
run(cfg, gitea, scanner, datetime.now(timezone.utc), emit)
|
||||
except BotError as exc:
|
||||
print(f"::error::CVE triage failed: {exc}", file=sys.stderr)
|
||||
emit(f"### CVE triage FAILED\n\n{exc}\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -76,7 +76,7 @@ bug but the honest answer, because proofs are claims about a specific tree on a
|
||||
|---|---|
|
||||
| 0 Foundations | ServiceDefinition schema + renderer, Kyverno L0 gate, conformance harness, CI |
|
||||
| 1 Cluster bring-up | 3-node k3s HA, embedded etcd, kube-vip VIP, Cilium, Flux, SOPS. Originally Proxmox VMs; **now 3× mini-PC bare metal** (`k3s-prod-1/2/3`, `192.168.2.10/.11/.12`, k3s v1.36.4+k3s1, Debian 13) |
|
||||
| 2 Platform core | Cilium · Longhorn · cert-manager (LE-prod wildcard) · ExternalDNS · Gateway API · split-horizon DNS, **both halves**: CoreDNS for pods, and **PiHole as the authoritative LAN resolver** for `${BASE_DOMAIN}` on udp/53 + tcp/53 at a pinned VIP (`192.168.2.23` on prod). The LAN half was claimed from the start but did not exist until 2026-09-14 — PiHole's Service was ClusterIP :80/:4180, so LAN clients ran on hand-maintained per-client `dnsmasq` files |
|
||||
| 2 Platform core | Cilium · Longhorn · cert-manager (LE-prod wildcard) · ExternalDNS · Gateway API · split-horizon DNS, **both halves**: CoreDNS for pods, and **PiHole as the LAN resolver**, answering an explicit list of `${BASE_DOMAIN}` names (LAN-only apps, gitea/nextcloud/keycloak/auth, the L4 VIPs — everything else resolves publicly to Cloudflare, since 2026-11-09) on udp/53 + tcp/53 at a pinned VIP (`192.168.2.23` on prod). The LAN half was claimed from the start but did not exist until 2026-09-14 — PiHole's Service was ClusterIP :80/:4180, so LAN clients ran on hand-maintained per-client `dnsmasq` files |
|
||||
| 3 Egress / VPN | ADR-0015 node-level OpenVPN split-tunnel + Cilium egress-gateway steering + host kill-switch |
|
||||
| 4 Observability | kube-prometheus-stack · Loki · Alloy · Alertmanager. **Velero unblocked 2026-09-13** — backs up to Cloudflare R2 (OQ-BKP closed; the `velero-plugin-for-aws` tagging incompatibility needed a pinned plugin). Longhorn has **no** backup target by decision: volume data is protected by Velero + kopia to R2. image-automation authored but disabled |
|
||||
| 5 IaaS | KubeVirt + CDI on real nested KVM; live-migratable RWX VM storage. The UniFi VM runs, but **UniFi OS Server is not yet installed in the guest** |
|
||||
|
||||
@@ -100,6 +100,11 @@ 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
|
||||
|
||||
@@ -176,8 +176,8 @@ or any UDP (`3478`/`10001`) — exactly the ports adoption rides. So:
|
||||
|
||||
**UI access:** `https://unifi.home.keithjackson.dev:11443`. That name resolves to the VIP and not to
|
||||
the Gateway on purpose: both `platform/coredns/coredns-custom.yaml` (in-cluster) and
|
||||
`apps/wave-0/pihole.yaml` (LAN split-horizon) carve `unifi.${BASE_DOMAIN}` out of the
|
||||
zone-wide `${GATEWAY_VIP}` answer and point it at `${UNIFI_L4_VIP}`. `${BASE_DOMAIN}` on prod is the
|
||||
`apps/wave-0/pihole.yaml` (LAN split-horizon, an explicit name list since 2026-11-09) point
|
||||
`unifi.${BASE_DOMAIN}` at `${UNIFI_L4_VIP}` rather than `${GATEWAY_VIP}`. `${BASE_DOMAIN}` on prod is the
|
||||
apex `home.keithjackson.dev` (confirmed live in the `cluster-vars` ConfigMap) after the 2026-09-13
|
||||
flip, so this hostname is current, not a dev leftover.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: jackett
|
||||
image: lscr.io/linuxserver/jackett:v0.24.3025-ls29
|
||||
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
|
||||
ports:
|
||||
- containerPort: 9117
|
||||
volumeMounts:
|
||||
|
||||
@@ -83,15 +83,25 @@
|
||||
# and would also fix the same latent defect in unifi and crafty; it belongs in its own change.
|
||||
#
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# loadBalancerSourceRanges — THE LAN RESTRICTION, ENFORCED IN THE DATAPATH.
|
||||
# loadBalancerSourceRanges — THE LAN + VPN RESTRICTION, ENFORCED IN THE DATAPATH.
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# Cilium enforces this list in eBPF on the ingress node, on the VIP, BEFORE load-balancing and
|
||||
# before SNAT. A packet to ${PIHOLE_DNS_VIP}:53 from outside ${LAN_CIDR} is dropped by the
|
||||
# datapath — it never reaches a backend and never reaches a network policy. This is what makes
|
||||
# before SNAT. A packet to ${PIHOLE_DNS_VIP}:53 from outside ${LAN_CIDR} and ${WG_CLIENT_CIDR} is
|
||||
# dropped by the datapath — it never reaches a backend and never reaches a network policy. This is what makes
|
||||
# "LAN clients only" a real, tested restriction rather than a comment: VERIFIED by narrowing the
|
||||
# range to a /24 that excludes the test client, after which the same query that had been answering
|
||||
# stopped being answered at all, and restoring it, after which it answered again.
|
||||
#
|
||||
# ${WG_CLIENT_CIDR} IS LISTED BECAUSE VPN CLIENTS REACH THIS CHECK UNTRANSLATED. wg0 is one of
|
||||
# Cilium's kube-proxy-replacement devices (measured 2026-11-08: `cilium-dbg status` lists eno1, wg0,
|
||||
# tun0), so a tunnel client's query is load-balanced as it enters from wg0, ahead of the iptables
|
||||
# MASQUERADE in platform/vpn-ingress/daemonset.yaml, and the source tested here is still 10.90.0.x
|
||||
# rather than the node's address. Without this entry a full-tunnel client's DNS is predicted to die
|
||||
# with DROP_NOT_IN_SRC_RANGE (derived from Cilium's source; NOT observed live, because no peer has
|
||||
# ever completed a handshake). The range is private and reachable only by a key in peers.conf, so
|
||||
# PiHole stays off the internet; conformance/tests/l0-static/pihole-dns-source-ranges fails the gate
|
||||
# if either entry goes missing or any range here stops being RFC1918.
|
||||
#
|
||||
# It is also the layer that survives the SNAT above. A CiliumNetworkPolicy cannot express this
|
||||
# restriction once the source has been rewritten: measured on this cluster, the forwarded query
|
||||
# arrives from the ingress node's internal router address and Cilium resolves that source to the
|
||||
@@ -113,6 +123,7 @@ spec:
|
||||
externalTrafficPolicy: Cluster
|
||||
loadBalancerSourceRanges:
|
||||
- ${LAN_CIDR}
|
||||
- ${WG_CLIENT_CIDR}
|
||||
selector:
|
||||
app.kubernetes.io/name: pihole
|
||||
ports:
|
||||
@@ -133,6 +144,7 @@ spec:
|
||||
externalTrafficPolicy: Cluster
|
||||
loadBalancerSourceRanges:
|
||||
- ${LAN_CIDR}
|
||||
- ${WG_CLIENT_CIDR}
|
||||
selector:
|
||||
app.kubernetes.io/name: pihole
|
||||
ports:
|
||||
@@ -158,7 +170,9 @@ spec:
|
||||
# "Policy denied ... identity world" drops in `cilium-dbg monitor`). So `world` is the only peer
|
||||
# selector that can describe LAN traffic that has been through the LoadBalancer, and the set of
|
||||
# addresses that can actually BE that traffic is fixed by loadBalancerSourceRanges before this
|
||||
# policy is consulted. Together: ${LAN_CIDR} in the datapath, udp/53 + tcp/53 here.
|
||||
# policy is consulted. Together: ${LAN_CIDR} + ${WG_CLIENT_CIDR} in the datapath, udp/53 + tcp/53
|
||||
# here. A VPN client delivered to a backend on its own ingress node keeps its 10.90.0.x source,
|
||||
# which Cilium also classifies as `world`, so this rule needs no new peer for it.
|
||||
#
|
||||
# This is NOT wider than the precedent it follows. unifi-allow-l4-ingress and crafty-allow-ingress
|
||||
# admit `fromEntities: [world, cluster, host]` on their L4 ports with NO source-range filter at all;
|
||||
@@ -187,7 +201,7 @@ spec:
|
||||
app.kubernetes.io/name: pihole
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world # LAN clients, post-LoadBalancer: see the block above for why not fromCIDR
|
||||
- world # LAN + VPN clients, post-LoadBalancer: see the block above for why not fromCIDR
|
||||
- host # this node's kubelet probes
|
||||
- remote-node # the node-to-node hop that forwards a query to wherever the pod runs
|
||||
toPorts:
|
||||
|
||||
@@ -73,7 +73,7 @@ spec:
|
||||
- name: FTLCONF_dns_rateLimit_count
|
||||
value: '0'
|
||||
- name: FTLCONF_misc_dnsmasq_lines
|
||||
value: local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
|
||||
value: local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
|
||||
- name: FTLCONF_webserver_api_password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -302,6 +302,8 @@ metadata:
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
annotations:
|
||||
external-dns.alpha.kubernetes.io/controller: none
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: shared
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
name: jackett
|
||||
kind: container
|
||||
source:
|
||||
image: lscr.io/linuxserver/jackett:v0.24.3025-ls29
|
||||
image: lscr.io/linuxserver/jackett:v0.24.3030-ls30
|
||||
exposure: internal # LAN-only web UI at jackett.${BASE_DOMAIN} (docs/13 row 4; R-DNS/R-NET)
|
||||
egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
||||
storage:
|
||||
|
||||
+58
-31
@@ -85,7 +85,7 @@ env:
|
||||
# Default is LOCAL, which answers only clients on the same subnet as the listening interface. The
|
||||
# pod's interface is on the CLUSTER pod CIDR, so every LAN client would be refused. ALL is
|
||||
# required for a LoadBalancer-fronted resolver; what keeps PiHole from being an OPEN resolver is
|
||||
# the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}]` on the DNS Services,
|
||||
# the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}, ${WG_CLIENT_CIDR}]` on the DNS Services,
|
||||
# enforced by Cilium in the datapath before load-balancing, plus a CiliumNetworkPolicy that admits
|
||||
# nothing but udp/53 and tcp/53. See apps/wave-0/pihole-dns.yaml.
|
||||
- name: FTLCONF_dns_listeningMode
|
||||
@@ -108,41 +108,61 @@ env:
|
||||
# `loadBalancer.mode: dsr`, which preserves the client address.
|
||||
- name: FTLCONF_dns_rateLimit_count
|
||||
value: "0"
|
||||
# THE SPLIT-HORIZON ANSWERS. A dnsmasq `address=/<domain>/<ip>` entry answers that domain AND
|
||||
# every subdomain of it, and dnsmasq resolves overlapping entries by LONGEST MATCHING DOMAIN —
|
||||
# so the first line is a wildcard for the whole zone and each later line is a specific override.
|
||||
# PRECEDENCE IS THEREFORE STRUCTURAL, not order-dependent: `unifi.${BASE_DOMAIN}` matches both
|
||||
# line 1 and line 2, line 2 is the longer domain, line 2 wins. Rewriting these in any order
|
||||
# changes nothing.
|
||||
# THE LAN ANSWERS — AN EXPLICIT LIST, NOT A ZONE WILDCARD (2026-11-09). A dnsmasq
|
||||
# `address=/<domain>/<ip>` entry answers that domain AND every subdomain of it (on LABEL
|
||||
# boundaries: `pihole.` does not match `pihole-dns.`), and dnsmasq resolves overlapping entries by
|
||||
# LONGEST MATCHING DOMAIN, so the order below is irrelevant. Every name NOT listed here is
|
||||
# FORWARDED to the upstreams above and gets the PUBLIC answer.
|
||||
#
|
||||
# A NEWLY ONBOARDED SERVICE NEEDS NO CHANGE HERE. Every catalog ingressRoute host is
|
||||
# `<label>.${BASE_DOMAIN}` served by the shared Gateway, so the wildcard already answers it with
|
||||
# ${GATEWAY_VIP} — the same guarantee, and the same reasoning, as the `template IN A` block in
|
||||
# platform/coredns/coredns-custom.yaml. Add a line here ONLY for a service that does not live
|
||||
# behind the Gateway: an L4 LoadBalancer on its own pinned VIP. Those are enumerated from the
|
||||
# repo, not from memory — grep `external-dns.alpha.kubernetes.io/hostname` on a Service, which
|
||||
# is the annotation that means "this Service publishes its own hostname at its own address":
|
||||
# apps/vms/unifi/50-service-l4.yaml (unifi -> ${UNIFI_L4_VIP}) and
|
||||
# platform/crafty/service-l4.yaml (crafty-mc -> ${CRAFTY_L4_VIP}). Those two, plus PiHole's own
|
||||
# resolver address, are the complete set of exceptions today.
|
||||
# WHY NO WILDCARD ANY MORE. Until 2026-11-09 the first entry was `address=/${BASE_DOMAIN}/
|
||||
# ${GATEWAY_VIP}`, answering the whole zone. That made sense while every public record was a
|
||||
# DNS-only CNAME to `wan.${BASE_DOMAIN}` (no NAT hairpin on this router, so the LAN NEEDED a
|
||||
# private answer). Since PR #156 every public name is a PROXIED CNAME to the Cloudflare Tunnel
|
||||
# (platform/externaldns/README.md), which works identically from the LAN — so a name that is
|
||||
# public now SHOULD resolve publicly from the LAN too, and the LAN no longer depends on PiHole for
|
||||
# it: a client that bypasses PiHole (DoH browser, phone private DNS, the router's fallback) gets
|
||||
# the same working answer. The wildcard had also become actively misleading: it answered names
|
||||
# that exist nowhere (`wan.` itself resolved to the Gateway VIP on the LAN).
|
||||
#
|
||||
# AAAA needs nothing: dnsmasq answers a AAAA query for an `address=`d name with NODATA
|
||||
# (NOERROR, no answer), so IPv6-capable clients fall back to the A record instead of getting
|
||||
# NXDOMAIN — the same behaviour coredns-custom hand-writes a `template IN AAAA` block for.
|
||||
# WHAT IS LISTED, and why each — enforced by conformance/tests/l0-static/pihole-lan-names:
|
||||
# -> ${GATEWAY_VIP}, LAN-ONLY: every HTTPRoute disowned from public DNS
|
||||
# (`external-dns.alpha.kubernetes.io/controller: none`, i.e. catalog `exposure: internal`,
|
||||
# plus plex and headlamp) — jackett, pihole, plex, qbittorrent, radarr, semaphore, sonarr,
|
||||
# headlamp. These have NO public record and the tunnel 404s them, so THIS LINE IS THEIR ONLY
|
||||
# PATH: drop one and the app is unreachable everywhere. A NEWLY ONBOARDED `internal` SERVICE
|
||||
# MUST BE ADDED HERE (the L0 check fails until it is). A new `public` service needs nothing.
|
||||
# -> ${GATEWAY_VIP}, PUBLIC BUT KEPT ON THE LAN PATH: gitea (git push, LFS, container registry)
|
||||
# and nextcloud (uploads) — Cloudflare's proxy caps a request body at 100 MB and a response
|
||||
# at 100 s, which a large push or upload exceeds; keycloak and auth (oauth2-proxy) — so the
|
||||
# OIDC login of those two LAN-path apps stays on the same path as the apps themselves.
|
||||
# -> their own L4 VIPs: unifi (${UNIFI_L4_VIP}) and crafty-mc (${CRAFTY_L4_VIP}) are not behind
|
||||
# the Gateway (grep `external-dns.alpha.kubernetes.io/hostname` on a Service:
|
||||
# apps/vms/unifi/50-service-l4.yaml, platform/crafty/service-l4.yaml); their public records
|
||||
# are DNS-only A records to the same RFC1918 VIPs, which this router's rebind protection
|
||||
# strips, so the LAN answer is still required. pihole-dns (${PIHOLE_DNS_VIP}) is PiHole's own
|
||||
# resolver address; plex-vip (${PLEX_L4_VIP}) is below.
|
||||
# NOT listed, deliberately, so they resolve via Cloudflare from the LAN as from anywhere: the apex,
|
||||
# grafana, crafty, seerr. In-cluster (platform/coredns/coredns-custom.yaml) and node
|
||||
# (/etc/hosts pins, node_baseline_hosts_pins) resolution are separate and unchanged.
|
||||
#
|
||||
# AAAA: dnsmasq answers a AAAA query for an `address=`d name with NODATA (NOERROR, no answer),
|
||||
# so IPv6-capable clients fall back to the A record instead of getting NXDOMAIN — the same
|
||||
# behaviour coredns-custom hand-writes a `template IN AAAA` block for. Unlisted (proxied) names
|
||||
# now get Cloudflare's real A and AAAA answers.
|
||||
#
|
||||
# Array-valued FTLCONF variables are SEMICOLON-separated. A JSON-looking list is accepted and
|
||||
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
|
||||
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
|
||||
# while resolution for the zone does not work. Confirmed both ways against the live image.
|
||||
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
|
||||
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
|
||||
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
|
||||
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
|
||||
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
|
||||
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
|
||||
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
|
||||
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
|
||||
# every client that takes the ADVERTISED connection cannot reach the server at all.
|
||||
#
|
||||
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the name of the tcp/32400 LoadBalancer
|
||||
# (apps/wave-0/plex-remote.yaml) and the domain of the certificate Plex serves there
|
||||
# (catalog/plex.yaml CERTDOMAIN / customCertificateDomain, Secret `plex-vip-tls`). It is NOT what
|
||||
# Plex advertises any more (ADVERTISE_IP is a resolver-independent plex.direct name since
|
||||
# 2026-10-05) and it has NO public record, so this entry is its only resolution — kept for
|
||||
# reaching the VIP by name with a matching certificate from a LAN shell. Measured 2026-10-04,
|
||||
# before it existed the zone wildcard answered it with ${GATEWAY_VIP}, where nothing listens on
|
||||
# 32400; without it now, the name is NXDOMAIN.
|
||||
#
|
||||
# ── local-ttl=60 — THE SPLIT-HORIZON ANSWER MUST BE CACHEABLE ────────────────────────────────
|
||||
# MEASURED 2026-10-18, diagnosing "DNS flapping between the cluster ingress and the public IP":
|
||||
@@ -172,10 +192,17 @@ env:
|
||||
# cache expires, the router falls back, and the public answer latches again. That is a real
|
||||
# outage, and the fix for it is PiHole availability, not DNS caching.
|
||||
#
|
||||
# SINCE 2026-11-09 THE LATCH IS MOSTLY HARMLESS. The measurement above predates the tunnel: the
|
||||
# public answer was then `wan.`, unreachable from the LAN. Now the public answer for every
|
||||
# proxied name is Cloudflare, which works from the LAN, and those names are not listed below at
|
||||
# all. A latch still bites only the listed names: gitea/nextcloud/keycloak/auth fall back to
|
||||
# Cloudflare (working, but under its 100 MB / 100 s limits); the LAN-only names and the L4 VIPs
|
||||
# get NXDOMAIN or a stripped answer until the router re-queries PiHole.
|
||||
#
|
||||
# Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from
|
||||
# configuration, /etc/hosts and DHCP leases), so one directive covers all five names.
|
||||
# configuration, /etc/hosts and DHCP leases), so one directive covers every listed name.
|
||||
- name: FTLCONF_misc_dnsmasq_lines
|
||||
value: "local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
|
||||
value: "local-ttl=60;address=/gitea.${BASE_DOMAIN}/${GATEWAY_VIP};address=/nextcloud.${BASE_DOMAIN}/${GATEWAY_VIP};address=/keycloak.${BASE_DOMAIN}/${GATEWAY_VIP};address=/auth.${BASE_DOMAIN}/${GATEWAY_VIP};address=/headlamp.${BASE_DOMAIN}/${GATEWAY_VIP};address=/cve.${BASE_DOMAIN}/${GATEWAY_VIP};address=/jackett.${BASE_DOMAIN}/${GATEWAY_VIP};address=/pihole.${BASE_DOMAIN}/${GATEWAY_VIP};address=/plex.${BASE_DOMAIN}/${GATEWAY_VIP};address=/qbittorrent.${BASE_DOMAIN}/${GATEWAY_VIP};address=/radarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/semaphore.${BASE_DOMAIN}/${GATEWAY_VIP};address=/sonarr.${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
|
||||
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
|
||||
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
|
||||
# carries the full rationale and the measurement that found it.
|
||||
|
||||
+8
-1
@@ -347,7 +347,14 @@ lifecycle:
|
||||
# `runbooks/plex-quicksync.md` is that procedure, plus how to verify it took.
|
||||
hardware:
|
||||
gpu: intel-quicksync
|
||||
exposure: public # edge port-forward + public DNS; clients + Seerr reach it (docs/09; R-DNS/R-NET)
|
||||
# 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)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
- name: config
|
||||
|
||||
@@ -78,6 +78,10 @@ resources:
|
||||
# `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have
|
||||
# stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch.
|
||||
- vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config)
|
||||
# CVE visibility scanner (docs/17, ADR-0027) — POST-platform tier (dependsOn platform-config,
|
||||
# wait:false): a failing scanner must never gate the platform, and nothing dependsOn it. Carries the
|
||||
# oauth2-proxy credential SOPS file, so both overlays add a decryption patch by name.
|
||||
- vuln-scanner.yaml # Flux Kustomization -> ./platform/vuln-scanner (dependsOn platform-config)
|
||||
# Cloudflare Tunnel connector (north-south INGRESS from the Cloudflare edge) — POST-platform tier
|
||||
# (dependsOn platform-config, wait:false), and NOTHING dependsOn it. Non-gating is load-bearing,
|
||||
# as for vpn-ingress: the Deployment cannot become Ready until the operator enrols the tunnel
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# Flux Kustomization: the CVE visibility scanner (docs/17, ADR-0027) — reconciles ./platform/vuln-scanner.
|
||||
#
|
||||
# ISOLATION IS THE POINT, as for clusters/base/cloudflared.yaml. A failing scanner, an unbound PVC or a
|
||||
# pending Certificate must never become a stalled platform, so:
|
||||
# * ITS OWN Kustomization, not a line in platform/kustomization.yaml;
|
||||
# * wait: false — its health is reported by the VulnScanStale/VulnDatabaseStale/VulnCoverageGap/
|
||||
# VulnCriticalFixAvailable/VulnTriageBotSilent alerts (platform/vuln-scanner/alerts.yaml), not by
|
||||
# this object's Ready condition, which reaches Ready on apply success;
|
||||
# * NOTHING dependsOn THIS. conformance/tests/l0-static/vuln-scanner-isolation fails the gate if any
|
||||
# Kustomization gains `dependsOn: vuln-scanner` or this one stops being wait:false.
|
||||
#
|
||||
# dependsOn: [platform-config] — a real edge: PrometheusRule/ServiceMonitor/Gateway API CRDs, the
|
||||
# Longhorn StorageClass, the Gateway, Keycloak's realm, and Longhorn itself come up through
|
||||
# `platform`/`platform-config` first.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
prune: true
|
||||
wait: false
|
||||
dependsOn:
|
||||
- name: platform-config
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./platform/vuln-scanner
|
||||
# REQUIRED: the HTTPRoute hostname, the oauth2-proxy issuer/redirect/cookie-domain args and every
|
||||
# alert's runbook_url are written `${BASE_DOMAIN}`. Substitution fails OPEN, so the file
|
||||
# conformance/tests/l0-static/flux-postbuild-substitution audits this tier.
|
||||
postBuild:
|
||||
substituteFrom:
|
||||
- kind: ConfigMap
|
||||
name: cluster-vars
|
||||
# SOPS decryption is layered per-environment: clusters/dev and clusters/prod each patch THIS
|
||||
# Kustomization by name with a `decryption:` stanza for the oauth2-proxy credentials.
|
||||
@@ -140,6 +140,21 @@ patches:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries
|
||||
# platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy
|
||||
# client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the
|
||||
# sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band.
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
|
||||
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
|
||||
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
|
||||
|
||||
@@ -131,6 +131,21 @@ patches:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# vuln-scanner (the CVE visibility scanner, clusters/base/vuln-scanner.yaml) carries
|
||||
# platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml — the per-pod oauth2-proxy
|
||||
# client and cookie secrets. Without this stanza the Secret applies full of ENC[...] and the
|
||||
# sidecar cannot authenticate; the rest of the scanner runs regardless. `sops-age` is out-of-band.
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vuln-scanner
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# cloudflared (the Cloudflare Tunnel connector, clusters/base/cloudflared.yaml) carries
|
||||
# platform/cloudflared/cloudflared-credentials.sops.yaml — the tunnel credentials JSON, the one
|
||||
# secret in that component. Without this stanza the Secret applies full of ENC[...]: the APPLY
|
||||
|
||||
@@ -135,6 +135,44 @@ requirements:
|
||||
safety_critical: false
|
||||
summary: "Update policy per-service configurable (cadence, channel, pinning)."
|
||||
|
||||
# --- Vulnerability visibility ---
|
||||
- id: R-VULN-01
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Inventory every running container image by digest with its workloads, from the cluster."
|
||||
- id: R-VULN-02
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Every running image evaluated against a current database at least every 24h; new images within 2h."
|
||||
- id: R-VULN-03
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Any target not evaluated is visibly reported with its reason; absence is never shown as clean."
|
||||
- id: R-VULN-04
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Authenticated page lists every CVE with affected images, containers and workloads; searchable and filterable."
|
||||
- id: R-VULN-05
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Inventory, scanning, reporting and triage run on a schedule with no human or AI action; loud failure."
|
||||
- id: R-VULN-06
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Scan failure, staleness and unaccepted CRITICAL fixes reported by email as grouped alerts."
|
||||
- id: R-VULN-07
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Scheduled bot keeps one dashboard issue and requests remediation via Renovate; no CVE detail exposed."
|
||||
- id: R-VULN-08
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Accepted risk declared in git with reason and mandatory expiry; reverts to open on expiry."
|
||||
- id: R-VULN-09
|
||||
doc: docs/17-vulnerability-visibility.md
|
||||
safety_critical: false
|
||||
summary: "Scanner digest-pinned, no auto-merged updates, no Secret read or write access, no git credential."
|
||||
|
||||
# --- Observability & alerting ---
|
||||
- id: R-OBS-01
|
||||
doc: docs/10-observability-and-alerting.md
|
||||
|
||||
@@ -411,6 +411,28 @@ SCRAPE_OWNERS: list[dict] = [
|
||||
"Keeping the two in one file makes that pair inseparable",
|
||||
},
|
||||
},
|
||||
# ── ADDED 2026-11-09 (CVE visibility, ADR-0027). A NEW OWNER, NOT A RELAXED ASSERTION. ──────
|
||||
# Registers the vuln_scanner_* family and the Service+ServiceMonitor that scrapes it.
|
||||
{
|
||||
"owner": "vuln-scanner: Service + ServiceMonitor on port `metrics` (:9090)",
|
||||
"prefixes": ("vuln_scanner_",),
|
||||
"file": "platform/vuln-scanner/servicemonitor.yaml",
|
||||
"require": [
|
||||
r"^kind:\s*ServiceMonitor\s*$",
|
||||
r"^\s*-\s*port:\s*metrics\s*$",
|
||||
],
|
||||
"forbid": [],
|
||||
"why": "vuln_scanner_* powers the five VulnScan alerts, the only email path for scan "
|
||||
"failure, staleness and overdue critical fixes (R-VULN-06). Two of those rules fire "
|
||||
"on an absent metric, so a lost scrape is loud rather than inert",
|
||||
"companion": {
|
||||
"file": "platform/vuln-scanner/kustomization.yaml",
|
||||
"require": [r"^\s*-\s*networkpolicy\.yaml\s*$"],
|
||||
"why": "platform/vuln-scanner/networkpolicy.yaml ships the namespace's default-deny "
|
||||
"and the ingress allow for Prometheus on :9090 in one file, so a baseline "
|
||||
"without its allow cannot empty the TSDB",
|
||||
},
|
||||
},
|
||||
# ── ADDED 2026-11-08 (feat/cloudflare-tunnel). A NEW OWNER, NOT A RELAXED ASSERTION. ───────
|
||||
# Purely additive, the same shape as the kured entry: it registers the cloudflared_* family
|
||||
# and the Service+ServiceMonitor that scrapes it, and makes the check STRICTER for that component.
|
||||
|
||||
@@ -39,7 +39,9 @@ spec:
|
||||
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.
|
||||
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
|
||||
@@ -26,9 +26,19 @@
|
||||
# (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;
|
||||
# AND (6) the rule evaluator is not vacuous: it is run against mutated copies of the real config
|
||||
# (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.
|
||||
# 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
|
||||
@@ -76,10 +86,10 @@ if not CONFIG.is_file():
|
||||
# headlamp: operator decision 2026-11-08 (LAN/VPN-only). qbittorrent/radarr/sonarr: catalog
|
||||
# `exposure: internal`, like jackett/pihole/semaphore.
|
||||
EXCLUDED_HOSTS = ("plex", "plex-vip", "jackett", "pihole", "pihole-dns", "semaphore",
|
||||
"qbittorrent", "radarr", "sonarr", "headlamp", "unifi", "crafty-mc")
|
||||
"qbittorrent", "radarr", "sonarr", "headlamp", "cve", "unifi", "crafty-mc")
|
||||
# Their Gateway-backend namespaces (unifi / crafty-mc are L4 VIPs, not Gateway backends).
|
||||
EXCLUDED_NAMESPACES = ("plex", "jackett", "pihole", "semaphore", "qbittorrent", "radarr",
|
||||
"sonarr", "headlamp")
|
||||
"sonarr", "headlamp", "vuln-scanner")
|
||||
FORBIDDEN_ENTITIES = {"kube-apiserver", "host", "remote-node", "cluster", "all"}
|
||||
# Any of these in a `service` means a route toward the Kubernetes API (or a raw-TCP/SSH bastion).
|
||||
API_MARKERS = ("kubernetes", "kube-apiserver", "apiserver", ":6443", "10.43.0.1", "tcp://",
|
||||
@@ -236,6 +246,69 @@ check(dep["spec"]["template"]["metadata"]["labels"].get("homelab.keithjackson.de
|
||||
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:
|
||||
|
||||
+4
-3
@@ -92,9 +92,10 @@
|
||||
# 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.
|
||||
# - 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.
|
||||
# - 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.
|
||||
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
|
||||
@@ -29,7 +29,17 @@
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free):
|
||||
# - That the record is gone from Cloudflare. Live read; belongs beside
|
||||
@@ -39,8 +49,11 @@
|
||||
# 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.
|
||||
# - 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.
|
||||
# - 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.
|
||||
# - 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
|
||||
@@ -141,9 +154,12 @@ def docs_of(p: Path):
|
||||
# unadjudicated set cannot grow without a review event. Same ratchet reasoning as
|
||||
# KNOWN_EXTERNAL_MONITORS in servicemonitor-ports-resolve.
|
||||
#
|
||||
# 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,
|
||||
# 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,
|
||||
# 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.
|
||||
#
|
||||
@@ -151,27 +167,32 @@ 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 Gateway VIP. The oauth2-proxy login endpoint is a "
|
||||
"publishes auth.${BASE_DOMAIN} -> the tunnel (proxied CNAME). 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 Gateway VIP, and it is the route that actually "
|
||||
"publishes keycloak.${BASE_DOMAIN} -> the tunnel (proxied CNAME), 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 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).",
|
||||
"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).",
|
||||
"platform/headlamp/httproute.yaml:headlamp/headlamp":
|
||||
"publishes headlamp.${BASE_DOMAIN} -> the Gateway VIP. A Kubernetes web UI with native "
|
||||
"Keycloak OIDC; same adjudication as grafana.",
|
||||
"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).",
|
||||
"platform/homepage/httproute.yaml:homepage/homepage":
|
||||
"publishes the APEX ${BASE_DOMAIN} itself -> the Gateway VIP, via the https-apex "
|
||||
"publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex "
|
||||
"listener. The apex is not a per-service name and cannot be withdrawn by an exposure "
|
||||
"declaration.",
|
||||
"runbooks/serving/httproute.yaml:runbooks/runbooks":
|
||||
@@ -180,16 +201,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 Gateway VIP (the Minecraft admin UI, gated by an "
|
||||
"publishes crafty.${BASE_DOMAIN} -> the tunnel (proxied CNAME) (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.",
|
||||
"genuinely wanted; the address it answers with is still RFC1918, DNS-only (9b).",
|
||||
"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) for the UniFi controller VM's inform/STUN/adoption ports. Hand-authored "
|
||||
"(192.168.2.21), DNS-only (9b), 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.",
|
||||
}
|
||||
@@ -515,6 +536,113 @@ 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:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — PiHole's DNS frontends admit the LAN and the VPN, and nothing public
|
||||
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||
#
|
||||
# loadBalancerSourceRanges is the only thing keeping PiHole from being an open resolver, and it
|
||||
# must also carry the WireGuard client subnet, because Cilium load-balances tunnel traffic on wg0
|
||||
# before the iptables MASQUERADE (so the source it tests is still 10.90.0.x). check.sh's header
|
||||
# states the assertions and what they do not prove.
|
||||
#
|
||||
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (standard library + PyYAML);
|
||||
# conformance/run.sh --layer l0 runs it directly and this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: pihole-dns-source-ranges
|
||||
labels:
|
||||
conformance.home.k3s/R-DNS-03: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-DNS-03"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
# WHOLE-REPO SCOPE: ci/touched-requirements.sh maps apps/* to no R-DNS requirement, so a change
|
||||
# to these Services would otherwise select nothing.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
Every PiHole LoadBalancer Service lists both the LAN and the WireGuard client subnet in
|
||||
loadBalancerSourceRanges, and every range resolves to RFC1918, so VPN clients can resolve
|
||||
and PiHole stays unreachable from the internet.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-pihole-source-ranges
|
||||
description: >-
|
||||
Collect the PiHole LoadBalancer Services, resolve each source range through cluster-vars,
|
||||
and assert both required ranges are present and every range is private.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — pihole-dns-source-ranges
|
||||
# Serves: R-DNS-03 (LAN split-horizon DNS — spec docs/09).
|
||||
#
|
||||
# THE BUG CLASS THIS CLOSES. PiHole's LoadBalancer frontends are the only thing stopping it being
|
||||
# an open resolver, and their `loadBalancerSourceRanges` is pulled in two opposite directions:
|
||||
# * Too WIDE, and the resolver is open: a public range, or 0.0.0.0/0, in this list is the whole
|
||||
# restriction gone, with nothing else in the path to catch it (the CiliumNetworkPolicy admits
|
||||
# `world` on :53, because that is the only peer that can describe post-LoadBalancer traffic).
|
||||
# * Too NARROW, and VPN DNS dies: wg0 is a Cilium kube-proxy-replacement device, so a tunnel
|
||||
# client's query is load-balanced with its 10.90.0.x source intact, ahead of the iptables
|
||||
# MASQUERADE. A LAN-only list is predicted to drop it as DROP_NOT_IN_SRC_RANGE, and the
|
||||
# tunnel would still report a healthy handshake.
|
||||
#
|
||||
# GIVEN the repository as git tracks it,
|
||||
# WHEN every type=LoadBalancer Service in namespace `pihole` is collected and each source range
|
||||
# is resolved through cluster-vars (base value AND the prod overlay's replacement),
|
||||
# THEN (1) the frontends exist and cover both udp/53 and tcp/53;
|
||||
# (2) every frontend lists ${LAN_CIDR} and ${WG_CLIENT_CIDR};
|
||||
# (3) every range resolves to a valid IPv4 CIDR inside RFC1918 — so no 0.0.0.0/0, no public
|
||||
# range, and no unresolvable token that could hide one;
|
||||
# AND (4) the check is NON-VACUOUS: a real checkout and a floor on the assertion count.
|
||||
#
|
||||
# WHAT THIS DOES NOT PROVE: that a query over the tunnel is actually answered. That needs a
|
||||
# handshake from a real client; see platform/vpn-ingress/README.md.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
cd "${ROOT}"
|
||||
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||
import ipaddress, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
REQUIRED_TOKENS = ("LAN_CIDR", "WG_CLIENT_CIDR")
|
||||
RFC1918 = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
|
||||
TOKEN = re.compile(r"^\$\{([A-Z0-9_]+)\}$")
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def docs_of(rel: str):
|
||||
try:
|
||||
return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace"))
|
||||
if isinstance(d, dict)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
FILES = [p for p in subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
|
||||
capture_output=True, text=True, check=True).stdout.split("\0")
|
||||
if p]
|
||||
YAML_FILES = [f for f in FILES if re.search(r"\.ya?ml$", f)]
|
||||
check(len(YAML_FILES) > 50, f"only {len(YAML_FILES)} YAML files seen — not a real checkout")
|
||||
|
||||
# ------------------------------------------------- every value a ${TOKEN} can take on this tree
|
||||
values: dict[str, set[str]] = {}
|
||||
for d in docs_of("clusters/base/cluster-vars.yaml"):
|
||||
if d.get("kind") == "ConfigMap":
|
||||
for k, v in (d.get("data") or {}).items():
|
||||
values.setdefault(k, set()).add(str(v))
|
||||
for d in docs_of("clusters/prod/kustomization.yaml"):
|
||||
for p in d.get("patches") or []:
|
||||
ops = p.get("patch") if isinstance(p, dict) else None
|
||||
ops = yaml.safe_load(ops) if isinstance(ops, str) else ops
|
||||
for op in ops if isinstance(ops, list) else []:
|
||||
m = re.fullmatch(r"/data/([A-Z0-9_]+)", str(op.get("path", "")))
|
||||
if m and op.get("op") in ("add", "replace"):
|
||||
values.setdefault(m.group(1), set()).add(str(op.get("value")))
|
||||
for tok in REQUIRED_TOKENS:
|
||||
check(values.get(tok), f"clusters/base/cluster-vars.yaml defines no {tok} (R-DNS-03)")
|
||||
|
||||
|
||||
def assert_private(where: str, cidr: str):
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
check(False, f"{where}: {cidr!r} is not a valid CIDR (R-DNS-03)")
|
||||
return
|
||||
check(net.version == 4 and any(net.subnet_of(r) for r in RFC1918),
|
||||
f"{where}: {cidr} is not inside RFC1918, so it opens PiHole beyond the LAN and the VPN "
|
||||
"(R-DNS-03)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- the frontends
|
||||
frontends = []
|
||||
for rel in YAML_FILES:
|
||||
for d in docs_of(rel):
|
||||
md, spec = d.get("metadata") or {}, d.get("spec") or {}
|
||||
if d.get("kind") == "Service" and md.get("namespace") == "pihole" \
|
||||
and spec.get("type") == "LoadBalancer":
|
||||
frontends.append((rel, md.get("name"), spec))
|
||||
|
||||
protocols = {(p.get("protocol", "TCP"), p.get("port"))
|
||||
for _, _, spec in frontends for p in spec.get("ports") or []}
|
||||
for want in (("UDP", 53), ("TCP", 53)):
|
||||
check(want in protocols, f"no PiHole LoadBalancer Service serves {want[0]}/{want[1]} — this "
|
||||
"check's corpus has moved and must be re-derived (R-DNS-03)")
|
||||
|
||||
for rel, name, spec in frontends:
|
||||
where = f"{rel}: Service/{name}"
|
||||
ranges = spec.get("loadBalancerSourceRanges") or []
|
||||
check(ranges, f"{where} has no loadBalancerSourceRanges, so it answers anyone who can reach "
|
||||
"the VIP (R-DNS-03)")
|
||||
tokens = {m.group(1) for m in (TOKEN.match(str(r)) for r in ranges) if m}
|
||||
for tok in REQUIRED_TOKENS:
|
||||
check(tok in tokens, f"{where} does not list ${{{tok}}} in loadBalancerSourceRanges "
|
||||
"(R-DNS-03)")
|
||||
for r in ranges:
|
||||
m = TOKEN.match(str(r))
|
||||
if not m:
|
||||
assert_private(where, str(r))
|
||||
continue
|
||||
resolved = values.get(m.group(1))
|
||||
check(resolved, f"{where}: {r} is not defined in cluster-vars, so its value cannot be "
|
||||
"shown to be private (R-DNS-03)")
|
||||
for v in sorted(resolved or []):
|
||||
assert_private(f"{where} ({r})", v)
|
||||
|
||||
check(CHECKS >= 15, f"only {CHECKS} assertions ran — this check has gone vacuous (R-DNS-03)")
|
||||
|
||||
if FAILURES:
|
||||
for f in FAILURES:
|
||||
print(f"check FAIL: {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"check OK: pihole-dns-source-ranges — {CHECKS} assertions over "
|
||||
f"{len(frontends)} PiHole LoadBalancer Service(s)")
|
||||
PY
|
||||
@@ -0,0 +1,45 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — PiHole answers an explicit LAN name list, and no LAN-only name is left out of it
|
||||
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||
#
|
||||
# Since PR #156 every public name is a proxied CNAME to the Cloudflare Tunnel and works from the
|
||||
# LAN, so PiHole's zone wildcard was replaced by an explicit list (catalog/pihole.yaml). That makes
|
||||
# the list load-bearing: a route disowned from public DNS and absent from it resolves nowhere. This
|
||||
# test derives the required names from the tree (every `controller: none` HTTPRoute, every hostname
|
||||
# the tunnel 404s, the L4 Services' own VIPs) and asserts the list covers them, carries no
|
||||
# zone-wide entry, and pins gitea and nextcloud to the Gateway VIP. It proves itself non-vacuous by
|
||||
# mutation. Rationale and the honest NOT-proven list: check.sh.
|
||||
#
|
||||
# WHY SCOPE, NOT REQUIREMENTS: the property spans catalog/, apps/, platform/ and runbooks/; a new
|
||||
# internal service in any of them can break it, and ci/touched-requirements.sh maps apps/* to no
|
||||
# requirement at all, so a requirement-scoped selection would skip it on precisely those changes.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: pihole-lan-names
|
||||
labels:
|
||||
conformance.home.k3s/R-DNS-03: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-DNS-03"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "true"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
PiHole answers an explicit list of LAN names rather than the whole zone; every HTTPRoute
|
||||
disowned from public DNS has an entry to the Gateway VIP; every hostname the Cloudflare
|
||||
Tunnel 404s has an entry, with the L4 names on their own VIPs; and gitea and nextcloud stay
|
||||
on the LAN path, off Cloudflare's request-size and timeout limits.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-pihole-explicit-lan-names
|
||||
description: >-
|
||||
Parse FTLCONF_misc_dnsmasq_lines from catalog/pihole.yaml and the rendered
|
||||
apps/wave-0/pihole.yaml, derive the required names from every `controller: none` HTTPRoute,
|
||||
the http_status:404 hostnames in platform/cloudflared/config.yaml and the L4 Services'
|
||||
lbipam annotations, assert the list covers them and contains no zone-wide entry, and run
|
||||
the evaluator on mutated lists to prove it is not vacuous.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — pihole-lan-names
|
||||
# Serves: R-DNS-03 (LAN split-horizon answers), and decisions/2026-11-09-pihole-explicit-lan-names.md.
|
||||
#
|
||||
# WHY THIS EXISTS. Since PR #156 every PUBLIC name is a proxied CNAME to the Cloudflare Tunnel and
|
||||
# works from the LAN, so PiHole no longer answers the whole zone with a wildcard: it answers an
|
||||
# EXPLICIT list (catalog/pihole.yaml, FTLCONF_misc_dnsmasq_lines) and forwards everything else to
|
||||
# the public resolvers. That inverts the old failure mode. With the wildcard, a forgotten name still
|
||||
# resolved; now a forgotten LAN-ONLY name — a route disowned from public DNS, which the tunnel also
|
||||
# 404s — resolves NOWHERE, and the app is silently unreachable from every client. Nothing in the
|
||||
# cluster turns red when that happens. So the list is derived from the tree and asserted here.
|
||||
#
|
||||
# GIVEN the repository as authored,
|
||||
# THEN (0) the dnsmasq value parses (only `local-ttl=` and `address=/<domain>/<${VAR}>` entries,
|
||||
# every VAR is a cluster-var), the catalog value and the rendered apps/wave-0/pihole.yaml
|
||||
# value are identical, and the inventories below are non-empty (anti-vacuous-pass);
|
||||
# (a) NO entry covers the whole zone: no `address=/${BASE_DOMAIN}/`, no `address=/#/`, and no
|
||||
# entry for any parent of the zone (dnsmasq matches every subdomain of an entry);
|
||||
# (b) every HTTPRoute hostname disowned from public DNS
|
||||
# (`external-dns.alpha.kubernetes.io/controller: none`, anywhere in apps/ platform/
|
||||
# runbooks/) has an explicit entry to ${GATEWAY_VIP} — a disowned route with no
|
||||
# `hostnames:` is itself a FAIL, because it cannot be checked;
|
||||
# (c) every hostname platform/cloudflared/config.yaml maps to http_status:404 has an entry;
|
||||
# the L4 DNS-only names (derived: Services carrying both
|
||||
# `external-dns.alpha.kubernetes.io/hostname` and `lbipam.cilium.io/ips` — unifi,
|
||||
# crafty-mc) must map to THAT Service's VIP, not the Gateway;
|
||||
# (d) gitea and nextcloud are pinned to ${GATEWAY_VIP} (Cloudflare's 100 MB body / 100 s
|
||||
# limits break git push/LFS and large uploads);
|
||||
# AND (e) the evaluator is not vacuous: it is run against mutated copies of the real entry list
|
||||
# (zone wildcard added; a disowned host dropped; a 404'd host dropped; gitea repointed;
|
||||
# unifi repointed to the Gateway) and must flag each one.
|
||||
#
|
||||
# NOT proven (static): that the running PiHole pods carry this value (FTL applies FTLCONF_* at
|
||||
# start; the env change rolls the StatefulSet), that public resolution of the unlisted names
|
||||
# reaches Cloudflare, or the substituted IPs. Runtime check: `dig @${PIHOLE_DNS_VIP} <name>`.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import copy
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("CHECK FAIL (pihole-lan-names): PyYAML is required", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
FAILURES, CHECKS = [], 0
|
||||
NAME = "pihole-lan-names"
|
||||
ZONE = "${BASE_DOMAIN}"
|
||||
GW = "${GATEWAY_VIP}"
|
||||
DISOWN = "external-dns.alpha.kubernetes.io/controller"
|
||||
EDNS_HOST = "external-dns.alpha.kubernetes.io/hostname"
|
||||
LB_IPS = "lbipam.cilium.io/ips"
|
||||
# Public, but must bypass Cloudflare's 100 MB request body / 100 s response limits.
|
||||
LAN_PINNED = ("gitea", "nextcloud")
|
||||
# Trees that hold applied manifests. conformance/ fixtures and .claude/ worktrees are not authored
|
||||
# surface (CLAUDE.md §0); decisions/ is prose.
|
||||
SCAN = ("apps", "platform", "runbooks")
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
def docs(path):
|
||||
try:
|
||||
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
|
||||
except yaml.YAMLError:
|
||||
return []
|
||||
|
||||
|
||||
def dnsmasq_value(objs):
|
||||
"""The FTLCONF_misc_dnsmasq_lines value from a list of docs (a ServiceDefinition or manifests)."""
|
||||
found = []
|
||||
for d in objs:
|
||||
envs = list(d.get("env") or [])
|
||||
tmpl = (((d.get("spec") or {}).get("template") or {}).get("spec") or {})
|
||||
for c in tmpl.get("containers") or []:
|
||||
envs += c.get("env") or []
|
||||
found += [e.get("value") for e in envs if e.get("name") == "FTLCONF_misc_dnsmasq_lines"]
|
||||
return found
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"CHECK FAIL ({NAME}): {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
CATALOG = ROOT / "catalog" / "pihole.yaml"
|
||||
RENDERED = ROOT / "apps" / "wave-0" / "pihole.yaml"
|
||||
CF = ROOT / "platform" / "cloudflared" / "config.yaml"
|
||||
for p in (CATALOG, RENDERED, CF):
|
||||
if not p.is_file():
|
||||
die(f"{p.relative_to(ROOT)} is absent — the surface this check guards moved; re-point or "
|
||||
"delete this check in the SAME change")
|
||||
|
||||
cat_vals = dnsmasq_value(docs(CATALOG))
|
||||
ren_vals = dnsmasq_value(docs(RENDERED))
|
||||
check(len(cat_vals) == 1, f"(0) catalog/pihole.yaml declares FTLCONF_misc_dnsmasq_lines exactly "
|
||||
f"once (found {len(cat_vals)})")
|
||||
check(len(ren_vals) == 1, f"(0) apps/wave-0/pihole.yaml carries FTLCONF_misc_dnsmasq_lines exactly "
|
||||
f"once (found {len(ren_vals)})")
|
||||
if not cat_vals:
|
||||
die("no dnsmasq value to check")
|
||||
check(cat_vals[:1] == ren_vals[:1],
|
||||
"(0) the rendered apps/wave-0/pihole.yaml dnsmasq value differs from catalog/pihole.yaml — "
|
||||
"re-render pihole (catalog/render.py --no-image-automation catalog/pihole.yaml)")
|
||||
|
||||
cv = next(d for d in docs(ROOT / "clusters" / "base" / "cluster-vars.yaml")
|
||||
if d.get("kind") == "ConfigMap")
|
||||
CLUSTER_VARS = set(cv["data"])
|
||||
BASE_DOMAIN_VALUE = cv["data"]["BASE_DOMAIN"]
|
||||
|
||||
ENTRY = re.compile(r"^address=/([^/]+)/(\$\{[A-Z0-9_]+\})$")
|
||||
|
||||
|
||||
def parse(value):
|
||||
"""Return (entries {domain: target}, problems)."""
|
||||
entries, problems = {}, []
|
||||
for raw in value.split(";"):
|
||||
line = raw.strip()
|
||||
if raw != line:
|
||||
problems.append(f"entry {raw!r} has surrounding whitespace (FTL writes it verbatim)")
|
||||
if not line:
|
||||
problems.append("empty entry (doubled or trailing `;`)")
|
||||
continue
|
||||
if re.fullmatch(r"local-ttl=\d+", line):
|
||||
continue
|
||||
m = ENTRY.match(line)
|
||||
if not m:
|
||||
problems.append(f"entry {line!r} is not `local-ttl=N` or `address=/<domain>/${{VAR}}` — "
|
||||
f"this check cannot reason about it")
|
||||
continue
|
||||
dom, tgt = m.groups()
|
||||
if tgt[2:-1] not in CLUSTER_VARS:
|
||||
problems.append(f"{dom} -> {tgt}: not a key of clusters/base/cluster-vars.yaml")
|
||||
if dom in entries and entries[dom] != tgt:
|
||||
problems.append(f"{dom} is listed twice with different targets")
|
||||
entries[dom] = tgt
|
||||
return entries, problems
|
||||
|
||||
|
||||
entries, problems = parse(cat_vals[0])
|
||||
for pr in problems:
|
||||
check(False, f"(0) {pr}")
|
||||
|
||||
# ── inventories, derived from the tree ──────────────────────────────────────────────────────────
|
||||
disowned, unnamed = set(), []
|
||||
l4 = {}
|
||||
for top in SCAN:
|
||||
for f in sorted((ROOT / top).rglob("*.yaml")):
|
||||
for d in docs(f):
|
||||
meta = d.get("metadata") or {}
|
||||
ann = meta.get("annotations") or {}
|
||||
if d.get("kind") == "HTTPRoute" and str(ann.get(DISOWN, "")).strip() == "none":
|
||||
hosts = (d.get("spec") or {}).get("hostnames") or []
|
||||
if not hosts:
|
||||
unnamed.append(f"{f.relative_to(ROOT)}:{meta.get('name')}")
|
||||
disowned.update(hosts)
|
||||
if d.get("kind") == "Service" and EDNS_HOST in ann and LB_IPS in ann:
|
||||
for h in str(ann[EDNS_HOST]).split(","):
|
||||
l4[h.strip()] = str(ann[LB_IPS]).strip()
|
||||
for u in unnamed:
|
||||
check(False, f"(b) disowned HTTPRoute {u} declares no `hostnames:` — it inherits a listener "
|
||||
f"hostname this check cannot see; name it explicitly")
|
||||
check(len(disowned) >= 5, f"(0) only {len(disowned)} disowned HTTPRoute hostnames found — the scan "
|
||||
f"is vacuous (the disown annotation was renamed?)")
|
||||
check(len(l4) >= 2, f"(0) only {len(l4)} L4 Services publishing their own hostname found (expected "
|
||||
f"unifi and crafty-mc) — the scan is vacuous")
|
||||
|
||||
cf = yaml.safe_load(CF.read_text())
|
||||
cf_404 = {r["hostname"] for r in cf.get("ingress") or []
|
||||
if r.get("hostname") and r.get("service") == "http_status:404"}
|
||||
check(len(cf_404) >= 5, f"(0) only {len(cf_404)} http_status:404 hostnames in the cloudflared "
|
||||
f"config — the scan is vacuous")
|
||||
|
||||
|
||||
def covers_zone(dom):
|
||||
"""True if an entry for `dom` answers every name in the zone (dom is the zone or a parent)."""
|
||||
if dom in ("#", "", "."):
|
||||
return True
|
||||
if dom == ZONE:
|
||||
return True
|
||||
# A literal parent (e.g. `keithjackson.dev`, `dev`) of the zone's substituted value.
|
||||
real = dom.replace(ZONE, BASE_DOMAIN_VALUE)
|
||||
return real == BASE_DOMAIN_VALUE or BASE_DOMAIN_VALUE.endswith("." + real)
|
||||
|
||||
|
||||
def evaluate(entries):
|
||||
bad = []
|
||||
# (a)
|
||||
for dom in entries:
|
||||
if covers_zone(dom):
|
||||
bad.append(f"(a) `address=/{dom}/` answers EVERY name in the zone — the wildcard is gone "
|
||||
f"on purpose (public names must resolve to Cloudflare from the LAN)")
|
||||
# (b)
|
||||
for h in sorted(disowned):
|
||||
if entries.get(h) != GW:
|
||||
bad.append(f"(b) {h} is disowned from public DNS but PiHole maps it to "
|
||||
f"{entries.get(h, 'NOTHING')} — expected {GW}; without it the app is "
|
||||
f"unreachable everywhere")
|
||||
# (c)
|
||||
for h in sorted(cf_404):
|
||||
if h in l4:
|
||||
if entries.get(h) != l4[h]:
|
||||
bad.append(f"(c) {h} is an L4 DNS-only name; PiHole maps it to "
|
||||
f"{entries.get(h, 'NOTHING')}, expected its own VIP {l4[h]}")
|
||||
elif h not in entries:
|
||||
bad.append(f"(c) the tunnel 404s {h} but PiHole has no entry for it — it would resolve "
|
||||
f"only publicly, and the tunnel refuses it")
|
||||
# (d)
|
||||
for n in LAN_PINNED:
|
||||
h = f"{n}.{ZONE}"
|
||||
if entries.get(h) != GW:
|
||||
bad.append(f"(d) {h} maps to {entries.get(h, 'NOTHING (public/Cloudflare)')}; it must be "
|
||||
f"pinned to {GW} — Cloudflare caps request bodies at 100 MB and responses at "
|
||||
f"100 s")
|
||||
return bad
|
||||
|
||||
|
||||
for v in evaluate(entries):
|
||||
check(False, v)
|
||||
|
||||
# (e) mutation self-test
|
||||
mutants = {}
|
||||
m = dict(entries); m[ZONE] = GW
|
||||
mutants["zone wildcard added"] = m
|
||||
m = dict(entries); m[BASE_DOMAIN_VALUE.split(".", 1)[1]] = GW
|
||||
mutants["parent-of-zone wildcard added"] = m
|
||||
m = dict(entries); m.pop(sorted(disowned)[0], None)
|
||||
mutants[f"disowned host {sorted(disowned)[0]} dropped"] = m
|
||||
pure_404 = sorted(h for h in cf_404 if h not in l4 and h not in disowned)
|
||||
if pure_404:
|
||||
m = dict(entries); m.pop(pure_404[0], None)
|
||||
mutants[f"404'd host {pure_404[0]} dropped"] = m
|
||||
m = dict(entries); m.pop(f"gitea.{ZONE}", None)
|
||||
mutants["gitea unpinned"] = m
|
||||
m = dict(entries); m[f"nextcloud.{ZONE}"] = "${UNIFI_L4_VIP}"
|
||||
mutants["nextcloud repointed"] = m
|
||||
if l4:
|
||||
h = sorted(l4)[0]
|
||||
m = dict(entries); m[h] = GW
|
||||
mutants[f"{h} repointed to the Gateway"] = m
|
||||
for name, mut in mutants.items():
|
||||
check(evaluate(mut), f"(e) self-test: the evaluator did NOT flag the mutant '{name}' — vacuous")
|
||||
check(len(mutants) >= 5, f"(e) only {len(mutants)} mutants built — the self-test degenerated")
|
||||
_, pr = parse("local-ttl=60;address=/x.${BASE_DOMAIN}/${NOT_A_CLUSTER_VAR}; address=/y/${GATEWAY_VIP}")
|
||||
check(len(pr) >= 2, "(e) self-test: the parser accepted an unknown VAR or a whitespace-padded entry")
|
||||
|
||||
if CHECKS < 15:
|
||||
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions; {len(entries)} PiHole names, {len(disowned)} disowned "
|
||||
f"routes, {len(cf_404)} tunnel-404 names, {len(l4)} L4 names")
|
||||
PY
|
||||
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — accepted CVE risk has a reason and a bounded expiry (R-VULN-08, docs/17 "Risk acceptance").
|
||||
# Offline, no cluster.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-acceptances-schema
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-08: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-08"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
platform/vuln-scanner/acceptances.yaml rejects any entry without a reason or expiry, with a
|
||||
malformed expiry, or expiring more than 180 days out. Proven with mutated negative fixtures.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-acceptance-schema
|
||||
description: >-
|
||||
Validate acceptances.yaml against the docs/17 shape, then run the validator on mutated
|
||||
negative fixtures to prove it can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-acceptances-schema
|
||||
# Serves: R-VULN-08 (docs/17 "Risk acceptance": "An entry with no reason or expiry is rejected by the gate").
|
||||
#
|
||||
# GIVEN platform/vuln-scanner/acceptances.yaml,
|
||||
# THEN (1) it is a YAML list;
|
||||
# (2) every entry has a non-empty id, a non-empty reason, an expires of the form YYYY-MM-DD
|
||||
# at most 180 days from today, and only the permitted keys (image is optional);
|
||||
# AND (3) the validator rejects mutated negative fixtures for each rule and accepts a good entry.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import datetime as dt, re, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-acceptances-schema"
|
||||
FAILURES, CHECKS = [], 0
|
||||
TODAY = dt.datetime.now(dt.timezone.utc).date()
|
||||
MAX_DAYS = 180
|
||||
ALLOWED_KEYS = {"id", "image", "reason", "expires"}
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
def validate(entries):
|
||||
errs = []
|
||||
if not isinstance(entries, list):
|
||||
return ["acceptances must be a YAML list"]
|
||||
for i, e in enumerate(entries):
|
||||
if not isinstance(e, dict):
|
||||
errs.append(f"entry {i} is not a mapping")
|
||||
continue
|
||||
if set(e) - ALLOWED_KEYS:
|
||||
errs.append(f"entry {i} has unknown keys {sorted(set(e) - ALLOWED_KEYS)}")
|
||||
for field in ("id", "reason"):
|
||||
if not isinstance(e.get(field), str) or not e[field].strip():
|
||||
errs.append(f"entry {i} has no non-empty {field}")
|
||||
if "image" in e and (not isinstance(e["image"], str) or not e["image"].strip()):
|
||||
errs.append(f"entry {i} has an empty image")
|
||||
exp = e.get("expires")
|
||||
if isinstance(exp, dt.date) and not isinstance(exp, dt.datetime):
|
||||
exp_s = exp.isoformat()
|
||||
elif isinstance(exp, str):
|
||||
exp_s = exp
|
||||
else:
|
||||
errs.append(f"entry {i} has no expires")
|
||||
continue
|
||||
if not DATE_RE.match(exp_s):
|
||||
errs.append(f"entry {i} expires is malformed: {exp_s!r}")
|
||||
continue
|
||||
try:
|
||||
d = dt.date.fromisoformat(exp_s)
|
||||
except ValueError:
|
||||
errs.append(f"entry {i} expires is not a real date: {exp_s!r}")
|
||||
continue
|
||||
if (d - TODAY).days > MAX_DAYS:
|
||||
errs.append(f"entry {i} expires {exp_s}, more than {MAX_DAYS} days out")
|
||||
return errs
|
||||
|
||||
|
||||
path = ROOT / "platform" / "vuln-scanner" / "acceptances.yaml"
|
||||
if not path.is_file():
|
||||
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/acceptances.yaml is absent", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
entries = yaml.safe_load(path.read_text())
|
||||
check(entries == [] or (isinstance(entries, list) and entries), "(1) acceptances.yaml parsed to a list")
|
||||
errs = validate(entries)
|
||||
check(not errs, f"(2) acceptances.yaml is valid: {errs}")
|
||||
|
||||
soon = (TODAY + dt.timedelta(days=30)).isoformat()
|
||||
far = (TODAY + dt.timedelta(days=200)).isoformat()
|
||||
good = {"id": "CVE-2026-00000", "image": "docker.io/library/x", "reason": "not reachable", "expires": soon}
|
||||
check(validate([good]) == [], "(3) a well-formed entry is accepted")
|
||||
check(validate([{k: v for k, v in good.items() if k != "image"}]) == [], "(3) image is optional (platform-wide)")
|
||||
for name, entry in {
|
||||
"no reason": {k: v for k, v in good.items() if k != "reason"},
|
||||
"empty reason": dict(good, reason=""),
|
||||
"no expires": {k: v for k, v in good.items() if k != "expires"},
|
||||
"no id": {k: v for k, v in good.items() if k != "id"},
|
||||
"malformed expiry": dict(good, expires="next-tuesday"),
|
||||
"impossible date": dict(good, expires="2027-13-45"),
|
||||
"past 180 days": dict(good, expires=far),
|
||||
"unknown key": dict(good, owner="me"),
|
||||
}.items():
|
||||
check(bool(validate([entry])), f"(3) the validator accepted a bad entry: {name}")
|
||||
check(bool(validate({"not": "a list"})), "(3) the validator accepted a non-list")
|
||||
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,29 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the five scanner alerts exist and are unit-tested, including the absent-metric cases
|
||||
# (R-VULN-06, docs/17 "Acceptance gates"). Offline, no cluster.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-alerts-unit-tested
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-06: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-06"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
VulnScanStale, VulnDatabaseStale, VulnCoverageGap, VulnCriticalFixAvailable and
|
||||
VulnTriageBotSilent exist, each promtool-tested firing, and the two absent-metric rules fire
|
||||
in a test with no such series. Proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-alert-unit-tests
|
||||
description: >-
|
||||
Parse alerts.yaml and alerts.promtool-test.yaml, assert every alert and the two absent cases
|
||||
are pinned, run the harness when promtool is present, and run the evaluator on mutated copies.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-alerts-unit-tested
|
||||
# Serves: R-VULN-06 (docs/17 "Acceptance gates": alerts unit-tested, including the absent-metric cases).
|
||||
#
|
||||
# GIVEN platform/vuln-scanner/alerts.yaml and alerts.promtool-test.yaml,
|
||||
# THEN (1) the rules are exactly the five contract alerts, with the two absent()-bearing rules;
|
||||
# (2) each alert is asserted FIRING in a promtool test;
|
||||
# (3) VulnScanStale and VulnTriageBotSilent each fire in a test with NO input series for their
|
||||
# own metric (the absent case) and in one where it vanishes (re-fire);
|
||||
# (4) each alert carries severity and a runbook_url;
|
||||
# (5) when promtool is present (the toolbox), the actual harness passes on this file;
|
||||
# AND (6) the evaluator is run on mutated copies (the absent arm deleted, a firing assertion
|
||||
# removed, an alert dropped) and must flag each (non-vacuity).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import copy, re, shutil, subprocess, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-alerts-unit-tested"
|
||||
FAILURES, CHECKS = [], 0
|
||||
ALERTS = {"VulnScanStale", "VulnDatabaseStale", "VulnCoverageGap", "VulnCriticalFixAvailable", "VulnTriageBotSilent"}
|
||||
ABSENT_ALERTS = {
|
||||
"VulnScanStale": "vuln_scanner_last_complete_run_timestamp_seconds",
|
||||
"VulnTriageBotSilent": "vuln_scanner_triage_heartbeat_timestamp_seconds",
|
||||
}
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
COMP = ROOT / "platform" / "vuln-scanner"
|
||||
for f in ("alerts.yaml", "alerts.promtool-test.yaml"):
|
||||
if not (COMP / f).is_file():
|
||||
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
rules = yaml.safe_load((COMP / "alerts.yaml").read_text())
|
||||
tests = yaml.safe_load((COMP / "alerts.promtool-test.yaml").read_text())
|
||||
|
||||
|
||||
def alert_rules(doc):
|
||||
return {r["alert"]: r for g in doc["spec"]["groups"] for r in g["rules"]}
|
||||
|
||||
|
||||
def eval_alerts(doc):
|
||||
bad = []
|
||||
got = alert_rules(doc)
|
||||
if set(got) != ALERTS:
|
||||
bad.append(f"alerts are {sorted(got)}, expected {sorted(ALERTS)}")
|
||||
for name, metric in ABSENT_ALERTS.items():
|
||||
if name in got and f"absent({metric})" not in got[name]["expr"].replace(" ", ""):
|
||||
bad.append(f"{name} lacks the absent({metric}) arm")
|
||||
for name, r in got.items():
|
||||
if not (r.get("labels") or {}).get("severity"):
|
||||
bad.append(f"{name} has no severity label")
|
||||
if "/runbooks/" not in ((r.get("annotations") or {}).get("runbook_url") or ""):
|
||||
bad.append(f"{name} has no runbook_url")
|
||||
return bad
|
||||
|
||||
|
||||
def firing_assertions(test):
|
||||
"""(alertname -> assertion list) for the firing expectations of one promtool test."""
|
||||
out = {}
|
||||
for pe in test.get("promql_expr_test") or []:
|
||||
expr, samples = pe.get("expr", ""), pe.get("exp_samples") or []
|
||||
m = re.search(r'alertname="(Vuln\w+)"', expr)
|
||||
if m and samples and 'alertstate="firing"' in (expr + " " + " ".join(s.get("labels", "") for s in samples)):
|
||||
out.setdefault(m.group(1), []).append(pe)
|
||||
return out
|
||||
|
||||
|
||||
def series_for(test):
|
||||
return [s.get("series", "") for s in test.get("input_series") or []]
|
||||
|
||||
|
||||
def eval_tests(doc):
|
||||
bad = []
|
||||
all_tests = doc.get("tests") or []
|
||||
fired = set()
|
||||
for t in all_tests:
|
||||
fired |= set(firing_assertions(t))
|
||||
missing = ALERTS - fired
|
||||
if missing:
|
||||
bad.append(f"no firing promtool assertion for {sorted(missing)}")
|
||||
for alert, metric in ABSENT_ALERTS.items():
|
||||
absent_case = any(
|
||||
alert in firing_assertions(t) and not any(s.startswith(metric) for s in series_for(t))
|
||||
for t in all_tests)
|
||||
if not absent_case:
|
||||
bad.append(f"{alert} has no firing test with NO {metric} series (the absent case)")
|
||||
vanish_case = any(
|
||||
alert in firing_assertions(t) and any(s.startswith(metric) and "stale" in str(i.get("values", ""))
|
||||
for i in t.get("input_series", []) for s in [i.get("series", "")])
|
||||
for t in all_tests)
|
||||
if not vanish_case:
|
||||
bad.append(f"{alert} has no test where its metric goes stale (the re-fire case)")
|
||||
return bad
|
||||
|
||||
|
||||
for v in eval_alerts(rules):
|
||||
check(False, f"(1,4) {v}")
|
||||
for v in eval_tests(tests):
|
||||
check(False, f"(2,3) {v}")
|
||||
check(len(alert_rules(rules)) == 5, "(1) five alert rules parsed")
|
||||
|
||||
# (5) the real harness, only when the rule engine is present.
|
||||
if shutil.which("promtool"):
|
||||
rc = subprocess.run(["bash", str(ROOT / "platform/monitoring/tests/run.sh"),
|
||||
"platform/vuln-scanner/alerts.promtool-test.yaml"],
|
||||
cwd=str(ROOT), capture_output=True, text=True)
|
||||
check(rc.returncode == 0, f"(5) the promtool harness failed: rc={rc.returncode}\n{rc.stdout[-800:]}{rc.stderr[-800:]}")
|
||||
|
||||
# (6) non-vacuity
|
||||
m = copy.deepcopy(rules)
|
||||
for g in m["spec"]["groups"]:
|
||||
for r in g["rules"]:
|
||||
if r["alert"] == "VulnTriageBotSilent":
|
||||
r["expr"] = r["expr"].split(" or ")[0]
|
||||
check(bool(eval_alerts(m)), "(6) evaluator missed: absent arm deleted")
|
||||
m = copy.deepcopy(rules)
|
||||
m["spec"]["groups"][0]["rules"].pop(1)
|
||||
check(bool(eval_alerts(m)), "(6) evaluator missed: alert dropped")
|
||||
m = copy.deepcopy(rules)
|
||||
for g in m["spec"]["groups"]:
|
||||
for r in g["rules"]:
|
||||
r["annotations"].pop("runbook_url", None)
|
||||
check(bool(eval_alerts(m)), "(6) evaluator missed: runbook_url removed")
|
||||
|
||||
m = copy.deepcopy(tests)
|
||||
for t in m["tests"]:
|
||||
t["promql_expr_test"] = [p for p in t["promql_expr_test"]
|
||||
if 'VulnCoverageGap' not in p.get("expr", "")]
|
||||
check(bool(eval_tests(m)), "(6) evaluator missed: firing assertion removed")
|
||||
m = copy.deepcopy(tests)
|
||||
m["tests"] = [t for t in m["tests"] if "ABSENT" not in t.get("name", "")]
|
||||
check(bool(eval_tests(m)), "(6) evaluator missed: absent-case tests removed")
|
||||
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — Prometheus receives aggregates only (R-VULN-06, docs/17 "Alerting").
|
||||
# Offline, no cluster. Depends on platform/vuln-scanner/src/metrics.py (the SCANNER's file).
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-metrics-no-per-cve-label
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-06: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-06"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
No vuln_scanner_* metric or alert rule carries a label other than state, severity or fixable,
|
||||
so Prometheus can never hold a per-CVE, per-image or per-workload series. Proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-metric-labels-are-aggregates
|
||||
description: >-
|
||||
Extract every vuln_scanner_* metric label from metrics.py and alerts.yaml, assert the
|
||||
allowlist, and run the extractor on mutated text to prove it can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-metrics-no-per-cve-label
|
||||
# Serves: R-VULN-06 ("Prometheus receives aggregates only ... MUST NOT receive a per-CVE series").
|
||||
#
|
||||
# GIVEN platform/vuln-scanner/src/metrics.py and platform/vuln-scanner/alerts.yaml,
|
||||
# THEN (1) every label name attached to a vuln_scanner_* metric is in the allowlist
|
||||
# (state, severity, fixable);
|
||||
# (2) metrics.py exposes every metric name from the contract;
|
||||
# (3) alerts.yaml selects vuln_scanner_* metrics with no label matcher outside the allowlist;
|
||||
# AND (4) the extractor is proven on mutated text: a `cve=` label, a `{image=...}` selector and a
|
||||
# per-workload label are each flagged, and label-free text is not.
|
||||
# A missing metrics.py is a FAIL: this is a whole-repo-scoped check and the file is part of the same
|
||||
# delivery (the check has no skip path).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import re, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-metrics-no-per-cve-label"
|
||||
FAILURES, CHECKS = [], 0
|
||||
ALLOWED = {"state", "severity", "fixable"}
|
||||
METRICS = (
|
||||
"vuln_scanner_report_generated_timestamp_seconds",
|
||||
"vuln_scanner_last_complete_run_timestamp_seconds",
|
||||
"vuln_scanner_database_updated_timestamp_seconds",
|
||||
"vuln_scanner_targets",
|
||||
"vuln_scanner_findings",
|
||||
"vuln_scanner_failed_targets_over_24h",
|
||||
"vuln_scanner_critical_fix_overdue",
|
||||
"vuln_scanner_triage_heartbeat_timestamp_seconds",
|
||||
)
|
||||
# Label keys that could only carry per-CVE or per-target identity, anywhere a metric could be
|
||||
# written. Catches label construction the name{...} scan cannot see (e.g. "cve_id": cve_id).
|
||||
IDENTITY_LABEL = re.compile(r'''["'](cve|cve_id|advisory|package|pkg|image|digest|workload|namespace|pod|container|target|target_id)["']\s*[:=]''')
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
def label_names(text):
|
||||
"""Every label key in `vuln_scanner_x{key="value",...}` occurrences (Python or PromQL text),
|
||||
tolerating f-strings' doubled braces and multi-line calls."""
|
||||
found = set()
|
||||
for m in re.finditer(r"vuln_scanner_[a-z0-9_]+\{", text):
|
||||
tail = text[m.end(): m.end() + 200]
|
||||
close = tail.find("}\n")
|
||||
chunk = tail[: close if close != -1 else None].split("\n", 1)[0]
|
||||
for key in re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*\\?[\"'{]", chunk):
|
||||
found.add(key)
|
||||
return found
|
||||
|
||||
|
||||
METRICS_PY = ROOT / "platform" / "vuln-scanner" / "src" / "metrics.py"
|
||||
ALERTS = ROOT / "platform" / "vuln-scanner" / "alerts.yaml"
|
||||
if not METRICS_PY.is_file():
|
||||
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/src/metrics.py is absent (the SCANNER's "
|
||||
f"deliverable); the aggregate-only property cannot be asserted without it", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
src = METRICS_PY.read_text()
|
||||
check(all(m in src for m in METRICS), f"(2) metrics.py names every contract metric "
|
||||
f"(missing: {[m for m in METRICS if m not in src]})")
|
||||
|
||||
labels = label_names(src)
|
||||
extra = labels - ALLOWED
|
||||
check(not extra, f"(1) metrics.py attaches labels outside the allowlist: {sorted(extra)}")
|
||||
hits = IDENTITY_LABEL.findall(src)
|
||||
check(not hits, f"(1) metrics.py constructs an identity-bearing metric label: {sorted(set(hits))}")
|
||||
|
||||
rules = yaml.safe_load(ALERTS.read_text())
|
||||
exprs = [r["expr"] for g in rules["spec"]["groups"] for r in g["rules"]]
|
||||
check(len(exprs) >= 5, "(3) alerts.yaml parsed five or more rules")
|
||||
alert_labels = set()
|
||||
for e in exprs:
|
||||
alert_labels |= label_names(e)
|
||||
check(not (alert_labels - ALLOWED),
|
||||
f"(3) alerts.yaml selects labels outside the allowlist: {sorted(alert_labels - ALLOWED)}")
|
||||
|
||||
# (4) non-vacuity
|
||||
negatives = {
|
||||
"cve label": 'vuln_scanner_findings{severity="HIGH",cve="CVE-1"} 1\n',
|
||||
"image selector": 'max(vuln_scanner_findings{image="docker.io/x"})\n',
|
||||
"workload label": 'f"vuln_scanner_targets{{workload=\\"{w}\\",state=\\"x\\"}} 1"\n',
|
||||
}
|
||||
for name, text in negatives.items():
|
||||
check(bool(label_names(text) - ALLOWED), f"(4) the extractor missed: {name}")
|
||||
ok_text = 'vuln_scanner_targets{state="scanned"} 3\nvuln_scanner_findings{severity="LOW",fixable="true"} 1\n'
|
||||
check(not (label_names(ok_text) - ALLOWED), "(4) the extractor flagged the allowed labels")
|
||||
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the scanner's Flux tier cannot stall the platform, and its network reach is exactly what
|
||||
# docs/17 "Security of the scanner itself" allows (R-VULN-09). Offline, no cluster.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-isolation
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-09: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-09"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
The vuln-scanner Flux tier is wait:false, in neither gating tier, and nothing dependsOn it;
|
||||
ingress is exactly the Gateway to 4180, the CI runner to 8081 and Prometheus to 9090;
|
||||
egress reaches the API, DNS, the public internet on 443 only with cluster and LAN excluded,
|
||||
and the Keycloak path the sidecar needs. Proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-isolation-and-network-shape
|
||||
description: >-
|
||||
Parse the Flux wiring and networkpolicy.yaml, assert isolation and the exact network shape,
|
||||
and run the evaluators on mutated copies to prove they can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-scanner-isolation
|
||||
# Serves: R-VULN-09 (docs/17 "Security of the scanner itself").
|
||||
#
|
||||
# GIVEN the repository as authored,
|
||||
# THEN (1) the Flux tier is wait:false, dependsOn platform-config, in neither gating tier, nothing
|
||||
# anywhere dependsOn it, and both overlays carry its SOPS decryption patch;
|
||||
# (2) ingress is EXACTLY: reserved:ingress to 4180, act-runner to 8081, prometheus to 9090;
|
||||
# (3) egress to the world is tcp/443 only through a CIDR rule excluding every private range,
|
||||
# and the only other destinations are the API entity, the Gateway entity on 443 and Keycloak
|
||||
# (the oauth2-proxy sidecar's OIDC path, the one deliberate addition to the spec's list);
|
||||
# no `world`, `all`, `cluster`, `host` or `remote-node` entity;
|
||||
# (4) the namespace ships a default-deny baseline and is not app-namespace=true (single owner);
|
||||
# AND (5) each evaluator is run on a mutated copy and must flag it (non-vacuity).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import copy, re, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-scanner-isolation"
|
||||
FAILURES, CHECKS = [], 0
|
||||
FORBIDDEN_ENTITIES = {"world", "all", "cluster", "host", "remote-node"}
|
||||
PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
def docs(path):
|
||||
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
|
||||
|
||||
|
||||
fk_path = ROOT / "clusters" / "base" / "vuln-scanner.yaml"
|
||||
np_path = ROOT / "platform" / "vuln-scanner" / "networkpolicy.yaml"
|
||||
for p in (fk_path, np_path):
|
||||
if not p.is_file():
|
||||
print(f"CHECK FAIL ({NAME}): {p.relative_to(ROOT)} is absent", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
# (1) Flux wiring
|
||||
fk = next(d for d in docs(fk_path) if d.get("kind") == "Kustomization")
|
||||
check(fk["spec"].get("wait") is False, "(1) clusters/base/vuln-scanner.yaml must be wait: false")
|
||||
check([d.get("name") for d in fk["spec"].get("dependsOn") or []] == ["platform-config"],
|
||||
"(1) the tier dependsOn exactly platform-config")
|
||||
check(fk["spec"].get("path") == "./platform/vuln-scanner", "(1) the tier reconciles ./platform/vuln-scanner")
|
||||
check((fk["spec"].get("postBuild") or {}).get("substituteFrom"),
|
||||
"(1) postBuild.substituteFrom is present (the route/alerts use ${BASE_DOMAIN})")
|
||||
check("vuln-scanner.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(),
|
||||
"(1) clusters/base/kustomization.yaml lists vuln-scanner.yaml")
|
||||
check("vuln-scanner" not in (ROOT / "platform" / "kustomization.yaml").read_text(),
|
||||
"(1) platform/kustomization.yaml must not include vuln-scanner (the gating tier)")
|
||||
for f in sorted((ROOT / "clusters").rglob("*.yaml")):
|
||||
for d in docs(f):
|
||||
for dep in (d.get("spec") or {}).get("dependsOn") or []:
|
||||
check(not (isinstance(dep, dict) and dep.get("name") == "vuln-scanner"),
|
||||
f"(1) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn vuln-scanner — "
|
||||
"a broken scanner must never stall anything")
|
||||
for env in ("dev", "prod"):
|
||||
text = (ROOT / "clusters" / env / "kustomization.yaml").read_text()
|
||||
check(re.search(r"name:\s*vuln-scanner\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n\s*decryption:",
|
||||
text) is not None,
|
||||
f"(1) clusters/{env}/kustomization.yaml carries the vuln-scanner SOPS decryption patch")
|
||||
|
||||
# Network shape
|
||||
np_docs = docs(np_path)
|
||||
dd = [d for d in np_docs if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny"]
|
||||
check(len(dd) == 1 and set(dd[0]["spec"]["policyTypes"]) == {"Ingress", "Egress"} and dd[0]["spec"].get("ingress") == [],
|
||||
"(4) default-deny baseline denies both directions with no ingress")
|
||||
ns = next(d for d in docs(ROOT / "platform" / "vuln-scanner" / "namespace.yaml") if d.get("kind") == "Namespace")
|
||||
check("homelab.keithjackson.dev/app-namespace" not in (ns["metadata"].get("labels") or {}),
|
||||
"(4) the namespace must not be app-namespace=true (the default-deny would be double-owned)")
|
||||
|
||||
|
||||
def cnp(name):
|
||||
return next((d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy" and d["metadata"]["name"] == name), None)
|
||||
|
||||
|
||||
def ports_of(rule):
|
||||
return {(p.get("port"), p.get("protocol", "TCP")) for tp in rule.get("toPorts") or [] for p in tp.get("ports") or []}
|
||||
|
||||
|
||||
def eval_ingress(policy):
|
||||
bad = []
|
||||
rules = policy["spec"].get("ingress") or []
|
||||
if len(rules) != 3:
|
||||
bad.append(f"expected exactly 3 ingress rules, found {len(rules)}")
|
||||
seen = set()
|
||||
for r in rules:
|
||||
ports = ports_of(r)
|
||||
if r.get("fromEntities") == ["ingress"] and ports == {("4180", "TCP")}:
|
||||
seen.add("gateway")
|
||||
elif len(r.get("fromEndpoints") or []) == 1:
|
||||
sel = r["fromEndpoints"][0].get("matchLabels") or {}
|
||||
if (sel.get("io.kubernetes.pod.namespace") == "gitea-actions"
|
||||
and sel.get("app.kubernetes.io/name") == "act-runner" and ports == {("8081", "TCP")}):
|
||||
seen.add("ci")
|
||||
elif (sel.get("io.kubernetes.pod.namespace") == "monitoring"
|
||||
and sel.get("app.kubernetes.io/name") == "prometheus" and ports == {("9090", "TCP")}):
|
||||
seen.add("prometheus")
|
||||
else:
|
||||
bad.append(f"unexpected ingress rule {r}")
|
||||
else:
|
||||
bad.append(f"unexpected ingress rule {r}")
|
||||
missing = {"gateway", "ci", "prometheus"} - seen
|
||||
if missing:
|
||||
bad.append(f"ingress paths missing: {sorted(missing)}")
|
||||
return bad
|
||||
|
||||
|
||||
def eval_egress(policy):
|
||||
bad = []
|
||||
rules = policy["spec"].get("egress") or []
|
||||
if len(rules) != 4:
|
||||
bad.append(f"expected exactly 4 egress rules, found {len(rules)}")
|
||||
world = 0
|
||||
for r in rules:
|
||||
ents = set(r.get("toEntities") or [])
|
||||
if ents & FORBIDDEN_ENTITIES:
|
||||
bad.append(f"egress admits forbidden entity {sorted(ents & FORBIDDEN_ENTITIES)}")
|
||||
if ents == {"kube-apiserver"} and not r.get("toPorts"):
|
||||
continue
|
||||
if ents == {"ingress"} and ports_of(r) == {("443", "TCP")}:
|
||||
continue
|
||||
if r.get("toEndpoints"):
|
||||
sel = r["toEndpoints"][0].get("matchLabels") or {}
|
||||
if not (len(r["toEndpoints"]) == 1 and sel.get("io.kubernetes.pod.namespace") == "keycloak"
|
||||
and sel.get("app") == "keycloak"):
|
||||
bad.append(f"egress to unexpected endpoints {r}")
|
||||
continue
|
||||
if r.get("toCIDRSet"):
|
||||
world += 1
|
||||
sets = r["toCIDRSet"]
|
||||
if len(sets) != 1 or sets[0].get("cidr") != "0.0.0.0/0":
|
||||
bad.append(f"unexpected CIDR rule {r}")
|
||||
continue
|
||||
if not PRIVATE_RANGES.issubset(set(sets[0].get("except") or [])):
|
||||
bad.append(f"the world CIDR rule does not exclude {sorted(PRIVATE_RANGES - set(sets[0].get('except') or []))}")
|
||||
if ports_of(r) != {("443", "TCP")}:
|
||||
bad.append(f"the world CIDR rule is not tcp/443 only ({sorted(ports_of(r))})")
|
||||
continue
|
||||
if r.get("toCIDR"):
|
||||
bad.append(f"egress names a bare CIDR rule {r}")
|
||||
continue
|
||||
if not ents:
|
||||
bad.append(f"unexpected egress rule {r}")
|
||||
if world != 1:
|
||||
bad.append(f"expected exactly one world CIDR rule, found {world}")
|
||||
return bad
|
||||
|
||||
|
||||
ing, eg = cnp("vuln-scanner-allow-ingress"), cnp("vuln-scanner-allow-egress")
|
||||
check(ing is not None, "(2) the ingress CiliumNetworkPolicy exists")
|
||||
check(eg is not None, "(3) the egress CiliumNetworkPolicy exists")
|
||||
if ing is not None:
|
||||
for v in eval_ingress(ing):
|
||||
check(False, f"(2) {v}")
|
||||
if eg is not None:
|
||||
for v in eval_egress(eg):
|
||||
check(False, f"(3) {v}")
|
||||
check(len([d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]) == 2,
|
||||
"(3) exactly two CiliumNetworkPolicies, so no other path is added silently")
|
||||
|
||||
# (5) non-vacuity
|
||||
if ing is not None and eg is not None:
|
||||
def m_ing_open(p): p["spec"]["ingress"].append({"fromEntities": ["world"], "toPorts": [{"ports": [{"port": "8081", "protocol": "TCP"}]}]})
|
||||
def m_ing_ns(p): p["spec"]["ingress"][1]["fromEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default"
|
||||
def m_ing_port(p): p["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"] = "8080"
|
||||
for name, fn in (("extra world ingress", m_ing_open), ("CI rule moved to another namespace", m_ing_ns),
|
||||
("Gateway reaches the page port", m_ing_port)):
|
||||
m = copy.deepcopy(ing)
|
||||
fn(m)
|
||||
check(bool(eval_ingress(m)), f"(5) ingress evaluator missed mutant: {name}")
|
||||
def m_eg_world(p): p["spec"]["egress"].append({"toEntities": ["world"]})
|
||||
def m_eg_lan(p):
|
||||
for r in p["spec"]["egress"]:
|
||||
if r.get("toCIDRSet"):
|
||||
r["toCIDRSet"][0]["except"] = ["10.0.0.0/8"]
|
||||
def m_eg_port(p):
|
||||
for r in p["spec"]["egress"]:
|
||||
if r.get("toCIDRSet"):
|
||||
r["toPorts"][0]["ports"].append({"port": "80", "protocol": "TCP"})
|
||||
def m_eg_endpoint(p):
|
||||
for r in p["spec"]["egress"]:
|
||||
if r.get("toEndpoints"):
|
||||
r["toEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default"
|
||||
for name, fn in (("world entity added", m_eg_world), ("LAN not excluded", m_eg_lan),
|
||||
("port 80 added", m_eg_port), ("endpoints moved", m_eg_endpoint)):
|
||||
m = copy.deepcopy(eg)
|
||||
fn(m)
|
||||
check(bool(eval_egress(m)), f"(5) egress evaluator missed mutant: {name}")
|
||||
|
||||
if CHECKS < 18:
|
||||
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the CVE scanner's supply chain and privileges are what docs/17 "Security of the scanner
|
||||
# itself" says (R-VULN-09). Offline, no cluster.
|
||||
#
|
||||
# WHY SCOPE, NOT REQUIREMENTS: the property spans platform/vuln-scanner and renovate.json5. A
|
||||
# requirement-scoped selection would miss a Renovate edit that quietly lets the scanner auto-merge.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-supply-chain
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-09: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-09"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
The scanner image is pinned by digest and is not a banned Trivy version; Renovate never
|
||||
auto-merges it and proposes it only after 7 days; its RBAC holds get/list/watch only and no
|
||||
Secret; the pod is unprivileged with bounded resources and a size-limited cache. Each is
|
||||
proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-supply-chain-and-privileges
|
||||
description: >-
|
||||
Parse platform/vuln-scanner/deployment.yaml, rbac.yaml and renovate.json5, assert the
|
||||
digest/version/Renovate/RBAC/security-context contract, and run each evaluator on mutated
|
||||
copies to prove it can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-scanner-supply-chain
|
||||
# Serves: R-VULN-09 (docs/17 "Security of the scanner itself").
|
||||
#
|
||||
# GIVEN the repository as authored,
|
||||
# THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:<ver>@sha256:<64hex>` — by digest, and a
|
||||
# tag so Renovate has something to follow;
|
||||
# (2) that version is not 0.69.4/0.69.5/0.69.6 (the compromised March 2026 releases);
|
||||
# (3) every pod image is digest-pinned except the oauth2-proxy sidecar, which follows the
|
||||
# per-app sidecar pattern so one Renovate branch moves them all;
|
||||
# (4) renovate.json5 keeps Trivy in the blast-radius list, automerge:false, minimumReleaseAge
|
||||
# of at least 7 days, and no later rule re-enables automerge for it;
|
||||
# (5) rbac.yaml: only ClusterRole/ClusterRoleBinding to the scanner SA, only get/list/watch, no
|
||||
# Secret, no wildcard, and only the resources the inventory needs;
|
||||
# (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host
|
||||
# namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage
|
||||
# limit on the scanner, and a size-limited /cache emptyDir.
|
||||
# (8) a renovate.json5 rule covers every file under platform/vuln-scanner: automerge:false,
|
||||
# minimumReleaseAge of at least 7 days, and no later rule turns auto-merge back on (the
|
||||
# python image holds the cluster-read token);
|
||||
# (9) the scanner names the private registry it cannot reach, so that registry reports
|
||||
# credential-required rather than a permanent failed.
|
||||
# AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import copy, json, re, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-scanner-supply-chain"
|
||||
FAILURES, CHECKS = [], 0
|
||||
COMP = ROOT / "platform" / "vuln-scanner"
|
||||
BANNED = {"0.69.4", "0.69.5", "0.69.6"}
|
||||
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
ALLOWED_VERBS = {"get", "list", "watch"}
|
||||
ALLOWED_RESOURCES = {
|
||||
"": {"pods", "nodes"},
|
||||
"apps": {"replicasets", "deployments", "statefulsets", "daemonsets"},
|
||||
"batch": {"jobs", "cronjobs"},
|
||||
"kubevirt.io": {"virtualmachineinstances"},
|
||||
}
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
def docs(path):
|
||||
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
|
||||
|
||||
|
||||
for f in ("deployment.yaml", "rbac.yaml"):
|
||||
if not (COMP / f).is_file():
|
||||
print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def split_ref(image):
|
||||
# A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port
|
||||
# stays in the repository part).
|
||||
ref, _, digest = image.partition("@")
|
||||
head, colon, tail = ref.rpartition(":")
|
||||
if colon and "/" not in tail:
|
||||
return head, tail, digest or None
|
||||
return ref, None, digest or None
|
||||
|
||||
|
||||
def containers(pod):
|
||||
return (pod.get("initContainers") or []) + (pod.get("containers") or [])
|
||||
|
||||
|
||||
def eval_images(pod):
|
||||
bad = []
|
||||
trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"]
|
||||
if len(trivy) != 1:
|
||||
bad.append(f"expected one Trivy image, found {len(trivy)}")
|
||||
for c in containers(pod):
|
||||
repo, tag, digest = split_ref(c["image"])
|
||||
if repo == "quay.io/oauth2-proxy/oauth2-proxy":
|
||||
if not tag or tag == "latest":
|
||||
bad.append("oauth2-proxy sidecar has no version tag")
|
||||
continue
|
||||
if not digest or not DIGEST.match(digest):
|
||||
bad.append(f"{c['name']} image {c['image']} is not pinned by digest")
|
||||
if not tag:
|
||||
bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow")
|
||||
if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED:
|
||||
bad.append(f"Trivy {tag} is a banned (compromised) version")
|
||||
return bad
|
||||
|
||||
|
||||
def strip_json5(text):
|
||||
# Drop // comments outside strings, then trailing commas.
|
||||
out, i, in_str, esc = [], 0, False, False
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
out.append(ch)
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if text.startswith("//", i):
|
||||
while i < len(text) and text[i] != "\n":
|
||||
i += 1
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
|
||||
|
||||
|
||||
TRIVY_PKG = "ghcr.io/aquasecurity/trivy"
|
||||
VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$")
|
||||
|
||||
|
||||
def eval_renovate(rules):
|
||||
bad = []
|
||||
trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])]
|
||||
if not trivy_rules:
|
||||
bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)")
|
||||
return bad
|
||||
if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or [])
|
||||
for r in trivy_rules):
|
||||
bad.append("Trivy is not in a blast-radius rule with automerge:false")
|
||||
ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r]
|
||||
if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7
|
||||
for a in ages):
|
||||
bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})")
|
||||
# Renovate applies packageRules in order, later wins; the last rule that names automerge for
|
||||
# Trivy (or matches it generically by update type) must be false.
|
||||
last = None
|
||||
for r in rules:
|
||||
if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or [])
|
||||
or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}):
|
||||
last = r
|
||||
if last is None or last.get("automerge") is not False:
|
||||
bad.append("the last packageRule deciding automerge for Trivy leaves it true")
|
||||
return bad
|
||||
|
||||
|
||||
SCANNER_FILES = "platform/vuln-scanner/**"
|
||||
|
||||
|
||||
def eval_renovate_files(rules):
|
||||
positions = [i for i, r in enumerate(rules) if SCANNER_FILES in (r.get("matchFileNames") or [])]
|
||||
if not positions:
|
||||
return [f"no packageRule has matchFileNames {SCANNER_FILES}"]
|
||||
bad = []
|
||||
rule = rules[positions[-1]]
|
||||
if rule.get("automerge") is not False:
|
||||
bad.append("the platform/vuln-scanner file rule is not automerge:false")
|
||||
age = VERSION_UPDATE_RULE_RE.match(rule.get("minimumReleaseAge") or "")
|
||||
if not age or int(age.group(1)) < 7:
|
||||
bad.append("the platform/vuln-scanner file rule has no minimumReleaseAge of at least 7 days")
|
||||
if any(r.get("automerge") is True for r in rules[positions[-1] + 1:]):
|
||||
bad.append("a rule after the platform/vuln-scanner file rule re-enables auto-merge")
|
||||
return bad
|
||||
|
||||
|
||||
def eval_private_registries(pod):
|
||||
scanner = next(c for c in pod["containers"] if c["name"] == "scanner")
|
||||
env = {e["name"]: e.get("value") for e in scanner.get("env") or []}
|
||||
if env.get("VULN_PRIVATE_REGISTRIES") != "gitea.${BASE_DOMAIN}":
|
||||
return ["the scanner does not name the private registry gitea.${BASE_DOMAIN}"]
|
||||
return []
|
||||
|
||||
|
||||
def eval_rbac(rbac):
|
||||
bad = []
|
||||
croles = [d for d in rbac if d.get("kind") == "ClusterRole"]
|
||||
if len(croles) != 1:
|
||||
bad.append(f"expected one ClusterRole, found {len(croles)}")
|
||||
return bad
|
||||
for rule in croles[0].get("rules") or []:
|
||||
verbs = set(rule.get("verbs") or [])
|
||||
if verbs - ALLOWED_VERBS:
|
||||
bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}")
|
||||
if "*" in verbs:
|
||||
bad.append("rule grants the verb wildcard")
|
||||
for group in rule.get("apiGroups") or []:
|
||||
if group == "*" or group not in ALLOWED_RESOURCES:
|
||||
bad.append(f"rule names apiGroup {group!r}")
|
||||
continue
|
||||
for res in rule.get("resources") or []:
|
||||
if res == "*" or res not in ALLOWED_RESOURCES[group]:
|
||||
bad.append(f"rule grants resource {group}/{res}")
|
||||
if "secrets" in (rule.get("resources") or []):
|
||||
bad.append("rule grants Secret access")
|
||||
kinds = {d.get("kind") for d in rbac}
|
||||
if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}:
|
||||
bad.append(f"unexpected RBAC kinds {sorted(kinds)}")
|
||||
for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"):
|
||||
if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]:
|
||||
bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole")
|
||||
return bad
|
||||
|
||||
|
||||
def eval_pod(pod):
|
||||
bad = []
|
||||
psc = pod.get("securityContext") or {}
|
||||
if psc.get("runAsNonRoot") is not True:
|
||||
bad.append("pod runAsNonRoot is not true")
|
||||
if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
|
||||
bad.append("pod seccompProfile is not RuntimeDefault")
|
||||
for f in ("hostNetwork", "hostPID", "hostIPC"):
|
||||
if pod.get(f):
|
||||
bad.append(f"pod sets {f}")
|
||||
if any("hostPath" in v for v in pod.get("volumes") or []):
|
||||
bad.append("pod mounts a hostPath volume")
|
||||
for c in containers(pod):
|
||||
sc = c.get("securityContext") or {}
|
||||
if sc.get("readOnlyRootFilesystem") is not True:
|
||||
bad.append(f"{c['name']} root filesystem is writable")
|
||||
if sc.get("allowPrivilegeEscalation") is not False:
|
||||
bad.append(f"{c['name']} allows privilege escalation")
|
||||
if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []):
|
||||
bad.append(f"{c['name']} does not drop ALL capabilities")
|
||||
limits = (c.get("resources") or {}).get("limits") or {}
|
||||
for lim in ("cpu", "memory"):
|
||||
if lim not in limits:
|
||||
bad.append(f"{c['name']} has no {lim} limit")
|
||||
if c["name"] == "scanner" and "ephemeral-storage" not in limits:
|
||||
bad.append("the scanner has no ephemeral-storage limit")
|
||||
vols = {v["name"]: v for v in pod.get("volumes") or []}
|
||||
if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"):
|
||||
bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)")
|
||||
return bad
|
||||
|
||||
|
||||
dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment")
|
||||
pod = dep["spec"]["template"]["spec"]
|
||||
for v in eval_images(pod) + eval_pod(pod):
|
||||
check(False, f"(1-3,6) {v}")
|
||||
check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)")
|
||||
check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy")
|
||||
|
||||
renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text()))
|
||||
rules = renovate.get("packageRules") or []
|
||||
check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)")
|
||||
for v in eval_renovate(rules):
|
||||
check(False, f"(4) {v}")
|
||||
for v in eval_renovate_files(rules):
|
||||
check(False, f"(8) {v}")
|
||||
for v in eval_private_registries(pod):
|
||||
check(False, f"(9) {v}")
|
||||
|
||||
rbac = docs(COMP / "rbac.yaml")
|
||||
for v in eval_rbac(rbac):
|
||||
check(False, f"(5) {v}")
|
||||
|
||||
# (7) non-vacuity
|
||||
def mutated(fn, base):
|
||||
m = copy.deepcopy(base)
|
||||
fn(m)
|
||||
return m
|
||||
|
||||
# Image mutants
|
||||
def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1"
|
||||
def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"])
|
||||
def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine"
|
||||
for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned),
|
||||
("python image unpinned", img_nopin)):
|
||||
check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}")
|
||||
|
||||
# RBAC mutants
|
||||
def rbac_secret(r):
|
||||
role = next(d for d in r if d.get("kind") == "ClusterRole")
|
||||
role["rules"][0]["resources"].append("secrets")
|
||||
def rbac_write(r):
|
||||
role = next(d for d in r if d.get("kind") == "ClusterRole")
|
||||
role["rules"][0]["verbs"].append("delete")
|
||||
def rbac_wild(r):
|
||||
role = next(d for d in r if d.get("kind") == "ClusterRole")
|
||||
role["rules"][1]["resources"] = ["*"]
|
||||
for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write),
|
||||
("resource wildcard granted", rbac_wild)):
|
||||
check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}")
|
||||
|
||||
# Renovate mutants
|
||||
def ren_auto(r):
|
||||
for x in r:
|
||||
if TRIVY_PKG in (x.get("matchPackageNames") or []):
|
||||
x["automerge"] = True
|
||||
def ren_age(r):
|
||||
for x in r:
|
||||
if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x:
|
||||
x["minimumReleaseAge"] = "2 days"
|
||||
def ren_drop(r):
|
||||
r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])]
|
||||
def ren_later(r):
|
||||
r.append({"matchUpdateTypes": ["digest"], "automerge": True})
|
||||
for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age),
|
||||
("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)):
|
||||
check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}")
|
||||
|
||||
# Renovate file-rule mutants
|
||||
def files_rule(r):
|
||||
return next(x for x in r if SCANNER_FILES in (x.get("matchFileNames") or []))
|
||||
def files_auto(r): files_rule(r)["automerge"] = True
|
||||
def files_age(r): files_rule(r)["minimumReleaseAge"] = "1 day"
|
||||
def files_drop(r): r.remove(files_rule(r))
|
||||
def files_later(r): r.append({"matchDatasources": ["docker"], "automerge": True})
|
||||
for name, fn in (("file rule automerge true", files_auto), ("file rule age shortened", files_age),
|
||||
("file rule removed", files_drop), ("later rule re-enables auto-merge", files_later)):
|
||||
check(bool(eval_renovate_files(mutated(fn, rules))), f"(7) file-rule evaluator missed mutant: {name}")
|
||||
|
||||
def reg_drop(p):
|
||||
next(c for c in p["containers"] if c["name"] == "scanner")["env"][:] = [
|
||||
e for e in next(c for c in p["containers"] if c["name"] == "scanner")["env"]
|
||||
if e["name"] != "VULN_PRIVATE_REGISTRIES"]
|
||||
check(bool(eval_private_registries(mutated(reg_drop, pod))), "(7) private-registry evaluator missed the removal")
|
||||
|
||||
# Pod mutants
|
||||
def pod_root(p): p["securityContext"]["runAsNonRoot"] = False
|
||||
def pod_host(p): p["hostNetwork"] = True
|
||||
def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False
|
||||
def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"]
|
||||
def pod_nocap(p):
|
||||
for v in p["volumes"]:
|
||||
if v["name"] == "cache":
|
||||
v["emptyDir"].pop("sizeLimit", None)
|
||||
for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw),
|
||||
("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)):
|
||||
check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}")
|
||||
|
||||
if CHECKS < 15:
|
||||
FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated")
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,31 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the CVE triage bot workflow fails loudly and is shaped like Renovate's (R-VULN-05,
|
||||
# R-VULN-07, docs/17 "The triage bot"). Offline, no cluster. Depends on .gitea/workflows/cve-triage.yaml
|
||||
# (the BOT's file); fails until that file is integrated.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-workflow-guards
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05, R-VULN-07"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
The bot workflow runs on the 07:15 cron, has a workflow_dispatch dry-run input, never runs
|
||||
concurrently with itself, and a step fails the job hard when RENOVATE_TOKEN is empty.
|
||||
Proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-workflow-guards
|
||||
description: >-
|
||||
Parse .gitea/workflows/cve-triage.yaml and assert the trigger, concurrency and hard
|
||||
credential failure, then run the evaluator on mutated copies to prove it can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — vuln-triage-bot-workflow-guards
|
||||
# Serves: R-VULN-05 (runs on a schedule, fails loudly), R-VULN-07 (bot conventions, docs/17).
|
||||
#
|
||||
# GIVEN .gitea/workflows/cve-triage.yaml,
|
||||
# THEN (1) it runs on `15 7 * * *` and has a workflow_dispatch `dry_run` input;
|
||||
# (2) concurrency is set (the workflow never runs against itself);
|
||||
# (3) a step fails the job HARD when RENOVATE_TOKEN is empty (exit non-zero, not skip);
|
||||
# AND (4) the evaluator is run on mutated copies and must flag each (non-vacuity).
|
||||
# A missing workflow file is a FAIL: this is whole-repo scoped and has no skip path.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)"
|
||||
exec python3 - "$ROOT" <<'PY'
|
||||
import copy, re, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
NAME = "vuln-triage-bot-workflow-guards"
|
||||
FAILURES, CHECKS = [], 0
|
||||
WF = ROOT / ".gitea" / "workflows" / "cve-triage.yaml"
|
||||
|
||||
|
||||
def check(cond, desc):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(desc)
|
||||
|
||||
|
||||
if not WF.is_file():
|
||||
print(f"CHECK FAIL ({NAME}): .gitea/workflows/cve-triage.yaml is absent (the BOT's deliverable); "
|
||||
f"whether it fails on a missing credential cannot be asserted without it", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
doc = yaml.safe_load(WF.read_text())
|
||||
|
||||
|
||||
def evaluate(doc):
|
||||
bad = []
|
||||
triggers = doc.get(True) if True in doc else doc.get("on") # YAML reads `on:` as boolean True
|
||||
if not isinstance(triggers, dict):
|
||||
return ["no triggers parsed"]
|
||||
if "15 7 * * *" not in [s.get("cron") for s in triggers.get("schedule") or [] if isinstance(s, dict)]:
|
||||
bad.append("no schedule with cron 15 7 * * *")
|
||||
inputs = ((triggers.get("workflow_dispatch") or {}).get("inputs") or {})
|
||||
if "dry_run" not in inputs:
|
||||
bad.append("workflow_dispatch has no dry_run input")
|
||||
conc = doc.get("concurrency")
|
||||
jobs = doc.get("jobs") or {}
|
||||
if not conc and not any(isinstance(j, dict) and j.get("concurrency") for j in jobs.values()):
|
||||
bad.append("no concurrency set (the bot must not run against itself)")
|
||||
hard_fail = False
|
||||
for j in jobs.values():
|
||||
for step in (j.get("steps") or []) if isinstance(j, dict) else []:
|
||||
run = str(step.get("run", ""))
|
||||
if ("RENOVATE_TOKEN" in run and re.search(r"(-z|!\s*-n)[^\n]*RENOVATE_TOKEN|RENOVATE_TOKEN[^\n]*(-z)", run)
|
||||
and re.search(r"exit\s+[1-9]", run)):
|
||||
hard_fail = True
|
||||
if not hard_fail:
|
||||
bad.append("no step fails hard (exit non-zero) when RENOVATE_TOKEN is empty")
|
||||
return bad
|
||||
|
||||
|
||||
for v in evaluate(doc):
|
||||
check(False, f"(1-3) {v}")
|
||||
check(isinstance(doc.get("jobs"), dict) and doc["jobs"], "(1) the workflow declares jobs")
|
||||
|
||||
# (4) non-vacuity
|
||||
def mutate(fn):
|
||||
m = copy.deepcopy(doc)
|
||||
fn(m)
|
||||
return m
|
||||
|
||||
def trig(m):
|
||||
return m.get(True) if True in m else m.get("on")
|
||||
|
||||
def no_cron(m): trig(m).pop("schedule", None)
|
||||
def no_dry(m): trig(m)["workflow_dispatch"] = {}
|
||||
def no_conc(m):
|
||||
m.pop("concurrency", None)
|
||||
for j in m.get("jobs", {}).values():
|
||||
if isinstance(j, dict):
|
||||
j.pop("concurrency", None)
|
||||
def no_guard(m):
|
||||
for j in m.get("jobs", {}).values():
|
||||
for step in j.get("steps") or []:
|
||||
if "RENOVATE_TOKEN" in str(step.get("run", "")):
|
||||
step["run"] = 'echo "::warning::RENOVATE_TOKEN unset, skipping"'
|
||||
for name, fn in (("cron removed", no_cron), ("dry_run input removed", no_dry),
|
||||
("concurrency removed", no_conc), ("credential check softened", no_guard)):
|
||||
check(bool(evaluate(mutate(fn))), f"(4) evaluator missed mutant: {name}")
|
||||
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"PASS ({NAME}): {CHECKS} assertions")
|
||||
PY
|
||||
@@ -0,0 +1,25 @@
|
||||
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
|
||||
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
|
||||
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-acceptances
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-08: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-08"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
|
||||
# a change to the scanner would select no vuln-scanner test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given acceptance files, When parsed and applied, Then expiry reverts to open and malformed entries never hide a finding.
|
||||
spec:
|
||||
steps:
|
||||
- name: run-offline-check
|
||||
description: Run check.sh, the same assertions the offline harness runs.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R-VULN-08: acceptance parsing, expiry and fail-safe behaviour."""
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
||||
import acceptances # noqa: E402
|
||||
import report # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
TODAY = date(2026, 11, 9)
|
||||
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
||||
A1 = "sha256:" + "a1" * 32
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(("ok " if condition else "FAIL ") + name)
|
||||
if not condition:
|
||||
FAILED.append(name)
|
||||
|
||||
|
||||
def parse(text):
|
||||
return acceptances.parse_text(text, TODAY)
|
||||
|
||||
|
||||
entries, errors = parse("""# header comment
|
||||
- id: CVE-2026-1
|
||||
image: docker.io/library/nginx # exact match
|
||||
reason: not reachable - feature disabled
|
||||
expires: 2027-02-01
|
||||
|
||||
- id: CVE-2026-2
|
||||
reason: "platform wide"
|
||||
expires: 2026-12-01
|
||||
""")
|
||||
check("valid flat entries parse with no errors", len(entries) == 2 and errors == [])
|
||||
check("inline comment is stripped", entries[0]["image"] == "docker.io/library/nginx")
|
||||
check("omitted image means platform-wide", entries[1]["image"] is None)
|
||||
check("empty list form is accepted", parse("# none\n[]\n") == ([], []))
|
||||
check("empty file is accepted", parse("") == ([], []))
|
||||
|
||||
for name, text in (
|
||||
("missing reason", "- id: CVE-1\n expires: 2026-12-01\n"),
|
||||
("missing expires", "- id: CVE-1\n reason: x\n"),
|
||||
("expires beyond 180 days", "- id: CVE-1\n reason: x\n expires: 2027-06-01\n"),
|
||||
("unknown key", "- id: CVE-1\n reason: x\n expires: 2026-12-01\n scope: all\n"),
|
||||
("not a date", "- id: CVE-1\n reason: x\n expires: soon\n"),
|
||||
("impossible date", "- id: CVE-1\n reason: x\n expires: 2026-13-45\n"),
|
||||
("duplicate key", "- id: CVE-1\n reason: x\n reason: y\n expires: 2026-12-01\n"),
|
||||
("flow value", "- id: CVE-1\n reason: [x]\n expires: 2026-12-01\n"),
|
||||
):
|
||||
found, problems = parse(text)
|
||||
check("rejected: " + name, found == [] and len(problems) == 1)
|
||||
|
||||
found, problems = parse("- id: CVE-1\n reason: x\n expires: 2026-12-01\n- id: bad entry\n expires: 2026-12-01\n")
|
||||
check("one bad entry is ignored, the valid one kept", len(found) == 1 and len(problems) == 1)
|
||||
found, problems = parse("- id: CVE-1\n reason: x\n")
|
||||
check("bad indentation invalidates the whole file", found == [] and problems)
|
||||
found, problems = parse("garbage\n- id: CVE-1\n reason: x\n expires: 2026-12-01\n")
|
||||
check("a stray line invalidates the whole file", found == [] and problems)
|
||||
check("missing file is an error, not an empty allowlist", acceptances.load("/nonexistent/acceptances.yaml")[1])
|
||||
|
||||
entry = {"id": "CVE-1", "image": None, "reason": "r", "expires": date(2026, 11, 9)}
|
||||
check("acceptance holds on its expiry day", acceptances.match([entry], "CVE-1", "any/image", TODAY) is entry)
|
||||
check("acceptance gone the day after", acceptances.match([entry], "CVE-1", "any/image", TODAY + timedelta(days=1)) is None)
|
||||
scoped = {"id": "CVE-1", "image": "docker.io/library/nginx", "reason": "r", "expires": date(2026, 12, 1)}
|
||||
check("image match is exact", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx-extra", TODAY) is None)
|
||||
check("image match accepts the exact image", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx", TODAY) is scoped)
|
||||
|
||||
|
||||
def state_with(findings):
|
||||
target = {"id": "docker.io/library/nginx@" + A1, "type": "image", "image": "docker.io/library/nginx", "digest": A1,
|
||||
"ref": "", "reason": None, "workloads": []}
|
||||
return {
|
||||
"inventory": [target], "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
|
||||
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
|
||||
"remediation": [], "inventoryError": None, "loadError": None,
|
||||
"scans": {A1: {"firstDiscoveredAt": "2026-11-01T00:00:00Z", "lastInventoriedAt": "2026-11-09T09:00:00Z",
|
||||
"lastAttemptAt": "2026-11-09T09:00:00Z", "lastSuccessAt": "2026-11-09T09:00:00Z", "outcome": "ok",
|
||||
"reason": None, "failedSince": None, "findings": findings,
|
||||
"firstSeen": {f["id"]: "2026-11-01T00:00:00Z" for f in findings}}},
|
||||
}
|
||||
|
||||
|
||||
FINDING = {"id": "CVE-1", "severity": "CRITICAL", "cvss": 9.0, "package": "p", "installedVersion": "1", "fixedVersion": "2",
|
||||
"title": "t", "url": ""}
|
||||
ST = state_with([FINDING])
|
||||
|
||||
|
||||
def status(when, accepted, errs=()):
|
||||
built = report.build(ST, accepted, list(errs), when)
|
||||
return built, built["findings"][0]
|
||||
|
||||
|
||||
def reason_entry(length):
|
||||
return acceptances.parse_text("- id: CVE-1\n reason: " + "r" * length + "\n expires: 2026-12-01\n", NOW.date())
|
||||
|
||||
|
||||
found, problems = reason_entry(3000)
|
||||
check("a reason over 2048 characters ignores the entry and reports it", found == [] and any("2048" in p for p in problems))
|
||||
found, problems = reason_entry(2048)
|
||||
check("mutation: a reason of exactly 2048 characters is valid", len(found) == 1 and problems == [])
|
||||
|
||||
entry = {"id": "CVE-1", "image": None, "reason": "not reachable", "expires": date(2026, 11, 9)}
|
||||
built, finding = status(NOW, [entry])
|
||||
check("unexpired acceptance marks the finding accepted", finding["status"] == "accepted"
|
||||
and finding["acceptance"] == {"reason": "not reachable", "expires": "2026-11-09"})
|
||||
check("accepted report is schema-valid", report.validate(built) == [])
|
||||
_, finding = status(NOW + timedelta(days=1), [entry])
|
||||
check("expired acceptance reverts to the finding's own status", finding["status"] == "fix-available" and "acceptance" not in finding)
|
||||
_, finding = status(NOW, [])
|
||||
check("mutation: no acceptance leaves it open/fix-available", finding["status"] == "fix-available")
|
||||
built, finding = status(NOW, [], ["bad entry"])
|
||||
check("an invalid entry degrades the run", built["run"]["status"] == "degraded")
|
||||
check("an invalid entry never hides the finding", finding["status"] == "fix-available")
|
||||
|
||||
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
||||
sys.exit(1 if FAILED else 0)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
|
||||
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
exec python3 "${HERE}/check.py"
|
||||
@@ -0,0 +1,28 @@
|
||||
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
|
||||
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
|
||||
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-api-metrics
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-02: "true"
|
||||
conformance.home.k3s/R-VULN-03: "true"
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-06: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05, R-VULN-06"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
|
||||
# a change to the scanner would select no vuln-scanner test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given a running engine, When the HTTP surfaces are exercised, Then the API validates, metrics are aggregates only, and listeners answer during a scan.
|
||||
spec:
|
||||
steps:
|
||||
- name: run-offline-check
|
||||
description: Run check.sh, the same assertions the offline harness runs.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R-VULN-02/03/05/06: internal API, metrics, publish refusal, and HTTP during a scan."""
|
||||
import copy
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
||||
import main # noqa: E402
|
||||
import metrics # noqa: E402
|
||||
import report # noqa: E402
|
||||
import server # noqa: E402
|
||||
import state as st # noqa: E402
|
||||
|
||||
logging.disable(logging.CRITICAL)
|
||||
FAILED = []
|
||||
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
||||
A1 = "sha256:" + "a1" * 32
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(("ok " if condition else "FAIL ") + name)
|
||||
if not condition:
|
||||
FAILED.append(name)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = NOW
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
class Client:
|
||||
def list_all(self, path):
|
||||
if path.endswith("pods"):
|
||||
return [{"metadata": {"namespace": "apps", "name": "p"}, "spec": {"containers": [{"name": "c", "image": "nginx:1"}]},
|
||||
"status": {"containerStatuses": [{"name": "c", "imageID": "docker.io/library/nginx@" + A1}]}}]
|
||||
return []
|
||||
|
||||
|
||||
class Runner:
|
||||
"""Blocks on release while a scan is 'running', so a test can prove the HTTP path stays live."""
|
||||
|
||||
def __init__(self):
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.release.set()
|
||||
self.result = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
|
||||
{"VulnerabilityID": "CVE-1", "Severity": "CRITICAL", "PkgName": "openssl", "InstalledVersion": "1",
|
||||
"FixedVersion": "2", "Title": "t", "PrimaryURL": "https://example.test/1"}]}]}
|
||||
|
||||
def download_db(self):
|
||||
pass
|
||||
|
||||
def db_info(self):
|
||||
return {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"}
|
||||
|
||||
def scan(self, _reference):
|
||||
self.entered.set()
|
||||
self.release.wait(10)
|
||||
return self.result
|
||||
|
||||
def clean_cache(self):
|
||||
pass
|
||||
|
||||
|
||||
def request(port, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request(method, path, body, headers or {})
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
conn.close()
|
||||
return resp.status, data, resp
|
||||
|
||||
|
||||
def post(port, document, content_type="application/json"):
|
||||
return request(port, "POST", "/triage", json.dumps(document), {"Content-Type": content_type})
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
accept = os.path.join(tmp, "a.yaml")
|
||||
Path(accept).write_text("[]\n")
|
||||
clock = Clock()
|
||||
runner = Runner()
|
||||
eng = main.Engine(tmp, Client(), runner, accept, clock=clock, publish_seconds=0)
|
||||
servers = server.start_all(eng, ("127.0.0.1", 0), ("127.0.0.1", 0), ("127.0.0.1", 0))
|
||||
page_port, internal, metrics_port = (s.server_address[1] for s in servers)
|
||||
|
||||
status, body, _ = request(internal, "GET", "/report.json")
|
||||
check("report.json is 503 before any report", status == 503)
|
||||
status, body, resp = request(page_port, "GET", "/")
|
||||
check("page is 503 before any report, with the CSP", status == 503 and resp.getheader("Content-Security-Policy"))
|
||||
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
||||
check("last-complete-run metric is absent before the first run", "last_complete_run" not in text)
|
||||
check("triage heartbeat metric is absent before the first POST", "triage_heartbeat" not in text)
|
||||
|
||||
# HTTP must stay live while a scan is blocked mid-flight.
|
||||
runner.release.clear()
|
||||
worker = threading.Thread(target=eng.tick, daemon=True)
|
||||
worker.start()
|
||||
check("the scan is genuinely in flight", runner.entered.wait(10))
|
||||
status_h, _, _ = request(internal, "GET", "/healthz")
|
||||
status_p, _, _ = request(page_port, "GET", "/healthz")
|
||||
status_m, _, _ = request(metrics_port, "GET", "/metrics")
|
||||
check("listeners answer during a scan", (status_h, status_p, status_m) == (200, 200, 200))
|
||||
runner.release.set()
|
||||
worker.join(10)
|
||||
check("the tick then completes", not worker.is_alive())
|
||||
|
||||
status, body, _ = request(internal, "GET", "/report.json")
|
||||
check("report.json serves a schema-valid report after a tick", status == 200 and report.validate(json.loads(body)) == [])
|
||||
|
||||
# Page and CSP over HTTP
|
||||
status, body, resp = request(page_port, "GET", "/?view=inventory")
|
||||
check("page served over HTTP carries the CSP and nosniff",
|
||||
status == 200 and resp.getheader("Content-Security-Policy").startswith("default-src 'none'") and resp.getheader("X-Content-Type-Options") == "nosniff")
|
||||
status, body, resp = request(page_port, "GET", "/missing")
|
||||
check("a 404 from the page listener also carries the CSP", status == 404 and resp.getheader("Content-Security-Policy"))
|
||||
|
||||
status, body, resp = request(page_port, "POST", "/")
|
||||
check("an unsupported method on the page listener also carries the CSP", status == 501 and resp.getheader("Content-Security-Policy"))
|
||||
|
||||
# Triage API
|
||||
valid = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [
|
||||
{"image": "docker.io/library/nginx", "state": "pr-open", "url": "https://gitea.test/pulls/9", "updatedAt": "2026-11-09T11:00:00Z"}]}
|
||||
status, body, _ = post(internal, valid)
|
||||
check("valid triage is 204", status == 204 and body == b"")
|
||||
check("remediation surfaces in the report", eng.report["remediation"][0]["state"] == "pr-open")
|
||||
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
||||
check("heartbeat metric appears and uses server receipt time, not the bot's", f"triage_heartbeat_timestamp_seconds {int(NOW.timestamp())}" in text)
|
||||
for name, mutate in (
|
||||
("unknown top-level key", lambda d: d.update(extra=1)),
|
||||
("missing heartbeat", lambda d: d.pop("heartbeatAt")),
|
||||
("unknown remediation key", lambda d: d["remediation"][0].update(x=1)),
|
||||
("bad remediation state", lambda d: d["remediation"][0].update(state="merged")),
|
||||
("javascript url", lambda d: d["remediation"][0].update(url="javascript:x")),
|
||||
("bad timestamp", lambda d: d.update(heartbeatAt="yesterday")),
|
||||
):
|
||||
document = copy.deepcopy(valid)
|
||||
mutate(document)
|
||||
status, _, _ = post(internal, document)
|
||||
check("triage rejects " + name, status == 400)
|
||||
for name, url in (("an uppercase scheme", "HTTP://gitea.test/pulls/1"), ("an empty url", ""), ("a javascript url", "javascript:alert(1)")):
|
||||
bad = json.loads(json.dumps(valid))
|
||||
bad["remediation"][0]["url"] = url
|
||||
check("triage rejects " + name, post(internal, bad)[0] == 400)
|
||||
check("triage rejects a wrong content type", post(internal, valid, "text/plain")[0] == 415)
|
||||
status, _, _ = request(internal, "POST", "/triage", b"x" * 300000, {"Content-Type": "application/json"})
|
||||
check("triage rejects an oversized body", status == 413)
|
||||
many = {"heartbeatAt": "2026-11-09T11:00:00Z", "remediation": [valid["remediation"][0]] * 5001}
|
||||
check("triage rejects more than 5000 entries", post(internal, many)[0] in (400, 413))
|
||||
check("rejected triage leaves state untouched", eng.state["remediation"][0]["url"] == "https://gitea.test/pulls/9")
|
||||
|
||||
# Metrics labels and values
|
||||
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
||||
allowed = {"state", "severity", "fixable"}
|
||||
labels = {k for line in text.splitlines() for k in re.findall(r"([A-Za-z_]+)=\"", line)}
|
||||
check("metric labels are within the allowlist", labels and labels <= allowed)
|
||||
check("no metric carries a CVE, package, image or workload", not re.search(r"CVE-|openssl|nginx|apps", text))
|
||||
for state in report.STATES:
|
||||
check("targets gauge present for " + state, 'vuln_scanner_targets{state="' + state + '"}' in text)
|
||||
check("critical fix findings counted with fixable true", 'vuln_scanner_findings{severity="CRITICAL",fixable="true"} 1' in text)
|
||||
check("every severity and fixability appears", text.count("vuln_scanner_findings{") == 10)
|
||||
check("failed-over-24h gauge exists", "vuln_scanner_failed_targets_over_24h 0" in text)
|
||||
check("critical fix overdue is 0 inside the 72h grace", "vuln_scanner_critical_fix_overdue 0" in text)
|
||||
clock.now += timedelta(hours=73)
|
||||
text = request(metrics_port, "GET", "/metrics")[1].decode()
|
||||
check("critical fix overdue is 1 after 72h", "vuln_scanner_critical_fix_overdue 1" in text)
|
||||
check("last-complete-run metric present after a complete run", "vuln_scanner_last_complete_run_timestamp_seconds" in text)
|
||||
for srv in servers:
|
||||
srv.shutdown()
|
||||
|
||||
# Invalid report: refuse and keep the old one.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
good = {"schemaVersion": 1, "generatedAt": "2026-11-09T10:00:00Z", "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
|
||||
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
|
||||
"targets": [], "findings": [], "remediation": []}
|
||||
check("a valid report publishes", report.publish(good, tmp) is True)
|
||||
before = Path(tmp, "report.json").read_bytes()
|
||||
for name, mutate in (
|
||||
("wrong schemaVersion", lambda r: r.update(schemaVersion=2)),
|
||||
("extra top-level key", lambda r: r.update(extra=1)),
|
||||
("bad timestamp", lambda r: r.update(generatedAt="soon")),
|
||||
("unknown state", lambda r: r.update(targets=[{"id": "x", "type": "image", "image": "x", "digest": None, "state": "fine",
|
||||
"reason": None, "lastScannedAt": None, "workloads": []}])),
|
||||
("dangling targetId", lambda r: r.update(findings=[{"id": "CVE-1", "targetId": "nope", "severity": "LOW", "cvss": None,
|
||||
"package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t",
|
||||
"url": "", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "open"}])),
|
||||
):
|
||||
broken = copy.deepcopy(good)
|
||||
mutate(broken)
|
||||
check("invalid report refused: " + name, report.publish(broken, tmp) is False)
|
||||
check("an invalid report writes nothing", Path(tmp, "report.json").read_bytes() == before)
|
||||
check("accepted without acceptance is invalid", report.validate({**good, "targets": [{"id": "x", "type": "image", "image": "x", "digest": None,
|
||||
"state": "scanned", "reason": None, "lastScannedAt": None, "workloads": []}], "findings": [{"id": "CVE-1", "targetId": "x", "severity": "LOW",
|
||||
"cvss": None, "package": "p", "installedVersion": "1", "fixedVersion": None, "title": "t", "url": "", "firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "accepted"}]}) != [])
|
||||
Path(tmp, "report.json").write_text("{not json")
|
||||
check("a corrupt published report is not served", report.load_published(tmp) is None)
|
||||
|
||||
# Crash-consistency of state: the temp+rename leaves no temp files behind and the file parses.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = st.Store(tmp)
|
||||
state = st.new_state()
|
||||
state["triageHeartbeatAt"] = "2026-11-09T11:00:00Z"
|
||||
store.save(state)
|
||||
check("state saves with no temp file left behind", sorted(os.listdir(tmp)) == ["state.json"])
|
||||
check("state survives a reload", st.Store(tmp).load()["triageHeartbeatAt"] == "2026-11-09T11:00:00Z")
|
||||
Path(tmp, "state.json").write_text("not json")
|
||||
loaded = st.Store(tmp).load()
|
||||
check("corrupt state starts empty and marks degraded", loaded["loadError"] and loaded["scans"] == {})
|
||||
check("corrupt state is preserved for inspection", Path(tmp, "state.json.corrupt").exists())
|
||||
|
||||
# Metrics module directly, without the report.
|
||||
empty_state = st.new_state()
|
||||
text = metrics.render(None, empty_state, NOW)
|
||||
check("with no report only the state-derived gauge exists", text == "vuln_scanner_failed_targets_over_24h 0\n")
|
||||
|
||||
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
||||
sys.exit(1 if FAILED else 0)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
|
||||
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
exec python3 "${HERE}/check.py"
|
||||
@@ -0,0 +1,25 @@
|
||||
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
|
||||
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
|
||||
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-inventory
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-01: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-01"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
|
||||
# a change to the scanner would select no vuln-scanner test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given recorded API lists, When the inventory is built, Then owner chains, digests, by-tag CronJobs and not-covered targets resolve per R-VULN-01.
|
||||
spec:
|
||||
steps:
|
||||
- name: run-offline-check
|
||||
description: Run check.sh, the same assertions the offline harness runs.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R-VULN-01: inventory from recorded API lists."""
|
||||
import copy
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
||||
import inventory # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(("ok " if condition else "FAIL ") + name)
|
||||
if not condition:
|
||||
FAILED.append(name)
|
||||
|
||||
|
||||
CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text())
|
||||
A1 = "sha256:" + "a1" * 32
|
||||
B2 = "sha256:" + "b2" * 32
|
||||
C3 = "sha256:" + "c3" * 32
|
||||
D4 = "sha256:" + "d4" * 32
|
||||
E5 = "sha256:" + "e5" * 32
|
||||
F6 = "sha256:" + "f6" * 32
|
||||
|
||||
|
||||
def by_id(targets):
|
||||
return {t["id"]: t for t in targets}
|
||||
|
||||
|
||||
def workloads(target):
|
||||
return {(w["namespace"], w["kind"], w["name"]): w["containers"] for w in target["workloads"]}
|
||||
|
||||
|
||||
targets = inventory.build(CLUSTER)
|
||||
ids = by_id(targets)
|
||||
|
||||
nginx = ids["docker.io/library/nginx@" + A1]
|
||||
check("ReplicaSet resolves to Deployment, not the ReplicaSet", workloads(nginx) == {
|
||||
("apps", "Deployment", "web"): ["app"], ("apps", "Deployment", "api"): ["server"]})
|
||||
check("docker-pullable:// prefix stripped and digest taken from imageID", nginx["digest"] == A1)
|
||||
check("init container inventoried", ("apps", "Deployment", "web") in workloads(ids["ghcr.io/org/tool@" + B2]))
|
||||
check("ephemeral container inventoried", "debug" in workloads(ids["docker.io/library/busybox@" + C3])[("apps", "Deployment", "web")])
|
||||
|
||||
backup = ids["ghcr.io/org/backup@" + E5]
|
||||
check("Job resolves to CronJob", workloads(backup) == {("ops", "CronJob", "backup"): ["backup"]})
|
||||
check("a CronJob with a running pod is not also inventoried from its template",
|
||||
not any(t["id"].startswith("ghcr.io/org/backup:") for t in targets))
|
||||
|
||||
nightly = ids["ghcr.io/org/nightly:2.1"]
|
||||
check("idle CronJob inventoried from template with no digest", nightly["digest"] is None
|
||||
and workloads(nightly) == {("ops", "CronJob", "nightly"): ["job"]})
|
||||
|
||||
solo = ids["docker.io/library/alpine:3.20"]
|
||||
check("empty imageID gives a by-tag target from the spec image", solo["digest"] is None)
|
||||
check("bare Pod is its own workload", ("ops", "Pod", "solo") in workloads(solo))
|
||||
check("Succeeded pod's image is not inventoried", not any(F6 in t["id"] for t in targets))
|
||||
check("virt-launcher stays owned by its VirtualMachineInstance",
|
||||
("kubevirt-vms", "VirtualMachineInstance", "vm1") in workloads(ids["quay.io/kubevirt/virt-launcher@sha256:" + "ab" * 32]))
|
||||
vm, node = ids["vm/kubevirt-vms/vm1"], ids["node/prod-1"]
|
||||
check("VM inventoried as non-container target", vm["type"] == "vm" and vm["digest"] is None and vm["reason"])
|
||||
check("node inventoried as non-container target", node["type"] == "node" and node["reason"])
|
||||
|
||||
# Non-vacuous: mutating the fixture changes the answer the assertions above rely on.
|
||||
broken = copy.deepcopy(CLUSTER)
|
||||
broken["replicasets"] = []
|
||||
broken["jobs"] = []
|
||||
broken_ids = by_id(inventory.build(broken))
|
||||
check("mutation: losing the ReplicaSet record leaves the ReplicaSet as the workload",
|
||||
("apps", "ReplicaSet", "web-5f") in workloads(broken_ids["docker.io/library/nginx@" + A1]))
|
||||
check("mutation: losing the Job record leaves the Job as the workload",
|
||||
("ops", "Job", "backup-123") in workloads(broken_ids["ghcr.io/org/backup@" + E5]))
|
||||
check("mutation: a Running phase on the Succeeded pod does inventory its image", any(
|
||||
F6 in t["id"] for t in inventory.build({**CLUSTER, "pods": [
|
||||
{**p, "status": {**p["status"], "phase": "Running"}} if p["metadata"]["name"] == "done-1" else p
|
||||
for p in CLUSTER["pods"]]})))
|
||||
|
||||
for reference, expected in (
|
||||
("nginx:1.27", ("docker.io/library/nginx", "1.27", None)),
|
||||
("nginx", ("docker.io/library/nginx", None, None)),
|
||||
("bitnami/redis:7", ("docker.io/bitnami/redis", "7", None)),
|
||||
("ghcr.io/org/app:v1", ("ghcr.io/org/app", "v1", None)),
|
||||
("registry.local:5000/x/y:2", ("registry.local:5000/x/y", "2", None)),
|
||||
("index.docker.io/library/nginx:1", ("docker.io/library/nginx", "1", None)),
|
||||
("ghcr.io/org/app:v1@" + A1, ("ghcr.io/org/app", "v1", A1)),
|
||||
):
|
||||
check("normalise " + reference, inventory.normalise_reference(reference) == expected)
|
||||
check("bare config id is not a digest", inventory.digest_from_image_id("sha256:" + "a1" * 32) is None)
|
||||
check("empty imageID has no digest", inventory.digest_from_image_id("") is None)
|
||||
|
||||
|
||||
class FakeListClient:
|
||||
"""list_all replaced; proves collect() tolerates exactly a 404 on the VMI API."""
|
||||
|
||||
def __init__(self, vmi_error):
|
||||
self.vmi_error = vmi_error
|
||||
|
||||
def list_all(self, path):
|
||||
if "kubevirt" in path and self.vmi_error:
|
||||
raise self.vmi_error
|
||||
return []
|
||||
|
||||
|
||||
check("VMI API 404 tolerated as no VMs", inventory.collect(FakeListClient(inventory.NotFound("x")))["vmis"] == [])
|
||||
try:
|
||||
inventory.collect(FakeListClient(inventory.InventoryError("403")))
|
||||
check("a non-404 VMI error is not swallowed", False)
|
||||
except inventory.InventoryError:
|
||||
check("a non-404 VMI error is not swallowed", True)
|
||||
|
||||
|
||||
class PagedAPI(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
return
|
||||
|
||||
def do_GET(self):
|
||||
if self.headers.get("Authorization") != "Bearer fixture-token":
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path.startswith("/apis/kubevirt"):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
if "continue=" in self.path:
|
||||
body = {"items": [{"metadata": {"name": "b"}}], "metadata": {}}
|
||||
else:
|
||||
body = {"items": [{"metadata": {"name": "a"}}], "metadata": {"continue": "page2"}}
|
||||
payload = json.dumps(body).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token = os.path.join(tmp, "token")
|
||||
Path(token).write_text("fixture-token\n")
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), PagedAPI)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
client = inventory.KubeClient("http://127.0.0.1:" + str(server.server_address[1]), token, os.devnull)
|
||||
items = client.list_all("/api/v1/pods")
|
||||
check("pagination follows the continue token with the bearer token", [i["metadata"]["name"] for i in items] == ["a", "b"])
|
||||
check("collect over real HTTP tolerates VMI 404", inventory.collect(client)["vmis"] == [])
|
||||
server.shutdown()
|
||||
|
||||
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
||||
sys.exit(1 if FAILED else 0)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
|
||||
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
exec python3 "${HERE}/check.py"
|
||||
@@ -0,0 +1,395 @@
|
||||
{
|
||||
"cronjobs": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "backup",
|
||||
"namespace": "ops"
|
||||
},
|
||||
"spec": {
|
||||
"jobTemplate": {
|
||||
"spec": {
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/backup:1.0",
|
||||
"name": "job"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "nightly",
|
||||
"namespace": "ops"
|
||||
},
|
||||
"spec": {
|
||||
"jobTemplate": {
|
||||
"spec": {
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/nightly:2.1",
|
||||
"name": "job"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "backup-123",
|
||||
"namespace": "ops",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "CronJob",
|
||||
"name": "backup"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "backup-99",
|
||||
"namespace": "ops",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "CronJob",
|
||||
"name": "backup"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prod-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prod-2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pods": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web-1",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "web-5f"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.27",
|
||||
"name": "app"
|
||||
}
|
||||
],
|
||||
"ephemeralContainers": [
|
||||
{
|
||||
"image": "busybox:1.37",
|
||||
"name": "debug"
|
||||
}
|
||||
],
|
||||
"initContainers": [
|
||||
{
|
||||
"image": "ghcr.io/org/tool:v2",
|
||||
"name": "init-db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker-pullable://docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"name": "app"
|
||||
}
|
||||
],
|
||||
"ephemeralContainerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/busybox@sha256:c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
|
||||
"name": "debug"
|
||||
}
|
||||
],
|
||||
"initContainerStatuses": [
|
||||
{
|
||||
"imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||
"name": "init-db"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web-2",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "web-5f"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.27",
|
||||
"name": "app"
|
||||
}
|
||||
],
|
||||
"initContainers": [
|
||||
{
|
||||
"image": "ghcr.io/org/tool:v2",
|
||||
"name": "init-db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"name": "app"
|
||||
}
|
||||
],
|
||||
"initContainerStatuses": [
|
||||
{
|
||||
"imageID": "ghcr.io/org/tool@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||
"name": "init-db"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "api-1",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "api-77"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.27",
|
||||
"name": "server"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"name": "server"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "db-0",
|
||||
"namespace": "data",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "StatefulSet",
|
||||
"name": "db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "postgres:16",
|
||||
"name": "postgres"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
"name": "postgres"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "backup-123-x",
|
||||
"namespace": "ops",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Job",
|
||||
"name": "backup-123"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/backup:1.0",
|
||||
"name": "backup"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "ghcr.io/org/backup@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
|
||||
"name": "backup"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "solo",
|
||||
"namespace": "ops"
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "alpine:3.20",
|
||||
"name": "toolbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "",
|
||||
"name": "toolbox"
|
||||
}
|
||||
],
|
||||
"phase": "Pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "done-1",
|
||||
"namespace": "ops",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Job",
|
||||
"name": "backup-99"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/old-backup:0.1",
|
||||
"name": "backup"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "ghcr.io/org/old-backup@sha256:f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6",
|
||||
"name": "backup"
|
||||
}
|
||||
],
|
||||
"phase": "Succeeded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "virt-launcher-vm1-abc",
|
||||
"namespace": "kubevirt-vms",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "VirtualMachineInstance",
|
||||
"name": "vm1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "quay.io/kubevirt/virt-launcher:v1.8",
|
||||
"name": "compute"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "quay.io/kubevirt/virt-launcher@sha256:abababababababababababababababababababababababababababababababab",
|
||||
"name": "compute"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
}
|
||||
],
|
||||
"replicasets": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web-5f",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Deployment",
|
||||
"name": "web"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "api-77",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Deployment",
|
||||
"name": "api"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"vmis": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vm1",
|
||||
"namespace": "kubevirt-vms"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
|
||||
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
|
||||
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-page
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-03: "true"
|
||||
conformance.home.k3s/R-VULN-04: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-03, R-VULN-04"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
|
||||
# a change to the scanner would select no vuln-scanner test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given a report fixture, When the page renders, Then all CVEs, containers and workloads appear in all views and hostile input is inert.
|
||||
spec:
|
||||
steps:
|
||||
- name: run-offline-check
|
||||
description: Run check.sh, the same assertions the offline harness runs.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R-VULN-03/04: the rendered page. Assertions run against the actual HTML output."""
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
||||
import page # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
||||
REPORT = json.loads((HERE / "fixtures/report.json").read_text())
|
||||
CLEAN_RE = re.compile(r"(?i)\bclean\b|no vulnerabilit")
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(("ok " if condition else "FAIL ") + name)
|
||||
if not condition:
|
||||
FAILED.append(name)
|
||||
|
||||
|
||||
def render(report=REPORT, query="", now=NOW):
|
||||
status, headers, body = page.render(report, query, now)
|
||||
return status, headers, body.decode("utf-8")
|
||||
|
||||
|
||||
class Tags(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tags = []
|
||||
self.attrs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags.append(tag)
|
||||
self.attrs.append((tag, dict(attrs)))
|
||||
|
||||
|
||||
def tags(body):
|
||||
parser = Tags()
|
||||
parser.feed(body)
|
||||
return parser
|
||||
|
||||
|
||||
def covers_everything(body, report):
|
||||
"""Every fixture CVE id, workload name and container name appears in the document."""
|
||||
missing = [f["id"] for f in report["findings"] if f["id"] not in body]
|
||||
for target in report["targets"]:
|
||||
for workload in target["workloads"]:
|
||||
if workload["name"] not in body:
|
||||
missing.append(workload["name"])
|
||||
missing.extend(c for c in workload["containers"] if c not in body)
|
||||
return missing
|
||||
|
||||
|
||||
def workload_view_covers(body, report):
|
||||
"""Every workload row with its containers, and every CVE in the expansions."""
|
||||
missing = covers_everything(body, report)
|
||||
return missing
|
||||
|
||||
|
||||
for view in ("cves", "workloads", "inventory"):
|
||||
status, headers, body = render(query="view=" + view)
|
||||
check(view + " view returns 200", status == 200)
|
||||
check(view + " view has the Content-Security-Policy with no script source", "script-src" not in headers["Content-Security-Policy"]
|
||||
and headers["Content-Security-Policy"].startswith("default-src 'none'"))
|
||||
check(view + " view sets nosniff", headers["X-Content-Type-Options"] == "nosniff")
|
||||
check(view + " view has no script element", "script" not in tags(body).tags)
|
||||
style_body = re.search(r"<style>(.*?)</style>", body, re.S).group(1)
|
||||
expected = base64.b64encode(hashlib.sha256(style_body.encode()).digest()).decode()
|
||||
check(view + " view's CSP hash matches its inline style", "'sha256-" + expected + "'" in headers["Content-Security-Policy"])
|
||||
check(view + " view header shows every state count", all(state + ": <strong>" in body for state in page.report_mod.STATES))
|
||||
check(view + " view shows a coverage warning", "INCOMPLETE COVERAGE" in body)
|
||||
check(view + " view makes no clean claim with unscanned targets", not CLEAN_RE.search(body))
|
||||
|
||||
status, headers, cves = render(query="view=cves")
|
||||
check("CVE view contains every CVE", all(f["id"] in cves for f in REPORT["findings"]))
|
||||
affected = {f["targetId"] for f in REPORT["findings"]}
|
||||
expected_in_cves = {(t["id"], w["name"]) for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"]}
|
||||
check("CVE view expansions name every workload of an affected target", all(name in cves for _, name in expected_in_cves))
|
||||
check("CVE view expansions name every container of an affected target",
|
||||
all(c in cves for t in REPORT["targets"] if t["id"] in affected for w in t["workloads"] for c in w["containers"]))
|
||||
for name in ("web", "api", "app", "server"):
|
||||
check("CVE view expansion names " + name, name in cves)
|
||||
status, headers, workloads = render(query="view=workloads")
|
||||
check("workloads view contains every workload and container, including zero-finding ones", covers_everything(workloads, REPORT) == [])
|
||||
status, headers, inventory = render(query="view=inventory")
|
||||
check("inventory view contains every target", all(t["id"] in inventory for t in REPORT["targets"]))
|
||||
check("inventory view contains every workload and container", covers_everything(inventory, REPORT) == [])
|
||||
mutated = copy.deepcopy(REPORT)
|
||||
mutated["findings"] = [f for f in mutated["findings"] if f["id"] != "CVE-2026-1003"]
|
||||
check("mutation: the coverage evaluator catches a dropped CVE", "CVE-2026-1003" in covers_everything(render(mutated, "view=workloads")[2], REPORT))
|
||||
|
||||
_, _, body = render(query="view=cves")
|
||||
check("hostile title is escaped", "<script>alert(1)</script>" 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 "<b onmouseover" in body)
|
||||
_, _, body = render(query="view=cves&q=" + "%3Cscript%3E")
|
||||
check("a hostile search term is escaped", "<script>" not in body)
|
||||
check("a linked CVE points at its advisory", 'href="https://example.test/CVE-2026-1001"' in render(query="view=cves")[2])
|
||||
|
||||
stale = copy.deepcopy(REPORT)
|
||||
stale["generatedAt"] = (NOW - timedelta(hours=27)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
_, _, body = render(stale)
|
||||
check("stale report shows a warning", "STALE REPORT" in body)
|
||||
check("stale report shows no normal state", "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
|
||||
check("mutation: a fresh report has no stale warning", "STALE REPORT" not in render(REPORT)[2])
|
||||
degraded = copy.deepcopy(REPORT)
|
||||
degraded["run"]["status"] = "degraded"
|
||||
_, _, body = render(degraded)
|
||||
check("degraded run shows a warning and no normal state", "DEGRADED RUN" in body and "Status: ATTENTION NEEDED" in body and "Status: CURRENT" not in body)
|
||||
|
||||
allgood = copy.deepcopy(REPORT)
|
||||
allgood["targets"] = [t for t in allgood["targets"] if t["state"] == "scanned"]
|
||||
allgood["findings"] = []
|
||||
_, _, body = render(allgood)
|
||||
check("the clean claim appears when every target is scanned with zero findings", "Clean: all 1 targets" in body)
|
||||
check("mutation: an all-scanned report shows the current state", "Status: CURRENT" in body)
|
||||
one_pending = copy.deepcopy(allgood)
|
||||
one_pending["targets"].append({"id": "x@sha256:" + "0" * 64, "type": "image", "image": "x", "digest": "sha256:" + "0" * 64,
|
||||
"state": "pending", "reason": None, "lastScannedAt": None, "workloads": []})
|
||||
_, _, body = render(one_pending)
|
||||
check("one pending target removes the clean claim", "Clean:" not in body and not CLEAN_RE.search(body))
|
||||
for state in ("failed", "stale", "credential-required", "by-tag", "not-covered"):
|
||||
variant = copy.deepcopy(allgood)
|
||||
variant["targets"].append({**one_pending["targets"][-1], "state": state})
|
||||
check("a " + state + " target removes the clean claim", not CLEAN_RE.search(render(variant)[2]))
|
||||
empty = copy.deepcopy(allgood)
|
||||
empty["targets"] = []
|
||||
check("zero targets is never a clean claim", not CLEAN_RE.search(render(empty)[2]))
|
||||
degraded_clean = copy.deepcopy(allgood)
|
||||
degraded_clean["run"]["status"] = "degraded"
|
||||
check("a degraded run never makes a clean claim", not CLEAN_RE.search(render(degraded_clean)[2]))
|
||||
|
||||
status, headers, body = render(None)
|
||||
check("no report yet is a 503 with the CSP", status == 503 and "Content-Security-Policy" in headers)
|
||||
check("no report yet makes no clean claim", not CLEAN_RE.search(body))
|
||||
|
||||
_, _, body = render(query="view=cves&severity=CRITICAL")
|
||||
check("the severity filter narrows the list", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
|
||||
check("a filter is carried in pagination and sort links", "severity=CRITICAL" in body)
|
||||
_, _, body = render(query="view=cves&status=accepted")
|
||||
check("accepted findings stay listed with their reason", "CVE-2026-1003" in body and "not reachable" in body)
|
||||
_, _, body = render(query="view=cves&fix=no")
|
||||
check("the fix filter excludes fixed CVEs", "CVE-2026-1001" not in body and "CVE-2026-1002" in body)
|
||||
_, _, body = render(query="view=inventory&status=credential-required")
|
||||
check("inventory filters on coverage state", "ghcr.io/org/private" in body and "postgres" not in body.split("<main>")[1])
|
||||
_, _, body = render(query="view=workloads&namespace=ops")
|
||||
check("the namespace filter narrows workloads", "nightly" in body and "<td>web</td>" not in body)
|
||||
_, _, body = render(query="view=cves&q=openssl")
|
||||
check("free text search matches packages", "CVE-2026-1001" in body and "CVE-2026-1004" not in body)
|
||||
_, _, body = render()
|
||||
check("default view excludes nothing", all(f["id"] in body for f in REPORT["findings"]))
|
||||
check("remediation column shows pr-open with a link", 'href="https://gitea.test/pulls/1"' in body and "pr-open" in body)
|
||||
|
||||
# Status and state belong to the line they describe (R-VULN-04).
|
||||
images = [t for t in REPORT["targets"] if t["type"] == "image" and t["workloads"]]
|
||||
check("fixture has two image targets with workloads", len(images) >= 2)
|
||||
mixed = copy.deepcopy(REPORT)
|
||||
first, second = next(t for t in mixed["targets"] if t["id"] == images[0]["id"]), next(t for t in mixed["targets"] if t["id"] == images[1]["id"])
|
||||
first["state"], first["reason"] = "scanned", None
|
||||
second["state"], second["reason"] = "stale", "last success long ago"
|
||||
base = dict(REPORT["findings"][0], id="CVE-MIXED")
|
||||
base.pop("acceptance", None)
|
||||
mixed["findings"] = [
|
||||
dict(base, targetId=first["id"], status="accepted", acceptance={"reason": "only-on-the-first", "expires": "2026-12-01"}),
|
||||
dict(base, targetId=second["id"], status="fix-available"),
|
||||
]
|
||||
_, _, body = render(mixed, "view=cves&q=CVE-MIXED")
|
||||
check("a mixed CVE reads as mixed, with counts", "mixed: 1 fix-available, 1 accepted" in body)
|
||||
check("a mixed CVE's row does not carry one image's acceptance", "<td>mixed: 1 fix-available, 1 accepted</td>" in body)
|
||||
first_line = page.e(first["id"]) + " — target scanned — status accepted (only-on-the-first, until 2026-12-01)"
|
||||
second_line = page.e(second["id"]) + " — target stale (last success long ago) — status fix-available"
|
||||
check("the accepted image's line carries its own acceptance", first_line in body)
|
||||
check("the other image's line carries its own status and state", second_line in body)
|
||||
check("no line attaches the acceptance to the unaccepted status", "status fix-available (only-on-the-first" not in body)
|
||||
check("workload lines inside the expansion carry state and status",
|
||||
"target stale (last success long ago) — status fix-available</li>" in body
|
||||
and "containers: " + page.e(", ".join(second["workloads"][0]["containers"]) or "none") + " — target stale" in body)
|
||||
_, _, plain = render(query="view=cves&status=accepted")
|
||||
check("mutation: a uniformly accepted CVE does not read as mixed", "mixed:" not in plain and "CVE-2026-1003" in plain)
|
||||
_, _, body = render(mixed, "view=workloads")
|
||||
check("the workloads images column labels each image's state", page.e(second["id"]) + " — target stale (last success long ago)" in body)
|
||||
check("the workloads expansion labels each finding's status, image and state",
|
||||
"status fix-available — " + page.e(second["id"]) + " — target stale (last success long ago)</li>" in body)
|
||||
check("the workloads expansion keeps an acceptance on its own line",
|
||||
"status accepted (only-on-the-first, until 2026-12-01) — " + page.e(first["id"]) + " — target scanned</li>" in body)
|
||||
|
||||
big = copy.deepcopy(REPORT)
|
||||
big["findings"] = [dict(REPORT["findings"][0], id="CVE-2026-" + format(i, "05d")) for i in range(250)]
|
||||
_, _, body = render(big, "view=cves&size=50&page=2")
|
||||
check("pagination states the total", "of 250 CVEs (page 2 of 5)" in body)
|
||||
check("pagination shows only its page", "CVE-2026-00049" not in body and "CVE-2026-00050" in body and "CVE-2026-00100" not in body)
|
||||
check("every page's links include next and previous", ">next<" in body and ">previous<" in body)
|
||||
|
||||
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
||||
sys.exit(1 if FAILED else 0)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
|
||||
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
exec python3 "${HERE}/check.py"
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"database": {
|
||||
"updatedAt": "2026-11-09T06:00:00Z",
|
||||
"version": "2"
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"cvss": 9.1,
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"fixedVersion": "3.0.2",
|
||||
"id": "CVE-2026-1001",
|
||||
"installedVersion": "1.0",
|
||||
"package": "openssl",
|
||||
"severity": "CRITICAL",
|
||||
"status": "fix-available",
|
||||
"targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"title": "OpenSSL flaw",
|
||||
"url": "https://example.test/CVE-2026-1001"
|
||||
},
|
||||
{
|
||||
"cvss": null,
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"fixedVersion": null,
|
||||
"id": "CVE-2026-1002",
|
||||
"installedVersion": "1.0",
|
||||
"package": "zlib",
|
||||
"severity": "LOW",
|
||||
"status": "open",
|
||||
"targetId": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"title": "<script>alert(1)</script>",
|
||||
"url": "https://example.test/CVE-2026-1002"
|
||||
},
|
||||
{
|
||||
"acceptance": {
|
||||
"expires": "2027-01-01",
|
||||
"reason": "not reachable"
|
||||
},
|
||||
"cvss": null,
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"fixedVersion": "16.2",
|
||||
"id": "CVE-2026-1003",
|
||||
"installedVersion": "1.0",
|
||||
"package": "libpq",
|
||||
"severity": "HIGH",
|
||||
"status": "accepted",
|
||||
"targetId": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
"title": "libpq flaw (retained from a prior scan)",
|
||||
"url": "https://example.test/CVE-2026-1003"
|
||||
},
|
||||
{
|
||||
"cvss": null,
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"fixedVersion": null,
|
||||
"id": "CVE-2026-1004",
|
||||
"installedVersion": "1.0",
|
||||
"package": "curl",
|
||||
"severity": "MEDIUM",
|
||||
"status": "open",
|
||||
"targetId": "ghcr.io/org/nightly:2.1",
|
||||
"title": "curl flaw",
|
||||
"url": "https://example.test/CVE-2026-1004"
|
||||
}
|
||||
],
|
||||
"generatedAt": "2026-11-09T10:00:00Z",
|
||||
"remediation": [
|
||||
{
|
||||
"image": "docker.io/library/nginx",
|
||||
"state": "pr-open",
|
||||
"updatedAt": "2026-11-09T10:00:00Z",
|
||||
"url": "https://gitea.test/pulls/1"
|
||||
}
|
||||
],
|
||||
"run": {
|
||||
"finishedAt": "2026-11-09T09:30:00Z",
|
||||
"startedAt": "2026-11-09T09:00:00Z",
|
||||
"status": "complete"
|
||||
},
|
||||
"schemaVersion": 1,
|
||||
"targets": [
|
||||
{
|
||||
"digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"id": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"image": "docker.io/library/nginx",
|
||||
"lastScannedAt": "2026-11-09T10:00:00Z",
|
||||
"reason": null,
|
||||
"state": "scanned",
|
||||
"type": "image",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [
|
||||
"app"
|
||||
],
|
||||
"kind": "Deployment",
|
||||
"name": "web",
|
||||
"namespace": "apps"
|
||||
},
|
||||
{
|
||||
"containers": [
|
||||
"server"
|
||||
],
|
||||
"kind": "Deployment",
|
||||
"name": "api",
|
||||
"namespace": "apps"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
"id": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
"image": "docker.io/library/postgres",
|
||||
"lastScannedAt": "2026-11-07T09:00:00Z",
|
||||
"reason": "trivy timed out",
|
||||
"state": "failed",
|
||||
"type": "image",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [
|
||||
"postgres"
|
||||
],
|
||||
"kind": "StatefulSet",
|
||||
"name": "db",
|
||||
"namespace": "data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": "sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
|
||||
"id": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
|
||||
"image": "ghcr.io/org/private",
|
||||
"lastScannedAt": null,
|
||||
"reason": "registry ghcr.io refused anonymous access",
|
||||
"state": "credential-required",
|
||||
"type": "image",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [
|
||||
"priv"
|
||||
],
|
||||
"kind": "Deployment",
|
||||
"name": "priv",
|
||||
"namespace": "apps"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": null,
|
||||
"id": "ghcr.io/org/nightly:2.1",
|
||||
"image": "ghcr.io/org/nightly",
|
||||
"lastScannedAt": "2026-11-09T10:00:00Z",
|
||||
"reason": null,
|
||||
"state": "by-tag",
|
||||
"type": "image",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [
|
||||
"job"
|
||||
],
|
||||
"kind": "CronJob",
|
||||
"name": "nightly",
|
||||
"namespace": "ops"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": null,
|
||||
"id": "vm/kubevirt-vms/vm1",
|
||||
"image": "",
|
||||
"lastScannedAt": null,
|
||||
"reason": "guest OS packages are not assessed by this scanner",
|
||||
"state": "not-covered",
|
||||
"type": "vm",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [],
|
||||
"kind": "VirtualMachineInstance",
|
||||
"name": "vm1",
|
||||
"namespace": "kubevirt-vms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": null,
|
||||
"id": "node/prod-1",
|
||||
"image": "",
|
||||
"lastScannedAt": null,
|
||||
"reason": "node OS packages and the k3s binary are not assessed by this scanner",
|
||||
"state": "not-covered",
|
||||
"type": "node",
|
||||
"workloads": [
|
||||
{
|
||||
"containers": [],
|
||||
"kind": "Node",
|
||||
"name": "prod-1",
|
||||
"namespace": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# L1 fixture test: runs check.py (python3 stdlib, source imported from platform/vuln-scanner/src,
|
||||
# no cluster, no network beyond loopback). conformance/run.sh --layer l1 runs check.sh offline;
|
||||
# this Chainsaw Test wraps the same script so an in-cluster run executes identical assertions.
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-scan-loop
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-02: "true"
|
||||
conformance.home.k3s/R-VULN-03: "true"
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-02, R-VULN-03, R-VULN-05"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# Whole-repo scope: the touched-path map keys nothing to platform/vuln-scanner, so without this
|
||||
# a change to the scanner would select no vuln-scanner test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given a fake Trivy runner and clock, When ticks run, Then shared digests scan once, failures isolate, and coverage states and first-seen persist.
|
||||
spec:
|
||||
steps:
|
||||
- name: run-offline-check
|
||||
description: Run check.sh, the same assertions the offline harness runs.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R-VULN-02/03/05: scan loop semantics with a fake Trivy runner and a controllable clock."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
||||
import main # noqa: E402
|
||||
import report # noqa: E402
|
||||
import trivy # noqa: E402
|
||||
|
||||
logging.disable(logging.CRITICAL)
|
||||
FAILED = []
|
||||
A1 = "sha256:" + "a1" * 32
|
||||
D4 = "sha256:" + "d4" * 32
|
||||
E5 = "sha256:" + "e5" * 32
|
||||
NGINX = "docker.io/library/nginx@" + A1
|
||||
POSTGRES = "docker.io/library/postgres@" + D4
|
||||
PRIVATE = "ghcr.io/org/private@" + E5
|
||||
NIGHTLY = "ghcr.io/org/nightly:2.1"
|
||||
CLUSTER = json.loads((HERE / "fixtures/cluster.json").read_text())
|
||||
|
||||
|
||||
def fixture(name):
|
||||
return json.loads((HERE / "fixtures" / name).read_text())
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(("ok " if condition else "FAIL ") + name)
|
||||
if not condition:
|
||||
FAILED.append(name)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
def advance(self, **delta):
|
||||
self.now += timedelta(**delta)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, cluster):
|
||||
self.cluster = cluster
|
||||
self.fail = False
|
||||
|
||||
def list_all(self, path):
|
||||
if self.fail:
|
||||
raise RuntimeError("api down")
|
||||
for name, item in (("pods", "/api/v1/pods"), ("replicasets", "replicasets"), ("jobs", "/jobs"),
|
||||
("cronjobs", "cronjobs"), ("nodes", "/nodes"), ("vmis", "virtualmachineinstances")):
|
||||
if path.endswith(item):
|
||||
return self.cluster[name]
|
||||
return []
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
"""Recorded results, no Trivy binary."""
|
||||
|
||||
def __init__(self):
|
||||
self.scans = []
|
||||
self.outcomes = {
|
||||
NGINX: fixture("trivy-nginx.json"), POSTGRES: fixture("trivy-postgres.json"),
|
||||
NIGHTLY: fixture("trivy-nightly.json"),
|
||||
PRIVATE: trivy.classify_failure("ghcr.io", "FATAL: GET https://ghcr.io/: UNAUTHORIZED: authentication required"),
|
||||
}
|
||||
self.db_age_hours = 1
|
||||
self.db_fails = False
|
||||
self.clock = None
|
||||
|
||||
def download_db(self):
|
||||
if self.db_fails:
|
||||
raise trivy.ScanFailure("failed", "database update failed")
|
||||
|
||||
def db_info(self):
|
||||
return {"updatedAt": (self.clock() - timedelta(hours=self.db_age_hours)).strftime("%Y-%m-%dT%H:%M:%SZ"), "version": "2"}
|
||||
|
||||
def scan(self, reference):
|
||||
self.scans.append(reference)
|
||||
outcome = self.outcomes[reference]
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
def clean_cache(self):
|
||||
pass
|
||||
|
||||
|
||||
def engine(tmp, runner=None, clock=None, acc="[]\n", cluster=CLUSTER, private=()):
|
||||
path = os.path.join(tmp, "acceptances.yaml")
|
||||
if not os.path.exists(path):
|
||||
Path(path).write_text(acc)
|
||||
clock = clock or Clock()
|
||||
runner = runner or FakeRunner()
|
||||
runner.clock = clock
|
||||
return main.Engine(tmp, FakeClient(cluster), runner, path, clock=clock, publish_seconds=0, private_registries=private), runner, clock
|
||||
|
||||
|
||||
def targets(eng):
|
||||
return {t["id"]: t for t in eng.report["targets"]}
|
||||
|
||||
|
||||
def findings(eng, target_id):
|
||||
return [f for f in eng.report["findings"] if f["targetId"] == target_id]
|
||||
|
||||
|
||||
def one_scan_per_digest(scans):
|
||||
return len(scans) == len(set(scans))
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.tick()
|
||||
t = targets(eng)
|
||||
check("two workloads sharing a digest produce exactly one scan", runner.scans.count(NGINX) == 1 and one_scan_per_digest(runner.scans))
|
||||
check("mutation: the digest-uniqueness evaluator rejects a double scan", not one_scan_per_digest(["a", "a"]))
|
||||
check("the shared digest maps back to both workloads",
|
||||
{(w["kind"], w["name"]) for w in t[NGINX]["workloads"]} == {("Deployment", "web"), ("Deployment", "api")})
|
||||
check("shared digest findings appear once per target", len(findings(eng, NGINX)) == 2)
|
||||
check("digest target is scanned", t[NGINX]["state"] == "scanned")
|
||||
check("idle CronJob template is by-tag after scanning, not scanned", t[NIGHTLY]["state"] == "by-tag")
|
||||
check("node is not-covered", t["node/prod-1"]["state"] == "not-covered")
|
||||
check("report is schema-valid", report.validate(eng.report) == [])
|
||||
check("credential-required is its own state, not failed", t[PRIVATE]["state"] == "credential-required")
|
||||
check("credential-required reason names the registry", "ghcr.io" in t[PRIVATE]["reason"])
|
||||
check("credential-required does not count toward failed_over_24h", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0)
|
||||
check("report is written to disk", json.loads(Path(tmp, "report.json").read_text())["generatedAt"])
|
||||
first_seen = {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)}
|
||||
|
||||
clock.advance(hours=21)
|
||||
runner.outcomes[POSTGRES] = trivy.ScanFailure("failed", "pull error: manifest unknown")
|
||||
eng.tick()
|
||||
t = targets(eng)
|
||||
check("a failed image leaves the run continuing", runner.scans.count(NGINX) == 2 and runner.scans.count(NIGHTLY) == 2)
|
||||
check("the failed target is failed", t[POSTGRES]["state"] == "failed")
|
||||
check("the failed target's reason is shown", "manifest unknown" in t[POSTGRES]["reason"])
|
||||
check("the failed target keeps its prior findings", [f["id"] for f in findings(eng, POSTGRES)] == ["CVE-2026-0010"])
|
||||
check("other targets stay scanned", t[NGINX]["state"] == "scanned")
|
||||
check("first-seen survives a re-scan", {f["id"]: f["firstSeenAt"] for f in findings(eng, NGINX)} == first_seen)
|
||||
check("run with one failed image is still complete (failure is per-target)", eng.report["run"]["status"] == "complete")
|
||||
|
||||
clock.advance(hours=25)
|
||||
check("failed target is counted over 24h after 25h", report.failed_over_24h(eng.state, clock()) == 1)
|
||||
eng.publish()
|
||||
check("a target whose last success aged past 24h is stale", targets(eng)[NGINX]["state"] == "stale")
|
||||
check("stale target keeps findings", len(findings(eng, NGINX)) == 2)
|
||||
|
||||
# Restart: a new Engine loads everything from disk.
|
||||
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
|
||||
check("report survives restart", eng2.report is not None and eng2.report_bytes() is not None)
|
||||
check("last complete run survives restart", eng2.state["lastCompleteRunAt"] is not None)
|
||||
eng2.publish()
|
||||
check("first-seen survives restart", {f["id"]: f["firstSeenAt"] for f in findings(eng2, NGINX)} == first_seen)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Not-scanned is never zero findings: the first tick has not scanned anything yet.
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.refresh_inventory()
|
||||
eng.publish()
|
||||
t = targets(eng)
|
||||
check("before scanning, digest targets are pending", t[NGINX]["state"] == "pending" and t[POSTGRES]["state"] == "pending")
|
||||
check("run object exists and is degraded before the first run", eng.report["run"]["status"] == "degraded")
|
||||
check("no fabricated findings before scanning", eng.report["findings"] == [])
|
||||
check("zero findings report still counts every target", len(eng.report["targets"]) == 5)
|
||||
clock.advance(hours=3)
|
||||
eng.publish()
|
||||
t = targets(eng)
|
||||
check("pending beyond 2h becomes failed, never silently absent", t[NGINX]["state"] == "failed")
|
||||
check("failed reason is the contract's text", t[NGINX]["reason"] == report.NOT_SCANNED_REASON)
|
||||
check("a never-scanned failed target has lastScannedAt null", t[NGINX]["lastScannedAt"] is None)
|
||||
clock.advance(hours=24)
|
||||
check("never-scanned target counts as failed over 24h", report.failed_over_24h(eng.state, clock()) >= 1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Database problems degrade the run but it still reports.
|
||||
eng, runner, clock = engine(tmp)
|
||||
runner.db_fails = True
|
||||
eng.tick()
|
||||
check("DB update failure degrades the run", eng.report["run"]["status"] == "degraded")
|
||||
check("DB update failure still scans and reports", runner.scans and eng.report["findings"])
|
||||
check("a degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
eng, runner, clock = engine(tmp)
|
||||
runner.db_age_hours = 49
|
||||
eng.tick()
|
||||
check("a DB older than 48h degrades the run", eng.report["run"]["status"] == "degraded")
|
||||
runner.db_age_hours = 1
|
||||
clock.advance(hours=21)
|
||||
eng.tick()
|
||||
check("mutation: a fresh DB gives a complete run", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# A target leaving the inventory leaves the report, with its findings.
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.tick()
|
||||
check("postgres is present first", POSTGRES in targets(eng))
|
||||
gone = dict(CLUSTER)
|
||||
gone["pods"] = [p for p in CLUSTER["pods"] if p["metadata"]["name"] != "db-0"]
|
||||
eng.client.cluster = gone
|
||||
clock.advance(minutes=15)
|
||||
eng.tick()
|
||||
check("a target leaving the inventory leaves the report", POSTGRES not in targets(eng))
|
||||
check("its findings go with it", not findings(eng, POSTGRES))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Unexpected exceptions are contained at each level.
|
||||
eng, runner, clock = engine(tmp)
|
||||
runner.outcomes[POSTGRES] = RuntimeError("boom")
|
||||
eng.tick()
|
||||
check("an unexpected scan exception fails only that target", targets(eng)[POSTGRES]["state"] == "failed")
|
||||
check("the run continued past it", runner.scans.count(NIGHTLY) == 1)
|
||||
eng.client.fail = True
|
||||
clock.advance(minutes=15)
|
||||
eng.tick()
|
||||
check("an inventory failure keeps the previous inventory", NGINX in targets(eng))
|
||||
check("an inventory failure degrades the report", eng.report["run"]["status"] == "degraded")
|
||||
|
||||
calls = []
|
||||
|
||||
def crashing_tick():
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("cycle crash")
|
||||
eng.stop.set()
|
||||
|
||||
eng.tick = crashing_tick
|
||||
eng.tick_seconds = 0
|
||||
thread = threading.Thread(target=eng.run_forever, daemon=True)
|
||||
thread.start()
|
||||
thread.join(10)
|
||||
check("a crash in one cycle is logged and the loop continues", len(calls) == 2 and not thread.is_alive())
|
||||
|
||||
class Killed(BaseException):
|
||||
"""What an OOM kill looks like from inside the process: no `except Exception` runs."""
|
||||
|
||||
|
||||
class KillingRunner(FakeRunner):
|
||||
def __init__(self, victim, data_dir):
|
||||
super().__init__()
|
||||
self.victim = victim
|
||||
self.data_dir = data_dir
|
||||
self.on_disk = None
|
||||
|
||||
def scan(self, reference):
|
||||
if reference == self.victim:
|
||||
self.on_disk = json.loads(Path(self.data_dir, "state.json").read_text()).get("inFlight")
|
||||
raise Killed()
|
||||
return super().scan(reference)
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# R-VULN-05 BLOCKER: a scan that kills the container must cost that image only.
|
||||
eng, runner, clock = engine(tmp, runner=KillingRunner(POSTGRES, tmp))
|
||||
killed = False
|
||||
try:
|
||||
eng.tick()
|
||||
except Killed:
|
||||
killed = True
|
||||
check("kill: the simulated OOM really aborted the process path", killed)
|
||||
check("kill: inFlight is on disk BEFORE the scan starts", runner.on_disk == D4)
|
||||
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
|
||||
check("kill: startup clears inFlight", eng2.state["inFlight"] is None)
|
||||
check("kill: clearing is persisted", json.loads(Path(tmp, "state.json").read_text())["inFlight"] is None)
|
||||
eng2.publish()
|
||||
t = targets(eng2)
|
||||
check("kill: the target is failed", t[POSTGRES]["state"] == "failed")
|
||||
check("kill: the reason is the killed-scan text", t[POSTGRES]["reason"] == main.KILLED_REASON)
|
||||
eng2.tick()
|
||||
t = targets(eng2)
|
||||
check("kill: images after the killed one get scanned", PRIVATE in runner2.scans and t[PRIVATE]["state"] == "credential-required")
|
||||
check("kill: the killed image is not retried on restart (no wedge)", POSTGRES not in runner2.scans)
|
||||
check("kill: the killed target stays failed with its reason", t[POSTGRES]["reason"] == main.KILLED_REASON)
|
||||
check("kill: inFlight is clear after a normal cycle", eng2.state["inFlight"] is None)
|
||||
clock.advance(hours=21)
|
||||
eng2.tick()
|
||||
check("kill: the killed image is retried after the sit-out", runner2.scans.count(POSTGRES) == 1)
|
||||
check("kill: a successful retry clears the failure", targets(eng2)[POSTGRES]["state"] == "scanned")
|
||||
check("kill: a successful retry clears killedAt", eng2.state["scans"][D4]["killedAt"] is None)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.refresh_inventory()
|
||||
eng2, runner2, clock2 = engine(tmp, runner=FakeRunner(), clock=clock)
|
||||
eng2.publish()
|
||||
check("mutation: a restart with no inFlight fails nothing", targets(eng2)[POSTGRES]["state"] == "pending")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# A registry the scanner cannot reach is credential-required, and Trivy is never invoked.
|
||||
eng, runner, clock = engine(tmp, private=[" GHCR.io ", ""])
|
||||
eng.tick()
|
||||
t = targets(eng)
|
||||
check("private registry: reported credential-required", t[PRIVATE]["state"] == "credential-required")
|
||||
check("private registry: the reason names the registry", "ghcr.io" in t[PRIVATE]["reason"])
|
||||
check("private registry: Trivy is never invoked for it", PRIVATE not in runner.scans and NIGHTLY not in runner.scans)
|
||||
check("private registry: other registries still scan", NGINX in runner.scans)
|
||||
check("private registry: not counted as failed", report.failed_over_24h(eng.state, clock() + timedelta(hours=48)) == 0)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.tick()
|
||||
check("mutation: with no private registry configured Trivy is invoked", PRIVATE in runner.scans)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# A corrupt state.json degrades the run that rebuilds from it, not every run thereafter.
|
||||
Path(tmp, "state.json").write_text("{not json")
|
||||
eng, runner, clock = engine(tmp)
|
||||
check("loadError: set after a corrupt state.json", eng.state["loadError"])
|
||||
eng.tick()
|
||||
check("loadError: the rebuilding run is degraded", eng.report["run"]["status"] == "degraded")
|
||||
check("loadError: cleared after a full run with a good inventory", eng.state["loadError"] is None)
|
||||
clock.advance(hours=21)
|
||||
eng.tick()
|
||||
check("loadError: the next full run is complete", eng.report["run"]["status"] == "complete" and eng.state["lastCompleteRunAt"])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
Path(tmp, "state.json").write_text("{not json")
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.client.fail = True
|
||||
eng.tick()
|
||||
check("mutation: loadError is kept when the inventory failed", eng.state["loadError"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# lastCompleteRunAt means a report reached the store; a refused report must not advance it.
|
||||
eng, runner, clock = engine(tmp)
|
||||
real_publish = report.publish
|
||||
report.publish = lambda *_: False
|
||||
try:
|
||||
eng.tick()
|
||||
finally:
|
||||
report.publish = real_publish
|
||||
check("a refused publish does not advance lastCompleteRunAt", eng.state["lastCompleteRunAt"] is None)
|
||||
clock.advance(hours=21)
|
||||
eng.tick()
|
||||
check("mutation: a successful publish does advance it", eng.state["lastCompleteRunAt"] is not None)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# One upstream string costs that string. Each of these used to freeze the whole report.
|
||||
long_reason = "x" * 3000
|
||||
eng, runner, clock = engine(tmp, acc="- id: CVE-2026-0010\n reason: " + long_reason + "\n expires: 2026-12-01\n")
|
||||
runner.outcomes[NGINX] = {"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
|
||||
{"VulnerabilityID": "CVE-UP", "Severity": "HIGH", "PkgName": "p", "InstalledVersion": "1",
|
||||
"PrimaryURL": "HTTPS://upper.example/x", "Title": "t\ud800"}]}]}
|
||||
eng.tick()
|
||||
check("hostile strings: the report still publishes", json.loads(Path(tmp, "report.json").read_text())["findings"] and report.validate(eng.report) == [])
|
||||
up = [f for f in findings(eng, NGINX) if f["id"] == "CVE-UP"][0]
|
||||
check("hostile strings: an uppercase scheme drops only the URL", up["url"] == "")
|
||||
check("hostile strings: a lone surrogate is removed", "\ud800" not in up["title"])
|
||||
check("hostile strings: an over-long acceptance is ignored, the finding stays open", [f for f in findings(eng, POSTGRES) if f["id"] == "CVE-2026-0010"][0]["status"] != "accepted")
|
||||
check("hostile strings: an over-long acceptance degrades the run", eng.report["run"]["status"] == "degraded")
|
||||
check("hostile strings: the degraded run is not recorded as complete", eng.state["lastCompleteRunAt"] is None)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Isolation: values already in state, whatever put them there, cannot refuse the whole report.
|
||||
eng, runner, clock = engine(tmp)
|
||||
eng.tick()
|
||||
clean = report.build(eng.state, [], [], clock())
|
||||
check("mutation: a clean state is not reported as repaired", clean["run"]["status"] == "complete" and clean == report.build(eng.state, [], [], clock()))
|
||||
record = eng.state["scans"][A1]
|
||||
record["findings"][0].update(url="HTTP://x", title="t" * 900, severity="weird", cvss=99, package="p" * 900)
|
||||
eng.state["database"] = {"updatedAt": "not-a-time", "version": "1"}
|
||||
eng.state["remediation"] = [{"image": "a/b", "state": "pr-open", "url": "HTTP://x", "updatedAt": "2026-11-09T00:00:00Z"}]
|
||||
target = next(i for i in eng.state["inventory"] if i["id"] == NIGHTLY)
|
||||
target["id"] = "ghcr.io/" + "o" * 3000 + ":1"
|
||||
target["image"] = "ghcr.io/" + "o" * 3000
|
||||
eng.state["scans"][target["id"]] = eng.state["scans"].pop(NIGHTLY) # a by-tag unit is keyed by its id
|
||||
built = report.build(eng.state, [], [], clock())
|
||||
check("isolation: the repaired report is schema-valid", report.validate(built) == [])
|
||||
check("isolation: a repair degrades the run", built["run"]["status"] == "degraded")
|
||||
bad = [f for f in built["findings"] if f["targetId"] == NGINX and f["title"] == "t" * 512][0]
|
||||
check("isolation: the bad finding survives with sanitised fields",
|
||||
bad["url"] == "" and bad["severity"] == "UNKNOWN" and bad["cvss"] is None and len(bad["package"]) == 256)
|
||||
check("isolation: every other finding is untouched", len(built["findings"]) == len(clean["findings"]))
|
||||
check("isolation: a bad remediation loses only its url", built["remediation"][0]["url"] is None)
|
||||
check("isolation: an unusable database is reported as unavailable", built["database"]["version"] == "unavailable")
|
||||
long_ids = [t for t in built["targets"] if len(t["id"]) > 2000]
|
||||
check("isolation: an over-long target stays listed", len(long_ids) == 1 and len(long_ids[0]["id"]) <= 2048)
|
||||
check("isolation: findings follow the shortened target id",
|
||||
any(f["targetId"] == long_ids[0]["id"] for f in built["findings"]))
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
returncode = 0
|
||||
|
||||
def __init__(self, stdout):
|
||||
self.stdout = stdout
|
||||
|
||||
|
||||
class VersionRunner(trivy.Runner):
|
||||
def __init__(self, stdout):
|
||||
super().__init__("/nonexistent", "/nonexistent")
|
||||
self.stdout = stdout
|
||||
|
||||
def _run(self, args, timeout):
|
||||
return FakeProcess(self.stdout)
|
||||
|
||||
|
||||
def db_info(updated, version):
|
||||
return VersionRunner(json.dumps({"VulnerabilityDB": {"UpdatedAt": updated, "Version": version}})).db_info()
|
||||
|
||||
|
||||
check("db_info rejects an unparseable timestamp", db_info("yesterday", "2") is None)
|
||||
check("db_info rejects a non-string timestamp", db_info(5, "2") is None)
|
||||
check("db_info bounds the version", len(db_info("2026-11-09T06:13:52.123456789Z", "v" * 500)["version"]) == 128)
|
||||
check("mutation: db_info keeps a good database", db_info("2026-11-09T06:13:52Z", "2") == {"updatedAt": "2026-11-09T06:13:52Z", "version": "2"})
|
||||
check("a credential failure's reason is bounded", len(trivy.classify_failure("r" * 3000, "401 unauthorized").reason) <= 1024)
|
||||
|
||||
for url, want in (("https://a.test/x", "https://a.test/x"), ("http://a.test/", "http://a.test/"), ("HTTPS://a.test/x", ""),
|
||||
("HTTP://a.test/x", ""), ("Https://a.test/x", ""), ("javascript:alert(1)", ""), ("", "")):
|
||||
check("safe_url " + repr(url), trivy.safe_url(url) == want)
|
||||
check("every url safe_url keeps satisfies the report schema",
|
||||
report.re.search(report.SCHEMA["properties"]["findings"]["items"]["properties"]["url"]["pattern"], trivy.safe_url("https://a.test/x")))
|
||||
|
||||
# Recorded Trivy output through the normaliser: untrusted text.
|
||||
parsed = trivy.parse_results({"SchemaVersion": 2, "Results": [{"Vulnerabilities": [
|
||||
{"VulnerabilityID": "CVE-1", "Severity": "weird", "PkgName": "p", "InstalledVersion": "1", "Title": "x\x07y",
|
||||
"PrimaryURL": "javascript:alert(1)", "CVSS": {"nvd": {"V3Score": 11}}},
|
||||
{"VulnerabilityID": "CVE-2", "Severity": "HIGH", "PkgName": "q", "InstalledVersion": "1", "FixedVersion": "2",
|
||||
"PrimaryURL": "https://example.test/x", "CVSS": {"nvd": {"V3Score": 7.5}}},
|
||||
]}]})
|
||||
check("unknown severity becomes UNKNOWN", parsed[0]["severity"] == "UNKNOWN")
|
||||
check("control characters are stripped from upstream text", "\x07" not in parsed[0]["title"])
|
||||
check("non-http(s) advisory URL becomes empty", parsed[0]["url"] == "")
|
||||
check("out-of-range CVSS is dropped", parsed[0]["cvss"] is None)
|
||||
check("http(s) URL is kept", parsed[1]["url"] == "https://example.test/x")
|
||||
check("a recorded report with no Results is zero findings", trivy.parse_results(fixture("trivy-empty.json")) == [])
|
||||
try:
|
||||
trivy.parse_results({"not": "a report"})
|
||||
check("an unrecognised document is a failure, never zero findings", False)
|
||||
except trivy.ScanFailure as exc:
|
||||
check("an unrecognised document is a failure, never zero findings", exc.kind == "failed")
|
||||
failure = trivy.classify_failure("ghcr.io", "GET failed: 401 Unauthorized")
|
||||
check("401 is credential-required", failure.kind == "credential-required" and "ghcr.io" in failure.reason)
|
||||
check("permission denied on local disk is a plain failure", trivy.classify_failure("r", "open /cache/x: permission denied").kind == "failed")
|
||||
check("a pull timeout is failed, not credential-required", trivy.classify_failure("r", "context deadline exceeded").kind == "failed")
|
||||
|
||||
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
||||
sys.exit(1 if FAILED else 0)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the fixture-driven assertions in check.py. See chainsaw-test.yaml for the requirement tags.
|
||||
# What this does NOT prove: anything at runtime (real Trivy, real API, real browser). L2/L3 cover that.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
exec python3 "${HERE}/check.py"
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"cronjobs": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "nightly",
|
||||
"namespace": "ops"
|
||||
},
|
||||
"spec": {
|
||||
"jobTemplate": {
|
||||
"spec": {
|
||||
"template": {
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/nightly:2.1",
|
||||
"name": "job"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"jobs": [],
|
||||
"nodes": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prod-1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pods": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web-1",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "web-5f"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.27",
|
||||
"name": "app"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"name": "app"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "api-1",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "api-77"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.27",
|
||||
"name": "server"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/nginx@sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"name": "server"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "db-0",
|
||||
"namespace": "data",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "StatefulSet",
|
||||
"name": "db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "postgres:16",
|
||||
"name": "postgres"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "docker.io/library/postgres@sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
"name": "postgres"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "priv-1",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "priv-1a"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "ghcr.io/org/private:1",
|
||||
"name": "priv"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"containerStatuses": [
|
||||
{
|
||||
"imageID": "ghcr.io/org/private@sha256:e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5",
|
||||
"name": "priv"
|
||||
}
|
||||
],
|
||||
"phase": "Running"
|
||||
}
|
||||
}
|
||||
],
|
||||
"replicasets": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web-5f",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Deployment",
|
||||
"name": "web"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "api-77",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Deployment",
|
||||
"name": "api"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "priv-1a",
|
||||
"namespace": "apps",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"controller": true,
|
||||
"kind": "Deployment",
|
||||
"name": "priv"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"vmis": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ArtifactName": "x",
|
||||
"SchemaVersion": 2
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"ArtifactName": "x",
|
||||
"Results": [
|
||||
{
|
||||
"Target": "t",
|
||||
"Vulnerabilities": [
|
||||
{
|
||||
"CVSS": {
|
||||
"nvd": {
|
||||
"V3Score": 9.8
|
||||
}
|
||||
},
|
||||
"FixedVersion": "3.0.2",
|
||||
"InstalledVersion": "1.0",
|
||||
"PkgName": "openssl",
|
||||
"PrimaryURL": "https://example.test/CVE-2026-0001",
|
||||
"Severity": "CRITICAL",
|
||||
"Title": "OpenSSL flaw",
|
||||
"VulnerabilityID": "CVE-2026-0001"
|
||||
},
|
||||
{
|
||||
"InstalledVersion": "1.0",
|
||||
"PkgName": "zlib",
|
||||
"PrimaryURL": "https://example.test/CVE-2026-0002",
|
||||
"Severity": "LOW",
|
||||
"Title": "t",
|
||||
"VulnerabilityID": "CVE-2026-0002"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SchemaVersion": 2
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"ArtifactName": "x",
|
||||
"Results": [
|
||||
{
|
||||
"Target": "t",
|
||||
"Vulnerabilities": [
|
||||
{
|
||||
"InstalledVersion": "1.0",
|
||||
"PkgName": "curl",
|
||||
"PrimaryURL": "https://example.test/CVE-2026-0020",
|
||||
"Severity": "MEDIUM",
|
||||
"Title": "t",
|
||||
"VulnerabilityID": "CVE-2026-0020"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SchemaVersion": 2
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"ArtifactName": "x",
|
||||
"Results": [
|
||||
{
|
||||
"Target": "t",
|
||||
"Vulnerabilities": [
|
||||
{
|
||||
"CVSS": {
|
||||
"nvd": {
|
||||
"V3Score": 7.5
|
||||
}
|
||||
},
|
||||
"FixedVersion": "16.2",
|
||||
"InstalledVersion": "1.0",
|
||||
"PkgName": "libpq",
|
||||
"PrimaryURL": "https://example.test/CVE-2026-0010",
|
||||
"Severity": "HIGH",
|
||||
"Title": "libpq flaw",
|
||||
"VulnerabilityID": "CVE-2026-0010"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SchemaVersion": 2
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# =============================================================================
|
||||
# L1 RENDERING — the bot ticks only qualifying approve rows (R-VULN-05, R-VULN-07)
|
||||
# Spec: docs/17 "The triage bot" and its L1 acceptance gates. GIVEN a Renovate dashboard in the
|
||||
# real issue #91 format plus a constructed Pending Approval section, WHEN the bot plans ticks,
|
||||
# THEN only `approve` rows exact-matching an image with an unaccepted CRITICAL with a fix change,
|
||||
# by exactly their marker, and every negative row stays byte-identical.
|
||||
#
|
||||
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-approval-ticks
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
|
||||
# not attribute to R-VULN-*, so a change to it must still select this test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
A tick is applied only to an approve row for an image with a qualifying finding, and dry
|
||||
runs, duplicate prevention and heartbeat ordering hold through the whole run.
|
||||
spec:
|
||||
steps:
|
||||
- name: plan-and-assert-ticks
|
||||
try:
|
||||
- script:
|
||||
content: bash ./render-check.sh
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L1: the bot ticks only qualifying approve rows, and a whole run behaves (R-VULN-05, R-VULN-07).
|
||||
|
||||
fixtures/renovate-dashboard.md follows issue #91's real format (`## Open` rebase rows, Detected
|
||||
Dependencies) plus a constructed Pending Approval section, because the live dashboard has none.
|
||||
Runs the real functions and the real run() over a fake Gitea and a fake scanner.
|
||||
|
||||
What this does NOT prove: that Renovate honours a tick; that the live dashboard keeps this format
|
||||
across Renovate upgrades (a format change yields no tick rather than a wrong one); that Gitea's
|
||||
API matches the fake; or anything about the runner's network path to the scanner.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[3]
|
||||
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
|
||||
bot = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cve_triage"] = bot
|
||||
spec.loader.exec_module(bot)
|
||||
|
||||
RAW = (HERE / "fixtures/report.json").read_bytes()
|
||||
FIXTURE = json.loads(RAW)
|
||||
BODY = (HERE / "fixtures/renovate-dashboard.md").read_text()
|
||||
OPEN_PRS = json.loads((HERE / "fixtures/open-prs.json").read_text())
|
||||
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
|
||||
GITEA = "https://gitea.example.test"
|
||||
REPO = "o/r"
|
||||
VULN = "http://vuln-scanner.vuln-scanner.svc.cluster.local:8081"
|
||||
FALLBACK = "http://10.43.0.99:8081"
|
||||
EXPECTED = {
|
||||
"renovate/nginx-2.x",
|
||||
"renovate/ghcr.io-example-app-2.x",
|
||||
"renovate/example-group",
|
||||
}
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(f"{'ok ' if condition else 'FAIL'} {name}")
|
||||
if not condition:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def fails(function):
|
||||
try:
|
||||
function()
|
||||
except AssertionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def raises(function):
|
||||
try:
|
||||
function()
|
||||
except bot.BotError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def planned(report_doc, body):
|
||||
report = bot.parse_report(json.dumps(report_doc).encode())
|
||||
rows = bot.parse_rows(body)
|
||||
return bot.select_ticks(rows, bot.qualifying_images(report))
|
||||
|
||||
|
||||
def assert_ticks(report_doc, body, expected):
|
||||
"""The tick plan is exactly `expected`, and writing it changes exactly those markers."""
|
||||
ticks = planned(report_doc, body)
|
||||
assert {row.branch for row in ticks} == expected, "tick plan differs"
|
||||
after = bot.apply_ticks(body, ticks)
|
||||
want = body
|
||||
for branch in expected:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
assert want.count(line) == 1, "expected row not found exactly once"
|
||||
want = want.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
assert after == want, "output differs from a marker-only edit"
|
||||
|
||||
|
||||
check("exactly the three qualifying approve rows are ticked, and nothing else changes",
|
||||
not fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
|
||||
after = bot.apply_ticks(BODY, planned(FIXTURE, BODY))
|
||||
changed = [(a, b) for a, b in zip(BODY.split("\n"), after.split("\n")) if a != b]
|
||||
check("three lines differ, same line count, same length", len(changed) == 3 and len(BODY) == len(after))
|
||||
for label, needle in {
|
||||
"rebase row of a qualifying image": "rebase-branch=renovate/ghcr.io-example-has-pr",
|
||||
"approve row for a non-matching package": "approve-branch=renovate/ghcr.io-other-unrelated",
|
||||
"approve row matching only by substring": "approve-branch=renovate/ghcr.io-foo-bar-extras",
|
||||
"approve row for a HIGH-only image": "approve-branch=renovate/ghcr.io-example-high-only",
|
||||
"approve row for an accepted-CRITICAL image": "approve-branch=renovate/ghcr.io-example-accepted-only",
|
||||
"approve row for a CRITICAL with no fix": "approve-branch=renovate/ghcr.io-example-nofix",
|
||||
"approve row with no container package": "approve-branch=renovate/mychart",
|
||||
"approvePr row of a qualifying image": "approvePr-branch=renovate/ghcr.io-example-app-automerge",
|
||||
"unschedule row of a qualifying image": "unschedule-branch=renovate/ghcr.io-example-pending-sched",
|
||||
"decoy approve row below Detected Dependencies": "approve-branch=renovate/decoy",
|
||||
}.items():
|
||||
original = next(line for line in BODY.split("\n") if needle in line)
|
||||
check(f"untouched: {label}", original in after.split("\n"))
|
||||
check("untouched: the approve-all row", "- [ ] <!-- approve-all-pending-prs -->" in after)
|
||||
check("untouched: the already ticked row keeps its mark",
|
||||
" - [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->" in after)
|
||||
|
||||
# Non-vacuity: each mutation must make the exact assertion above fail.
|
||||
def mutated_finding(image, **fields):
|
||||
doc = copy.deepcopy(FIXTURE)
|
||||
target = next(t for t in doc["targets"] if t["image"] == image)
|
||||
finding = next(f for f in doc["findings"] if f["targetId"] == target["id"])
|
||||
finding.update(fields)
|
||||
return doc
|
||||
|
||||
|
||||
for label, doc in {
|
||||
"HIGH-only image raised to CRITICAL": mutated_finding("ghcr.io/example/high-only", severity="CRITICAL"),
|
||||
"accepted CRITICAL un-accepted": mutated_finding("ghcr.io/example/accepted-only", status="fix-available"),
|
||||
"qualifying finding accepted": mutated_finding("ghcr.io/example/app", status="accepted"),
|
||||
"CRITICAL gains a fix": mutated_finding("ghcr.io/example/nofix", status="fix-available", fixedVersion="2"),
|
||||
}.items():
|
||||
check(f"non-vacuity: mutated fixture fails ({label})", fails(lambda: assert_ticks(doc, BODY, EXPECTED)))
|
||||
|
||||
mutated_body = BODY.replace("rebase-branch=renovate/ghcr.io-example-has-pr", "approve-branch=renovate/ghcr.io-example-has-pr")
|
||||
check("non-vacuity: a rebase row rewritten as approve is then ticked",
|
||||
fails(lambda: assert_ticks(FIXTURE, mutated_body, EXPECTED)))
|
||||
|
||||
real_match = bot.row_matches
|
||||
bot.row_matches = lambda row, qualifying: any(q in p for p in row.packages for q in qualifying)
|
||||
check("non-vacuity: a substring matcher fails the assertion", fails(lambda: assert_ticks(FIXTURE, BODY, EXPECTED)))
|
||||
bot.row_matches = real_match
|
||||
|
||||
for raw, want in {
|
||||
"docker.io/library/nginx": "nginx",
|
||||
"library/nginx": "nginx",
|
||||
"nginx:1.27": "nginx",
|
||||
"Docker.IO/Pihole/Pihole": "pihole/pihole",
|
||||
"ghcr.io/library/x": "ghcr.io/library/x",
|
||||
"registry.example:5000/a/b:v1@sha256:" + "0" * 64: "registry.example:5000/a/b",
|
||||
"bad image": "",
|
||||
"": "",
|
||||
}.items():
|
||||
check(f"normalise_image({raw[:40]!r})", bot.normalise_image(raw) == want)
|
||||
|
||||
ticks = planned(FIXTURE, BODY)
|
||||
moved = BODY.replace(" - [ ] <!-- approve-branch=renovate/nginx-2.x -->", " - [ ] <!-- rebase-branch=renovate/nginx-2.x -->")
|
||||
check("apply_ticks refuses a row that stopped being approve", raises(lambda: bot.apply_ticks(moved, ticks)))
|
||||
crlf = BODY.replace("\n", "\r\n")
|
||||
crlf_after = bot.apply_ticks(crlf, planned(FIXTURE, crlf))
|
||||
check("CRLF body keeps every line ending", crlf_after.count("\r\n") == crlf.count("\r\n") and crlf_after.count("[x]") == 4)
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
"""A fake Gitea and scanner reached through the transport interface. Records every write."""
|
||||
|
||||
def __init__(self, renovate_body=BODY, report=RAW):
|
||||
self.report = report
|
||||
self.labels = []
|
||||
self.issues = {91: {"number": 91, "title": "Dependency Dashboard", "state": "open", "labels": [], "body": renovate_body, "pull_request": None}}
|
||||
self.next_issue = 200
|
||||
self.writes = []
|
||||
self.triage = None
|
||||
self.fail = None
|
||||
self.dns_down = set()
|
||||
self.seen_hosts = []
|
||||
|
||||
def __call__(self, method, url, headers, body, max_bytes):
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
self.seen_hosts.append(parts.netloc)
|
||||
if parts.netloc in self.dns_down:
|
||||
raise bot.DnsFailure()
|
||||
if self.fail and self.fail[0] == method and self.fail[1] in parts.path:
|
||||
return 500, b""
|
||||
payload = json.loads(body) if body else None
|
||||
if parts.netloc != "gitea.example.test":
|
||||
if method == "GET" and parts.path == "/report.json":
|
||||
return 200, self.report
|
||||
if method == "POST" and parts.path == "/triage":
|
||||
self.writes.append(("POST", "triage"))
|
||||
self.triage = payload
|
||||
return 204, b""
|
||||
return 404, b""
|
||||
path = parts.path.removeprefix(f"/api/v1/repos/{REPO}")
|
||||
query = dict(urllib.parse.parse_qsl(parts.query))
|
||||
page = int(query.get("page", "1"))
|
||||
if method != "GET":
|
||||
self.writes.append((method, path))
|
||||
if method == "GET" and path == "/labels":
|
||||
return 200, json.dumps(self.labels if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/labels":
|
||||
label = {"id": 900 + len(self.labels), "name": payload["name"]}
|
||||
self.labels.append(label)
|
||||
return 201, json.dumps(label).encode()
|
||||
if method == "GET" and path == "/pulls":
|
||||
return 200, json.dumps(OPEN_PRS if page == 1 else []).encode()
|
||||
if method == "GET" and path == "/issues":
|
||||
items = list(self.issues.values())
|
||||
if "labels" in query:
|
||||
items = [i for i in items if query["labels"] in {x["name"] for x in i["labels"]}]
|
||||
if "q" in query:
|
||||
items = [i for i in items if query["q"] in i["title"]]
|
||||
return 200, json.dumps(items if page == 1 else []).encode()
|
||||
if method == "POST" and path == "/issues":
|
||||
number = self.next_issue
|
||||
self.next_issue += 1
|
||||
names = [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
self.issues[number] = {"number": number, "title": payload["title"], "state": "open", "labels": names, "body": payload["body"], "pull_request": None}
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
if path.startswith("/issues/") and path.endswith("/labels") and method == "POST":
|
||||
number = int(path.split("/")[2])
|
||||
self.issues[number]["labels"] += [x for x in self.labels if x["id"] in payload["labels"]]
|
||||
return 200, b"[]"
|
||||
if path.startswith("/issues/"):
|
||||
number = int(path.split("/")[2])
|
||||
if method == "GET":
|
||||
return 200, json.dumps(self.issues[number]).encode()
|
||||
if method == "PATCH":
|
||||
self.issues[number].update(payload)
|
||||
return 201, json.dumps(self.issues[number]).encode()
|
||||
return 404, b""
|
||||
|
||||
|
||||
def execute(world, dry_run=False, fallback=FALLBACK):
|
||||
cfg = {"dry_run": dry_run, "page_url": "https://cve.example.test", "renovate_issue": 91}
|
||||
gitea = bot.GiteaClient(GITEA, REPO, "fixture-token", world)
|
||||
scanner = bot.ScannerClient(VULN, fallback, world)
|
||||
lines = []
|
||||
summary = bot.run(cfg, gitea, scanner, NOW, lines.append)
|
||||
return summary, "\n".join(lines)
|
||||
|
||||
|
||||
def dashboards(world):
|
||||
return [i for i in world.issues.values() if i["title"] == bot.DASHBOARD_TITLE]
|
||||
|
||||
|
||||
# Dry run: reads everything, writes nothing, reports the plan as counts.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world, dry_run=True)
|
||||
check("dry run performs no writes", world.writes == [] and world.triage is None and not dashboards(world))
|
||||
check("dry run plans the three ticks and the issue", summary["ticks"] == 3 and summary["dashboard"]["issue_created"] == 1)
|
||||
check("dry run leaves the dashboard body untouched", world.issues[91]["body"] == BODY)
|
||||
|
||||
NAMES = {"CVE-2026", "fixture-pkg", "fixture-ns-", "fixture-workload", "nginx", "ghcr.io"}
|
||||
|
||||
|
||||
def leaks(text_value):
|
||||
return [name for name in NAMES if name.lower() in text_value.lower()]
|
||||
|
||||
|
||||
check("dry-run summary is counts only", not leaks(text))
|
||||
|
||||
# Live run.
|
||||
world = FakeWorld()
|
||||
summary, text = execute(world)
|
||||
board = dashboards(world)
|
||||
check("live run creates one label and one dashboard issue", len(world.labels) == 1 and len(board) == 1)
|
||||
check("the dashboard issue carries only its own label", [x["name"] for x in board[0]["labels"]] == ["cve-dashboard"])
|
||||
check("issue body and summary are counts only", not leaks(board[0]["body"]) and not leaks(text))
|
||||
check("exactly one PATCH to issue #91", world.writes.count(("PATCH", "/issues/91")) == 1)
|
||||
want_body = BODY
|
||||
for branch in EXPECTED:
|
||||
line = f" - [ ] <!-- approve-branch={branch} -->"
|
||||
want_body = want_body.replace(line, line.replace("[ ]", "[x]", 1))
|
||||
check("the PATCHed body is the marker-only edit", world.issues[91]["body"] == want_body)
|
||||
check("the heartbeat is the last write", world.writes[-1] == ("POST", "triage"))
|
||||
check("heartbeat shape is exact", set(world.triage) == {"heartbeatAt", "remediation"} and world.triage["heartbeatAt"] == "2026-11-09T07:15:00Z")
|
||||
for entry in world.triage["remediation"]:
|
||||
if set(entry) != {"image", "state", "url", "updatedAt"} or entry["state"] not in bot.REMEDIATION_STATES:
|
||||
check("remediation entry shape", False)
|
||||
states = {e["image"]: (e["state"], e["url"]) for e in world.triage["remediation"]}
|
||||
for image, want in {
|
||||
"docker.io/library/nginx": ("approval-requested", None),
|
||||
"ghcr.io/example/app": ("approval-requested", None),
|
||||
"ghcr.io/example/grouped": ("approval-requested", None),
|
||||
"ghcr.io/example/already-ticked": ("approval-requested", None),
|
||||
"ghcr.io/example/high-only": ("approval-requested", None),
|
||||
"ghcr.io/example/has-pr": ("pr-open", f"{GITEA}/{REPO}/pulls/173"),
|
||||
"ghcr.io/example/pr-by-title": ("pr-open", f"{GITEA}/{REPO}/pulls/174"),
|
||||
"ghcr.io/example/tracked-clean": ("no-update-identified", None),
|
||||
"ghcr.io/foo/bar": ("no-update-identified", None),
|
||||
"ghcr.io/example/untracked": ("not-tracked", None),
|
||||
"ghcr.io/example/pending-sched": ("not-tracked", None),
|
||||
}.items():
|
||||
check(f"remediation for {image}", states.get(image) == want)
|
||||
check("one remediation entry per image", len(states) == len(FIXTURE["targets"]))
|
||||
|
||||
# A second run updates the same issue.
|
||||
before = len(world.issues)
|
||||
second = copy.deepcopy(FIXTURE)
|
||||
second["targets"][-1]["state"] = "stale"
|
||||
world.report = json.dumps(second).encode()
|
||||
summary, _ = execute(world)
|
||||
check("a second run creates no duplicate", len(dashboards(world)) == 1 and len(world.issues) == before)
|
||||
check("a second run updates the issue", summary["dashboard"]["issue_updated"] == 1 and "| stale | 1 |" in dashboards(world)[0]["body"])
|
||||
check("a second run has nothing left to tick", summary["ticks"] == 0)
|
||||
|
||||
# The dashboard issue is found even without its label, and a closed one is reopened.
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
world.issues[150] = {"number": 150, "title": "CVE Dashboard", "state": "closed", "labels": [], "body": "old", "pull_request": None}
|
||||
summary, _ = execute(world)
|
||||
check("an unlabelled closed dashboard is reused, relabelled and reopened",
|
||||
len(dashboards(world)) == 1 and world.issues[150]["state"] == "open"
|
||||
and [x["name"] for x in world.issues[150]["labels"]] == ["cve-dashboard"] and summary["dashboard"]["issue_created"] == 0)
|
||||
|
||||
world = FakeWorld()
|
||||
world.labels.append({"id": 900, "name": "cve-dashboard"})
|
||||
for number in (150, 151):
|
||||
world.issues[number] = {"number": number, "title": "CVE Dashboard", "state": "open", "labels": [world.labels[0]], "body": "x", "pull_request": None}
|
||||
check("two dashboard issues fail the run", raises(lambda: execute(world)))
|
||||
check("two dashboard issues write nothing", world.writes == [])
|
||||
|
||||
# Failures end the run before the heartbeat.
|
||||
world = FakeWorld()
|
||||
world.fail = ("PATCH", "/issues/91")
|
||||
check("a failed tick PATCH fails the run", raises(lambda: execute(world)))
|
||||
check("a failed run posts no heartbeat", world.triage is None)
|
||||
world = FakeWorld(report=b"{not json")
|
||||
check("an unreadable report fails the run", raises(lambda: execute(world)))
|
||||
check("an unreadable report writes nothing", world.writes == [])
|
||||
world = FakeWorld()
|
||||
world.issues[91]["title"] = "Something else"
|
||||
check("issue 91 being something else fails the run", raises(lambda: execute(world)))
|
||||
world = FakeWorld()
|
||||
world.issues[91]["state"] = "closed"
|
||||
check("a closed Renovate dashboard fails the run", raises(lambda: execute(world)))
|
||||
|
||||
# DNS fallback, and nothing but DNS failure selects it.
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
summary, _ = execute(world)
|
||||
check("DNS failure uses the fallback for report and heartbeat", summary["fallback_used"] and world.triage is not None)
|
||||
check("the fallback was actually contacted", "10.43.0.99:8081" in world.seen_hosts)
|
||||
world = FakeWorld()
|
||||
world.dns_down.add("vuln-scanner.vuln-scanner.svc.cluster.local:8081")
|
||||
check("DNS failure with no fallback fails the run", raises(lambda: execute(world, fallback="")))
|
||||
|
||||
|
||||
def refused(method, url, headers, body, max_bytes):
|
||||
raise bot.BotError("GET /report.json: ConnectionRefusedError")
|
||||
|
||||
|
||||
check("a refused connection fails the run and does not fall back",
|
||||
raises(lambda: bot.ScannerClient(VULN, FALLBACK, refused).report()))
|
||||
|
||||
for label, env, expected_ok in (
|
||||
("missing token", {}, False),
|
||||
("present token", {"RENOVATE_TOKEN": "x"}, True),
|
||||
("bad DRY_RUN", {"RENOVATE_TOKEN": "x", "DRY_RUN": "maybe"}, False),
|
||||
("non-http API URL", {"RENOVATE_TOKEN": "x", "VULN_API_URL": "file:///etc/passwd"}, False),
|
||||
):
|
||||
try:
|
||||
bot.load_config(env)
|
||||
ok = True
|
||||
except bot.BotError:
|
||||
ok = False
|
||||
check(f"load_config: {label}", ok is expected_ok)
|
||||
|
||||
print(f"{len(failures)} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"number": 173, "title": "chore(deps): Update ghcr.io/example/has-pr Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-has-pr-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/173"},
|
||||
{"number": 174, "title": "chore(deps): Update ghcr.io/example/pr-by-title Docker tag to v2", "head": {"ref": "renovate/ghcr.io-example-pr-by-title-2.x"}, "html_url": "https://gitea.example.test/o/r/pulls/174"},
|
||||
{"number": 160, "title": "WIP: chore(spec): not Renovate work", "head": {"ref": "chore/adopt-spec-sync"}, "html_url": "https://gitea.example.test/o/r/pulls/160"}
|
||||
]
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more.
|
||||
|
||||
## Pending Approval
|
||||
|
||||
The following branches are pending approval. To create them, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- approve-branch=renovate/nginx-2.x -->chore(deps): Update nginx Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-app-2.x -->chore(deps): Update ghcr.io/example/app Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/example-group -->chore(deps): Update example group (`ghcr.io/example/grouped`, `ghcr.io/example/other`)
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-foo-bar-extras-2.x -->chore(deps): Update ghcr.io/foo/bar-extras Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-other-unrelated-2.x -->chore(deps): Update ghcr.io/other/unrelated Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-high-only-2.x -->chore(deps): Update ghcr.io/example/high-only Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-accepted-only-2.x -->chore(deps): Update ghcr.io/example/accepted-only Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/ghcr.io-example-nofix-2.x -->chore(deps): Update ghcr.io/example/nofix Docker tag to v2
|
||||
- [ ] <!-- approve-branch=renovate/mychart-2.x -->chore(deps): Update Helm release mychart to v2
|
||||
- [x] <!-- approve-branch=renovate/ghcr.io-example-already-ticked-2.x -->chore(deps): Update ghcr.io/example/already-ticked Docker tag to v2
|
||||
- [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
|
||||
|
||||
## Awaiting Schedule
|
||||
|
||||
The following updates are awaiting their schedule. To get an update now, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- unschedule-branch=renovate/ghcr.io-example-pending-sched-2.x -->chore(deps): Update ghcr.io/example/pending-sched Docker tag to v2
|
||||
|
||||
## Pending Branch Automerge
|
||||
|
||||
The following branches have automerge enabled. To abort a branch automerge, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- approvePr-branch=renovate/ghcr.io-example-app-automerge -->chore(deps): Update ghcr.io/example/app Docker tag to v2
|
||||
|
||||
## Open
|
||||
|
||||
The following updates have all been created. To force a retry/rebase of any, click on a checkbox below.
|
||||
|
||||
- [ ] <!-- rebase-branch=renovate/gitea.home.keithjackson.dev-home-network-toolbox-main -->[chore(deps): Update gitea.home.keithjackson.dev/home-network/toolbox:main Docker digest to 83a3501](pulls/161)
|
||||
- [ ] <!-- rebase-branch=renovate/kube-prometheus-stack-93.x -->[chore(deps): Update Helm release kube-prometheus-stack to v93](pulls/164)
|
||||
- [ ] <!-- rebase-branch=renovate/ghcr.io-example-has-pr-2.x -->[chore(deps): Update ghcr.io/example/has-pr Docker tag to v2](pulls/173)
|
||||
- [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once**
|
||||
|
||||
## Detected Dependencies
|
||||
|
||||
<details><summary>flux (13)</summary>
|
||||
<blockquote>
|
||||
|
||||
<details><summary>platform/cert-manager/helmrelease.yaml (1)</summary>
|
||||
|
||||
- `cert-manager >=1.21.0 <1.22.0` → [Updates: `>=1.21.3 <1.22.0`]
|
||||
|
||||
</details>
|
||||
|
||||
</blockquote>
|
||||
</details>
|
||||
|
||||
<details><summary>kubernetes (69)</summary>
|
||||
<blockquote>
|
||||
|
||||
<details><summary>apps/wave-0/jackett.yaml (4)</summary>
|
||||
|
||||
- `lscr.io/linuxserver/jackett v0.24.3025-ls29` → [Updates: `v0.24.3030-ls30`]
|
||||
- `quay.io/oauth2-proxy/oauth2-proxy v7.15.6`
|
||||
- `NetworkPolicy networking.k8s.io/v1`
|
||||
- `ghcr.io/example/tracked-clean 1.0`
|
||||
- `ghcr.io/foo/bar 1.0`
|
||||
- `ghcr.io/example/pending-sched 1.0` → [Updates: `2.0`]
|
||||
- `ghcr.io/example/app 1.0` → [Updates: `2.0`]
|
||||
- [ ] <!-- approve-branch=renovate/decoy-below-detected -->chore(deps): Update nginx Docker tag to v9
|
||||
|
||||
</details>
|
||||
|
||||
</blockquote>
|
||||
</details>
|
||||
@@ -0,0 +1,425 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-11-09T06:00:00Z",
|
||||
"database": {
|
||||
"updatedAt": "2026-11-09T01:00:00Z",
|
||||
"version": "fixture-db"
|
||||
},
|
||||
"run": {
|
||||
"startedAt": "2026-11-09T05:00:00Z",
|
||||
"finishedAt": "2026-11-09T05:30:00Z",
|
||||
"status": "complete"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"id": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"type": "image",
|
||||
"image": "docker.io/library/nginx",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-0",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-0",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/app",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-1",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-1",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/grouped",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-2",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-2",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/foo/bar",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-3",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-3",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/has-pr",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-4",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-4",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/pr-by-title",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-5",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-5",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/already-ticked",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-6",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-6",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/untracked",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-7",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-7",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/pending-sched",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-8",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-8",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/high-only",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-9",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-9",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/accepted-only",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-10",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-10",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/nofix",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-11",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-11",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ghcr.io/example/tracked-clean@sha256:000000000000000000000000000000000000000000000000000000000000000c",
|
||||
"type": "image",
|
||||
"image": "ghcr.io/example/tracked-clean",
|
||||
"digest": "sha256:000000000000000000000000000000000000000000000000000000000000000c",
|
||||
"state": "scanned",
|
||||
"reason": null,
|
||||
"lastScannedAt": "2026-11-09T05:00:00Z",
|
||||
"workloads": [
|
||||
{
|
||||
"namespace": "fixture-ns-12",
|
||||
"kind": "Deployment",
|
||||
"name": "fixture-workload-12",
|
||||
"containers": [
|
||||
"main"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"id": "CVE-2026-1000",
|
||||
"targetId": "docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-0",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1010",
|
||||
"targetId": "ghcr.io/example/app@sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-1",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1020",
|
||||
"targetId": "ghcr.io/example/grouped@sha256:0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-2",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1030",
|
||||
"targetId": "ghcr.io/foo/bar@sha256:0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-3",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1040",
|
||||
"targetId": "ghcr.io/example/has-pr@sha256:0000000000000000000000000000000000000000000000000000000000000004",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-4",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1050",
|
||||
"targetId": "ghcr.io/example/pr-by-title@sha256:0000000000000000000000000000000000000000000000000000000000000005",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-5",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1060",
|
||||
"targetId": "ghcr.io/example/already-ticked@sha256:0000000000000000000000000000000000000000000000000000000000000006",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-6",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1070",
|
||||
"targetId": "ghcr.io/example/untracked@sha256:0000000000000000000000000000000000000000000000000000000000000007",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-7",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1080",
|
||||
"targetId": "ghcr.io/example/pending-sched@sha256:0000000000000000000000000000000000000000000000000000000000000008",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-8",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1090",
|
||||
"targetId": "ghcr.io/example/high-only@sha256:0000000000000000000000000000000000000000000000000000000000000009",
|
||||
"severity": "HIGH",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-9",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "fix-available"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1100",
|
||||
"targetId": "ghcr.io/example/accepted-only@sha256:000000000000000000000000000000000000000000000000000000000000000a",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-10",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": "9.9.9",
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "accepted",
|
||||
"acceptance": {
|
||||
"reason": "fixture",
|
||||
"expires": "2027-01-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-1110",
|
||||
"targetId": "ghcr.io/example/nofix@sha256:000000000000000000000000000000000000000000000000000000000000000b",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.0,
|
||||
"package": "fixture-pkg-11",
|
||||
"installedVersion": "1.0",
|
||||
"fixedVersion": null,
|
||||
"title": "fixture",
|
||||
"url": "https://advisory.example/x",
|
||||
"firstSeenAt": "2026-11-01T00:00:00Z",
|
||||
"status": "open"
|
||||
}
|
||||
],
|
||||
"remediation": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
|
||||
# their limits are in check.py. rc 0 pass, 1 fail.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# =============================================================================
|
||||
# L1 RENDERING — CVE Dashboard issue carries aggregates only (R-VULN-05, R-VULN-07)
|
||||
# Spec: docs/17 "The triage bot". GIVEN a report fixture with hostile and distinctive strings,
|
||||
# WHEN the bot renders the dashboard issue, THEN no CVE id, package, image, workload, namespace or
|
||||
# other report string appears, the counts are exact, and a deliberately leaking body is caught.
|
||||
#
|
||||
# render-check.sh is a pure offline check; this Chainsaw Test wraps the SAME script.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-triage-bot-dashboard-aggregates
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-05: "true"
|
||||
conformance.home.k3s/R-VULN-07: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-05,R-VULN-07"
|
||||
conformance.home.k3s/layer: "L1"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
# The script under test lives in .gitea/workflows/, which requirement-scoped selection does
|
||||
# not attribute to R-VULN-*, so a change to it must still select this test.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/intent: >-
|
||||
A dashboard issue rendered from a fixture contains counts and timestamps only, never a CVE id,
|
||||
package, image or workload name.
|
||||
spec:
|
||||
steps:
|
||||
- name: render-and-assert-aggregates-only
|
||||
try:
|
||||
- script:
|
||||
content: bash ./render-check.sh
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L1: the CVE Dashboard issue is aggregates only (R-VULN-05, R-VULN-07).
|
||||
|
||||
Exercises the real renderer in .gitea/workflows/scripts/cve_triage.py over fixtures/report.json,
|
||||
whose every name is distinctive so a leak cannot be confused with template text.
|
||||
|
||||
What this does NOT prove: that Gitea accepts the issue, or that the issue is the one the live bot
|
||||
wrote. Those need a real run and are not exercised by any L1 test.
|
||||
"""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[3]
|
||||
spec = importlib.util.spec_from_file_location("cve_triage", ROOT / ".gitea/workflows/scripts/cve_triage.py")
|
||||
bot = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cve_triage"] = bot
|
||||
spec.loader.exec_module(bot)
|
||||
|
||||
RAW = (HERE / "fixtures/report.json").read_bytes()
|
||||
FIXTURE = json.loads(RAW)
|
||||
NOW = datetime(2026, 11, 9, 7, 15, tzinfo=timezone.utc)
|
||||
PAGE = "https://cve.example.test"
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, condition):
|
||||
print(f"{'ok ' if condition else 'FAIL'} {name}")
|
||||
if not condition:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def sensitive_names(doc):
|
||||
"""Every string in the fixture that must never reach the issue."""
|
||||
names = set()
|
||||
for target in doc["targets"]:
|
||||
for field in ("id", "image", "digest", "reason"):
|
||||
if target.get(field):
|
||||
names.add(target[field])
|
||||
if target.get("image"):
|
||||
names.add(bot.normalise_image(target["image"]))
|
||||
names.add(target["image"].rsplit("/", 1)[-1])
|
||||
for workload in target["workloads"]:
|
||||
names.update((workload["namespace"], workload["name"]))
|
||||
names.update(workload["containers"])
|
||||
for finding in doc["findings"]:
|
||||
for field in ("id", "package", "installedVersion", "fixedVersion", "title", "url"):
|
||||
if finding.get(field):
|
||||
names.add(finding[field])
|
||||
if finding.get("acceptance"):
|
||||
names.add(finding["acceptance"]["reason"])
|
||||
names.add(doc["database"]["version"])
|
||||
return {name for name in names if name}
|
||||
|
||||
|
||||
def assert_no_names(body, names):
|
||||
lowered = body.lower()
|
||||
leaked = sorted(name for name in names if name.lower() in lowered)
|
||||
assert not leaked, f"{len(leaked)} fixture names leaked into the issue body"
|
||||
|
||||
|
||||
def rendered(raw=RAW, now=NOW):
|
||||
return bot.render_dashboard(bot.aggregate(bot.parse_report(raw)), PAGE, now)
|
||||
|
||||
|
||||
def fails(function):
|
||||
try:
|
||||
function()
|
||||
except AssertionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def rejected(doc):
|
||||
try:
|
||||
bot.parse_report(json.dumps(doc).encode())
|
||||
except bot.BotError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
body = rendered()
|
||||
names = sensitive_names(FIXTURE)
|
||||
check("fixture carries a non-trivial set of names", len(names) >= 25)
|
||||
check("rendered body contains no fixture name", not fails(lambda: assert_no_names(body, names)))
|
||||
check("body has no CVE or GHSA identifier at all", not re.search(r"CVE-\d|GHSA-", body, re.I))
|
||||
check("hostile title's markup is absent", "onerror" not in body and "evil.example" not in body and "@keith" not in body)
|
||||
|
||||
# Non-vacuity: the assertion above must detect each class of leak.
|
||||
for label, leak in {
|
||||
"CVE id": "CVE-2026-11111",
|
||||
"package": "fixture-libcrit",
|
||||
"image": "ghcr.io/fixture-org/fixture-api",
|
||||
"workload": "fixture-workload-db",
|
||||
"namespace": "fixture-ns-gamma",
|
||||
}.items():
|
||||
check(f"non-vacuity: a leaked {label} is caught", fails(lambda: assert_no_names(body + leak, names)))
|
||||
|
||||
for row in (
|
||||
"| CRITICAL | 1 | 1 | 1 |",
|
||||
"| HIGH | 0 | 1 | 0 |",
|
||||
"| MEDIUM | 0 | 0 | 0 |",
|
||||
"| LOW | 1 | 0 | 0 |",
|
||||
"| UNKNOWN | 0 | 0 | 0 |",
|
||||
"| scanned | 1 |",
|
||||
"| pending | 0 |",
|
||||
"| stale | 1 |",
|
||||
"| failed | 0 |",
|
||||
"| credential-required | 1 |",
|
||||
"| by-tag | 0 |",
|
||||
"| not-covered | 1 |",
|
||||
"Unaccepted CRITICAL with a fix: **1**",
|
||||
"4 targets.",
|
||||
"| Report generated | 2026-11-09T06:00:00Z |",
|
||||
f"[CVE page]({PAGE})",
|
||||
):
|
||||
check(f"body states {row!r}", row in body)
|
||||
check("a fresh report carries no warning", "WARNING" not in body)
|
||||
|
||||
mutated = copy.deepcopy(FIXTURE)
|
||||
extra = copy.deepcopy(mutated["findings"][0])
|
||||
extra["id"] = "CVE-2026-55555"
|
||||
mutated["findings"].append(extra)
|
||||
changed = rendered(json.dumps(mutated).encode())
|
||||
check("non-vacuity: a mutated fixture changes the counts", "| CRITICAL | 1 | 2 | 1 |" in changed and changed != body)
|
||||
|
||||
check("stale report warns", "older than 26 hours" in rendered(now=NOW + timedelta(hours=30)))
|
||||
degraded = copy.deepcopy(FIXTURE)
|
||||
degraded["run"]["status"] = "degraded"
|
||||
check("degraded run warns", "did not complete cleanly" in rendered(json.dumps(degraded).encode()))
|
||||
old_db = copy.deepcopy(FIXTURE)
|
||||
old_db["database"]["updatedAt"] = "2026-11-01T00:00:00Z"
|
||||
check("stale database warns", "database is older" in rendered(json.dumps(old_db).encode()))
|
||||
|
||||
for label, mutate in {
|
||||
"wrong schemaVersion": lambda d: d.update(schemaVersion=2),
|
||||
"unknown severity": lambda d: d["findings"][0].update(severity="SEVERE"),
|
||||
"unknown status": lambda d: d["findings"][0].update(status="ignored"),
|
||||
"unknown target state": lambda d: d["targets"][0].update(state="clean"),
|
||||
"finding on an unknown target": lambda d: d["findings"][0].update(targetId="nope"),
|
||||
"bad timestamp": lambda d: d.update(generatedAt="yesterday <b>"),
|
||||
"invalid image": lambda d: d["targets"][0].update(image="bad image <x>"),
|
||||
}.items():
|
||||
doc = copy.deepcopy(FIXTURE)
|
||||
mutate(doc)
|
||||
check(f"parse rejects {label}", rejected(doc))
|
||||
|
||||
check("dashboard label is not one the PM acts on", bot.DASHBOARD_LABEL not in bot.FORBIDDEN_LABELS)
|
||||
configmap = (ROOT / "platform/pm-agent/configmap.yaml").read_text()
|
||||
match = re.search(r'^\s*PM_REQUIRE_LABEL:\s*"([^"]*)"', configmap, re.M)
|
||||
check("PM_REQUIRE_LABEL is readable", match is not None)
|
||||
check("the PM's label is in FORBIDDEN_LABELS and is not ours",
|
||||
match is not None and match.group(1) in bot.FORBIDDEN_LABELS and match.group(1) != bot.DASHBOARD_LABEL)
|
||||
|
||||
print(f"{len(failures)} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-11-09T06:00:00Z",
|
||||
"database": {"updatedAt": "2026-11-09T01:00:00Z", "version": "fixture-db-v2-20261109"},
|
||||
"run": {"startedAt": "2026-11-09T05:00:00Z", "finishedAt": "2026-11-09T05:30:00Z", "status": "complete"},
|
||||
"targets": [
|
||||
{"id": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "type": "image", "image": "ghcr.io/fixture-org/fixture-api", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "state": "scanned", "reason": null, "lastScannedAt": "2026-11-09T05:10:00Z", "workloads": [{"namespace": "fixture-ns-alpha", "kind": "Deployment", "name": "fixture-workload-api", "containers": ["fixture-container-api"]}]},
|
||||
{"id": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "type": "image", "image": "docker.io/library/fixturedb", "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "state": "stale", "reason": "fixture-reason-stale-text", "lastScannedAt": "2026-11-07T05:10:00Z", "workloads": [{"namespace": "fixture-ns-beta", "kind": "StatefulSet", "name": "fixture-workload-db", "containers": ["fixture-container-db"]}]},
|
||||
{"id": "fixture.example:5000/priv/fixture-private:v1", "type": "image", "image": "fixture.example:5000/priv/fixture-private", "digest": null, "state": "credential-required", "reason": "fixture.example:5000 refused anonymous access", "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-gamma", "kind": "CronJob", "name": "fixture-workload-cron", "containers": ["fixture-container-cron"]}]},
|
||||
{"id": "vm/fixture-ns-vm/fixture-vm-name", "type": "vm", "image": null, "digest": null, "state": "not-covered", "reason": null, "lastScannedAt": null, "workloads": [{"namespace": "fixture-ns-vm", "kind": "VirtualMachineInstance", "name": "fixture-vm-name", "containers": []}]}
|
||||
],
|
||||
"findings": [
|
||||
{"id": "CVE-2026-11111", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.8, "package": "fixture-libcrit", "installedVersion": "1.0.1-installed", "fixedVersion": "1.0.2-fixed", "title": "<img src=x onerror=alert(1)> @keith [click](http://evil.example/hook) | injected |", "url": "https://advisory.example/CVE-2026-11111", "firstSeenAt": "2026-11-01T00:00:00Z", "status": "fix-available"},
|
||||
{"id": "GHSA-fixt-ure0-0001", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "CRITICAL", "cvss": 9.1, "package": "fixture-libopen", "installedVersion": "2.0.0-installed", "fixedVersion": null, "title": "fixture title without a fix", "url": "https://advisory.example/ghsa", "firstSeenAt": "2026-11-02T00:00:00Z", "status": "open"},
|
||||
{"id": "CVE-2026-22222", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "CRITICAL", "cvss": 9.0, "package": "fixture-libaccepted", "installedVersion": "3.0.0-installed", "fixedVersion": "3.0.1-fixed", "title": "fixture accepted title", "url": "https://advisory.example/accepted", "firstSeenAt": "2026-10-01T00:00:00Z", "status": "accepted", "acceptance": {"reason": "fixture-acceptance-reason", "expires": "2027-01-01"}},
|
||||
{"id": "CVE-2026-33333", "targetId": "docker.io/library/fixturedb@sha256:2222222222222222222222222222222222222222222222222222222222222222", "severity": "HIGH", "cvss": 7.5, "package": "fixture-libhigh", "installedVersion": "4.0.0-installed", "fixedVersion": "4.0.1-fixed", "title": "fixture high title", "url": "https://advisory.example/high", "firstSeenAt": "2026-11-03T00:00:00Z", "status": "fix-available"},
|
||||
{"id": "CVE-2026-44444", "targetId": "ghcr.io/fixture-org/fixture-api@sha256:1111111111111111111111111111111111111111111111111111111111111111", "severity": "LOW", "cvss": null, "package": "fixture-liblow", "installedVersion": "5.0.0-installed", "fixedVersion": null, "title": "fixture low title", "url": "", "firstSeenAt": "2026-11-04T00:00:00Z", "status": "open"}
|
||||
],
|
||||
"remediation": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# L1 offline check for the CVE triage bot: fixture-driven, no cluster, no network. Assertions and
|
||||
# their limits are in check.py. rc 0 pass, 1 fail.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHONDONTWRITEBYTECODE=1 exec python3 "${here}/check.py"
|
||||
@@ -64,6 +64,12 @@ spec:
|
||||
annotations:
|
||||
# conformance-dns.<this cluster's BASE_DOMAIN> — never a literal apex.
|
||||
external-dns.alpha.kubernetes.io/hostname: (join('.', ['conformance-dns', $baseDomain]))
|
||||
# DNS-ONLY (2026-11-08). external-dns runs with --cloudflare-proxied globally (the
|
||||
# gateway names point at the Cloudflare Tunnel). Without this opt-out the probe
|
||||
# record is orange-clouded, 1.1.1.1 answers Cloudflare anycast instead of the LB IP,
|
||||
# and the exact-IP poll below can never match. Same opt-out every real
|
||||
# `service`-source publisher carries (unifi, crafty-mc).
|
||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
# No real backend needed — Cilium LB-IPAM assigns a VIP regardless, and
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# =============================================================================
|
||||
# L2 BEHAVIORAL SCAFFOLD — the internal API is unreachable from an unrelated namespace (R-VULN-09)
|
||||
# Catalog: docs/17 "Acceptance gates" (L2).
|
||||
#
|
||||
# A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081.
|
||||
#
|
||||
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
|
||||
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-internal-api-isolated
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-09: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-09"
|
||||
conformance.home.k3s/layer: "L2"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/intent: >-
|
||||
A pod in an unrelated namespace cannot reach vuln-scanner :8081 or :9090, while the CI runner identity can reach :8081.
|
||||
spec:
|
||||
steps:
|
||||
- name: given-an-unrelated-namespace
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: create a throwaway namespace and probe pod"'
|
||||
- name: when-connecting
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: attempt tcp to vuln-scanner:8081 and :9090"'
|
||||
- name: then-denied
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert a timeout on both, and success from the act-runner identity on :8081"'
|
||||
@@ -0,0 +1,36 @@
|
||||
# =============================================================================
|
||||
# L2 BEHAVIORAL SCAFFOLD — new workload digest appears in the report (R-VULN-01, R-VULN-02)
|
||||
# Catalog: docs/17 "Acceptance gates" (L2).
|
||||
#
|
||||
# A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window.
|
||||
#
|
||||
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
|
||||
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-new-digest-reported
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-01: "true"
|
||||
conformance.home.k3s/R-VULN-02: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-01, R-VULN-02"
|
||||
conformance.home.k3s/layer: "L2"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/intent: >-
|
||||
A newly started workload's digest appears in report.json, mapped to that workload, within the 2-hour window.
|
||||
spec:
|
||||
steps:
|
||||
- name: given-a-new-workload
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: start a pod from an image digest not yet in the report"'
|
||||
- name: when-the-scanner-ticks
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: wait, bounded by the 2h SLO, polling GET /report.json from the CI runner identity"'
|
||||
- name: then-digest-is-reported
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert a target with that digest and the workload mapping, state not pending past 2h"'
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# L2 BEHAVIORAL SCAFFOLD — the CVE page requires a platform-admins login (R-VULN-04)
|
||||
# Catalog: docs/17 "Acceptance gates" (L2).
|
||||
#
|
||||
# An unauthenticated request to cve.<domain> is redirected to Keycloak, and a logged-in user outside platform-admins gets 403.
|
||||
#
|
||||
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
|
||||
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-page-requires-admin
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-04: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-04"
|
||||
conformance.home.k3s/layer: "L2"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/intent: >-
|
||||
An unauthenticated request to cve.<domain> is redirected to Keycloak, and a logged-in user outside platform-admins gets 403.
|
||||
spec:
|
||||
steps:
|
||||
- name: given-the-page-route
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: resolve cve.<domain> through the Gateway"'
|
||||
- name: when-unauthenticated
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: request the page with no session"'
|
||||
- name: then-redirected-to-keycloak
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert a 302 to the homelab realm"'
|
||||
- name: then-non-admin-forbidden
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert 403 for a session outside platform-admins"'
|
||||
@@ -0,0 +1,40 @@
|
||||
# =============================================================================
|
||||
# L3 FAULT-INJECTION SCAFFOLD — breaking registry access produces failed targets and the coverage alert (R-VULN-03, R-VULN-06)
|
||||
# Catalog: docs/17 "Acceptance gates" (L3).
|
||||
#
|
||||
# With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h.
|
||||
#
|
||||
# STATUS: scaffold only. It asserts nothing and DOES NOT EXECUTE IN CI: vars.CLUSTER_AVAILABLE is unset,
|
||||
# so L2/L3 conformance does not run. Coverage from this file is a named gap, not evidence.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-registry-failure
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-03: "true"
|
||||
conformance.home.k3s/R-VULN-06: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-03, R-VULN-06"
|
||||
conformance.home.k3s/layer: "L3"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/intent: >-
|
||||
With registry access broken for one image, that target becomes failed with its reason, prior findings are retained, and VulnCoverageGap fires after 24h.
|
||||
spec:
|
||||
steps:
|
||||
- name: given-a-scanned-target
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: record a scanned target and its findings"'
|
||||
- name: when-registry-access-is-broken
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: inject a fault blocking that registry"'
|
||||
- name: then-target-failed
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert state failed, reason present, findings retained, run continues"'
|
||||
- name: then-coverage-alert-fires
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: assert VulnCoverageGap fires once the failure passes 24h"'
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
date: 2026-11-08
|
||||
branch: fix/cloudflared-creds-permission
|
||||
title: cloudflared CrashLoop — credentials Secret unreadable by uid 65532; add fsGroup, pin it in L0
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **`platform/cloudflared/deployment.yaml`**: pod `securityContext.fsGroup: 65532`. Root cause of
|
||||
the CrashLoop that followed #152/#153: the pod runs as `65532:65532` with no `fsGroup`, and the
|
||||
Secret volume is `defaultMode: 0440`. The kubelet therefore laid `credentials.json` down as
|
||||
`root:root 0440`, which uid 65532 (not owner, not in gid 0) cannot read. cloudflared logged
|
||||
`couldn't read tunnel credentials ... permission denied`, then fell back to origin-cert lookup
|
||||
and ended on the misleading `error parsing tunnel ID: Error locating origin cert`. With
|
||||
`fsGroup` the files are group-owned by 65532, readable by the container and no other uid; the
|
||||
mode stays 0440 (not loosened to 0444). Same fix as `platform/keycloak/client-secret-reconciler.yaml`.
|
||||
Still non-root, `runAsNonRoot: true`, unchanged otherwise.
|
||||
- **`conformance/tests/l0-static/cloudflared-ingress-rules/check.sh`**: new assertion (7). For each
|
||||
Secret volume a container mounts, it models kubelet ownership (uid 0, gid = fsGroup or 0, item
|
||||
mode / defaultMode / API default 0644) against the container's effective runAsUser and its groups
|
||||
(runAsGroup + fsGroup + supplementalGroups) and fails if the file is unreadable. An unset
|
||||
runAsUser fails (image default is not knowable offline). Conservative: it does not credit the
|
||||
kubelet OR-ing group-read into fsGroup volumes. Mutation self-test: fsGroup removed, mode 0400,
|
||||
runAsUser unset must each be flagged. Chainsaw step description updated to match.
|
||||
- **`runbooks/cloudflare-tunnel.md`**: new first "likely cause" (permission denied on
|
||||
credentials.json → the origin-cert error is a red herring) plus a "read the first error line"
|
||||
note; causes renumbered. New section **Rotating the tunnel credential**: create a new tunnel,
|
||||
swap the UUID and the encrypted credentials, merge, verify (new tunnel has two connectors, old
|
||||
has none), then `cloudflared tunnel cleanup`/`delete <old>`.
|
||||
- NOT touched, on purpose: `tunnel:` in `config.yaml` and `cloudflared-credentials.sops.yaml`. The
|
||||
operator is rotating the credential (it was exposed in a transcript) in a separate PR.
|
||||
|
||||
## What I verified
|
||||
|
||||
- Live (k3s-prod, read-only, 2026-11-08 ~21:38Z): Deployment pod securityContext is
|
||||
`{"runAsGroup":65532,"runAsNonRoot":true,"runAsUser":65532,"seccompProfile":...}`, no
|
||||
`fsGroup`; creds volume `defaultMode: 288` (= 0440). All three pods CrashLoopBackOff (38
|
||||
restarts). Secret `cloudflared-credentials` has the one key `credentials.json` (key names only).
|
||||
- Pinned image `cloudflare/cloudflared@sha256:4955524a...` config (`skopeo inspect --config`):
|
||||
`User: 65532:65532`, `Entrypoint: [cloudflared]`, `Cmd: [version]`.
|
||||
- The new assertion FAILS (rc 1) on the pre-fix deployment.yaml with
|
||||
`container cloudflared (uid 65532, groups [65532]) cannot read Secret volume creds ... files are
|
||||
0:0 mode 0440`, and PASSES (51 assertions) with the fix. All three mutants are flagged.
|
||||
- Kyverno admission policies under `platform/kyverno/admission/` that mention securityContext
|
||||
fields are scoped to the conformance-trigger and pm-agent Jobs only; none constrains fsGroup here.
|
||||
- Toolbox gate: see the PR description for the rc and skipped stages.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the pods actually become Ready after merge.** Not deployed by me; Flux applies it. The
|
||||
file mode inside the running container was not observed (pods crash before an exec is possible,
|
||||
and exec would be an operator action anyway). The readability claim rests on the kubelet's
|
||||
documented fsGroup behaviour and the identical, working keycloak reconciler.
|
||||
- Whether the CURRENT credential is otherwise valid (UUID/TunnelID match, tunnel exists). Moot:
|
||||
the operator is rotating it.
|
||||
- The rotation runbook section has not been executed; `cloudflared tunnel cleanup` / `delete`
|
||||
syntax is from the CLI's documented commands, not run here.
|
||||
- The deployment comment says the image ENTRYPOINT is `cloudflared --no-autoupdate`; the pinned
|
||||
image's ENTRYPOINT is plain `cloudflared`. Left unchanged (out of scope); flagged for follow-up.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
date: 2026-11-08
|
||||
branch: feat/dns-via-cloudflare-tunnel
|
||||
title: Public DNS via the Cloudflare Tunnel (Phase 2) — proxied CNAMEs, Plex/Headlamp LAN-only, L0 guard
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
One atomic change, so the conformance guard lands with the DNS flip.
|
||||
|
||||
- `platform/gateway/gateway.yaml`: the shared Gateway's `external-dns.alpha.kubernetes.io/target`
|
||||
is now the literal `fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com` (was
|
||||
`wan.${BASE_DOMAIN}`). Comment block rewritten.
|
||||
- `platform/externaldns/helmrelease.yaml`: `extraArgs: [--cloudflare-proxied]` (global, because
|
||||
v0.23.0 reads per-object `cloudflare-proxied` from the route/Service, never the Gateway). The
|
||||
public-target comment block rewritten, including the prefix-regression risk.
|
||||
- `apps/vms/unifi/50-service-l4.yaml`, `platform/crafty/service-l4.yaml`:
|
||||
`external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"` (L4 A records stay DNS-only). They
|
||||
are the only `service`-source publishers in the tree and live (measured).
|
||||
- Plex web UI LAN/VPN-only: `catalog/plex.yaml` `exposure: public` → `internal`; `apps/wave-0/plex.yaml`
|
||||
re-rendered with the canonical command into a scratch dir and ONLY plex.yaml copied back. The
|
||||
render diff is exactly +2 lines (`annotations: external-dns.alpha.kubernetes.io/controller: none`
|
||||
on HTTPRoute plex-32400). No other golden moved, and nothing was onboarded (vm-demo/keycloak files
|
||||
were not touched). ADVERTISE_IP, the L4 VIP .24 and `plex-remote.yaml` are untouched.
|
||||
- Headlamp LAN/VPN-only: `controller: none` on the hand-authored `platform/headlamp/httproute.yaml`.
|
||||
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh`: header and
|
||||
register prose updated; new assertion (9):
|
||||
(9a) Gateway target == `<tunnel: from platform/cloudflared/config.yaml>.cfargotunnel.com`, and
|
||||
`--cloudflare-proxied` is set; (9b) every `service`-source publisher has `cloudflare-proxied:
|
||||
"false"` and no HTTPRoute opts out of proxying; (9c) no undisowned route names a hostname the
|
||||
tunnel 404s, the only `service` publishers of a 404'd name are the declared L4 set
|
||||
{unifi, crafty-mc}, and every disowned route's hostname IS 404'd by the tunnel (lists kept
|
||||
consistent both ways); (9d) plex and headlamp pinned disowned. Its `chainsaw-test.yaml` gap prose
|
||||
updated.
|
||||
- `conformance/tests/l2-behavioral/dns-exposure/chainsaw-test.yaml`: fixture Service gets
|
||||
`cloudflare-proxied: "false"`, else the global flag proxies it and the exact-IP poll fails.
|
||||
- Docs: `platform/externaldns/ddns.yaml` header (wan. is WireGuard-only), `platform/externaldns/README.md`,
|
||||
`platform/gateway/README.md`, `runbooks/plex-remote-access.md`, `runbooks/cloudflare-tunnel.md`
|
||||
(Phase 2 done; operator step to close WAN tcp/80+443 and keep udp/51820 + tcp/32400; rotation
|
||||
now also swaps the Gateway UUID; rollback path).
|
||||
|
||||
## What I verified
|
||||
|
||||
- external-dns v0.23.0 source, read at the tag: `source/gateway.go` takes target overrides from
|
||||
`gw.Annotations` and provider-specific (incl. `cloudflare-proxied`) from the ROUTE's annotations;
|
||||
`annotations.SetAnnotationPrefix` re-derives `CloudflarePrefix`, so the alpha-prefixed key is the
|
||||
one read here; `provider/cloudflare` `shouldBeProxied` starts from `--cloudflare-proxied` and a
|
||||
parsed `"false"` annotation overrides it.
|
||||
- (8b) only forbids `annotation-prefix` / `enable-legacy-annotation-prefix` in extraArgs; a mutation
|
||||
confirmed `--cloudflare-proxied` passes and `--annotation-prefix=x/` fails.
|
||||
- The L0 check passes (116 assertions) and each new assertion FAILS on its mutation: Gateway target
|
||||
back to wan (9a), flag removed (9a), unifi proxied "true" (9b), headlamp undisowned (9c,9d), plex
|
||||
golden undisowned (9c,9d,(1)), jackett removed from the tunnel's 404 list (9c).
|
||||
`cloudflared-ingress-rules` still passes; `render.py --selftest` PASS.
|
||||
- Live, read-only (kubectl k3s-prod, ~23:55Z): 17 HTTPRoutes; the six internal apps carry
|
||||
`controller: none`; plex and headlamp do not (yet); the only Services with an external-dns
|
||||
hostname are crafty/crafty-l4-tcp and unifi/unifi-l4-tcp; Gateway target is currently
|
||||
`wan.home.keithjackson.dev`.
|
||||
- Toolbox gate result: see the PR description (rc and skipped stages quoted there).
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- Anything after merge: that external-dns actually UPDATEs the eight public names to proxied CNAMEs
|
||||
to the tunnel, DELETEs plex + headlamp and their TXT, and leaves unifi/crafty-mc/wan untouched.
|
||||
Expected effect is listed in the PR; the PM must read external-dns logs and `dig @1.1.1.1`.
|
||||
- A rendered HelmRelease: I read the chart 1.23.0 `templates/deployment.yaml` (a list `extraArgs`
|
||||
is emitted item-by-item via `tpl . $ | quote`), but did not `helm template` it.
|
||||
- kubectl OIDC from nodes: nodes resolve publicly, so `keycloak.` discovery/JWKS now goes through
|
||||
Cloudflare + the tunnel. No `oidc-ca-file` is set (system roots), so the edge cert should validate,
|
||||
but Cloudflare bot/WAF settings on that path are untested.
|
||||
- RISK: if the annotation prefix stops being honoured at runtime again (11-07 incident class),
|
||||
`target` is ignored and every name becomes a PROXIED A record at 192.168.2.20, while internal
|
||||
names publish. Assertion (8) only keeps the tree and `values.annotationPrefix` consistent; it
|
||||
cannot catch a controller that ignores the flag. No runtime alert exists for it.
|
||||
- The router WAN 80/443 close is an operator step, not done. The Pi-hole wildcard trim is a later PR.
|
||||
- Follow-up: spec requirement IDs for this (proposed R-DNS-04/05, R-SEC-04) once the spec ADR merges;
|
||||
`platform/pm-agent/README.md` and a comment in `catalog/pihole.yaml` still describe the wan. CNAME.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
date: 2026-11-08
|
||||
branch: fix/wg-client-mtu-docs
|
||||
title: WireGuard client profiles default to MTU 1280; VPN runbook corrected and given a troubleshooting section
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **`infra/vpn-ingress/new-peer.sh`**: `WG_MTU` default 1420 -> 1280, still overridable, reason in a
|
||||
comment. Only new profiles change; the phone's existing profiles are not touched.
|
||||
- **`runbooks/vpn-remote-access.md`**: the verification table no longer says the Gateway sees a
|
||||
`host`-identity source; the limitations no longer say everything arrives SNATed or that IPv6 leaks
|
||||
on Android; the "if nothing handshakes" advice no longer claims `wg show` separates a bad forward
|
||||
from a bad key. New "Troubleshooting" section covering the four cases in the brief.
|
||||
- **`platform/vpn-ingress/daemonset.yaml`**: header comment only, SNAT claim corrected.
|
||||
- **`platform/vpn-ingress/README.md`**: one sentence, so `MTU = 1420` reads as the server's value.
|
||||
|
||||
Rollout on merge: the WireGuard DaemonSet does **not** roll. The `runbooks` ConfigMap is content
|
||||
hashed and `vpn-remote-access.md` is in it, so the runbooks site rolls.
|
||||
|
||||
Left unchanged, for the operator:
|
||||
|
||||
- **`platform/vpn-ingress/peers.conf`** still says "No peers enrolled yet". A comment-only edit on a
|
||||
throwaway copy moved the ConfigMap from `wg-peers-29mf6k867t` to `wg-peers-gggkk5d4tm` and changed
|
||||
the DaemonSet, so correcting it restarts every `wg0`. Fix it alongside the next peer change.
|
||||
- **`clusters/base/cluster-vars.yaml`** coverage comment still omits `WG_CLIENT_CIDR` (sensitive path).
|
||||
- **Server `wg0` MTU stays 1420**, because changing it restarts all WireGuard pods. For TCP a 1280
|
||||
client should be enough on its own: it advertises an MSS of 1240, which caps what the far end
|
||||
sends. That protection does not reach UDP. A server-to-phone datagram with an inner size of
|
||||
1281-1420 bytes is still sent as up to 1480 outer bytes and could be lost on the same path; QUIC
|
||||
should recover by probing, large DNS answers are the likeliest casualty. If that shows up, the
|
||||
fix is a lower server MTU, as a deliberate rollout.
|
||||
- **Other stale text, outside this brief:** `platform/vpn-ingress/README.md` still says no peer has
|
||||
ever completed a handshake, and the runbook's status line still calls the VPN inert.
|
||||
|
||||
## What I verified
|
||||
|
||||
- Upstream `wireguard-android` `GoBackend.java`, master (last commit to that file `ecc3c0a`,
|
||||
2026-03-15): `builder.setMtu(config.getInterface().getMtu().orElse(1280))`, and `allowFamily()` is
|
||||
skipped when `sawDefaultRoute && config.getPeers().size() == 1`.
|
||||
- Live, read-only, 2026-11-09 UTC, on the `k3s-prod-1` pod and agent:
|
||||
- `wg0` MTU is 1420 on all three pods. Both peers have handshakes and transfer on prod-1 only; the
|
||||
other two pods read `0`.
|
||||
- BPF conntrack holds `UDP SVC 10.90.0.x -> 192.168.2.23:53` and flows to `192.168.2.24:32400`.
|
||||
Their backend entries carry `SourceSecurityID=2` (`world`). So the source stays `10.90.0.x`
|
||||
through the load balancer on those two VIPs.
|
||||
- `/proc/net/nf_conntrack` is readable from the pod, which has no `conntrack` binary.
|
||||
- Offline, pinned toolbox: `kubectl kustomize platform/vpn-ingress` gives identical ConfigMap and
|
||||
DaemonSet hashes on `origin/main` and on this branch.
|
||||
- `new-peer.sh` writes `MTU = 1280` by default and `MTU = 1420` with `WG_MTU=1420`. Run with
|
||||
throwaway keys and stdout discarded; profiles shredded; nothing enrolled.
|
||||
- No test or conformance check pins the client MTU (`grep` for `WG_MTU` and `1420`).
|
||||
- Gate (pinned toolbox, from this worktree, on the committed tree): **rc 3, UNDETERMINED, not green.**
|
||||
`gitea-settings` is the only stage counted as not run (no token locally). `conformance` (L2/L3)
|
||||
also skipped as the declared no-cluster gap, so R-ENV-01 and R-OBS-04 are not certified.
|
||||
`spec-immutability`, `approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden` and `l1`
|
||||
passed. No sensitive path is touched.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That MTU was the cause.** It is inferred from one before-and-after, on one phone and one carrier,
|
||||
reported by the operator. The path MTU was never measured.
|
||||
- **That 1280 is the best value.** It is the value that worked, and the IPv6 minimum. A higher one
|
||||
may work too.
|
||||
- That the phone's app is on `GoBackend` rather than the kernel backend, so that a blank field meant
|
||||
1280 on this device. Only the upstream default was read.
|
||||
- That TCP MSS behaves as described, or that large UDP replies are actually lost. No packet capture
|
||||
was taken.
|
||||
- That the Gateway VIP `.20` sees `10.90.0.x` / `world`. Conntrack held no tunnel flows to it when
|
||||
checked.
|
||||
- Android's IPv6 blocking on a real device; read from source only.
|
||||
- `wg-quick` IPv6 behaviour on Linux, and any non-Android client at 1280.
|
||||
- `/proc/net/nf_conntrack` output during a key mismatch. The `[UNREPLIED]` reading comes from the
|
||||
earlier measurement in the brief; I saw only healthy entries.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
date: 2026-11-08
|
||||
branch: fix/wg-home-peer-pihole-sources
|
||||
title: Enrol keith-phone-home and admit the WireGuard subnet to PiHole's DNS frontends
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **`platform/vpn-ingress/peers.conf`**: new peer `keith-phone-home`, `10.90.0.3/32`, key generated
|
||||
on the phone. It is the `home` half of a two-tunnel profile (Endpoint `192.168.2.10:51820`),
|
||||
because the router has no hairpin NAT and `wan.` is unusable from inside the house.
|
||||
- **`apps/wave-0/pihole-dns.yaml`**: `${WG_CLIENT_CIDR}` added after `${LAN_CIDR}` on both
|
||||
`pihole-dns-udp` and `pihole-dns-tcp`. This file is hand-written, not rendered (`catalog/render.py`
|
||||
only lists it in `EXTRA_UNITS`), so it is the source of truth and `--emit-waves` was not run.
|
||||
`catalog/pihole.yaml` changes in one comment only, so nothing renders differently.
|
||||
- **New L0 guard `conformance/tests/l0-static/pihole-dns-source-ranges`** (whole-repo scope,
|
||||
R-DNS-03): every PiHole LoadBalancer Service lists both tokens, and every range resolves through
|
||||
cluster-vars (base and prod overlay) to RFC1918.
|
||||
- **`platform/vpn-ingress/README.md`**: corrected "clients arrive SNATed" (false for LoadBalancer
|
||||
VIPs) and the IPv6 claim (Android's `GoBackend` blocks IPv6 for a single-peer default route);
|
||||
documented the home/away pattern, why `wan.` fails from inside, and the prod-1 dependency.
|
||||
|
||||
Rollout on merge: the `wg-peers` ConfigMap hash changes (`m9kcckcb5t` -> `29mf6k867t`), so the
|
||||
DaemonSet rolls, `maxUnavailable: 1`, `maxSurge: 0`, one node at a time in an order the controller
|
||||
chooses. Each roll deletes and recreates that node's `wg0`, so an established session on that node
|
||||
drops and must re-handshake. `keith-phone` has no session today (see below), so nothing is
|
||||
interrupted in practice.
|
||||
|
||||
**The one risk worth watching:** `wg0` is in Cilium's auto-detected device list and `devices` is
|
||||
still unpinned. On 2026-10-06 a crash-looping `wg0` churned that list and all three nodes lost their
|
||||
main-table default route. A rollout is one delete/create per node, not a loop, so it should not
|
||||
repeat that, but after merge check `ip route get 1.1.1.1` on each node.
|
||||
|
||||
The Service change is an in-place update: same VIP, same Service objects, no pod restart.
|
||||
|
||||
Checked on the rest of the path and left unchanged:
|
||||
|
||||
- Gateway VIP `.20` (`gateway/cilium-gateway-shared`): no source ranges, live. Nothing needed.
|
||||
- `pihole-allow-lan-dns-ingress` admits `world` on :53, which covers an untranslated 10.90.0.x
|
||||
source. `platform/vpn-ingress/networkpolicy.yaml` does not apply to a hostNetwork pod.
|
||||
- No L0 check pinned these ranges to the LAN; none needed updating.
|
||||
|
||||
Flagged, not changed (out of scope):
|
||||
|
||||
- `runbooks/vpn-remote-access.md` "Known limitations" repeats both corrected claims, and step 3 of
|
||||
its verification table says the Gateway sees a `host`-identity source.
|
||||
- `platform/vpn-ingress/daemonset.yaml`'s header repeats the SNAT claim.
|
||||
- `clusters/base/cluster-vars.yaml`'s substitution-coverage comment does not list
|
||||
`WG_CLIENT_CIDR` under apps-wave-0. The variable is defined and substituted there regardless.
|
||||
- `peers.conf` still says "No peers enrolled yet" above the enrolled peers.
|
||||
- PiHole's NodePorts are not covered by source ranges (already acknowledged in `pihole-dns.yaml`).
|
||||
Tunnel clients can reach node addresses, so they can reach those too; they are enrolled devices.
|
||||
- Pinning Cilium's `devices` remains the open hardening for the churn risk above.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live, read-only, 2026-11-08:
|
||||
|
||||
- `cilium-dbg status` on one agent: `KubeProxyReplacement: True [eno1 ..., wg0 10.90.0.1, tun0 ...]`
|
||||
and `Masquerading: BPF [eno1, wg0, tun0]`. So `wg0` really is a load-balancing device.
|
||||
- `pihole-dns-tcp` and `-udp` carry `ranges=[192.168.2.0/24]`; no other LoadBalancer Service has
|
||||
source ranges. Live `cluster-vars`: `WG_CLIENT_CIDR=10.90.0.0/24`, `LAN_CIDR=192.168.2.0/24`.
|
||||
- `wg show wg0 latest-handshakes` on all three pods: `keith-phone` = `0`.
|
||||
- `wan.home.keithjackson.dev` resolves publicly to a single A record (via 1.1.1.1).
|
||||
|
||||
Offline, in the pinned toolbox:
|
||||
|
||||
- `flux envsubst --strict` renders both Services to `[192.168.2.0/24, 10.90.0.0/24]`.
|
||||
- `kubectl kustomize platform/vpn-ingress`: `wg-peers-m9kcckcb5t` on the old `peers.conf`,
|
||||
`wg-peers-29mf6k867t` on the new one.
|
||||
- Guard mutation test, each restored afterwards. Baseline passes (22 assertions, 2 Services). All
|
||||
seven fail with rc 1 and a named message: WG range dropped from one Service; `0.0.0.0/0` added;
|
||||
`8.8.8.0/24` added; `WG_CLIENT_CIDR` made public in cluster-vars; prod overlay sets `LAN_CIDR` to
|
||||
`0.0.0.0/0`; ranges block removed; undefined token added.
|
||||
- The Android claim was read from upstream `GoBackend.java` (`wireguard-android`, master): the
|
||||
`allowFamily` calls are skipped when `sawDefaultRoute && config.getPeers().size() == 1`.
|
||||
- Gate (pinned toolbox, from this worktree, on the committed tree): **rc 3, UNDETERMINED, not
|
||||
green.** `gitea-settings` is the only stage counted as not run (no token locally). `conformance`
|
||||
(L2/L3) also skipped as the declared no-cluster gap. `spec-immutability`, `decisions-log`, `lint`,
|
||||
`l0` (including the new guard), `render-golden` and `l1` passed. `approval-policy` reported N/A
|
||||
offline; it will be red in CI until the operator overrides, because `conformance/**` is sensitive.
|
||||
- `git merge-tree` against `feat/pihole-explicit-lan-names` (#157): no conflicts.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **`DROP_NOT_IN_SRC_RANGE` was NOT observed live.** It is a prediction from Cilium's source. Only
|
||||
the `wg0` device membership was measured; the drop itself has never been seen.
|
||||
- **Full-tunnel behaviour was NOT observed live**, on either profile. **No handshake has ever been
|
||||
recorded for any peer**: `keith-phone` reads `0` on all three pods (a counter that resets when a
|
||||
pod restarts, last 22 days ago, so it covers only the time since; earlier entries here also
|
||||
record none).
|
||||
- That tunnel DNS works after this change, including the reply path from a backend on another node
|
||||
and from one on the ingress node. That needs the phone: `dig` over the tunnel, while watching
|
||||
`cilium-dbg monitor --type drop`.
|
||||
- That the Gateway serves tunnel clients. Its source is predicted to be `world`, as for a LAN
|
||||
browser, which is what the apps admit; not tested.
|
||||
- That the new peer's key is the one on the phone. I copied the public key from the brief.
|
||||
- That the rollout leaves node default routes intact. Reasoned from the 2026-10-06 incident being
|
||||
a loop, not measured.
|
||||
- That a LAN client is unaffected while the Service's source ranges are updated. Expected, not
|
||||
measured.
|
||||
- IPv6 behaviour on any non-Android client.
|
||||
- That the WAN forward targets prod-1: it is off-repo.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
date: 2026-11-09
|
||||
branch: feat/pihole-explicit-lan-names
|
||||
title: PiHole answers an explicit LAN name list instead of a zone wildcard (public names resolve to Cloudflare from the LAN)
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**`catalog/pihole.yaml` → `apps/wave-0/pihole.yaml`.** `FTLCONF_misc_dnsmasq_lines` no longer carries
|
||||
`address=/${BASE_DOMAIN}/${GATEWAY_VIP}`. Every name not listed is now forwarded to 8.8.8.8/8.8.4.4 and
|
||||
gets the public answer. The final list (`local-ttl=60` kept):
|
||||
|
||||
| Name (`.${BASE_DOMAIN}`) | Answer | Why |
|
||||
|---|---|---|
|
||||
| jackett, pihole, plex, qbittorrent, radarr, semaphore, sonarr, headlamp | `${GATEWAY_VIP}` | LAN-only: every HTTPRoute with `external-dns.alpha.kubernetes.io/controller: none`, derived from the tree. No public record, tunnel 404s them; this entry is their only path |
|
||||
| gitea, nextcloud | `${GATEWAY_VIP}` | public, but Cloudflare's proxy caps bodies at 100 MB / responses at 100 s (git push, LFS, uploads) |
|
||||
| keycloak, auth | `${GATEWAY_VIP}` | keep the OIDC flow of the LAN-path apps on one path |
|
||||
| unifi | `${UNIFI_L4_VIP}` | L4 VIP, not behind the Gateway |
|
||||
| crafty-mc | `${CRAFTY_L4_VIP}` | L4 VIP, not behind the Gateway |
|
||||
| pihole-dns | `${PIHOLE_DNS_VIP}` | the resolver's own address |
|
||||
| plex-vip | `${PLEX_L4_VIP}` | kept, see below |
|
||||
|
||||
Not listed, so they resolve to Cloudflare from the LAN: the apex, grafana, crafty, seerr.
|
||||
|
||||
**The derived LAN-only set matches the brief's list exactly** (8 names; the tree was walked, not the
|
||||
list trusted). The `runbooks/serving` route has no `hostnames:` but is not disowned (it inherits the
|
||||
apex, public).
|
||||
|
||||
**plex-vip is kept.** Nothing advertises it any more (`ADVERTISE_IP` is a `plex.direct` name since
|
||||
2026-10-05), but it is not unused: it is Plex's `customCertificateDomain` (`CERTDOMAIN`, Secret
|
||||
`plex-vip-tls`), the name of the tcp/32400 VIP, and the 404'd name in the tunnel config. It has no
|
||||
public record, so this entry is its only resolution.
|
||||
|
||||
**New L0 check `conformance/tests/l0-static/pihole-lan-names`** (`scope: whole-repo`, so picked up by
|
||||
the runner's scope selection with no other wiring). Asserts: (a) no zone-wide entry (zone, `#`, or any
|
||||
parent of the zone); (b) every `controller: none` hostname → `${GATEWAY_VIP}`, and a disowned route
|
||||
with no `hostnames:` fails; (c) every tunnel-404 hostname has an entry, with the L4 names (derived
|
||||
from Services carrying both the external-dns hostname and an lbipam VIP) on their own VIP; (d) gitea
|
||||
and nextcloud → `${GATEWAY_VIP}`; plus catalog == rendered value and a built-in mutation self-test.
|
||||
|
||||
**Stale docs corrected:** `platform/pm-agent/README.md`, `runbooks/pm-agent.md`,
|
||||
`platform/externaldns/README.md`, `platform/cloudflared/README.md`, `platform/gateway/gateway.yaml`
|
||||
(comment), `platform/headlamp/httproute.yaml` (comment), `apps/vms/unifi/README.md`, `README.md`,
|
||||
`schemas/renderer-contract.md`; `runbooks/cloudflare-tunnel.md` gains the two consequences below.
|
||||
`platform/cloudflared/deployment.yaml`: the image entrypoint comment was wrong (comment-only).
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Render:** the baseline `render.py --no-image-automation catalog/pihole.yaml` was byte-identical to
|
||||
the committed `apps/wave-0/pihole.yaml` before the change, and the file was replaced by that
|
||||
command's output after it; `--emit-waves` was not run.
|
||||
- **Mutation, against the real files (10 mutants, each rc 1 with the right assertion, each restored):**
|
||||
(a) zone wildcard restored; (b) headlamp and sonarr entries removed; (b) a disowned route with no
|
||||
`hostnames:`; (c) a new tunnel-404 host with no entry; (c) pihole-dns removed; (c) unifi pointed at
|
||||
the Gateway; (d) gitea removed; (d) nextcloud repointed; (0) rendered value drifted from the catalog.
|
||||
- **Rollout mechanics, measured live (read-only):** StatefulSet `updateStrategy` RollingUpdate
|
||||
partition 0, `podManagementPolicy: Parallel`, no `minReadySeconds`, 2 replicas, PDB minAvailable 1.
|
||||
The last rollout (2026-11-08 06:47–06:48Z) replaced pihole-1 then pihole-0: FTL logged "started" ~1 s
|
||||
after the container, the pod went Ready ~16 s after the container, and pihole-0 started ~9 s later.
|
||||
- **Expected LAN DNS blip:** no total outage; one replica answers throughout. Each of the two pod
|
||||
replacements can lose a few UDP queries for ~1–2 s while the terminating pod leaves the Service
|
||||
(clients retry). For roughly 30–60 s the two pods answer differently for the apex, grafana, crafty
|
||||
and seerr; both answers work. Cached answers age out in 60 s (`local-ttl`).
|
||||
- **Live DNS before the change:** @8.8.8.8 returns Cloudflare (104.21.51.91 / 172.67.178.27) for the
|
||||
apex, grafana, seerr, crafty and gitea, and nothing for jackett; @192.168.2.23 returns 192.168.2.20
|
||||
for all of them (the wildcard).
|
||||
- **Cloudflared image:** `skopeo inspect --config` on the pinned digest (amd64): Entrypoint
|
||||
`["cloudflared"]`, Cmd `["version"]`, User `65532:65532`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The post-merge behaviour.** Nothing here ran against a live PiHole with the new list; the
|
||||
expected answers are from dnsmasq semantics and the tree. After reconcile, check
|
||||
`dig @192.168.2.23` for one name per row, and grafana returning Cloudflare.
|
||||
- **Whether every LAN client path works via Cloudflare.** Measured 2xx/3xx through Cloudflare by the
|
||||
brief, not re-measured from a LAN client here.
|
||||
- **The FTL DNS-listening moment.** "FTL started" is a log line, not a successful query; the rollout
|
||||
timing above assumes DNS is up shortly after it.
|
||||
|
||||
**Accepted consequences, stated plainly:**
|
||||
- The apex, grafana, crafty and seerr now depend on the internet and the tunnel from the LAN too. An
|
||||
ISP or Cloudflare outage, or stopping the connectors, makes them unreachable from the LAN where they
|
||||
previously kept working. If grafana in particular should survive an internet outage, add it to the
|
||||
list (one entry; the check permits it).
|
||||
- The tunnel rollback (revert to `wan.`) now also requires restoring the wildcard; recorded in
|
||||
`runbooks/cloudflare-tunnel.md`.
|
||||
|
||||
**Flagged, not changed:**
|
||||
- The `pihole` container has no readiness probe; only the `oauth2-proxy` sidecar does, so "Ready" says
|
||||
nothing about DNS. The measured margin (FTL ~1 s, Ready ~16 s) makes this benign in a normal rollout
|
||||
but a slow FTL start would not hold the rollout back. A DNS readiness probe needs a schema/renderer
|
||||
decision.
|
||||
|
||||
**Stale statements left alone:** `platform/coredns/coredns-custom.yaml` header ("both resolve the whole
|
||||
zone ... by wildcard", brief said not to touch CoreDNS); `clusters/base/cluster-vars.yaml` comments
|
||||
(sensitive path); `schemas/renderer-contract.md` §`exposure` first bullets (manual edge port-forward
|
||||
wording); historical `infra/PROD-PROVISIONING.md`, `platform/externaldns/README.md` TODO list.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
date: 2026-11-09
|
||||
branch: feat/vuln-visibility
|
||||
title: Automated CVE visibility for production (R-VULN-01..09, ADR-0027)
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Implements `docs/17-vulnerability-visibility.md` from spec PR Home-Network/home-network-k3s#4
|
||||
(commit `0fb44bb`, open, not merged). `ci/SPEC_PIN` is deliberately not bumped: the merge SHA does
|
||||
not exist yet, and open PR #160 owns the pin backlog.
|
||||
|
||||
- `platform/vuln-scanner/`: stdlib Python scanner in a ConfigMap on a digest-pinned
|
||||
`python:3.14.9-alpine`, Trivy 0.76.1 by digest copied in by an initContainer, a per-app
|
||||
oauth2-proxy sidecar for `cve.${BASE_DOMAIN}`, read-only ClusterRole, RWO Longhorn PVC for the
|
||||
report and state, size-limited emptyDir for the DB and scan cache, pinned Service ClusterIP
|
||||
`10.43.210.10` as the bot's DNS fallback, five alerts with promtool tests and a runbook.
|
||||
- `clusters/base/vuln-scanner.yaml`: its own Flux tier, `wait: false`, `dependsOn: platform-config`,
|
||||
nothing depends on it.
|
||||
- `.gitea/workflows/cve-triage.yaml` and `scripts/cve_triage.py`: the bot, cron `15 7 * * *`,
|
||||
counts-only `CVE Dashboard` issue with its own `cve-dashboard` label, approve-row ticks only.
|
||||
- `renovate.json5`: Trivy on the blast-radius list, 7-day minimum release age, and everything under
|
||||
`platform/vuln-scanner/**` non-automerging.
|
||||
- `conformance/`: R-VULN-01..09 in the registry; L0 checks and L1 fixture tests (scanner, page,
|
||||
bot) wired by scope, L2/L3 authored as scaffolds.
|
||||
- PiHole LAN names and the Homepage link gained the CVE page.
|
||||
|
||||
### Decisions a future reader will want
|
||||
|
||||
- Page is server-rendered with **no JavaScript**, not inline JS: the CSP has no `script-src`,
|
||||
escaping is in one place, and the L1 test exercises the real rendered page, which a JS page could
|
||||
not do without a browser engine. Filters, sort and pagination are URL parameters.
|
||||
- Three pod ports, not two. Prometheus gets 9090 only, so it cannot read `report.json`.
|
||||
- A killed image (OOM, eviction) is recorded as failed from an in-flight marker and sits out full
|
||||
runs for 20h, so it cannot wedge the scan loop.
|
||||
- Private registries are named in `VULN_PRIVATE_REGISTRIES` and short-circuit to
|
||||
`credential-required` before Trivy runs (see escalation 1).
|
||||
- Bot's job container is the repo's toolbox image by digest: `actions/checkout` needs node and the
|
||||
pull is already proven by the merge gate.
|
||||
|
||||
### Spec escalations
|
||||
|
||||
1. Coverage state `credential-required` is defined as "the registry refused anonymous access", but
|
||||
the egress contract excludes LAN ranges, so the scanner cannot reach the private registry to be
|
||||
refused. Implemented by naming private registries in configuration. The spec should define the
|
||||
state by configuration or admit the registry to egress.
|
||||
2. The egress contract lists only the Kubernetes API, DNS and public TCP/443, but the SSO sidecar
|
||||
must reach Keycloak. Egress additionally allows the Gateway on 443 and Keycloak. The spec should
|
||||
name this path.
|
||||
|
||||
## What I verified
|
||||
|
||||
- Toolbox gate on the integrated tree: **rc 3 (UNDETERMINED, not green)**. Stages that did not run:
|
||||
`spec-immutability` and `gitea-settings`; `conformance` (L2/L3) logged SKIPPED, no cluster.
|
||||
`approval-policy`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` reported green.
|
||||
`approval-policy` is N/A offline and proves nothing locally.
|
||||
- All `vuln-*` L0 and L1 checks pass in the toolbox, each with a mutated-fixture non-vacuity case.
|
||||
- `renovate-config-validator` from `ghcr.io/renovatebot/renovate:44.167.0`, the tag `renovate.yaml`
|
||||
runs: validated successfully. This is a read-only container run of a public image.
|
||||
- Trivy 0.76.1 digest: registry `created` 2026-10-21, 19 days old at authoring; cosign keyless
|
||||
verification passed (cosign 3.1.3); `cp` and the CA bundle confirmed from a layer listing.
|
||||
- ClusterIP `10.43.210.10` confirmed unused against the live Service list, read-only.
|
||||
- An independent reviewer checked the diff against docs/17 and found no anonymous leak path, no way
|
||||
for an unscanned image to read as clean, no way for the bot to tick a `rebase-branch` row, and no
|
||||
platform stall path. One BLOCKER and five other code findings were fixed with tests.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
Nothing here has been deployed. No runtime behaviour has been observed.
|
||||
|
||||
- **R-VULN-04 is NOT delivered until the operator adds the Keycloak client.** It needs a new key in
|
||||
`platform/keycloak/keycloak-realm-clients.sops.yaml`, which needs the age private key, and a
|
||||
one-time `kcadm` against the live realm. Exact steps: `platform/vuln-scanner/README.md`, "Operator
|
||||
action required". Until then login fails closed with `invalid_client`. Those steps are UNVERIFIED.
|
||||
- A real Trivy run: every scanner test uses a recorded fixture and a fake runner.
|
||||
- The real Kubernetes API, real browser rendering, OIDC login and the `platform-admins` restriction.
|
||||
- Whether in-cluster DNS resolves from a DinD job container, and whether the pinned ClusterIP
|
||||
fallback is reachable from one. The workflow has never run; Gitea behaviour is tested against a
|
||||
fake only.
|
||||
- The `## Pending Approval` row format is from Renovate's source as recalled, because issue #91 has
|
||||
no live approve row today. The `approve-branch` marker itself is documented.
|
||||
- L2 and L3 are scaffolds and do not run in CI.
|
||||
- Renovate `minimumReleaseAge` behaviour on a real update, as opposed to the config validating.
|
||||
- Cgroup v2 memory behaviour under the scanner's limits is inferred, not measured.
|
||||
@@ -53,6 +53,7 @@
|
||||
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
|
||||
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
|
||||
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
|
||||
# WG_MTU default 1280 (client tunnel MTU; see the comment on the default below)
|
||||
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
|
||||
#
|
||||
# REQUIRES: `wg`, `qrencode`, `sops` — all three in the dev container.
|
||||
@@ -80,7 +81,14 @@ PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.conf"
|
||||
# cannot resolve until it is already connected.
|
||||
: "${WG_ENDPOINT_HOST:=wan.home.keithjackson.dev}"
|
||||
: "${WG_LISTEN_PORT:=51820}"
|
||||
: "${WG_MTU:=1420}"
|
||||
# The CLIENT's tunnel MTU. Deliberately lower than the server's 1420 (daemonset.yaml), which only
|
||||
# describes the 1500-byte path at the node's end. A phone's path is the carrier's, often behind
|
||||
# carrier NAT, and is not known to carry 1500. Measured 2026-11-09, one Android phone on one
|
||||
# carrier: at 1420 the handshake was healthy but names and pages failed; clearing the app's MTU
|
||||
# field (its userspace backend then uses 1280) fixed it. 1280 is also the IPv6 minimum link MTU, so
|
||||
# it stays valid if the tunnel ever gains IPv6. For a device only ever on a wired or known-1500
|
||||
# path, WG_MTU=1420 recovers about 10% of per-packet payload.
|
||||
: "${WG_MTU:=1280}"
|
||||
|
||||
SPLIT=0
|
||||
OUT_DIR=""
|
||||
|
||||
@@ -17,6 +17,8 @@ outbound-only from the cluster (no router port forward, no hairpin, no split-DNS
|
||||
| `networkpolicy.yaml` | default-deny (single owner — not an app namespace) + Prometheus scrape allow + egress CNP: world 7844 tcp/udp and 443, the Gateway (`ingress` entity), and Gateway backends by `gateway-access=shared` minus the LAN-only namespaces. |
|
||||
| `servicemonitor.yaml`, `alerts.yaml` | Headless metrics Service + ServiceMonitor scraping :2000, and the two alerts, both written to fire (not go quiet) when their series vanish. |
|
||||
|
||||
**Phase 1 only.** This component changes no live traffic: Cloudflare sends a hostname through the
|
||||
tunnel only when its DNS record is a CNAME to `<UUID>.cfargotunnel.com`, and nothing here creates
|
||||
one. Moving DNS (external-dns, Gateway annotations, Pi-hole/CoreDNS split horizon) is a later change.
|
||||
Cloudflare sends a hostname through the tunnel only when its DNS record is a proxied CNAME to
|
||||
`<UUID>.cfargotunnel.com`; nothing in this directory creates one. Those records are published by
|
||||
external-dns from the shared Gateway's target annotation (Phase 2, PR #156; see
|
||||
`platform/externaldns/README.md`). LAN resolution: `catalog/pihole.yaml` (explicit names only since
|
||||
2026-11-09), so public names reach Cloudflare from the LAN too.
|
||||
@@ -54,6 +54,10 @@ ingress:
|
||||
# tunnel alongside decision 4.
|
||||
- hostname: headlamp.${BASE_DOMAIN}
|
||||
service: http_status:404
|
||||
# The CVE page (docs/17: internal only, a list of what is exploitable where). Its route is disowned
|
||||
# from public DNS (platform/vuln-scanner/httproute.yaml); this is the second layer.
|
||||
- hostname: cve.${BASE_DOMAIN}
|
||||
service: http_status:404
|
||||
# Non-HTTP L4 VIPs (their own LoadBalancer addresses, not the Gateway). Listed so a DNS flip
|
||||
# that CNAMEs them at the tunnel by mistake gets a 404, not the Gateway's catch-all.
|
||||
- hostname: unifi.${BASE_DOMAIN}
|
||||
|
||||
@@ -47,6 +47,14 @@ spec:
|
||||
# The image is distroless `nonroot` (65532).
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
# fsGroup makes the kubelet GROUP-own the Secret volume's files to 65532. Without it they
|
||||
# are root:root, and defaultMode 0440 (below) is unreadable to uid 65532 — every replica
|
||||
# CrashLooped on "couldn't read tunnel credentials ... permission denied" (2026-11-08),
|
||||
# which cloudflared then misreports as "Error locating origin cert". Same lesson as
|
||||
# platform/keycloak/client-secret-reconciler.yaml. fsGroup + 0440 keeps the credential
|
||||
# unreadable to any other uid rather than relaxing it to world-readable 0444.
|
||||
# Pinned by the L0 check cloudflared-ingress-rules, assertion (7).
|
||||
fsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
topologySpreadConstraints:
|
||||
@@ -62,8 +70,9 @@ spec:
|
||||
- name: cloudflared
|
||||
# Tag + digest: Renovate's `kubernetes` manager (renovate.json5, platform/**) bumps both.
|
||||
image: docker.io/cloudflare/cloudflared:2026.10.7@sha256:4955524aeaa379e154023d5a027c250f7b35041a2e7e950cf13f144bc9b9c5ba
|
||||
# The image ENTRYPOINT is `cloudflared --no-autoupdate`; these replace its CMD. The tunnel
|
||||
# UUID, credentials path, metrics address and ingress rules all come from the file.
|
||||
# The image ENTRYPOINT is `cloudflared` and its CMD is `version` (read from the pinned
|
||||
# digest's image config, 2026-11-09); these args replace that CMD. `no-autoupdate`, the
|
||||
# tunnel UUID, credentials path, metrics address and ingress rules all come from the file.
|
||||
args:
|
||||
- tunnel
|
||||
- --config
|
||||
@@ -121,6 +130,8 @@ spec:
|
||||
secret:
|
||||
# cloudflared-credentials.sops.yaml — the tunnel credentials JSON, by name only.
|
||||
secretName: cloudflared-credentials
|
||||
# 0440, owner root, group fsGroup 65532 (pod securityContext) — readable by the
|
||||
# container user through its group, by nobody else.
|
||||
defaultMode: 0440
|
||||
---
|
||||
# One replica may be disrupted at a time — enough for kured's one-node-at-a-time drain to
|
||||
|
||||
@@ -147,3 +147,4 @@ spec:
|
||||
- radarr
|
||||
- sonarr
|
||||
- headlamp
|
||||
- vuln-scanner
|
||||
@@ -101,6 +101,11 @@ metadata:
|
||||
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
|
||||
# external-dns publishes A crafty-mc.home.keithjackson.dev -> the pinned VIP (Java server target).
|
||||
external-dns.alpha.kubernetes.io/hostname: "crafty-mc.${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
|
||||
|
||||
@@ -20,29 +20,49 @@ A wildcard `*.keithjackson.dev` matches only one label deep, so `<svc>.home.keit
|
||||
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
|
||||
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
|
||||
|
||||
## Public target + in-cluster DDNS (2026-10-03)
|
||||
## Public target: the Cloudflare Tunnel, proxied (2026-11-08)
|
||||
|
||||
Gateway-published records are **CNAMEs to `wan.home.keithjackson.dev`**, not the Gateway VIP:
|
||||
Gateway-published records are **proxied CNAMEs to the Cloudflare Tunnel**
|
||||
(`platform/cloudflared`), not the WAN IP and not the Gateway VIP:
|
||||
|
||||
```
|
||||
plex.home.keithjackson.dev CNAME wan.home.keithjackson.dev <- external-dns (Gateway `target` annotation)
|
||||
wan.home.keithjackson.dev A <current WAN IP> <- cloudflare-ddns (ddns.yaml), every 5m
|
||||
gitea.home.keithjackson.dev CNAME (proxied) fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com <- external-dns
|
||||
unifi.home.keithjackson.dev A (DNS-only) 192.168.2.21 <- external-dns, `service` source (L4)
|
||||
crafty-mc.home... A (DNS-only) 192.168.2.22 <- external-dns, `service` source (L4)
|
||||
wan.home.keithjackson.dev A (DNS-only) <current WAN IP> <- cloudflare-ddns (ddns.yaml): WireGuard udp/51820 ONLY
|
||||
```
|
||||
|
||||
- The `target` annotation is on the **shared Gateway** (`platform/gateway/gateway.yaml`), so it
|
||||
applies to every route that publishes at all. `internal` routes (`controller: none`) still publish
|
||||
nothing.
|
||||
- `ddns.yaml` runs `favonia/cloudflare-ddns` in this namespace with the same `cloudflare-api-token`.
|
||||
It detects the WAN IP through Cloudflare's trace endpoint, so the pod **must not** carry
|
||||
`egress-class: vpn`.
|
||||
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
|
||||
(split-horizon) and never use the public path.
|
||||
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node, the apiserver's
|
||||
OIDC issuer `keycloak.home.keithjackson.dev` resolves to the WAN IP. kubectl SSO therefore relies on
|
||||
the router hairpinning (NAT reflection) WAN:443 back to the Gateway.
|
||||
- The `target` annotation is on the **shared Gateway** (`platform/gateway/gateway.yaml`); external-dns
|
||||
reads it from the Gateway only, so it applies to every route that publishes at all. Its UUID must
|
||||
equal `tunnel:` in `platform/cloudflared/config.yaml` (L0 check
|
||||
`internal-services-publish-no-public-dns` (9a)).
|
||||
- `--cloudflare-proxied` (HelmRelease `extraArgs`) orange-clouds every record by default — the only
|
||||
practical lever, because v0.23.0 reads the per-object `cloudflare-proxied` annotation from the
|
||||
route/Service, never the Gateway. **Every `service`-source publisher must carry
|
||||
`external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"`** (L4 ports cannot ride the HTTP
|
||||
proxy); (9b) fails the gate otherwise.
|
||||
- `internal` routes (`controller: none`) still publish nothing. Plex's web UI and Headlamp joined that
|
||||
set on 2026-11-08 (LAN/VPN-only); the tunnel also 404s every such name (9c/9d).
|
||||
- Public answers are Cloudflare anycast **A and AAAA** — Cloudflare's edge, not an origin address.
|
||||
- **Pods** resolve the whole zone to the Gateway VIP via CoreDNS (split-horizon) and never use the
|
||||
public path. **LAN clients** use PiHole, which since 2026-11-09 answers an EXPLICIT list
|
||||
(`catalog/pihole.yaml`): the LAN-only names, plus gitea/nextcloud/keycloak/auth (kept off
|
||||
Cloudflare's 100 MB / 100 s limits and on one OIDC path), go to the Gateway VIP and the L4 names to
|
||||
their own VIPs; every other public name (apex, grafana, crafty, seerr) resolves to Cloudflare from
|
||||
the LAN exactly as from anywhere else, so no split-DNS is needed for them. Guard:
|
||||
`conformance/tests/l0-static/pihole-lan-names`.
|
||||
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node the apiserver's
|
||||
OIDC issuer `keycloak.home.keithjackson.dev` now resolves to Cloudflare and its discovery/JWKS
|
||||
fetches go through the tunnel (publicly trusted edge cert; no `oidc-ca-file` is configured). The
|
||||
router NAT hairpin is no longer on that path.
|
||||
|
||||
Check: `dig +short @1.1.1.1 plex.home.keithjackson.dev` → `wan.home.keithjackson.dev.` then the WAN IP;
|
||||
`kubectl -n external-dns logs deploy/cloudflare-ddns`.
|
||||
Check: `dig +short @1.1.1.1 gitea.home.keithjackson.dev` → Cloudflare anycast addresses (no CNAME is
|
||||
visible for a proxied record); `kubectl -n external-dns logs deploy/external-dns`.
|
||||
|
||||
### History
|
||||
|
||||
2026-10-03 → 2026-11-08: records were DNS-only CNAMEs to `wan.home.keithjackson.dev`, with the router
|
||||
forwarding WAN tcp/80+443 to the Gateway VIP. Before 2026-10-03 they answered the Gateway VIP itself.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
# In-cluster dynamic DNS for the home WAN (2026-10-03).
|
||||
#
|
||||
# Keeps ONE record, `wan.${BASE_DOMAIN}` (A, IPv4 only, not proxied), pointed at the home WAN's
|
||||
# current public IP. Every public name external-dns publishes from the shared Gateway is a CNAME to
|
||||
# it (platform/gateway/gateway.yaml `external-dns.alpha.kubernetes.io/target`), so a WAN IP change
|
||||
# moves all of them through this one record, with no Git change and no external-dns involvement.
|
||||
# current public IP.
|
||||
#
|
||||
# WIREGUARD-ONLY SINCE 2026-11-08. From 2026-10-03 every public name external-dns published from the
|
||||
# shared Gateway was a CNAME to this record. They are now proxied CNAMEs to the Cloudflare Tunnel
|
||||
# (platform/gateway/gateway.yaml `external-dns.alpha.kubernetes.io/target`), so NOTHING in the zone
|
||||
# points here any more. The record stays for exactly one consumer: the WireGuard VPN endpoint
|
||||
# (udp/51820, platform/vpn-ingress, infra/vpn-ingress/new-peer.sh), which cannot ride Cloudflare's
|
||||
# HTTP proxy and needs the real WAN address. It must stay DNS-only (`PROXIED` unset/false below) —
|
||||
# an orange-clouded A record would hand peers a Cloudflare anycast address that drops UDP.
|
||||
#
|
||||
# OWNERSHIP SPLIT WITH EXTERNAL-DNS. This record carries no external-dns TXT registry entry, so
|
||||
# external-dns (policy: sync, owner `prod`) never treats it as its own and never deletes it.
|
||||
@@ -64,7 +70,7 @@ spec:
|
||||
- name: IP6_PROVIDER
|
||||
value: "none" # IPv4 WAN only; leaves any AAAA alone
|
||||
- name: PROXIED
|
||||
value: "false" # DNS-only: Plex/Gitea/etc. terminate TLS on the Gateway
|
||||
value: "false" # DNS-only: WireGuard udp/51820 needs the real WAN IP
|
||||
- name: UPDATE_CRON
|
||||
value: "@every 5m"
|
||||
- name: DELETE_ON_STOP
|
||||
|
||||
@@ -107,29 +107,47 @@ spec:
|
||||
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
|
||||
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# THE PUBLIC TARGET — DECIDED 2026-10-03 (operator): every Gateway-published name goes public
|
||||
# THE PUBLIC TARGET — THE CLOUDFLARE TUNNEL, PROXIED (2026-11-08; supersedes 2026-10-03)
|
||||
#
|
||||
# Until 2026-10-03 every gateway-sourced record answered with the Gateway VIP (192.168.2.20,
|
||||
# RFC1918), so nothing published here was reachable from the internet. Two facts constrained the
|
||||
# fix, and both still hold:
|
||||
# * `external-dns.alpha.kubernetes.io/target` is read in v0.21.0's gateway-httproute source ONLY
|
||||
# from the GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
|
||||
# gw.Annotations)`) and from a ListenerSet listener — NEVER from an HTTPRoute. A target on the
|
||||
# shared Gateway therefore moves EVERY gateway-sourced record at once.
|
||||
# * The home WAN IP is residential and can change, so a literal IP in Git would rot.
|
||||
# History: until 2026-10-03 every gateway-sourced record answered the Gateway VIP (RFC1918);
|
||||
# from 2026-10-03 they were CNAMEs to `wan.${BASE_DOMAIN}` (DNS-only, the home WAN IP, with the
|
||||
# router forwarding tcp/80+443 to the VIP). Both published an origin address of ours. Now:
|
||||
# * platform/gateway/gateway.yaml sets `target: <tunnel-uuid>.cfargotunnel.com` on the shared
|
||||
# Gateway. `target` is read in v0.23.0's gateway-httproute source ONLY from the GATEWAY
|
||||
# (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(gw.Annotations)`),
|
||||
# never from an HTTPRoute, so this one value moves every gateway-sourced record at once.
|
||||
# * `--cloudflare-proxied` below orange-clouds every record by default. It is GLOBAL because
|
||||
# that is the only practical lever: v0.23.0 reads the per-object `cloudflare-proxied`
|
||||
# annotation from the ROUTE (or Service), never the Gateway (source/gateway.go:
|
||||
# `annotations.ProviderSpecificAnnotations(annots)` with `annots := meta.Annotations` of the
|
||||
# route), so a Gateway-level opt-in does not exist. A CNAME to cfargotunnel.com MUST be
|
||||
# proxied — DNS-only it resolves to nothing routable — so "proxied by default" is the right
|
||||
# default for this zone's gateway names.
|
||||
# * The provider honours a per-object `"false"` over the global default (provider/cloudflare
|
||||
# shouldBeProxied: ParseBool of the annotation overrides proxiedByDefault). The key it reads
|
||||
# is `<annotationPrefix>cloudflare-proxied` (annotations.SetAnnotationPrefix re-derives
|
||||
# CloudflarePrefix), i.e. `external-dns.alpha.kubernetes.io/cloudflare-proxied` here.
|
||||
#
|
||||
# THE `service` SOURCE MUST OPT OUT. The L4 LoadBalancers (crafty-mc, unifi) publish an A record
|
||||
# at their own LB VIP; Cloudflare's proxy is HTTP(S)-only, so a proxied A for a game or UniFi
|
||||
# inform port is broken by construction. Each carries `cloudflare-proxied: "false"` and stays
|
||||
# DNS-only, exactly as before this change. conformance/tests/l0-static/
|
||||
# internal-services-publish-no-public-dns (assertion 9b) FAILS on any `service`-source publisher
|
||||
# without it, so a new L4 Service cannot silently become a proxied record.
|
||||
#
|
||||
# The operator's answers: (a) yes, ALL of them — the five `exposure: public` apps AND the admin
|
||||
# UIs (auth, crafty, grafana, headlamp, the apex), which sit behind Keycloak / oauth2-proxy;
|
||||
# (b) the address is kept current IN-CLUSTER. Implementation:
|
||||
# * platform/gateway/gateway.yaml sets `target: wan.${BASE_DOMAIN}` on the shared Gateway, so
|
||||
# each published name becomes a CNAME to that one hostname;
|
||||
# * platform/externaldns/ddns.yaml (favonia/cloudflare-ddns) keeps `wan.${BASE_DOMAIN}` an A
|
||||
# record at the current WAN IP. That record has no external-dns TXT registry entry, so this
|
||||
# instance never owns or garbage-collects it.
|
||||
# Internal services are unchanged: their routes carry `controller: none` (above), which is
|
||||
# checked BEFORE the target is read, so they still publish nothing. The `service` source (the L4
|
||||
# LoadBalancers crafty-mc, unifi) does not read the Gateway's annotation and still publishes its
|
||||
# LB VIP. Whether those should go public is a separate, still-open question.
|
||||
# checked BEFORE the target is read, so they still publish nothing. Plex's web UI and Headlamp
|
||||
# joined that set on 2026-11-08 (operator: LAN/VPN-only). `wan.${BASE_DOMAIN}` (ddns.yaml) has no
|
||||
# external-dns TXT registry entry and is untouched; it now serves only WireGuard udp/51820.
|
||||
#
|
||||
# THE RISK THIS ADDS, AND WHAT GUARDS IT. If the annotation prefix ever stops being honoured
|
||||
# again (the 2026-11-07 v0.23.0 incident), `target` is ignored and every name falls back to the
|
||||
# Gateway's own address — and with this flag it would be published as a PROXIED A record at
|
||||
# ${GATEWAY_VIP} (RFC1918; Cloudflare 1003/522s for every public name) while `controller: none`
|
||||
# is also ignored and internal names publish. Assertion (8) of the L0 check above ties the
|
||||
# prefix this tree writes to `values.annotationPrefix`; (8b) forbids overriding it here.
|
||||
extraArgs:
|
||||
- --cloudflare-proxied
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
|
||||
#
|
||||
|
||||
@@ -30,5 +30,9 @@ east-west default-deny (docs/09).
|
||||
|
||||
- **Phase 2 (WS-NET):** pin the L2-announced ingress VIP / IP pool (Cilium L2 announcement, docs/09);
|
||||
add the HTTP→HTTPS redirect route; confirm the cross-namespace ReferenceGrant pattern with WS-APPS.
|
||||
- **Public exposure** is a **manual edge port-forward** of 80/443 to the Gateway VIP (single SOHO
|
||||
router, docs/09) — a documented manual step, the analogue of the old pfSense exception.
|
||||
- **Public exposure** is via the **Cloudflare Tunnel** (2026-11-08, `platform/cloudflared`): the
|
||||
Gateway's `external-dns.alpha.kubernetes.io/target` is `<tunnel-uuid>.cfargotunnel.com`, so every
|
||||
published name is a proxied CNAME to the tunnel and cloudflared forwards to this Gateway's
|
||||
ClusterIP. No inbound WAN 80/443 forward is needed (the old manual edge port-forward is retired —
|
||||
runbooks/cloudflare-tunnel.md). LAN/VPN-only names (internal apps, plex, headlamp) carry
|
||||
`controller: none` on their route and are 404'd by the tunnel.
|
||||
@@ -8,22 +8,36 @@ metadata:
|
||||
name: shared
|
||||
namespace: gateway
|
||||
annotations:
|
||||
# PUBLIC DNS TARGET (2026-10-03, operator decision). Without this, external-dns publishes every
|
||||
# route on this Gateway with the Gateway's OWN address (${GATEWAY_VIP}, RFC1918), which is
|
||||
# unreachable from the internet. With it, every published `<svc>.${BASE_DOMAIN}` (and the apex)
|
||||
# becomes a CNAME to `wan.${BASE_DOMAIN}`, an A record that platform/externaldns/ddns.yaml keeps
|
||||
# pointed at the home WAN's current public IP. The WAN forwards 80/443 to ${GATEWAY_VIP}.
|
||||
# PUBLIC DNS TARGET = THE CLOUDFLARE TUNNEL (2026-11-08, operator decision; replaces the
|
||||
# 2026-10-03 `wan.${BASE_DOMAIN}` target). external-dns v0.23.0's gateway-httproute source reads
|
||||
# `target` from the GATEWAY only (source/gateway.go: `overrides: annotations.
|
||||
# TargetsFromTargetAnnotation(gw.Annotations)`) — never from an HTTPRoute — so this one value moves
|
||||
# EVERY published route on this Gateway at once. Each published `<svc>.${BASE_DOMAIN}` (and the
|
||||
# apex) becomes a CNAME to `<tunnel-uuid>.cfargotunnel.com`, and platform/externaldns/helmrelease.yaml
|
||||
# sets `--cloudflare-proxied` so the CNAME is orange-clouded: public clients resolve Cloudflare
|
||||
# anycast, Cloudflare terminates edge TLS (Total TLS) and hands the request to the in-cluster
|
||||
# cloudflared connectors (platform/cloudflared), which forward to this Gateway's ClusterIP. No
|
||||
# origin address of ours appears in public DNS, and no inbound WAN port is needed for HTTP(S).
|
||||
#
|
||||
# Scope is EVERY route on this Gateway that publishes at all — the operator chose to make the
|
||||
# admin UIs (auth, grafana, headlamp, crafty, apex) publicly resolvable alongside the five
|
||||
# `exposure: public` apps. `internal` services stay unpublished via their
|
||||
# `external-dns.alpha.kubernetes.io/controller: none` route annotation, which external-dns checks
|
||||
# BEFORE it reads this target. external-dns v0.21.0 reads `target` from the Gateway only, never
|
||||
# from an HTTPRoute — see platform/externaldns/helmrelease.yaml.
|
||||
# THE UUID IS A LITERAL, AND IT MUST EQUAL `tunnel:` IN platform/cloudflared/config.yaml. A CNAME
|
||||
# to any other <uuid>.cfargotunnel.com is a dead name (Cloudflare answers 1016). That equality is
|
||||
# asserted on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns
|
||||
# (assertion 9a). A literal rather than a cluster-var keeps this change off clusters/**.
|
||||
#
|
||||
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
|
||||
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
|
||||
external-dns.alpha.kubernetes.io/target: "wan.${BASE_DOMAIN}"
|
||||
# Scope: every route on this Gateway that publishes at all. `internal` services (and the
|
||||
# operator-designated LAN/VPN-only names: plex, headlamp) stay unpublished via their
|
||||
# `external-dns.alpha.kubernetes.io/controller: none` route annotation, which external-dns
|
||||
# applies BEFORE it reads this target. Those same names map to `http_status:404` in the tunnel's
|
||||
# ingress rules, so even a hand-made record cannot reach them through the tunnel.
|
||||
#
|
||||
# In-cluster clients are unaffected: CoreDNS answers ${BASE_DOMAIN} with ${GATEWAY_VIP} directly
|
||||
# (split-horizon). LAN clients use PiHole's EXPLICIT list (catalog/pihole.yaml, since 2026-11-09):
|
||||
# LAN-only names and gitea/nextcloud/keycloak/auth get ${GATEWAY_VIP}; every other published
|
||||
# name takes this public path through Cloudflare, from the LAN as from anywhere.
|
||||
#
|
||||
# `wan.${BASE_DOMAIN}` (platform/externaldns/ddns.yaml) is NO LONGER a target of anything here;
|
||||
# it stays only as the WireGuard udp/51820 endpoint.
|
||||
external-dns.alpha.kubernetes.io/target: "fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com"
|
||||
spec:
|
||||
gatewayClassName: cilium
|
||||
listeners:
|
||||
|
||||
@@ -18,6 +18,13 @@ metadata:
|
||||
annotations:
|
||||
homelab.keithjackson.dev/auth-mode: oidc
|
||||
homelab.keithjackson.dev/auth-groups: platform-admins
|
||||
# LAN/VPN-ONLY (operator decision 2026-11-08). Disowned from external-dns, so the public
|
||||
# headlamp record and its TXT are DELETED and nothing republishes them; LAN/VPN clients keep
|
||||
# resolving the name via PiHole's explicit entry for it (-> the Gateway VIP). The Cloudflare
|
||||
# Tunnel also maps headlamp.${BASE_DOMAIN} to http_status:404 (platform/cloudflared/config.yaml).
|
||||
# Same mechanism the renderer emits for `exposure: internal` services; asserted by
|
||||
# conformance/tests/l0-static/internal-services-publish-no-public-dns (9c).
|
||||
external-dns.alpha.kubernetes.io/controller: none
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: shared
|
||||
|
||||
@@ -43,6 +43,13 @@ data:
|
||||
href: https://grafana.${BASE_DOMAIN}
|
||||
description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager)
|
||||
icon: mdi-chart-areaspline
|
||||
# The CVE page (platform/vuln-scanner, docs/17): behind SSO, restricted to platform-admins.
|
||||
# Homepage has no per-group surfaces, so this tile is shown to everyone who can open this
|
||||
# dashboard; the page itself enforces platform-admins.
|
||||
- CVE Report:
|
||||
href: https://cve.${BASE_DOMAIN}
|
||||
description: Container image CVE inventory (platform-admins)
|
||||
icon: mdi-shield-bug
|
||||
- Semaphore:
|
||||
href: https://semaphore.${BASE_DOMAIN}
|
||||
description: Ansible automation & CI
|
||||
|
||||
@@ -72,11 +72,14 @@ Both are deliberate, and both fail **visibly** rather than silently:
|
||||
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
|
||||
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
|
||||
*node* process, so it resolves through the node's public upstreams
|
||||
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
|
||||
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
|
||||
IP — which this router does not hairpin; it answers :443 with its own placeholder
|
||||
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. At the time,
|
||||
the public answer for every name in this zone was a CNAME to `wan.home.keithjackson.dev`
|
||||
— the WAN IP — which this router does not hairpin; it answered :443 with its own placeholder
|
||||
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
|
||||
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
|
||||
`x509: certificate is not valid for any names`. (Since 2026-11-08 the public answer is
|
||||
Cloudflare's edge via the Tunnel, not the WAN IP — see `platform/externaldns/README.md`.
|
||||
The pin is still the right path: it keeps registry pulls on the LAN and off Cloudflare's
|
||||
proxy.) The Gateway, the wildcard certificate
|
||||
and the digest were never implicated. The fix is the pin this repository already uses
|
||||
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
|
||||
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
|
||||
|
||||
@@ -42,8 +42,9 @@ no version string to match against a CVE, and no amplification to abuse.
|
||||
|
||||
That is the whole security argument, and it is why the exposed port carries no IP allowlist (which
|
||||
would also have made roaming clients unusable — a phone's address changes with every cell tower).
|
||||
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN: a
|
||||
resolver answers anybody, so it must be scoped by address. WireGuard answers nobody.
|
||||
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN and
|
||||
this tunnel's client subnet: a resolver answers anybody, so it must be scoped by address. WireGuard
|
||||
answers nobody.
|
||||
|
||||
What it does **not** protect against is a stolen device — anyone holding a device's private key
|
||||
*is* that device. That is what revocation is for: delete the stanza, merge, done (~1 min).
|
||||
@@ -55,9 +56,9 @@ What it does **not** protect against is a stolen device — anyone holding a dev
|
||||
┌──────────────┐ udp/51820 ┌───────────────┐ forward ┌────────────────────────────┐
|
||||
│ WireGuard app│─────────────▶│ WAN 51820 ──▶ │───────────▶│ wg0 10.90.0.1/24 │
|
||||
│ 10.90.0.2/32 │ over the │ node IP:51820 │ │ decrypt, then FORWARD │
|
||||
└──────────────┘ internet └───────────────┘ │ SNAT to the node's addr │
|
||||
└──────────────┘ internet └───────────────┘ │ SNAT (LAN), eBPF LB (VIPs)│
|
||||
└─────────────┬──────────────┘
|
||||
│ as `host`/`remote-node`
|
||||
│ translated per destination (below)
|
||||
┌─────────────────────────────────┼──────────────────────┐
|
||||
▼ ▼ ▼
|
||||
LAN 192.168.2.0/24 Gateway VIP .20 (16 web UIs) WAN / internet
|
||||
@@ -86,9 +87,21 @@ resolved, because `apps/wave-0/pihole-dns.yaml`'s ingress policy deliberately om
|
||||
entity. A VPN that looks up and is useless is the worst available outcome, and nothing in the
|
||||
manifests would have hinted at it.
|
||||
|
||||
In the **host** netns there is no pod identity to carry. Forwarded client traffic is SNATed to the
|
||||
node's own address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||
byte-for-byte what a LAN client looks like. Three further problems vanish with it:
|
||||
In the **host** netns there is no pod identity to carry, which is what avoids the 403s. Clients do
|
||||
**not**, however, all arrive SNATed to the node. Measured 2026-11-08: Cilium counts `wg0` among its
|
||||
kube-proxy-replacement devices (`cilium-dbg status`: `eno1`, `wg0`, `tun0`), so what a packet looks
|
||||
like depends on where it is going:
|
||||
|
||||
| destination | handled by | source the destination sees |
|
||||
|---|---|---|
|
||||
| LAN hosts, the internet | kernel FORWARD, then the iptables MASQUERADE in [`daemonset.yaml`](./daemonset.yaml) | the node's address |
|
||||
| LoadBalancer VIPs (Gateway `.20`, PiHole `.23`, ...) | Cilium's eBPF load balancer, as the packet enters from `wg0`, **before** MASQUERADE | `10.90.0.x` at the VIP's source-range check, identity `world` |
|
||||
|
||||
The second row is why PiHole's `loadBalancerSourceRanges` has to list `${WG_CLIENT_CIDR}` as well as
|
||||
the LAN: left LAN-only, tunnel DNS is predicted to die with `DROP_NOT_IN_SRC_RANGE`. That prediction
|
||||
and the row's source column come from reading Cilium's datapath source; **no peer has ever completed
|
||||
a handshake, so neither has been observed live.** The Gateway VIP carries no source ranges and needs
|
||||
no change. Three further problems vanish with the host netns:
|
||||
|
||||
* `net.ipv4.ip_forward` is **already 1** on these nodes (`node_baseline` writes
|
||||
`/etc/sysctl.d/99-k3s-cilium.conf`). It is *not* in the kubelet's safe-sysctl allowlist, so a
|
||||
@@ -99,7 +112,9 @@ byte-for-byte what a LAN client looks like. Three further problems vanish with i
|
||||
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node`) and no
|
||||
`externalTrafficPolicy` trade-off to make at all.
|
||||
* **No double encapsulation.** Client UDP lands on the node's own NIC, so the underlay is a flat
|
||||
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` simply correct.
|
||||
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` correct on the
|
||||
server's `wg0`. It says nothing about a client's own path: `new-peer.sh` defaults client profiles
|
||||
to `1280` (see "Troubleshooting" in `runbooks/vpn-remote-access.md`).
|
||||
|
||||
`hostNetwork` is *permitted* here, and not by oversight:
|
||||
`platform/kyverno/policies/restrict-host-namespaces.yaml` matches `namespaceSelector:
|
||||
@@ -163,6 +178,30 @@ internet path. To enforce less *server-side*, add filtering to the `PostUp` chai
|
||||
before the accept rules) — the FORWARD chain there is the only enforcement point, since no
|
||||
NetworkPolicy applies.
|
||||
|
||||
## Phone profiles: one tunnel for home, one for away
|
||||
|
||||
A phone that needs the LAN-only apps runs full tunnel (`AllowedIPs = 0.0.0.0/0`, `DNS = 192.168.2.23`)
|
||||
everywhere, so it needs **two tunnels**, each its own peer with its own key and address:
|
||||
|
||||
| tunnel | peer | address | Endpoint | use when |
|
||||
|---|---|---|---|---|
|
||||
| `away` | `keith-phone` | `10.90.0.2` | `wan.home.keithjackson.dev:51820` | off the home network |
|
||||
| `home` | `keith-phone-home` | `10.90.0.3` | `192.168.2.10:51820` | on the home Wi-Fi |
|
||||
|
||||
**`wan.` cannot work from inside the house.** It is an unproxied A record holding the house's WAN
|
||||
address (`platform/externaldns/README.md`), and the router has no hairpin NAT, so a LAN client that
|
||||
sends to it never comes back in through the port-forward. The Cloudflare Tunnel is no way round: it
|
||||
carries HTTP(S) to the Gateway and WireGuard is UDP to a node, so `wan.` has to stay DNS-only and
|
||||
`away` has to keep using it.
|
||||
|
||||
**`home` depends on `k3s-prod-1`.** Its Endpoint is that node's address, with no failover: if
|
||||
prod-1 is down, `home` cannot handshake even though the other two listeners are healthy. The fix
|
||||
is client-side: edit the tunnel's Endpoint to `192.168.2.11` or `.12`, since every node answers the
|
||||
same keys. No server change and no new key is needed. The same dependency applies to `away` if the
|
||||
WAN forward points at prod-1.
|
||||
|
||||
Android allows one active VPN at a time, so switch tunnels by hand when leaving or arriving.
|
||||
|
||||
## Operating it
|
||||
|
||||
* **Enrol a device:** `./infra/vpn-ingress/new-peer.sh <name> <octet>` → paste the printed `[Peer]`
|
||||
@@ -215,10 +254,13 @@ right way; one was a defect in my own manifest.
|
||||
handshake — and would be a real secret, forcing `peers.conf` into SOPS and costing the
|
||||
reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in the
|
||||
`wg-server-key` Secret.
|
||||
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
|
||||
still reaches IPv6 sites *outside* the tunnel. Functionally harmless, a privacy leak if the point
|
||||
was to hide the client's network. Fix is an `fd..` ULA on `wg0` plus `::/0` in the client's
|
||||
`AllowedIPs`; not done because nothing here needs it.
|
||||
* **No IPv6.** The server offers no IPv6 tunnel address and `new-peer.sh` emits only `0.0.0.0/0`.
|
||||
What happens to a client's IPv6 then depends on the client. The **Android app does not leak it**:
|
||||
its `GoBackend` skips `allowFamily()` when the config is one peer with a default route, so Android
|
||||
blocks IPv6 for the VPN's duration and apps fall back to IPv4. Other clients are not covered by
|
||||
that code; expect `wg-quick` on a dual-stack Linux host to leave IPv6 outside the tunnel. Fix is
|
||||
an `fd..` ULA on `wg0` plus `::/0` in the client's `AllowedIPs`; not done because nothing here
|
||||
needs it.
|
||||
* **No in-cluster-service reach beyond what the LAN has.** Clients reach cluster apps the way a LAN
|
||||
browser does — through the Gateway VIP. Pod IPs and `10.43.0.0/16` service IPs are not routed
|
||||
into the tunnel.
|
||||
@@ -30,9 +30,12 @@
|
||||
# `cluster` entity, so VPN clients would not have been able to resolve `*.${BASE_DOMAIN}` either.
|
||||
# The VPN would have looked up and been useless, which is the worst failure shape available.
|
||||
#
|
||||
# In the host netns there is no pod identity to carry: forwarded client traffic is SNATed to the
|
||||
# NODE's address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||
# exactly what a LAN client looks like. Three further problems disappear with it:
|
||||
# In the host netns there is no pod identity to carry, so a client never looks like a pod. What a
|
||||
# destination sees depends on how the packet is handled. LAN hosts and the internet: forwarded,
|
||||
# then MASQUERADEd to the NODE's address by the PostUp rule below. LoadBalancer VIPs: Cilium's load
|
||||
# balancer takes the packet as it enters from wg0, before that rule, so the source is still
|
||||
# 10.90.0.x with identity `world`, like a LAN browser (measured 2026-11-09 for PiHole .23 and Plex
|
||||
# .24; see README). Three further problems disappear with it:
|
||||
# * net.ipv4.ip_forward is ALREADY 1 on these nodes (node_baseline writes it to
|
||||
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
|
||||
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
|
||||
|
||||
@@ -90,3 +90,7 @@
|
||||
[Peer]
|
||||
PublicKey = pBRSHrRyG2hnsuZ6FvHfEzF8hOVTF5stHUb/7fW2Km8=
|
||||
AllowedIPs = 10.90.0.2/32
|
||||
# keith-phone-home — <owner>, enrolled 2026-11-08 (at-home profile, LAN Endpoint; no hairpin NAT)
|
||||
[Peer]
|
||||
PublicKey = AT8lBJHqrVkv8RL9n0qWDsh5nyl0e3qgrfTAaYKgBz0=
|
||||
AllowedIPs = 10.90.0.3/32
|
||||
@@ -0,0 +1,113 @@
|
||||
# vuln-scanner — scheduled CVE visibility for what is running
|
||||
|
||||
Answers three questions with no manual step: what is running, what is it vulnerable to, and can the
|
||||
answer be trusted. Contract: spec `docs/17-vulnerability-visibility.md`, ADR-0027 (R-VULN-01..09).
|
||||
Hand-authored platform tooling (not a catalog service), reconciled by its own non-gating Flux tier
|
||||
`clusters/base/vuln-scanner.yaml`. Operator runbook: `runbooks/vuln-scanner.md`.
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Scanner source (`python3`, stdlib only), mounted from a ConfigMap at `/app` | `src/` |
|
||||
| Accepted risk (R-VULN-08) | `acceptances.yaml` |
|
||||
| Pod, security context, sidecar | `deployment.yaml` |
|
||||
| Read-only identity (R-VULN-09) | `rbac.yaml` |
|
||||
| Network: Gateway→4180, CI→8081, Prometheus→9090; API, DNS, public 443 | `networkpolicy.yaml` |
|
||||
| Aggregate metrics and the five alerts | `servicemonitor.yaml`, `alerts.yaml`, `alerts.promtool-test.yaml` |
|
||||
|
||||
The page is `cve.<domain>`, internal only, gated by the per-pod oauth2-proxy sidecar to `platform-admins`.
|
||||
The route is disowned from public DNS and the tunnel 404s it; PiHole answers it on the LAN.
|
||||
|
||||
## Operator action required: the Keycloak client (R-VULN-04 is NOT delivered until this is done)
|
||||
|
||||
Until these steps are done the page cannot complete a login (`invalid_client`, fail-closed). The
|
||||
scanner, its metrics and its alerts are unaffected.
|
||||
|
||||
An agent cannot do this, because every route runs through one encrypted file. The client's secret
|
||||
must be the key `vuln-scanner-client-secret` in `platform/keycloak/keycloak-realm-clients.sops.yaml`:
|
||||
the reconciler mounts only that Secret, and the L0 check `keycloak-client-secret-reconciler` rejects a
|
||||
placeholder that reads any other (a second source would be declared but never healed). That check
|
||||
also requires the client and the key to land in the same change. Adding a key needs the age private
|
||||
key. Separately, a completed realm import never re-runs, so a new client has to be created in the
|
||||
live realm once whatever git says. `*.sops.yaml` is a sensitive path, so the PR needs an approval or
|
||||
an operator-posted `/approve-override` (`infra/gitea/approval-policy.json`).
|
||||
|
||||
**Order matters.** Create the live client first: the reconciler treats every `*-client-secret` key as
|
||||
a client that must exist, and fails every 15-minute run with `NOT FOUND` otherwise
|
||||
(`KeycloakClientSecretReconcilerFailing`).
|
||||
|
||||
1. Create the client in the live realm. No secret is passed: the reconciler sets it from git after
|
||||
the merge, and it reads the admin credential from the pod's own environment, so nothing is echoed.
|
||||
|
||||
```bash
|
||||
kubectl --context k3s-prod -n keycloak exec keycloak-0 -- bash -c '
|
||||
K="/opt/keycloak/bin/kcadm.sh"; C="--config /tmp/kcadm-vuln.config"
|
||||
$K config credentials $C --server http://localhost:8080 --realm master \
|
||||
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
D=home.keithjackson.dev
|
||||
ID=$($K create clients $C -r homelab -i -s clientId=vuln-scanner -s "name=CVE report (oauth2-proxy)" \
|
||||
-s protocol=openid-connect -s enabled=true -s publicClient=false -s standardFlowEnabled=true \
|
||||
-s directAccessGrantsEnabled=false -s "redirectUris=[\"https://cve.$D/oauth2/callback\"]" \
|
||||
-s "webOrigins=[\"https://cve.$D\"]")
|
||||
$K create clients/$ID/protocol-mappers/models $C -r homelab -s name=groups -s protocol=openid-connect \
|
||||
-s protocolMapper=oidc-group-membership-mapper -s "config.\"full.path\"=false" \
|
||||
-s "config.\"id.token.claim\"=true" -s "config.\"access.token.claim\"=true" \
|
||||
-s "config.\"userinfo.token.claim\"=true" -s "config.\"claim.name\"=groups"
|
||||
rm -f /tmp/kcadm-vuln.config'
|
||||
```
|
||||
|
||||
`D` is the prod `BASE_DOMAIN`; check it against the `cluster-vars` ConfigMap first.
|
||||
|
||||
2. On a branch, generate one value and set it in both encrypted files, through pipes so it is never
|
||||
echoed. `client-secret` in the scanner's file is replaced so that the two match:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c '
|
||||
cs=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
sops set platform/keycloak/keycloak-realm-clients.sops.yaml \
|
||||
"[\"stringData\"][\"vuln-scanner-client-secret\"]" "\"$cs\""
|
||||
sops set platform/vuln-scanner/vuln-scanner-oauth2-proxy-credentials.sops.yaml \
|
||||
"[\"stringData\"][\"client-secret\"]" "\"$cs\""'
|
||||
```
|
||||
|
||||
3. Add to `platform/keycloak/keycloak-realm-import.yaml`, beside the `crafty` client:
|
||||
|
||||
```yaml
|
||||
# spec.placeholders
|
||||
VULN_SCANNER_CLIENT_SECRET:
|
||||
secret:
|
||||
name: keycloak-realm-clients
|
||||
key: vuln-scanner-client-secret
|
||||
# spec.realm.clients
|
||||
- clientId: vuln-scanner
|
||||
name: CVE report (oauth2-proxy)
|
||||
protocol: openid-connect
|
||||
enabled: true
|
||||
publicClient: false
|
||||
standardFlowEnabled: true
|
||||
directAccessGrantsEnabled: false
|
||||
secret: "$(env:VULN_SCANNER_CLIENT_SECRET)"
|
||||
redirectUris:
|
||||
- https://cve.${BASE_DOMAIN}/oauth2/callback
|
||||
webOrigins:
|
||||
- https://cve.${BASE_DOMAIN}
|
||||
defaultClientScopes: [email, profile, roles]
|
||||
protocolMappers:
|
||||
- name: groups
|
||||
protocol: openid-connect
|
||||
protocolMapper: oidc-group-membership-mapper
|
||||
config:
|
||||
"full.path": "false"
|
||||
"id.token.claim": "true"
|
||||
"access.token.claim": "true"
|
||||
"userinfo.token.claim": "true"
|
||||
"claim.name": groups
|
||||
```
|
||||
|
||||
4. Regenerate the derived URL file with `python3 ci/render-keycloak-client-urls.py --write`, and add
|
||||
`vuln-scanner-client-secret: REPLACE-ME` to `keycloak-realm-clients.sops.yaml.example`. Commit all of
|
||||
it in one PR, gate, merge.
|
||||
|
||||
5. After Flux applies it, the next reconciler run sets the client's secret and URLs from git. Prove it
|
||||
with the L2 test `keycloak-client-secrets-match-git`, then log in at `https://cve.<domain>`. The
|
||||
`platform-admins` restriction is the sidecar's `--allowed-group`; the `groups` mapper above is what
|
||||
supplies the claim it reads, so a user outside that group gets a 403 from oauth2-proxy.
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user