Repository navigation
ci: enforce the container scan exception policy - #27
Merged
Merged
Conversation
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
added a commit
that referenced
this pull request
Oct 25, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
scripts/ci/guard-trivyignore.shto theRepository guardjob. 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:77runs Trivy with--severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yamlfor 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) show0 HIGH/CRITICALfor 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 instatement,expired_atat most 90 days ahead, listed indocs/security/container-vulnerabilities.md, "no broad or permanent rules";trivyignore.yaml:3-8, docs lines 25-28) were not enforced. An entry withoutexpired_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:%qoutput, exit 0/1/2):vulnerabilities: [], orvulnerabilities:with entries of- id:andpaths|purls|statement|expired_at:lines (full-line comments allowed); any other section, key or YAML style fails;pathsorpurlsis a non-empty quoted list without*/?;statementis a non-empty quoted text;expired_atis 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 indocs/security/container-vulnerabilities.md.datedifference);GUARD_TODAYfixes the date for the tests.paths/purlsvalues must not be blank.statement: "why\"continues the YAML string over the followingexpired_atline, so Trivy applies a permanent exception while the check saw a valid date;paths: ["\x2a"]is the wildcard*to YAML;# 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/purlsvalues without backslash or inner double quote.pathsas 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 inpaths/purlsas well. A NUL byte is now found separately (bash'sreaddrops 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 inRepository guard(guard and its tests) and both scripts in the job's ShellCheck step.docs/security/container-vulnerabilities.mdanddocs/architecture/development-lifecycle-contract.mdname the guard.Testing performed
On 73cd9dd (macOS
/bin/bash3.2.57; and bash 5.2.37 inbash@sha256:e900b34300c5bca37879a980687da197234b65325044647e76198a4cd3c0abb7on a copy of the files):TOTAL 46 passed=41 failed=5, bash 5.2TOTAL 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 fixTOTAL 46 passed=46 failed=0under both; the guard on the repository:OK: 0 exception(s); ShellCheck 0.11.0 (the CI image) clean.*?→ 4 failures.trivy fsof 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.[p]…,[!x]…,[a-z]…and{…,…}paths; with the fix it rejects all four.FATAL);scan.shrecords any non-zero exit of a gated scan as FAIL, and the control scan needs exit 1 and thegosufinding.On 1117a01 (macOS,
/bin/bash3.2.57; and bash 5.2.37 inbash:5.2@sha256:e900b343…on a copy of the files):TOTAL 40 passed=33 failed=7, eachexit=0 want=1); the 2 new positive cases passed. After the fixTOTAL 40 passed=40 failed=0under both bash versions; the guard on the repository:OK: 0 exception(s); ShellCheck 0.11.0 clean.TOTAL 40 passed=37 failed=3each).trivy fsof 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 --ignorefileasscan.sh):vulnerabilities: []→ exit 1, all four reported (genuine findings still fail);expired_atin 37 days) → guard OK, Trivy suppresses exactly that CVE, exit 1 for the rest (a real exception still works);expired_at,\x2apath, entry hidden behind CR / NEL / LS → before: guard OK while Trivy suppressed the CVE; now: guard exit 1;---second document,Expired_atcase variant, BOM → guard exit 1 (before and now); Trivy reads only the first document;readdrops NUL), Trivy fails withyaml: 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_atwith 2099 first, repeatedpaths, blank path), the fourth (the same key in two entries) passed as it must. After the fixTOTAL 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/bash3.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=0GUARD=<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.shOK, its tests 36/36;scripts/ci/guard-dockerfile-frontend.shOK, its tests 73/73.Security considerations
No other exception source:
scan.shruns Trivy in a container (working directory/) with onlysecurity/mounted and an explicit--ignorefile, noTRIVY_*environment and no--config,--ignore-policyor--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
statementis true; that stays a review task.container-vulnerabilities.md(review L-27-3, Low, accepted).purlsvalue 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.ci.ymlhas noschedule: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.rm: cannot remove ... Permission denied(run 40101147084, log lines 937-939; exit status unaffected); scan logs are not uploaded as artifacts.