enhance(#3908): the scanners distinguish an empty diff from one they could not compute (#3937)

* 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:
Tom Boucher
2026-08-27 13:11:13 -04:00
committed by GitHub
parent 7f3119a29f
commit 1e67ec9737
31 changed files with 681 additions and 78 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* gen-exit-code-registry.cjs — generates THREE byte-identical/derived
* gen-exit-code-registry.cjs — generates FOUR byte-identical/derived
* artifacts from the declaration at gsd-core/bin/shared/exit-codes.json:
* - gsd-core/bin/lib/exit-code-registry.cjs (tsc-adjacent build tree)
* - scripts/lib/exit-code-registry.cjs (committed, for scripts/
@@ -12,28 +12,34 @@
* against the shape the .cjs artifacts above actually export — generated
* from the SAME ENTRY_FIELD_TYPES table serializeRegistry() uses, so the
* two can never independently drift).
* - gsd-core/bin/shared/exit-codes.sh (POSIX sh, safe under
* `set -u`: one `export EXIT_<NAME>=<code>` per entry, sourced by the
* bash scanners under scripts/ so a shell caller never re-invents a
* literal exit-code integer — ADR-3889 Phase 4, #3908).
*
* ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is
* allocated") Phase 1 (#3905) built the single-output allocator; Phase 2
* (#3906) added the second .cjs emission so scripts/ has its own committed
* copy instead of reaching into gitignored build output, and a follow-up
* closed the review finding that the .d.cts was hand-maintained with no gate
* by generating it here too.
* copy instead of reaching into gitignored build output; a follow-up closed
* the review finding that the .d.cts was hand-maintained with no gate by
* generating it here too; Phase 4 (#3908) added the shell fragment so the
* three bash scanners can source symbolic names instead of hardcoding
* integers.
*
* The two .cjs artifacts are byte-identical: serializeRegistry() only encodes
* the DECLARATION path (for the banner comment), never the output path, so
* one generated string is written to both locations unchanged. The .d.cts is
* a separate, smaller derivation (a structural type, not a per-entry table)
* but is generated and --check-gated exactly the same way.
* one generated string is written to both locations unchanged. The .d.cts
* and .sh artifacts are separate, smaller derivations but are generated and
* --check-gated exactly the same way.
*
* Nothing in this script emits a registered exit code itself; wiring
* consumers onto the registry is separate work.
*
* Usage:
* node scripts/gen-exit-code-registry.cjs # same as --write
* node scripts/gen-exit-code-registry.cjs --write # write all three artifacts
* node scripts/gen-exit-code-registry.cjs --write # write all four artifacts
* node scripts/gen-exit-code-registry.cjs --check # exit 1 if ANY committed artifact is stale
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> --sh-out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose
*/
@@ -47,6 +53,7 @@ const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared
const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs');
const DEFAULT_SCRIPTS_OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
const DEFAULT_DTS_OUTPUT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
const DEFAULT_SH_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.sh');
/**
* Single source of the entry field list (name -> TS type), in emission order.
@@ -82,14 +89,15 @@ const REASON = Object.freeze({
});
const USAGE_MESSAGE = [
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--json]',
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--sh-out <path>] [--json]',
' (no flag) same as --write',
' --write write all three generated registry artifacts',
' --write write all four generated registry artifacts',
' --check exit 1 if ANY committed artifact is stale',
' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)',
' --out override the primary output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)',
' --scripts-out override the secondary output artifact path (default: scripts/lib/exit-code-registry.cjs)',
' --dts-out override the ambient type declaration path (default: src/exit-code-registry.d.cts)',
' --sh-out override the shell-sourceable fragment path (default: gsd-core/bin/shared/exit-codes.sh)',
' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose',
].join('\n');
@@ -435,6 +443,42 @@ function serializeDts(declarationPath) {
].join('\n');
}
/**
* Generate the shell-sourceable fragment: one `export EXIT_<NAME>=<code>`
* line per declared entry, POSIX sh, safe under `set -u` (a sourced file that
* only ever ASSIGNS variables can never trip an unset-variable check,
* regardless of what the caller's shell had in scope beforehand).
*
* Consumed by the bash scanners under scripts/ via `. gsd-core/bin/shared/
* exit-codes.sh` (ADR-3889 Phase 4, #3908) so a shell caller resolves a
* symbolic name instead of hardcoding a literal integer that can silently
* drift from the registry.
*/
function serializeSh(entries, declarationPath) {
const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/');
const banner = [
'#!/bin/sh',
'# GENERATED FILE — DO NOT EDIT BY HAND.',
`# Source of truth: ${relDeclaration}. Regenerate with:`,
'# node scripts/gen-exit-code-registry.cjs --write',
'#',
'# One `export EXIT_<NAME>=<code>` per gsd-core/bin/shared/exit-codes.json',
'# entry (ADR-3889 §2, #3905/#3906/#3908). POSIX sh, safe under `set -u`:',
'# sourcing this file only ever ASSIGNS variables, never reads one, so it',
'# cannot trip an unset-variable check regardless of the caller\'s existing',
'# environment.',
'#',
'# Usage (from a scanner under scripts/):',
'# . "$(dirname "$0")/../gsd-core/bin/shared/exit-codes.sh"',
'# exit "$EXIT_UNAVAILABLE"',
'',
].join('\n');
const lines = entries.map((e) => `export EXIT_${e.name}=${e.code}`);
return banner + lines.join('\n') + '\n';
}
/**
* Load, validate, and serialize the declaration in one step.
* @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}}
@@ -446,7 +490,7 @@ function buildRegistryContent(declarationPath) {
const validated = validateEntries(loaded.entries);
if (!validated.ok) return validated;
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath) };
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath), entries: loaded.entries };
}
function printFail(result) {
@@ -487,7 +531,7 @@ function emitOk(reason, humanMessage, json) {
console.log(humanMessage);
}
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
@@ -503,10 +547,13 @@ function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
const dtsContent = serializeDts(declarationPath);
fs.mkdirSync(path.dirname(dtsPath), { recursive: true });
fs.writeFileSync(dtsPath, dtsContent, 'utf8');
const shContent = serializeSh(result.entries, declarationPath);
fs.mkdirSync(path.dirname(shPath), { recursive: true });
fs.writeFileSync(shPath, shContent, 'utf8');
emitOk(
REASON.OK,
`ok gen-exit-code-registry: wrote ${outPath}\nok gen-exit-code-registry: wrote ${scriptsOutPath}\n`
+ `ok gen-exit-code-registry: wrote ${dtsPath}`,
+ `ok gen-exit-code-registry: wrote ${dtsPath}\nok gen-exit-code-registry: wrote ${shPath}`,
json,
);
return 0;
@@ -542,23 +589,25 @@ function checkOneArtifact(artifactLabel, artifactPath, content) {
}
/**
* --check verifies ALL THREE committed artifacts against the same freshly
* --check verifies ALL FOUR committed artifacts against the same freshly
* generated content and fails naming which one drifted (or is missing) if
* any does. Checked in a fixed order (primary, secondary, dts) so a
* any does. Checked in a fixed order (primary, secondary, dts, sh) so a
* single-artifact failure is always reported deterministically.
*/
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
return 1;
}
const dtsContent = serializeDts(declarationPath);
const shContent = serializeSh(result.entries, declarationPath);
const artifacts = [
['primary', outPath, result.content],
['secondary', scriptsOutPath, result.content],
['dts', dtsPath, dtsContent],
['sh', shPath, shContent],
];
for (const [artifactLabel, artifactPath, content] of artifacts) {
const checked = checkOneArtifact(artifactLabel, artifactPath, content);
@@ -572,14 +621,15 @@ function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
REASON.OK,
`ok gen-exit-code-registry: ${outPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${scriptsOutPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}`,
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${shPath} matches ${declarationPath}`,
json,
);
return 0;
}
/**
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, json:boolean}}
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, shPath:?string, json:boolean}}
*/
function parseArgs(argv) {
let mode = null;
@@ -587,6 +637,7 @@ function parseArgs(argv) {
let outPath = null;
let scriptsOutPath = null;
let dtsPath = null;
let shPath = null;
let json = false;
for (let i = 0; i < argv.length; i++) {
@@ -622,12 +673,18 @@ function parseArgs(argv) {
dtsPath = value;
} else if (arg.startsWith('--dts-out=')) {
dtsPath = arg.slice('--dts-out='.length);
} else if (arg === '--sh-out') {
const value = argv[++i];
if (value === undefined) throw new Error('--sh-out requires a value');
shPath = value;
} else if (arg.startsWith('--sh-out=')) {
shPath = arg.slice('--sh-out='.length);
} else {
throw new Error(`unrecognized argument: ${arg}`);
}
}
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, json };
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json };
}
function main() {
@@ -649,10 +706,11 @@ function main() {
const outPath = args.outPath || DEFAULT_OUTPUT_PATH;
const scriptsOutPath = args.scriptsOutPath || DEFAULT_SCRIPTS_OUTPUT_PATH;
const dtsPath = args.dtsPath || DEFAULT_DTS_OUTPUT_PATH;
const shPath = args.shPath || DEFAULT_SH_OUTPUT_PATH;
return args.mode === 'check'
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, args.json)
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, args.json);
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json)
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json);
}
if (require.main === module) process.exitCode = main();
@@ -664,6 +722,7 @@ module.exports = {
DEFAULT_OUTPUT_PATH,
DEFAULT_SCRIPTS_OUTPUT_PATH,
DEFAULT_DTS_OUTPUT_PATH,
DEFAULT_SH_OUTPUT_PATH,
ENTRY_FIELD_TYPES,
isAllocatableCode,
bandFor,
@@ -672,6 +731,7 @@ module.exports = {
loadDeclaration,
serializeRegistry,
serializeDts,
serializeSh,
buildRegistryContent,
parseArgs,
main,

View File

@@ -6,12 +6,29 @@
# scripts/prompt-injection-scan.sh --file path/to/file # Scan a single file
# scripts/prompt-injection-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 "prompt-injection-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"
# ─── Patterns ────────────────────────────────────────────────────────────────
# Each pattern is a POSIX extended regex. Keep alphabetized by category.
#
@@ -175,33 +192,73 @@ collect_files() {
case "$mode" in
--diff)
local base="${1:-origin/main}"
# Get changed files in the diff, filter to scannable extensions
git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>/dev/null \
| grep -E '\.(md|cjs|js|json|yml|yaml|sh)$' || true
# 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 -E`
# exits 1 when nothing matches the scannable extensions (e.g. a diff
# touching only non-scannable file types), 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 -E '\.(md|cjs|js|json|yml|yaml|sh)$' || true
;;
--file)
if [[ -f "$1" ]]; then
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 \( -name '*.md' -o -name '*.cjs' -o -name '*.js' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.sh' \) \
! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' 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 \( -name '*.md' -o -name '*.cjs' -o -name '*.js' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.sh' \) \
! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' 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
}
@@ -240,7 +297,7 @@ scan_file() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path>" >&2
exit 2
exit "$EXIT_USAGE"
fi
local mode="$1"
@@ -250,8 +307,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 — that is NO_INPUT, not a
# silent clean pass.
echo "prompt-injection-scan: no files to scan"
exit 0
exit "$EXIT_NO_INPUT"
fi
local total=0

View File

@@ -17,10 +17,12 @@
# This flag does not change secret-detection logic — only which
# exclusions are applied.
#
# 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)
#
# Annotation format for .secretscanignore (required for --strict compliance):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
@@ -39,6 +41,21 @@
# cannot permanently hide secrets. See SECURITY.md for the audit runbook.
set -euo pipefail
# ─── Exit-code registry (ADR-3889, #3908) ────────────────────────────────────
# Resolved relative to THIS script's location, not the caller's cwd, so this
# works regardless of where the scanner is invoked from. 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 "secret-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"
# ─── Global mode flag ─────────────────────────────────────────────────────────
STRICT_MODE=false
@@ -235,7 +252,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)
@@ -243,24 +284,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
}
@@ -300,7 +358,7 @@ scan_file() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
exit "$EXIT_USAGE"
fi
# Parse --strict flag first (may appear anywhere in argv)
@@ -316,7 +374,7 @@ main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
exit "$EXIT_USAGE"
fi
load_ignorelist
@@ -328,8 +386,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 "secret-scan: no files to scan"
exit 0
exit "$EXIT_NO_INPUT"
fi
local total=0