From 29391281378875f61633f1fbf3f853038d237684 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:39:24 +0200 Subject: [PATCH] Say publicly why a finding that is not fixed does not apply (#89) Every scanner pointed at this repository will eventually report something that is not exploitable here. The choices are to fix it, to suppress it silently, or to say publicly why it does not apply, and the third is the only one a downstream consumer can act on and the only one that survives the person who made the judgement leaving. `security/statements.json` is that document. It carries two kinds of entry, because there are two ways a finding stops reaching the gate and they do not have the same shape. A finding a scanner raised here and somebody dismissed has an identifier, so it carries a per-finding disposition. A class taken out of what a scanner is pointed at has no identifier and no list of what would have been found, so it carries a scope and, in a field of its own, what is consequently NOT being looked for. That last field is the one a consumer needs and the one an author will otherwise leave out. Both kinds live in one document because a consumer reads one thing and needs to see both what was examined and found not to apply and what was never examined. A document holding only dismissals lets a reader conclude that everything else came back clean, and where a class was excluded that conclusion is false and nothing says so. The failure it prevents is the cheaper path being the quieter one. Dismissing a finding costs a written statement; narrowing the scanner so the finding never fires costs nothing, and the configuration change looks like tuning. A rule whose honest route is more expensive than its evasion selects for the evasion without anybody deciding to evade, so the check reaches an exclusion and not only a dismissal. `.github/statements/statements.sh` refuses a document that does not parse before any rule reads it, an entry missing a field or carrying one blank, a status outside the vocabulary the document declares, one identifier used twice, a statement naming a configuration that is not tracked, a directive a register excludes with no statement, and a statement for a directive no register excludes any more. The two directions are what stop the pointer rotting from either end. The registers are DERIVED rather than listed, in the script and in the document both: every tracked file under .github/ whose name begins with `excluded` or is `suppressions`, excluding the scripts that read them. A register added tomorrow under that shape is read on the day it lands, where a list written here would drift against the thing it describes. Twenty-one fixtures run before the gate judges anything, and both Done-when proofs were watched on the real tree rather than only on a fixture: a trailing comma in the document reddens the run before any rule reads it, and a rule added to .github/shell-analysis/excluded-rules with no statement reddens it by name. What it cannot reach is printed on every run rather than only written down. Whether a statement is TRUE is a judgement no reading of the tree makes. An exclusion that is not a directive - the persona a workflow does not select, the codepoint a pattern does not list - is stated in the document and derived by nothing, so one added tomorrow with no statement is silent. A dismissal on the code-scanning surface is a state on the repository rather than a byte in the tree, so the four entries for those are written by hand and nothing compares them against the live alerts. The comment at the head of .github/workflows/scorecard.yml said the reasoning for a dismissal lived only in the dismissal, where a reader who cannot list the alerts cannot reach it. That stops being true with this change, so the comment says what is true now and states the bound rather than dropping it. The means is a JSON document read with jq, which is on the runner image, beside bash and awk for the registers, matching the other checks in this gate. JSON rather than this repository's own block-register shape because the audience is a downstream consumer rather than this gate: a bespoke format is not something a consumer acts on. The document says of itself that it is NOT an OpenVEX document and does not claim to be - no advisory against a dependency here has been raised, every entry is a policy check rather than a vulnerability, and the fields are named after OpenVEX's so that projecting it later is a mapping rather than a translation. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/statements/statements.sh | 425 +++++++++++++++++++++++++++++++ .github/workflows/scorecard.yml | 13 +- .github/workflows/statements.yml | 66 +++++ CONTRIBUTING.md | 14 + security/statements.json | 91 +++++++ 5 files changed, 605 insertions(+), 4 deletions(-) create mode 100644 .github/statements/statements.sh create mode 100644 .github/workflows/statements.yml create mode 100644 security/statements.json diff --git a/.github/statements/statements.sh b/.github/statements/statements.sh new file mode 100644 index 0000000..0d288eb --- /dev/null +++ b/.github/statements/statements.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash +# Every scanner exclusion and every dismissed finding has a published statement (#89). +# +# Every scanner pointed at this repository will eventually report something that is +# not exploitable here. The choices are to fix it, to suppress it silently, or to +# say publicly why it does not apply, and the third is the only one a downstream +# consumer can act on. `security/statements.json` is where that is said, and this +# is what stops it drifting against the configurations it describes. +# +# WHY AN EXCLUSION IS INSIDE THE RULE AND NOT BESIDE IT. A directory a scanner is +# never pointed at produces no finding, so nobody dismisses anything, so a rule +# written only about dismissals owes nothing for it. But a consumer reading the +# published statements sees the dismissals with their reasons and concludes that +# everything else was looked at and came back clean, and where a class was excluded +# that conclusion is false and nothing says so. A rule whose honest route costs a +# written statement while its evasion - narrowing the scanner so the finding never +# fires - costs nothing selects for the evasion, and it does so without anybody +# deciding to evade, because the configuration change looks like tuning. +# +# The rules live here as shell functions rather than as steps inside the workflow +# because each one owes a fixture proving it bites, and a fixture run against a +# second copy of the logic proves the copy. `selftest` and `check` call the same +# functions, so a rule cannot pass its fixture and refuse something else in the +# gate. +# +# Verbs: +# selftest run every fixture and prove each rule bites +# check apply the rules to the document and the tree, and refuse +# +# The set of configurations is DERIVED rather than written down here or in the +# document. `derived_directives` reads every tracked file under .github/ whose name +# begins with `excluded` or is `suppressions`, which is the shape this repository +# gives a register of directives a scanner is told to skip. A register added +# tomorrow under that shape is read on the day it lands, and a document that +# enumerated them instead would drift against the thing it describes. + +set -euo pipefail + +# The fields every entry of each kind must carry, non-empty. +# +# `rule` is deliberately absent from the exclusion list. An exclusion expressed as a +# directive in a register carries the directive; one expressed as a persona nobody +# selected or a codepoint nobody listed has no directive to carry, and demanding one +# would be demanding an identifier that does not exist. `rule` is `null` there, and +# the two directions below are written so that null means "not derivable" rather +# than "unchecked". +FINDING_FIELDS='id scanner subject status justification impact retires_when' +EXCLUSION_FIELDS='id scanner configuration scope reason not_looked_for retires_when' + +# What a dismissed finding may say about itself. A free-text status is a status +# nobody can group on, which is the whole point of the document being machine +# readable. +FINDING_STATUSES='not_applicable not_exploitable accepted fixed' + +# -------------------------------------------------------------------------- +# Rule 1. The document parses and carries the shape the rest of the rules read. +# +# It is first because every rule after it reads fields out of the document, and a +# checker that cannot parse its subject reports nothing and reads exactly like a +# clean run. +# -------------------------------------------------------------------------- + +doc_shape() { + local doc="$1" + + if ! jq -e . "$doc" > /dev/null 2>&1; then + echo "REFUSE ${doc} is not valid JSON, so nothing below could read it" + return 0 + fi + + jq -r --arg doc "$doc" ' + def bad(m): "REFUSE\t" + $doc + "\t" + m; + [ + (if type != "object" then bad("is not a JSON object") else empty end), + (if has("version") and (.version | type) == "number" then empty + else bad("carries no numeric `version`") end), + (if has("updated") and (.updated | type) == "string" and (.updated | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) then empty + else bad("carries no `updated` date written as YYYY-MM-DD") end), + (if has("about") and (.about | type) == "array" and (.about | length) > 0 then empty + else bad("carries no `about`, which is what a consumer reads before the entries") end), + (if has("findings") and (.findings | type) == "array" then empty + else bad("carries no `findings` array") end), + (if has("exclusions") and (.exclusions | type) == "array" then empty + else bad("carries no `exclusions` array") end) + ] | .[] + ' "$doc" +} + +# -------------------------------------------------------------------------- +# Rule 2, 3 and 4. Every entry carries its fields, its status is one the document +# declares, and no identifier is used twice. +# +# An empty string is refused as hard as an absent key. A field somebody left blank +# to get past a required-fields check is the silent suppression this document +# exists to replace, wearing the shape of a statement. +# -------------------------------------------------------------------------- + +doc_entries() { + local doc="$1" + + jq -e . "$doc" > /dev/null 2>&1 || return 0 + + jq -r --arg doc "$doc" \ + --arg ff "$FINDING_FIELDS" \ + --arg ef "$EXCLUSION_FIELDS" \ + --arg st "$FINDING_STATUSES" ' + def bad(m): "REFUSE\t" + $doc + "\t" + m; + def entlabel(kind; i; e): kind + " entry " + (i | tostring) + + (if (e.id | type) == "string" and e.id != "" then " (" + e.id + ")" else "" end); + def fields(kind; list; arr): + [ arr | to_entries[] as $p + | ($p.value) as $e + | (list | split(" "))[] as $f + | if ($e | has($f)) and (($e[$f] | type) == "string") and ($e[$f] != "") + then empty + else bad(entlabel(kind; $p.key; $e) + " carries no `" + $f + "`, or carries it empty") end + ]; + ((.findings // []) | if type == "array" then . else [] end) as $fs + | ((.exclusions // []) | if type == "array" then . else [] end) as $xs + | (fields("findings"; $ff; $fs)) + + (fields("exclusions"; $ef; $xs)) + + [ $fs | to_entries[] as $p + | if (($st | split(" ")) | index($p.value.status)) != null then empty + else bad(entlabel("findings"; $p.key; $p.value) + " carries status `" + + (($p.value.status // "") | tostring) + + "`, which is not one this document declares") end ] + + [ ($fs + $xs) | map(.id) | group_by(.) | .[] + | select(length > 1) + | bad("uses the identifier `" + .[0] + "` " + (length | tostring) + " times") ] + | .[] + ' "$doc" +} + +# -------------------------------------------------------------------------- +# The directives this repository actually carries, derived rather than declared. +# +# One line per directive, as CONFIGURATIONRULE. A comment line and a blank +# line are not directives; the first whitespace-delimited token of anything else is +# the identifier, which is the shape every register of this kind in the tree takes. +# -------------------------------------------------------------------------- + +register_paths() { + git ls-files '.github/*' | awk ' + { + sub(/\r$/, "") + if ($0 == "") next + p = $0 + i = length(p) + while (i > 0 && substr(p, i, 1) != "/") i-- + base = substr(p, i + 1) + # A register is data. The script that reads one frequently shares its + # directory and half its name, and reading a shell file line by line as + # though every statement in it were a directive turns two hundred lines of + # source into two hundred refusals, which is a gate nobody can read. + if (length(base) > 3 && substr(base, length(base) - 2) == ".sh") next + if (base == "suppressions" || substr(base, 1, 8) == "excluded") print p + } + ' | sort -u +} + +directives_in() { + awk -v cfg="$1" ' + { + line = $0 + sub(/\r$/, "", line) + if (line ~ /^[ \t]*#/) next + if (line ~ /^[ \t]*$/) next + n = split(line, t, /[ \t]+/) + tok = (t[1] == "") ? t[2] : t[1] + if (tok == "") next + printf "%s\t%s\n", cfg, tok + } + ' +} + +derived_directives() { + local p + while IFS= read -r p; do + [ -n "$p" ] || continue + directives_in "$p" < "$p" + done < <(register_paths) +} + +# -------------------------------------------------------------------------- +# Rule 5, 6 and 7. The two directions, and the path. +# +# A directive with no entry is the case the whole document is for. An entry naming +# a directive nothing carries any more is the other direction, and it fails closed +# for the reason the store of waivers in the fleet does: a statement about +# something that is no longer excluded tells a consumer the scanner is narrower +# than it is. An entry naming a configuration that is not tracked is a pointer at +# nothing, which rots in silence. +# -------------------------------------------------------------------------- + +coverage() { + local doc="$1" directives="$2" universe="$3" + + jq -e . "$doc" > /dev/null 2>&1 || return 0 + + # Every input is a real file rather than a process substitution. A jq built for + # Windows cannot open the /proc descriptor a substitution hands it, and the + # failure is a message on standard error and an empty verdict, which reads + # exactly like a run that found nothing. + local dj uj + dj="$(mktemp)"; uj="$(mktemp)" + jq -R 'split("\t") | {configuration: .[0], rule: .[1]}' < "$directives" > "$dj" + jq -R '.' < "$universe" > "$uj" + + jq -rn --arg doc "$doc" \ + --slurpfile d "$dj" \ + --slurpfile u "$uj" \ + --slurpfile j "$doc" ' + def bad(m): "REFUSE\t" + $doc + "\t" + m; + ($j[0].exclusions // []) as $xs + | ($d | map(select(.configuration != null and .configuration != ""))) as $dir + | ($u | map(select(. != ""))) as $tracked + | [ $dir[] as $one + | if ([ $xs[] | select(.configuration == $one.configuration and .rule == $one.rule) ] | length) > 0 + then empty + else bad("names no statement for `" + $one.rule + "` in " + $one.configuration + + ", which that configuration excludes") end ] + + [ $xs[] | select(.rule != null and .rule != "") as $e + | if ([ $dir[] | select(.configuration == $e.configuration and .rule == $e.rule) ] | length) > 0 + then empty + else bad("carries a statement for `" + $e.rule + "` in " + $e.configuration + + ", which that configuration does not exclude") end ] + + [ $xs[] | select((.configuration | type) == "string" and .configuration != "") as $e + | if ($tracked | index($e.configuration)) != null then empty + else bad("names configuration `" + $e.configuration + "`, which is not a tracked path in this tree") end ] + | .[] + ' + rm -f "$dj" "$uj" +} + +# -------------------------------------------------------------------------- +# selftest +# +# Every fixture below judges its own document against its own directive list and +# its own path universe. A row that judged this repository would prove the state of +# the tree on the day it ran, not the rule. +# -------------------------------------------------------------------------- + +selftest_failures=0 + +assert_out() { + local what="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf 'ok %s\n' "$what" + else + printf 'FAIL %s\n expected: %s\n actual: %s\n' \ + "$what" "$(printf '%s' "$expected" | tr '\n' '|')" "$(printf '%s' "$actual" | tr '\n' '|')" + selftest_failures=$((selftest_failures + 1)) + fi +} + +# The refusals one document produces, with the message alone. +judge_shape() { + local df + df="$(mktemp)" + printf '%s' "$1" > "$df" + { doc_shape "$df"; doc_entries "$df"; } | awk -F'\t' '$1 == "REFUSE" { print $3 }' + rm -f "$df" +} + +judge_coverage() { + local body="$1" dirs="$2" uni="$3" + local df ddf uf + df="$(mktemp)"; ddf="$(mktemp)"; uf="$(mktemp)" + printf '%s' "$body" > "$df" + printf '%s' "$dirs" > "$ddf" + printf '%s' "$uni" > "$uf" + coverage "$df" "$ddf" "$uf" | awk -F'\t' '$1 == "REFUSE" { print $3 }' + rm -f "$df" "$ddf" "$uf" +} + +FIXTURE_FINDING='{"id":"s/A","scanner":"Scorecard","subject":"a check","status":"not_applicable","justification":"one maintainer","impact":"nothing stands in place of it","retires_when":"a second account exists"}' +FIXTURE_EXCLUSION='{"id":"sc/SC1","scanner":"shellcheck","configuration":".github/x/excluded-rules","rule":"SC1","scope":"the rule alone","reason":"it is the ordinary case here","not_looked_for":"the mistake the rule exists for","retires_when":"the checks stop doing that"}' + +doc_with() { + printf '{"version":1,"updated":"2026-09-02","about":["why"],"findings":[%s],"exclusions":[%s]}' "$1" "$2" +} + +selftest() { + local good_dirs='.github/x/excluded-rules SC1 +' + local good_uni='.github/x/excluded-rules +' + + echo "== the document parses and carries its shape ==" + assert_out "passes: a document carrying one entry of each kind" \ + "" "$(judge_shape "$(doc_with "$FIXTURE_FINDING" "$FIXTURE_EXCLUSION")")" + assert_out "bites: a trailing comma, which is the edit somebody actually makes" \ + "is not valid JSON, so nothing below could read it" \ + "$(judge_shape '{"version":1,"updated":"2026-09-02","about":["why"],"findings":[],"exclusions":[],}')" + assert_out "bites: the exclusions array missing entirely" \ + "carries no \`exclusions\` array" \ + "$(judge_shape '{"version":1,"updated":"2026-09-02","about":["why"],"findings":[]}')" + assert_out "bites: a date written the other way round" \ + "carries no \`updated\` date written as YYYY-MM-DD" \ + "$(judge_shape '{"version":1,"updated":"02-09-2026","about":["why"],"findings":[],"exclusions":[]}')" + assert_out "bites: an about section emptied rather than removed" \ + "carries no \`about\`, which is what a consumer reads before the entries" \ + "$(judge_shape '{"version":1,"updated":"2026-09-02","about":[],"findings":[],"exclusions":[]}')" + assert_out "passes: both arrays present and empty, which is a tree with nothing to state" \ + "" "$(judge_shape '{"version":1,"updated":"2026-09-02","about":["why"],"findings":[],"exclusions":[]}')" + + echo "== every entry carries its fields ==" + assert_out "bites: an exclusion with no not_looked_for, which is the field an author leaves out" \ + "exclusions entry 0 (sc/SC1) carries no \`not_looked_for\`, or carries it empty" \ + "$(judge_shape "$(doc_with "$FIXTURE_FINDING" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c 'del(.not_looked_for)')")")" + assert_out "bites: the same field present and blank, which passes a presence test" \ + "exclusions entry 0 (sc/SC1) carries no \`not_looked_for\`, or carries it empty" \ + "$(judge_shape "$(doc_with "$FIXTURE_FINDING" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c '.not_looked_for = ""')")")" + assert_out "bites: a finding with no retires_when, so its disposition never expires" \ + "findings entry 0 (s/A) carries no \`retires_when\`, or carries it empty" \ + "$(judge_shape "$(doc_with "$(printf '%s' "$FIXTURE_FINDING" | jq -c 'del(.retires_when)')" "$FIXTURE_EXCLUSION")")" + assert_out "bites: a status outside the declared vocabulary" \ + "findings entry 0 (s/A) carries status \`wontfix\`, which is not one this document declares" \ + "$(judge_shape "$(doc_with "$(printf '%s' "$FIXTURE_FINDING" | jq -c '.status = "wontfix"')" "$FIXTURE_EXCLUSION")")" + assert_out "bites: one identifier used by a finding and an exclusion" \ + "uses the identifier \`s/A\` 2 times" \ + "$(judge_shape "$(doc_with "$FIXTURE_FINDING" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c '.id = "s/A"')")")" + assert_out "passes: a null rule, which is an exclusion with no directive to name" \ + "" "$(judge_shape "$(doc_with "$FIXTURE_FINDING" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c '.rule = null')")")" + + echo "== the two directions, and the path ==" + assert_out "passes: one directive with one statement naming it" \ + "" "$(judge_coverage "$(doc_with "" "$FIXTURE_EXCLUSION")" "$good_dirs" "$good_uni")" + assert_out "bites: a directive nothing states, which is the case the document exists for" \ + "names no statement for \`SC2\` in .github/x/excluded-rules, which that configuration excludes" \ + "$(judge_coverage "$(doc_with "" "$FIXTURE_EXCLUSION")" "$(printf '.github/x/excluded-rules\tSC1\n.github/x/excluded-rules\tSC2\n')" "$good_uni")" + assert_out "bites: a statement for a directive that has been taken back out" \ + "carries a statement for \`SC1\` in .github/x/excluded-rules, which that configuration does not exclude" \ + "$(judge_coverage "$(doc_with "" "$FIXTURE_EXCLUSION")" "" "$good_uni")" + assert_out "bites: a statement naming a configuration that is not tracked" \ + "$(printf 'names no statement for `SC1` in .github/x/excluded-rules, which that configuration excludes\ncarries a statement for `SC1` in .github/y/excluded-rules, which that configuration does not exclude\nnames configuration `.github/y/excluded-rules`, which is not a tracked path in this tree')" \ + "$(judge_coverage "$(doc_with "" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c '.configuration = ".github/y/excluded-rules"')")" "$good_dirs" "$good_uni")" + assert_out "passes: a null rule against a directive list that does not carry it" \ + "" "$(judge_coverage "$(doc_with "" "$(printf '%s' "$FIXTURE_EXCLUSION" | jq -c '.rule = null')")" "" "$good_uni")" + + echo "== what a register line is, and is not ==" + assert_out "reads: the identifier and not the reason after it" \ + "$(printf 'r\tSC2016')" \ + "$(printf 'SC2016 Every rule in this gate is an awk program.\n' | directives_in r)" + assert_out "does not read: a comment line, which is where every one of these registers explains itself" \ + "" "$(printf '# SC2016 would go here\n' | directives_in r)" + assert_out "does not read: a blank line" \ + "" "$(printf '\n \n' | directives_in r)" + assert_out "reads: an identifier written with leading whitespace" \ + "$(printf 'r\tSC2016')" \ + "$(printf ' SC2016 a reason\n' | directives_in r)" + + echo + if [ "$selftest_failures" -ne 0 ]; then + echo "::error::$selftest_failures statement fixture(s) did not hold. The rules below are not the rules that were proven, so this run judges nothing." + return 1 + fi + echo "Every fixture held. The rules the gate applies are the rules these fixtures ran." +} + +# -------------------------------------------------------------------------- +# check +# -------------------------------------------------------------------------- + +DOCUMENT='security/statements.json' + +check() { + local dirs uni out refusals=0 findings exclusions ndir + + if ! git ls-files --error-unmatch "$DOCUMENT" > /dev/null 2>&1; then + echo "::error file=${DOCUMENT}::${DOCUMENT} is not a tracked file. Every rule below reads it, so this run judges nothing." + return 1 + fi + + dirs="$(mktemp)"; uni="$(mktemp)" + derived_directives > "$dirs" + git ls-files > "$uni" + + ndir="$(awk 'END { print NR }' "$dirs")" + findings="$(jq -r '(.findings // []) | length' "$DOCUMENT" 2>/dev/null || echo '?')" + exclusions="$(jq -r '(.exclusions // []) | length' "$DOCUMENT" 2>/dev/null || echo '?')" + + echo "Document read: ${DOCUMENT}." + echo "Registers derived: every tracked file under .github/ whose name begins with 'excluded' or is 'suppressions'." + echo "Registers found: $(register_paths | tr '\n' ' ')" + echo "Directives in them: ${ndir}. Statements in the document: ${findings} finding(s), ${exclusions} exclusion(s)." + echo + + echo "-- every statement has its fields, and every exclusion is named from both ends" + out="$({ doc_shape "$DOCUMENT"; doc_entries "$DOCUMENT"; coverage "$DOCUMENT" "$dirs" "$uni"; })" + while IFS=$'\t' read -r tag a b; do + [ "$tag" = "REFUSE" ] || continue + echo "::error file=${a}::${a} ${b}" + echo " ${a} ${b}" + refusals=$((refusals + 1)) + done <&2; exit 2 ;; +esac diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0eb9fef..59c89ea 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -4,10 +4,15 @@ # checks (Branch-Protection, Token-Permissions, Pinned-Dependencies, # Dangerous-Workflow, etc.) and uploads the findings as SARIF to the # code-scanning tab. The score is treated as a checklist, not a guarantee. Some -# of what it raises here is dismissed rather than fixed, and the reasoning for a -# dismissal lives in the dismissal itself: nothing in this tree holds it, and a -# reader who cannot list the alerts cannot reach it. #89 is where a written -# statement about a finding that is not going to be fixed will live. +# of what it raises here is dismissed rather than fixed. THIS COMMENT SAID THE +# REASONING FOR A DISMISSAL LIVED ONLY IN THE DISMISSAL, WHERE A READER WHO CANNOT +# LIST THE ALERTS CANNOT REACH IT. `security/statements.json` holds it now, under +# #89, with a disposition and a condition that retires it for each of the four +# dismissed here. The dismissal comment is still what the code-scanning tab shows +# and nothing keeps the two in step: the document is written by hand, a dismissal +# is a state on the repository rather than a byte in this tree, and +# `.github/statements/statements.sh` prints that bound on every run rather than +# implying it compared them. # # Runs where publishing is valid: on push to the default branch, on a weekly # schedule (keeps the Maintained check fresh and re-scores on upstream changes), diff --git a/.github/workflows/statements.yml b/.github/workflows/statements.yml new file mode 100644 index 0000000..a586bd1 --- /dev/null +++ b/.github/workflows/statements.yml @@ -0,0 +1,66 @@ +# Every scanner exclusion has a published statement (#89). +# +# A scanner reports something that does not apply here, and the choices are to fix +# it, to suppress it silently, or to say publicly why it does not apply. +# `security/statements.json` is the third. This job validates that document and +# compares it against the exclusion registers this repository actually carries, in +# both directions, so a malformed edit reddens a pull request rather than a +# release, and a directive added with no statement reddens one too. +# +# The check-run name is exactly `Every scanner exclusion has a statement`, on the +# job. GitHub takes that name from the job's `name:` and falls back to the job id, +# and a ruleset matches the literal, so the name a requirement would be attached to +# is visible in this file rather than derived from it. +# +# `.github/statements/statements.sh` holds the rules and the fixtures. Every run +# proves every rule against its own violating document and its own near miss before +# it judges anything, so a rule cannot pass its fixture and refuse something else +# in the gate. +# +# The job carries no `if:` and no path filter, for the reason +# .github/workflows/build.yml already gives: GitHub creates a check run for a job +# it started and then skipped, carrying the same name a job that did the work +# would. A path filter would be wrong here for a second reason: the change this +# check exists to catch is one that narrows a scanner, and that change touches a +# scanner's configuration rather than the document it owes a statement to. +# +# Nothing here compiles. The document and the registers are tracked text, so this +# check has a verdict on a tree the compiler cannot build. `jq` is what reads the +# document and is present on the runner image; nothing is installed here. +name: statements + +on: + pull_request: + branches: ["**"] + types: [opened, synchronize, reopened] + push: + branches: [main] + +# Deny at the workflow level and grant per job, so a job added later starts with +# nothing rather than with what this one needs. +permissions: {} + +concurrency: + # Namespaced on the workflow name rather than the bare word, for the reason + # #178 recorded: a group string two workflows share means the run created + # second cancels the other, and the gate that dies that way leaves a green tick + # beside no verdict. + group: statements-workflow-${{ github.ref }} + cancel-in-progress: true + +jobs: + statements: + name: Every scanner exclusion has a statement + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # check out the document and the registers the rules read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes, so do not leave the token in .git/config. + persist-credentials: false + + - name: Prove every rule bites, then apply the rule set + run: bash .github/statements/statements.sh check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 277441d..18bbbea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -179,6 +179,20 @@ a field names is the clause that actually moved is a judgement no reading of the text makes, and a later record that narrows an earlier one and writes no field at all is silent to every rule in it. +**`Every scanner exclusion has a statement`** validates +`security/statements.json`, which says publicly why a finding this repository does +not fix does not apply, and compares it against the exclusion registers the tree +carries. A malformed document is refused before any rule reads it, an entry +missing a field or carrying one blank is refused, and the comparison runs in both +directions: a directive a register excludes with no statement, and a statement for +a directive no register excludes any more. The registers are derived rather than +listed, so one added tomorrow is read on the day it lands. +`.github/statements/statements.sh` proves every rule against its own violating +document and its own near miss before it judges anything, and prints what it +cannot reach: whether a statement is true, an exclusion that is not a directive in +a register, and a finding dismissed on the code-scanning surface, which is a state +on the repository rather than a byte in the tree. + **`Analyse the shell the gate runs (shellcheck)`** analyses every tracked shell file. The rules it does not refuse are in `.github/shell-analysis/excluded-rules` with the reason for each. diff --git a/security/statements.json b/security/statements.json new file mode 100644 index 0000000..91ba88b --- /dev/null +++ b/security/statements.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "updated": "2026-09-02", + "about": [ + "Every scanner pointed at this repository will eventually report something that is not exploitable here, or not reachable from any path this core takes. The choices are to fix it, to suppress it silently, or to say publicly why it does not apply. This document is the third, and it is the only one a downstream consumer can act on.", + "It carries two kinds of entry, because there are two ways a finding stops reaching the gate and they do not have the same shape. A `findings` entry is a finding a scanner raised against this repository and somebody dismissed; it has an identifier, so it carries a per-finding disposition. An `exclusions` entry is a class taken out of what a scanner is pointed at; it has no identifier and no list of what would have been found, so it carries a scope, and it carries what is consequently NOT being looked for. That last field is the one a consumer needs and the one an author will otherwise leave out.", + "Both kinds live here rather than in two documents, because a consumer reads one thing and needs to see both what was examined and found not to apply, and what was never examined. A document holding only dismissals would let a reader conclude that everything else was looked at and came back clean, and where a class was excluded that conclusion is false and nothing would say so.", + "THIS IS NOT AN OPENVEX DOCUMENT AND IT DOES NOT CLAIM TO BE ONE. No advisory against a dependency of this repository has been raised yet, so there is nothing here for OpenVEX's vocabulary to describe; every `findings` entry below is a policy check rather than a vulnerability. Where `status`, `justification` and `impact` appear they are named after OpenVEX's fields so that projecting this document is a mapping rather than a translation, and no such projection has been produced. The shape a dependency advisory takes here is decided when the first one arrives, and not before.", + "The scanner configurations this repository holds are not listed here. `.github/statements/statements.sh` derives them and refuses a directive with no entry below, which is what stops this list drifting against the thing it describes." + ], + "findings": [ + { + "id": "scorecard/BranchProtectionID", + "scanner": "Scorecard", + "subject": "the branch protection this repository sets on its default branch", + "status": "not_applicable", + "justification": "The check asks for required approvers, code owners, stale-review dismissal and last-push approval. Every one of those names a second account, and this repository has one maintainer, so a rule nobody can satisfy is switched off rather than left red forever.", + "impact": "The part of the check that is about being up to date with the base is met by a stronger route than the setting: the branch-health watcher builds the merge locally against the current base and judges the result, rather than asking whether a branch pointer is behind.", + "retires_when": "A second account can approve a change on this repository. Every clause above becomes satisfiable in the same moment." + }, + { + "id": "scorecard/CodeReviewID", + "scanner": "Scorecard", + "subject": "whether a change reaching the default branch was approved by somebody other than its author", + "status": "not_applicable", + "justification": "There is one maintainer here, so the check is unsatisfiable by construction rather than unmet by choice.", + "impact": "Nothing stands in place of a second reader. Every pull request body on this board says plainly whether anybody other than the author has read the change, and where nobody has it says so and lets its evidence stand in place of a review. A negative disclosure is what is offered here, not a substitute control.", + "retires_when": "A second account reads changes on this repository." + }, + { + "id": "scorecard/CIIBestPracticesID", + "scanner": "Scorecard", + "subject": "whether this repository holds an OpenSSF Best Practices badge", + "status": "not_applicable", + "justification": "The badge criteria include a second reviewer and a vulnerability-response process with a named team. Neither exists for a project with one maintainer, so the badge is unobtainable rather than unclaimed.", + "impact": "A consumer looking for the assurances the badge stands for does not get them from a badge here. What this repository offers instead is the gate, which is derivable rather than asserted, and SECURITY.md, which says how to report something.", + "retires_when": "The project has the second reviewer and the named response team the criteria ask for, at which point the badge is applied for rather than dismissed." + }, + { + "id": "scorecard/FuzzingID", + "scanner": "Scorecard", + "subject": "whether a fuzzing harness for this repository is registered with OSS-Fuzz or an equivalent", + "status": "not_applicable", + "justification": "There is no untrusted input surface in this tree today that a fuzzer would reach, and onboarding to OSS-Fuzz needs maintainers who can be paged.", + "impact": "THIS DISPOSITION IS TRUE OF THE TREE AS IT STANDS AND NOT OF THE TREE THIS BOARD HAS PLANNED. 0101 puts a server's answers and an artwork byte stream on the untrusted list, 0055 calls the decoder the most attacked surface this repository will carry, and issue #86 is open to fuzz every parser reachable from that list and replay the seed corpus inside the gating build. A consumer reading a permanent dismissal here would take the absence of a fuzzer for a decision rather than for a state, and it is a state.", + "retires_when": "A parser reachable from 0101's untrusted list lands in this tree, which is what #86 is open for. The dismissal does not expire on its own and nothing raises the finding again when that happens, which is why the condition is written here rather than left in the dismissal." + } + ], + "exclusions": [ + { + "id": "shellcheck/SC2016", + "scanner": "shellcheck", + "configuration": ".github/shell-analysis/excluded-rules", + "rule": "SC2016", + "scope": "the rule alone, across every tracked shell file the analysis reads", + "reason": "Every rule in this gate is an awk or sed program held in single quotes, and every fixture is a piece of markdown holding backticks, so an unexpanded expression inside single quotes is the ordinary case here rather than a mistake.", + "not_looked_for": "A single-quoted string that was meant to interpolate a shell variable and silently does not. Nothing else refuses one, so a value that should have been expanded and was not passes every route in this repository.", + "retires_when": "The checks stop carrying another language's source as literal data." + }, + { + "id": "shellcheck/SC2094", + "scanner": "shellcheck", + "configuration": ".github/shell-analysis/excluded-rules", + "rule": "SC2094", + "scope": "the rule alone, across every tracked shell file the analysis reads", + "reason": "It is raised twice on one line of .github/doc-paths/doc-paths.sh, where a document's path is passed as an argument and redirected as standard input on the same command. Nothing in that pipeline writes the file it reads, and the analyser cannot see that the argument is a label rather than a destination.", + "not_looked_for": "A command in this tree that genuinely does read and write one file in one pipeline, which is the corruption the rule exists for. It would pass unremarked anywhere in the shell this gate is written in.", + "retires_when": "A command in this tree does read and write one file in one pipeline, at which point the exclusion has to be argued rather than kept." + }, + { + "id": "zizmor/pedantic-persona", + "scanner": "zizmor", + "configuration": ".github/workflows/zizmor.yml", + "rule": null, + "scope": "one of the scanner's two personas, across every workflow file it reads. The gate runs the regular persona at --min-severity=low; the pedantic persona is not run.", + "reason": "The regular persona is the one the scanner documents for continuous integration. The pedantic persona adds low-severity hygiene findings - undocumented permissions, missing concurrency, pin-comment mismatches, unnamed jobs - which are stylistic rather than security-blocking, and some are context-blind: it would demand run-cancelling concurrency on a workflow that must never be cancelled part way through.", + "not_looked_for": "Every pedantic-persona finding, on every workflow in this repository. No advisory or hygiene finding from that persona will appear in this document or in the code-scanning tab at all, so a consumer reading a clean zizmor result is reading a clean REGULAR-persona result and nothing more.", + "retires_when": "The pedantic backlog is worth gating on this board. It is not tracked as an issue here; the argument for leaving it ungated was made on the repository these guards were adapted from, at Flowfin/jellyfin-plugin-sso#263." + }, + { + "id": "unicode-guard/U+FEFF", + "scanner": "unicode-guard", + "configuration": ".github/workflows/unicode-guard.yml", + "rule": null, + "scope": "one codepoint, U+FEFF, absent from the set the guard's pattern refuses, across every tracked text file the guard reads", + "reason": "A leading UTF-8 byte order mark is legitimate and appears in files this tree may carry, so banning the codepoint outright would refuse honest work, and the bidirectional reordering attack the guard exists for does not rely on it.", + "not_looked_for": "A U+FEFF anywhere other than at the start of a file, where it is a zero-width no-break space and is as invisible as the codepoints beside it in the pattern. The exclusion is written on the codepoint rather than on its position, so the guard does not distinguish the legitimate leading mark from one in the middle of a line.", + "retires_when": "The guard can tell a leading byte order mark from the same codepoint elsewhere in a file, at which point the position is excluded rather than the codepoint." + } + ] +}