* feat(#3908): the scanners distinguish an empty diff from one they could not compute collect_files ended 2>/dev/null || true, which destroyed the evidence three ways: the redirect discarded git's diagnostic, the pipe replaced git's status with grep's, and || true forced success regardless. Four distinct conditions - an established-empty diff, a bad ref, no repository, and a repository with no commits - all reported clean, and a secret scanner reporting clean because git failed is indistinguishable from an all-clear to any gate consuming it. git now runs separately from the filter so its status and diagnostic both survive. An established-empty diff exits NO_INPUT; a scope that could not be established exits UNAVAILABLE; the usage sites move off 2 to USAGE. || true is retained on the filter alone, where it is correct: a diff of only images is empty, not failed. Codes are sourced from a generated shell fragment rather than written into three scripts, so a re-allocation cannot desync them, and a missing fragment fails loudly instead of falling back to literals. The security workflow is updated in the same change: without it, a docs-only PR would newly fail the job. * fix(#3908): keep scanner stderr out of the file list, and drop try/finally from test bodies Capturing git and find output with 2>&1 was right for the failure path but wrong for the success path: a warning emitted alongside a successful diff flowed into the file list and was treated as a filename. stderr is now captured separately, forwarded as a warning on success and as the diagnostic on failure, and never folded into the list. Also converts the control tests' try/finally blocks to t.after(), which CONTRIBUTING bans inside a test body because it masks failures. * chore(#3908): backfill changeset pr number * docs(#3908): record the scanners' four-outcome exit contract SECURITY.md is root-level, so the docs gate correctly held: a Changed fragment owes a file under docs/. The contract also belongs where the feature is described, as REQ-SCAN-INJ-05. docs/FEATURES.md is GENERATED from per-feature fragments (#3840) - the first edit went into the generated file and gen-features --check caught it, which is the same edit-the-output drift this epic exists to close. The fragment is the source; FEATURES.md is regenerated. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -9,12 +9,29 @@
|
||||
# scripts/base64-scan.sh --file path/to/file # Scan a single file
|
||||
# scripts/base64-scan.sh --dir agents/ # Scan all files in a directory
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = clean
|
||||
# 1 = findings detected
|
||||
# 2 = usage error
|
||||
# Exit codes (ADR-3889, #3908 — registered in gsd-core/bin/shared/exit-codes.json):
|
||||
# 0 = clean
|
||||
# 1 = findings detected
|
||||
# $EXIT_USAGE (64) = usage error (bad argv, missing --file/--dir target)
|
||||
# $EXIT_NO_INPUT (66) = ran; scope established; zero files in scope (genuinely empty)
|
||||
# $EXIT_UNAVAILABLE (69) = could not establish scope (bad ref, not a repo, unreadable dir)
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Exit-code registry (ADR-3889, #3908) ────────────────────────────────────
|
||||
# Resolved relative to THIS script's location, not the caller's cwd. Loud,
|
||||
# non-zero failure if the fragment is missing — never fall back to a guessed
|
||||
# literal integer, and never let a missing registry silently degrade to
|
||||
# exit 0.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EXIT_CODES_SH="$SCRIPT_DIR/../gsd-core/bin/shared/exit-codes.sh"
|
||||
if [[ ! -f "$EXIT_CODES_SH" ]]; then
|
||||
echo "base64-scan: FATAL: exit-code registry not found at $EXIT_CODES_SH" >&2
|
||||
echo " Regenerate with: node scripts/gen-exit-code-registry.cjs --write" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
. "$EXIT_CODES_SH"
|
||||
|
||||
# ── Locale hardening (#116) ───────────────────────────────────────────────────
|
||||
# BSD tr (macOS) treats input bytes as multi-byte characters under any UTF-8
|
||||
# locale. When the input to `tr -cd '[:print:]'` contains bytes that are not
|
||||
@@ -180,7 +197,31 @@ collect_files() {
|
||||
case "$mode" in
|
||||
--diff)
|
||||
local base="${1:-origin/main}"
|
||||
git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>/dev/null \
|
||||
# Run git separately from the filter pipe so its OWN exit status (not
|
||||
# grep's) decides whether the diff could be established. stdout and
|
||||
# stderr are captured SEPARATELY (never merged with `2>&1`) so that a
|
||||
# warning git writes to stderr on an otherwise successful diff can
|
||||
# never be mistaken for a filename in the file list. On failure the
|
||||
# captured stderr is emitted as the diagnostic; on success it is
|
||||
# forwarded as a warning, never folded into the file list.
|
||||
# `|| true` on the filter below is CORRECT (not gratuitous): `grep -v`
|
||||
# exits 1 when every line is filtered out (e.g. an all-images diff),
|
||||
# which is a legitimate empty result, not a failure to run.
|
||||
local raw status err_file
|
||||
err_file=$(mktemp)
|
||||
raw=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>"$err_file")
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
cat "$err_file" >&2
|
||||
rm -f "$err_file"
|
||||
exit "$EXIT_UNAVAILABLE"
|
||||
fi
|
||||
if [[ -s "$err_file" ]]; then
|
||||
echo "Warning: git diff emitted stderr output:" >&2
|
||||
cat "$err_file" >&2
|
||||
fi
|
||||
rm -f "$err_file"
|
||||
printf '%s\n' "$raw" \
|
||||
| grep -vE '\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|otf|zip|tar|gz|pdf)$' || true
|
||||
;;
|
||||
--file)
|
||||
@@ -188,24 +229,41 @@ collect_files() {
|
||||
echo "$1"
|
||||
else
|
||||
echo "Error: file not found: $1" >&2
|
||||
exit 2
|
||||
exit "$EXIT_USAGE"
|
||||
fi
|
||||
;;
|
||||
--dir)
|
||||
local dir="$1"
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
echo "Error: directory not found: $dir" >&2
|
||||
exit 2
|
||||
exit "$EXIT_USAGE"
|
||||
fi
|
||||
find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
|
||||
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>/dev/null || true
|
||||
# Same treatment as --diff: a `find` that fails (e.g. permission
|
||||
# denied) must not be reported as an empty directory, and stdout/stderr
|
||||
# are captured separately so a stderr warning never enters the file list.
|
||||
local raw status err_file
|
||||
err_file=$(mktemp)
|
||||
raw=$(find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
|
||||
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>"$err_file")
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
cat "$err_file" >&2
|
||||
rm -f "$err_file"
|
||||
exit "$EXIT_UNAVAILABLE"
|
||||
fi
|
||||
if [[ -s "$err_file" ]]; then
|
||||
echo "Warning: find emitted stderr output:" >&2
|
||||
cat "$err_file" >&2
|
||||
fi
|
||||
rm -f "$err_file"
|
||||
printf '%s\n' "$raw"
|
||||
;;
|
||||
--stdin)
|
||||
cat
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> | --stdin" >&2
|
||||
exit 2
|
||||
exit "$EXIT_USAGE"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -309,7 +367,7 @@ extract_and_check_blobs() {
|
||||
main() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: $0 --diff [base] | --file <path> | --dir <path>" >&2
|
||||
exit 2
|
||||
exit "$EXIT_USAGE"
|
||||
fi
|
||||
|
||||
load_ignorelist
|
||||
@@ -321,8 +379,12 @@ main() {
|
||||
files=$(collect_files "$mode" "$@")
|
||||
|
||||
if [[ -z "$files" ]]; then
|
||||
# collect_files already exited (UNAVAILABLE/USAGE) for anything that
|
||||
# could not establish scope. Reaching here with an empty result means
|
||||
# scope WAS established and is genuinely empty (e.g. an all-images
|
||||
# diff) — that is NO_INPUT, not a silent clean pass.
|
||||
echo "base64-scan: no files to scan"
|
||||
exit 0
|
||||
exit "$EXIT_NO_INPUT"
|
||||
fi
|
||||
|
||||
local total=0
|
||||
|
||||
Reference in New Issue
Block a user