Skip to content

ci: enforce the container scan exception policy - #27

Merged
knittingcode merged 5 commits into
mainfrom
ci/fcrm-auto3-scan-exceptions
Oct 25, 2026
Merged

knittingcode merged 5 commits into
mainfrom
ci/fcrm-auto3-scan-exceptions

Conversation

@knittingcode

@knittingcode knittingcode commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds scripts/ci/guard-trivyignore.sh to the Repository guard job. It checks the exception file of the container vulnerability gate (security/trivyignore.yaml) against the policy that was so far only written in comments and docs. No exception exists today, so the scan gate itself behaves exactly as before.

Motivation and technical context

The audit of the container scan (CI hardening of an existing gate) found that the gate fails closed: tests/app/scan.sh:77 runs Trivy with --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml for api-prod, edge-prod and db (scan.sh:104-106), dev images are report-only (scan.sh:107-109), and a known-vulnerable control image must fail (scan.sh:116-124). The latest main run (40101147084) and the #24 run (40109172174) show 0 HIGH/CRITICAL for the runtime images and the report-only web-dev finding.

The only way to suppress a runtime finding is security/trivyignore.yaml. Its rules (one specific CVE per entry, the affected path, the evidence in statement, expired_at at most 90 days ahead, listed in docs/security/container-vulnerabilities.md, "no broad or permanent rules"; trivyignore.yaml:3-8, docs lines 25-28) were not enforced. An entry without expired_at, with a wildcard path, or a new top-level section would have been accepted by CI and suppressed findings without review.

Implementation

  • scripts/ci/guard-trivyignore.sh (bash, same conventions as the existing guards: %q output, exit 0/1/2):
    • accepts only the documented form: vulnerabilities: [], or vulnerabilities: with entries of - id: and paths|purls|statement|expired_at: lines (full-line comments allowed); any other section, key or YAML style fails;
    • per entry: id is one CVE or GHSA id, no duplicates; paths or purls is a non-empty quoted list without */?; statement is a non-empty quoted text; expired_at is a valid date no more than 90 days after today (UTC; an already expired entry is accepted, Trivy no longer applies it); the id appears in docs/security/container-vulnerabilities.md.
    • Date arithmetic is done in bash (no GNU/BSD date difference); GUARD_TODAY fixes the date for the tests.
  • bf506bc (review L-27-1, L-27-2): a key repeated within one entry is rejected (a parser keeping the first value could otherwise apply an unchecked expiry such as 2099-01-01), and paths/purls values must not be blank.
  • 1117a01 (review FCRM-AUTO-004): the check must read the file exactly as Trivy's YAML parser does. Three kinds of content were accepted by the check while Trivy 0.67.2 (the pinned scanner) applied something else, each verified with Trivy itself (see Testing performed):
    • a backslash escape in a double-quoted value: statement: "why\" continues the YAML string over the following expired_at line, so Trivy applies a permanent exception while the check saw a valid date; paths: ["\x2a"] is the wildcard * to YAML;
    • CR, NEL (U+0085) and LS (U+2028), which YAML treats as line breaks: # note<CR> - id: CVE-… hides a whole entry (no path, statement, expiry or docs listing) inside what the check read as a comment, or inside a statement.
      The check now accepts printable ASCII only (one trailing CR of a CRLF line is still stripped) and quoted statement/paths/purls values without backslash or inner double quote.
  • 73cd9dd (review FCRM-INT-005): Trivy matches paths as doublestar glob patterns, so a character class ("[p]ackage-lock.json", "[!x]…") or braces ("{a,b}") widen a path like * and ?; Trivy 0.67.2 applied such entries while the check reported OK. [, ], { and } are now rejected in paths/purls as well. A NUL byte is now found separately (bash's read drops it, or ends the line at it under bash 3.2), so the check no longer reports OK for a file that Trivy refuses.
  • scripts/ci/tests/guard-trivyignore.test.sh: 46 cases in temporary Git repositories (valid forms, each rule violated, CRLF, missing file, not a work tree).
  • .github/workflows/ci.yml: two steps in Repository guard (guard and its tests) and both scripts in the job's ShellCheck step.
  • Docs: docs/security/container-vulnerabilities.md and docs/architecture/development-lifecycle-contract.md name the guard.

Testing performed

On 73cd9dd (macOS /bin/bash 3.2.57; and bash 5.2.37 in bash@sha256:e900b34300c5bca37879a980687da197234b65325044647e76198a4cd3c0abb7 on a copy of the files):

  • The 6 new negative cases were added first and run against 1117a01: bash 3.2 TOTAL 46 passed=41 failed=5, bash 5.2 TOTAL 46 passed=40 failed=6 (the NUL-in-statement case is red only under bash 5, which drops the NUL; bash 3.2 cuts the line there and already failed it). After the fix TOTAL 46 passed=46 failed=0 under both; the guard on the repository: OK: 0 exception(s); ShellCheck 0.11.0 (the CI image) clean.
  • Negative controls: NUL check removed → 1 failure; wildcard set reverted to *? → 4 failures.
  • End to end with the pinned Trivy 0.67.2 (trivy fs of a lodash 4.17.15 lockfile, --severity HIGH,CRITICAL --exit-code 1 --ignorefile), 75 files each run through the guard and Trivy: escaped quotes, \x2a, \*, \u002a, single-quoted and plain wildcards; hidden line breaks (CR, NEL, U+2028, U+2029, FF, VT, tab); flow vs block sequences; anchors, aliases and merge keys; several documents, ..., %YAML; duplicate keys (YAML: unmarshal error), quoted and explicit keys; case variants; BOM; trailing spaces and comments; 200-300 kB lines; null/empty/RFC 3339/quoted/tagged/far expiries; missing, empty or blank paths.
    • Before the fix the guard reported OK while Trivy applied a broader rule for [p]…, [!x]…, [a-z]… and {…,…} paths; with the fix it rejects all four.
    • Every other malicious file is rejected by the guard. The guard now reports OK only for policy-compliant files, and for each of them Trivy suppressed at most the one named CVE (or nothing), never for more than the named path or package; an already expired entry is not applied.
    • A scanner failure cannot pass: a missing, unreadable or directory ignore file, a YAML error, an invalid purl or a missing database each give Trivy exit 1 (FATAL); scan.sh records any non-zero exit of a gated scan as FAIL, and the control scan needs exit 1 and the gosu finding.

On 1117a01 (macOS, /bin/bash 3.2.57; and bash 5.2.37 in bash:5.2@sha256:e900b343… on a copy of the files):

  • The 7 new negative cases were added first and all failed against bf506bc (TOTAL 40 passed=33 failed=7, each exit=0 want=1); the 2 new positive cases passed. After the fix TOTAL 40 passed=40 failed=0 under both bash versions; the guard on the repository: OK: 0 exception(s); ShellCheck 0.11.0 clean.
  • Negative controls: printable-ASCII rule removed → the 3 CR/NEL/LS comment cases fail; backslash exclusion removed → the 3 escape cases fail (TOTAL 40 passed=37 failed=3 each).
  • End to end with the pinned Trivy 0.67.2 (trivy fs of a lockfile with lodash 4.17.15; HIGH findings CVE-2020-8203, CVE-2021-23337, CVE-2026-4800, NSWG-ECO-516; same --severity HIGH,CRITICAL --exit-code 1 --ignorefile as scan.sh):
    • vulnerabilities: [] → exit 1, all four reported (genuine findings still fail);
    • a compliant entry for CVE-2020-8203 (expired_at in 37 days) → guard OK, Trivy suppresses exactly that CVE, exit 1 for the rest (a real exception still works);
    • the same entry already expired → guard OK, Trivy reports it again;
    • escaped quote over expired_at, \x2a path, entry hidden behind CR / NEL / LS → before: guard OK while Trivy suppressed the CVE; now: guard exit 1;
    • --- second document, Expired_at case variant, BOM → guard exit 1 (before and now); Trivy reads only the first document;
    • a NUL byte in a comment: the guard reports OK (bash read drops NUL), Trivy fails with yaml: control characters are not allowed, so the scan gate fails closed.

On bf506bc: the four new cases were added first; three failed against 44e92bb (repeated expired_at with 2099 first, repeated paths, blank path), the fourth (the same key in two entries) passed as it must. After the fix TOTAL 31 passed=31 failed=0; the guard on the repository: OK; ShellCheck clean; negative control (repeated-key check removed): 2 of 31 fail.

On 44e92bb, run locally (macOS; the earlier version of this text said bash 5, the shell on this machine is /bin/bash 3.2.57):

  • scripts/ci/guard-trivyignore.sh → OK: 0 exception(s) in security/trivyignore.yaml follow the policy (checked on 2026-10-24)
  • scripts/ci/tests/guard-trivyignore.test.sh → TOTAL 27 passed=27 failed=0
  • Negative controls (mutated copies of the guard, GUARD=<copy>): no 90-day limit, no docs check, wildcards allowed, missing expiry allowed, any key allowed, duplicates allowed, other sections allowed: each one fails exactly one test (TOTAL 27 passed=26 failed=1).
  • scripts/ci/guard-tracked-files.sh OK, its tests 36/36; scripts/ci/guard-dockerfile-frontend.sh OK, its tests 73/73.
  • ShellCheck 0.11.0 on both new scripts: clean. actionlint 1.7.12 (the CI image) on the workflow: clean.
  • CI on this branch: not awaited at the time of writing.

Security considerations

No other exception source: scan.sh runs Trivy in a container (working directory /) with only security/ mounted and an explicit --ignorefile, no TRIVY_* environment and no --config, --ignore-policy or --vex, so the file the guard checks is the only one Trivy reads. Strengthens the review requirement for any future scan exception; it cannot weaken the gate (it adds a failing condition only). Values from the file are printed with %q, so crafted content cannot inject workflow commands. No secrets or local configuration involved.

Known limitations

  • The guard checks form and policy, not whether the statement is true; that stays a review task.
  • The docs-listing rule is a substring match anywhere in container-vulnerabilities.md (review L-27-3, Low, accepted).
  • A purls value without a version (pkg:npm/lodash) applies to every version of that package (Trivy's purl matching); it still names one package and one CVE. The guard does not parse purls: a malformed purl passes the guard, but Trivy then refuses the file, so the scan fails closed.
  • Findings published after the last push are still detected only by the next CI run: ci.yml has no schedule: trigger (docs lines 137-138 record that CVE-2026-111497 was found this way). A nightly run is a separate owner decision (runner time, notifications) and is not part of this PR.
  • Other audit observations, not changed here: Trivy writes its cache as root, so the scan's cleanup prints rm: cannot remove ... Permission denied (run 40101147084, log lines 937-939; exit status unaffected); scan logs are not uploaded as artifacts.

security/trivyignore.yaml may suppress findings of the runtime image
gate (tests/app/scan.sh). Its rules (one CVE per entry, the affected
path, the evidence in a statement, an expiry at most 90 days ahead, a
listing in docs/security/container-vulnerabilities.md, no broad or
permanent rules) were only written down; nothing checked them, so an
entry without expired_at would have suppressed a finding permanently.

scripts/ci/guard-trivyignore.sh checks every entry against these rules
in the Repository guard job and accepts only the documented form, so
other sections or keys fail instead of passing unnoticed. Its
regression suite covers each rule; ShellCheck runs on both scripts. The
file has no entries today, so the gate itself is unchanged.
A key that appeared twice in one exception entry was accepted with its
last value; a YAML parser that keeps the first value could then apply an
expiry the guard never checked (for example 2099-01-01 followed by a
valid date). A path or purl of only blanks was accepted as well. The
guard now reports both; four new regression cases, three of which failed
before.
The line-based check and Trivy's YAML parser disagreed on three kinds of
content, each verified with Trivy 0.67.2 to apply an exception the check
reported as compliant:

- a backslash escape in a double-quoted value: \" continued the
  statement over the expired_at line (a permanent exception), and \x2a
  in paths became a wildcard;
- CR, NEL (U+0085) and LS (U+2028), which YAML treats as line breaks:
  an entry without expiry, statement or path hidden inside a comment or
  a statement.

The check now accepts printable ASCII only (a CRLF line ending is still
stripped) and quoted values without backslash or inner double quote.
Seven new regression cases failed on the previous check; two positive
cases (CRLF entry, statement with apostrophes and #) guard against false
positives.
Trivy matches the paths of an exception as doublestar patterns, so a
character class ("[p]ackage-lock.json", "[!x]...") or braces
("{a,b}") widen a path just as * and ? do; Trivy 0.67.2 applied such
entries while the guard reported OK. The guard now rejects [ ] { } in
paths and purls besides * and ?.

bash's read drops a NUL byte (bash 5) or ends the line at it (bash 3.2),
so a NUL was invisible to the line check while YAML refuses the file.
The guard now finds NUL bytes separately and reports their line, so it
no longer reports OK for a file Trivy cannot read.
@knittingcode
knittingcode merged commit 7d0e815 into main Oct 25, 2026
2 checks passed
@knittingcode
knittingcode deleted the ci/fcrm-auto3-scan-exceptions branch October 25, 2026 17:09
knittingcode added a commit that referenced this pull request Oct 25, 2026
tests/devenv/unit.sh keeps both new blocks: main's U-25 (login check of the stack suite) and this branch's U-26 (image removal by name).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant