fix(ci): make both safety guards fail loudly instead of vacuously #30

Merged
zach merged 1 commit from fix/guards-fail-loudly into main 2026-07-25 21:45:55 +00:00
Owner

Both guards could report success while checking nothing

Verified empirically, not inferred.

tools/check-hardening-drift.sh — run against an empty baseline, and again against a 50-line truncation, it printed hardening-drift: OK (no enabled hardening symbol regressed vs baseline) and exited 0 both times. The shape is while … done < <(enabled_hardening "$baseline"): no matches means the loop never iterates, regressed stays 0, and the success path runs. build-kernel.yml:356 only tests that the baseline file exists, so a truncated or zero-length releases/<tag>/final.config would retire the only guard covering the 31 inherited hardening symbols — with a green log and output indistinguishable from a real pass.

build/configure-kernel.sh's fidelity assertion — same shape, same outcome. An empty intent.config yields fidelity assertion: OK (0 intent lines, all landed). set -e can't catch it: the inline $(grep -c …) in that message is a command substitution used as an argument, which never fails the shell.

Fixes

Following the anti-vacuous-pass pattern validate.yml already uses in its intent-matches-policy and dep-list-parity jobs.

drift — a ≥1000 CONFIG_-line floor on both inputs (real configs carry ~6,800, so this only catches truncation); the baseline symbol set is materialized before the loop and floored at 15. That second floor is a genuinely distinct check, not belt-and-braces: exclude_re is built from intent.config, so a mangled intent can collapse the comparison set against a perfectly good baseline. The success line now reports the count, so a future vacuous pass is self-evident in the log.

fidelity — parse once into a variable, then three guards: nothing parsed; a ≥60-line floor (92 today); and a candidates-vs-parsed equality check. The third catches what a floor cannot — CRLF endings, trailing whitespace after is not set, #CONFIG_X without the space — each of which silently drops that pin rather than failing. The CRLF case is not hypothetical: it parses all 69 =y pins and drops all 23 off-pins. The guard reports exactly which lines fell through.

ALLOW_HARDENING_REGRESSION was inert

check-hardening-drift.sh:89 has honoured it since it was written, and both configs/POLICY.md:296 and docs/operators/config-architecture.md:138 tell the operator to set it as a repo variable — but the drift step's env: listed only WORKDIR and SERIES, so vars.ALLOW_HARDENING_REGRESSION never reached the script.

That matters right now: without it, the only route past a drift failure is a commit to main, which the stale-tag guard from 3cd8e59 then turns into "delete and re-cut the tag" — mid-CVE-SLA. Plumbed the same way ALLOW_STALE_TAG and REQUIRE_NET_ISOLATION already are.

Also in the drift step: hard-fail an empty SERIES (it would make the baseline glob match nothing and silently skip the guard), and make the no-baseline branch a ::warning:: — a skipped guard is a materially different outcome from a passed one, and a bare echo scrolls past in a 40-minute log.

runner-setup.md gains a repo-variables table; it previously mentioned only REQUIRE_NET_ISOLATION, in prose.

Verification

Both changed blocks were tested by extracting them from the real files and running against real in-repo fixtures (12 released final.configs, plus both authentic seed configs fetched and sha256-matched against their pins).

drift:

case result
v7.1.3 → v7.1.4 release configs exit 0, 29 inherited hardening symbols checked
7.0.12 seed → 7.1.4 seed (the v7.1.5 case) exit 0, 31 … checked, none regressed
empty baseline / 50-line truncation / empty candidate exit 1, names the file and the count
inherited symbol dropped (CONFIG_KFENCE_DEFERRABLE) exit 1, REGRESSION …
same, with ALLOW_HARDENING_REGRESSION=1 exit 0 — hatch works
intent mangled to exclude every inherited symbol exit 1 — second floor fires

fidelity:

case result
real intent + real shipped final.config exit 0, 92 intent lines checked, all landed
empty intent exit 2, guard 1
40-line truncation exit 2, guard 2
CRLF intent exit 2, guard 3 — 92 look like kconfig lines, but only 69 parsed
trailing space after is not set exit 2, guard 3
a pin that did not land exit 2, MISS … — unchanged behaviour

Validators: shellcheck build/*.sh tools/*.sh clean, yamllint -d relaxed exit 0, py_compile clean, workflow-expressions greps clean.

Note on the imminent v7.1.5 build

All four published 7.1.x releases were built from the 7.0.12 seed; v7.1.5 will be the first on the real 7.1.4 seed. I ran this guard between the two authentic seed configs and it passes (31 symbols, none regressed) — so the seed advance should not trip it. The ALLOW_HARDENING_REGRESSION fix is insurance for the case where the 7.1.4→7.1.5 tree delta drops something.

🤖 Generated with Claude Code

## Both guards could report success while checking nothing Verified empirically, not inferred. **`tools/check-hardening-drift.sh`** — run against an empty baseline, and again against a 50-line truncation, it printed `hardening-drift: OK (no enabled hardening symbol regressed vs baseline)` and exited 0 both times. The shape is `while … done < <(enabled_hardening "$baseline")`: no matches means the loop never iterates, `regressed` stays 0, and the success path runs. [build-kernel.yml:356](.forgejo/workflows/build-kernel.yml:356) only tests that the baseline file *exists*, so a truncated or zero-length `releases/<tag>/final.config` would retire the only guard covering the **31 inherited hardening symbols** — with a green log and output indistinguishable from a real pass. **`build/configure-kernel.sh`**'s fidelity assertion — same shape, same outcome. An empty `intent.config` yields `fidelity assertion: OK (0 intent lines, all landed)`. `set -e` can't catch it: the inline `$(grep -c …)` in that message is a command substitution used as an *argument*, which never fails the shell. ## Fixes Following the anti-vacuous-pass pattern `validate.yml` already uses in its `intent-matches-policy` and `dep-list-parity` jobs. **drift** — a ≥1000 `CONFIG_`-line floor on *both* inputs (real configs carry ~6,800, so this only catches truncation); the baseline symbol set is materialized before the loop and floored at 15. That second floor is a genuinely distinct check, not belt-and-braces: `exclude_re` is built *from* `intent.config`, so a mangled intent can collapse the comparison set against a perfectly good baseline. The success line now reports the count, so a future vacuous pass is self-evident in the log. **fidelity** — parse once into a variable, then three guards: nothing parsed; a ≥60-line floor (92 today); and a candidates-vs-parsed equality check. The third catches what a floor cannot — CRLF endings, trailing whitespace after `is not set`, `#CONFIG_X` without the space — each of which silently drops that pin rather than failing. **The CRLF case is not hypothetical:** it parses all 69 `=y` pins and drops all 23 off-pins. The guard reports exactly which lines fell through. ## `ALLOW_HARDENING_REGRESSION` was inert `check-hardening-drift.sh:89` has honoured it since it was written, and both `configs/POLICY.md:296` and `docs/operators/config-architecture.md:138` tell the operator to set it as a repo variable — but the drift step's `env:` listed only `WORKDIR` and `SERIES`, so `vars.ALLOW_HARDENING_REGRESSION` never reached the script. That matters right now: without it, the only route past a drift failure is a commit to `main`, which the stale-tag guard from `3cd8e59` then turns into "delete and re-cut the tag" — mid-CVE-SLA. Plumbed the same way `ALLOW_STALE_TAG` and `REQUIRE_NET_ISOLATION` already are. Also in the drift step: hard-fail an empty `SERIES` (it would make the baseline glob match nothing and silently *skip* the guard), and make the no-baseline branch a `::warning::` — a skipped guard is a materially different outcome from a passed one, and a bare `echo` scrolls past in a 40-minute log. `runner-setup.md` gains a repo-variables table; it previously mentioned only `REQUIRE_NET_ISOLATION`, in prose. ## Verification Both changed blocks were tested by extracting them from the real files and running against real in-repo fixtures (12 released `final.config`s, plus both authentic seed configs fetched and sha256-matched against their pins). **drift:** | case | result | |---|---| | `v7.1.3 → v7.1.4` release configs | exit 0, `29 inherited hardening symbols checked` | | 7.0.12 seed → 7.1.4 seed (**the v7.1.5 case**) | exit 0, `31 … checked, none regressed` | | empty baseline / 50-line truncation / empty candidate | exit 1, names the file and the count | | inherited symbol dropped (`CONFIG_KFENCE_DEFERRABLE`) | exit 1, `REGRESSION …` | | same, with `ALLOW_HARDENING_REGRESSION=1` | exit 0 — hatch works | | intent mangled to exclude every inherited symbol | exit 1 — second floor fires | **fidelity:** | case | result | |---|---| | real intent + real shipped `final.config` | exit 0, `92 intent lines checked, all landed` | | empty intent | exit 2, guard 1 | | 40-line truncation | exit 2, guard 2 | | CRLF intent | exit 2, guard 3 — `92 look like kconfig lines, but only 69 parsed` | | trailing space after `is not set` | exit 2, guard 3 | | a pin that did not land | exit 2, `MISS …` — unchanged behaviour | Validators: `shellcheck build/*.sh tools/*.sh` clean, `yamllint -d relaxed` exit 0, `py_compile` clean, `workflow-expressions` greps clean. ## Note on the imminent v7.1.5 build All four published 7.1.x releases were built from the **7.0.12** seed; v7.1.5 will be the first on the real 7.1.4 seed. I ran this guard between the two authentic seed configs and it **passes** (31 symbols, none regressed) — so the seed advance should not trip it. The `ALLOW_HARDENING_REGRESSION` fix is insurance for the case where the 7.1.4→7.1.5 *tree* delta drops something. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(ci): make both safety guards fail loudly instead of vacuously
All checks were successful
validate / shellcheck (pull_request) Successful in 12s
validate / yamllint (pull_request) Successful in 12s
validate / workflow-expressions (pull_request) Successful in 4s
validate / pycompile (pull_request) Successful in 4s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / no-placeholder-digests (pull_request) Has been skipped
f21868de50
The two checks that stand between a seed bump and a silently weakened kernel
could both report success while checking nothing. Verified empirically, not
inferred.

tools/check-hardening-drift.sh: run against an EMPTY baseline, and against a
50-line truncation, it printed

  hardening-drift: OK (no enabled hardening symbol regressed vs baseline)

and exited 0 both times. The shape is `while ... done < <(enabled_hardening
"$baseline")`: no matches means the loop never iterates, `regressed` stays 0,
and the success path runs. build-kernel.yml only tests that the baseline file
EXISTS, so a truncated or zero-length releases/<tag>/final.config would retire
the only guard covering the 31 inherited hardening symbols -- with a green log
and no output distinguishable from a real pass.

build/configure-kernel.sh's fidelity assertion: same shape, same outcome. An
empty intent.config yields "fidelity assertion: OK (0 intent lines, all
landed)". `set -e` cannot catch it, because the inline `$(grep -c ...)` in that
message is a command substitution used as an ARGUMENT, which never fails the
shell.

Fixes, following the anti-vacuous-pass pattern validate.yml already uses in its
intent-matches-policy and dep-list-parity jobs:

  - drift: a >=1000 CONFIG_-line floor on BOTH inputs (real configs carry
    ~6,800, so this only catches truncation); the baseline symbol set is
    materialized before the loop and floored at 15 (31 today) -- a second,
    distinct check, because exclude_re is built FROM intent.config and a
    mangled intent can collapse the comparison set against a perfectly good
    baseline; the success line now reports the count.
  - fidelity: parse once into a variable, then three guards -- nothing parsed,
    a >=60 line floor (92 today), and a candidates-vs-parsed equality check.
    The third catches what a floor cannot: CRLF endings, trailing whitespace
    after "is not set", "#CONFIG_X" without the space. Each of those silently
    drops that pin instead of failing. The CRLF case is not hypothetical -- it
    parses all 69 `=y` pins and drops all 23 off-pins, and the guard reports
    exactly which lines fell through.

Separately: ALLOW_HARDENING_REGRESSION was inert. check-hardening-drift.sh has
honoured it since it was written, and both configs/POLICY.md and
docs/operators/config-architecture.md tell the operator to set it as a repo
variable -- but the drift step's env: block listed only WORKDIR and SERIES, so
`vars.ALLOW_HARDENING_REGRESSION` never reached the script. Without it the only
route past a drift failure is a commit to main, which the stale-tag guard then
turns into "delete and re-cut the tag", mid-CVE-SLA. Plumb it the same way
ALLOW_STALE_TAG and REQUIRE_NET_ISOLATION already are, hard-fail an empty
SERIES (which would silently skip the guard), and make the no-baseline branch a
:⚠️: so a SKIPPED guard is visible in the run summary.

Document all three hatches in a repo-variables table in runner-setup.md, which
previously mentioned only REQUIRE_NET_ISOLATION, in prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zach merged commit 72645b757e into main 2026-07-25 21:45:55 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
unredacted/linux-hardened-unredacted!30
No description provided.