* 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:
@@ -55,16 +55,19 @@ function makeEntry(overrides) {
|
||||
/**
|
||||
* #3906 (ADR-3889 Phase 2): the generator now emits THREE artifacts — a
|
||||
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
|
||||
* `.d.cts` type declaration (src/exit-code-registry.d.cts). Every existing
|
||||
* call site below only overrides the PRIMARY path via `--out`; without
|
||||
* matching `--scripts-out`/`--dts-out` overrides, a `--write` here would
|
||||
* clobber the real committed `scripts/lib/exit-code-registry.cjs` and
|
||||
* `src/exit-code-registry.d.cts` — dangerous since test files in this repo
|
||||
* run in parallel. Rather than touch every call site, this single seam
|
||||
* derives co-located, per-call-unique secondary/dts paths from whatever
|
||||
* `.d.cts` type declaration (src/exit-code-registry.d.cts). #3908 (Phase 4)
|
||||
* added a FOURTH: the shell-sourceable fragment (gsd-core/bin/shared/
|
||||
* exit-codes.sh). Every existing call site below only overrides the PRIMARY
|
||||
* path via `--out`; without matching `--scripts-out`/`--dts-out`/`--sh-out`
|
||||
* overrides, a `--write` here would clobber the real committed
|
||||
* `scripts/lib/exit-code-registry.cjs`, `src/exit-code-registry.d.cts`, and
|
||||
* `gsd-core/bin/shared/exit-codes.sh` — dangerous since test files in this
|
||||
* repo run in parallel. Rather than touch every call site, this single seam
|
||||
* derives co-located, per-call-unique secondary/dts/sh paths from whatever
|
||||
* `--out` value the test already supplies, whenever the caller has not
|
||||
* already supplied its own `--scripts-out`/`--dts-out`. Calls with no
|
||||
* explicit `--out` (the "real committed set" checks) are left untouched.
|
||||
* already supplied its own `--scripts-out`/`--dts-out`/`--sh-out`. Calls
|
||||
* with no explicit `--out` (the "real committed set" checks) are left
|
||||
* untouched.
|
||||
*/
|
||||
function ensureScriptsOut(args) {
|
||||
const outIdx = args.indexOf('--out');
|
||||
@@ -73,6 +76,7 @@ function ensureScriptsOut(args) {
|
||||
const extra = [];
|
||||
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
|
||||
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
|
||||
if (!args.includes('--sh-out')) extra.push('--sh-out', `${outValue}.sh`);
|
||||
return extra.length === 0 ? args : [...args, ...extra];
|
||||
}
|
||||
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -46,6 +46,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -80,6 +80,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -45,6 +45,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -45,6 +45,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -81,6 +81,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -11,6 +11,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -44,6 +44,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -115,6 +115,7 @@
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json",
|
||||
"gsd-core/bin/shared/exit-codes.json",
|
||||
"gsd-core/bin/shared/exit-codes.sh",
|
||||
"gsd-core/bin/shared/model-catalog.json",
|
||||
"gsd-core/bin/shared/runtime-aliases.manifest.json",
|
||||
"gsd-core/bin/verify-reapply-patches.cjs",
|
||||
|
||||
@@ -32,14 +32,17 @@
|
||||
// Migrating these to a parsed IR would add ceremony without changing
|
||||
// what is verified — the strings ARE the typed surface.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, createTempGitProject } = require('./helpers.cjs');
|
||||
const { runHook } = require('./helpers/process-seam.cjs');
|
||||
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
|
||||
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||
const SCRIPTS = {
|
||||
@@ -47,6 +50,10 @@ const SCRIPTS = {
|
||||
base64: path.join(PROJECT_ROOT, 'scripts', 'base64-scan.sh'),
|
||||
secret: path.join(PROJECT_ROOT, 'scripts', 'secret-scan.sh'),
|
||||
};
|
||||
// ADR-3889 (#3908): the generated exit-code registry — codes are resolved
|
||||
// via exitCodeFor(), never hardcoded, so this suite stays correct if the
|
||||
// registry's integers ever change.
|
||||
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
|
||||
|
||||
// Helper: create a temp file with given content, run scanner, return { status, stdout, stderr }
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
@@ -189,7 +196,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
assert.equal(result.status, 0);
|
||||
});
|
||||
|
||||
test('exits 2 on missing arguments', () => {
|
||||
test('exits USAGE on missing arguments', () => {
|
||||
try {
|
||||
execFileSync(SCRIPTS.injection, [], {
|
||||
encoding: 'utf-8',
|
||||
@@ -198,7 +205,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
});
|
||||
assert.fail('Should have exited non-zero');
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
assert.equal(err.status, exitCodeFor('USAGE'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -280,7 +287,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
assert.equal(result.status, 0);
|
||||
});
|
||||
|
||||
test('exits 2 on missing arguments', () => {
|
||||
test('exits USAGE on missing arguments', () => {
|
||||
try {
|
||||
execFileSync(SCRIPTS.base64, [], {
|
||||
encoding: 'utf-8',
|
||||
@@ -289,7 +296,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
});
|
||||
assert.fail('Should have exited non-zero');
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
assert.equal(err.status, exitCodeFor('USAGE'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -492,7 +499,7 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
assert.equal(result.status, 0);
|
||||
});
|
||||
|
||||
test('exits 2 on missing arguments', () => {
|
||||
test('exits USAGE on missing arguments', () => {
|
||||
try {
|
||||
execFileSync(SCRIPTS.secret, [], {
|
||||
encoding: 'utf-8',
|
||||
@@ -501,7 +508,240 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
|
||||
});
|
||||
assert.fail('Should have exited non-zero');
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 2);
|
||||
assert.equal(err.status, exitCodeFor('USAGE'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Exit-Code Contract (ADR-3889 Phase 4, #3908) ──────────────────────────
|
||||
//
|
||||
// Drives the real scripts through tests/helpers/process-seam.cjs's `runHook`
|
||||
// (never a hand-rolled spawnSync — a review blocker on P3). Every repo
|
||||
// fixture is a throwaway temp git repo built via
|
||||
// createTempGitProject/gitOrThrow; nothing here depends on, or mutates, this
|
||||
// repo's own git state, since test files in this suite run in parallel.
|
||||
//
|
||||
// The "shared" describes assert the SAME code across all three scanners for
|
||||
// input classes that collapse identically regardless of scanner-specific
|
||||
// extension filtering (a bad ref, no repo, no commits, an established-empty
|
||||
// diff, an all-images diff, and every usage error). The "controls" describe
|
||||
// is load-bearing: without a "files changed, no findings" case per scanner,
|
||||
// an implementation that returns UNAVAILABLE unconditionally would satisfy
|
||||
// every assertion above it.
|
||||
|
||||
describe('scanner exit-code contract', { skip: IS_WINDOWS }, () => {
|
||||
const SCANNERS = [
|
||||
['secret-scan', SCRIPTS.secret],
|
||||
['base64-scan', SCRIPTS.base64],
|
||||
['prompt-injection-scan', SCRIPTS.injection],
|
||||
];
|
||||
|
||||
function runScanner(scriptPath, args, opts = {}) {
|
||||
return runHook(scriptPath, args, { interpreter: 'bash', timeoutMs: HOOK_FANOUT_TIMEOUT_MS, ...opts });
|
||||
}
|
||||
|
||||
describe('shared exit-code classes (identical across all three scanners)', () => {
|
||||
let repo;
|
||||
before(() => { repo = createTempGitProject('gsd-scan-shared-'); });
|
||||
after(() => { cleanup(repo); });
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(`${name}: nonexistent --diff ref -> UNAVAILABLE, git diagnostic on stderr`, () => {
|
||||
const result = runScanner(scriptPath, ['--diff', 'refs/heads/does-not-exist-xyz'], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
|
||||
assert.ok(result.stderr.length > 0, 'git\'s own diagnostic must survive on stderr');
|
||||
});
|
||||
|
||||
test(`${name}: --file with a nonexistent path -> USAGE`, () => {
|
||||
const result = runScanner(scriptPath, ['--file', path.join(repo, 'does-not-exist.md')], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('USAGE'));
|
||||
});
|
||||
|
||||
test(`${name}: --dir with a nonexistent path -> USAGE`, () => {
|
||||
const result = runScanner(scriptPath, ['--dir', path.join(repo, 'does-not-exist-dir')], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('USAGE'));
|
||||
});
|
||||
|
||||
test(`${name}: unknown mode -> USAGE`, () => {
|
||||
const result = runScanner(scriptPath, ['--bogus-mode'], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('USAGE'));
|
||||
});
|
||||
|
||||
test(`${name}: no argv at all -> USAGE`, () => {
|
||||
const result = runScanner(scriptPath, [], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('USAGE'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('outside a git repository -> UNAVAILABLE', () => {
|
||||
let nonRepoDir;
|
||||
before(() => { nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-norepo-')); });
|
||||
after(() => { cleanup(nonRepoDir); });
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, () => {
|
||||
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: nonRepoDir });
|
||||
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('repo with no commits -> UNAVAILABLE', () => {
|
||||
let emptyRepo;
|
||||
before(() => {
|
||||
emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-nocommit-'));
|
||||
gitOrThrow(['init'], { cwd: emptyRepo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
});
|
||||
after(() => { cleanup(emptyRepo); });
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, () => {
|
||||
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: emptyRepo });
|
||||
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('established-empty diff (base === HEAD) -> NO_INPUT', () => {
|
||||
let repo;
|
||||
before(() => {
|
||||
repo = createTempGitProject('gsd-scan-emptydiff-');
|
||||
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
});
|
||||
after(() => { cleanup(repo); });
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, () => {
|
||||
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
|
||||
assert.equal(result.exitCode, exitCodeFor('NO_INPUT'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('all-images diff -> NO_INPUT (not a failure, not UNAVAILABLE)', () => {
|
||||
let repo;
|
||||
before(() => {
|
||||
repo = createTempGitProject('gsd-scan-images-');
|
||||
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
|
||||
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['commit', '-m', 'add image only'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
});
|
||||
after(() => { cleanup(repo); });
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, () => {
|
||||
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
|
||||
assert.equal(
|
||||
result.exitCode, exitCodeFor('NO_INPUT'),
|
||||
`expected NO_INPUT — stdout: ${result.stdout} stderr: ${result.stderr}`,
|
||||
);
|
||||
assert.notEqual(result.exitCode, 1, `${name} must not report the all-images diff as a failure`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────────────────
|
||||
describe('controls: clean scan / findings scan / mixed diff still work', () => {
|
||||
test('secret-scan: clean scan with a real file still exits 0', (t) => {
|
||||
const repo = createTempGitProject('gsd-scan-clean-secret-');
|
||||
t.after(() => cleanup(repo));
|
||||
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
fs.writeFileSync(path.join(repo, 'code.txt'), 'clean text content\n');
|
||||
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['commit', '-m', 'add clean file'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
|
||||
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
|
||||
});
|
||||
|
||||
test('secret-scan: a diff WITH a real secret still reports findings (exit 1)', (t) => {
|
||||
const repo = createTempGitProject('gsd-scan-findings-secret-');
|
||||
t.after(() => cleanup(repo));
|
||||
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
const key = ['AKIA', 'IOSFODNN7EXAMPLE'].join('');
|
||||
fs.writeFileSync(path.join(repo, 'secret.txt'), `aws_key = "${key}"\n`);
|
||||
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['commit', '-m', 'add secret'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
|
||||
assert.equal(result.exitCode, 1, result.stdout + result.stderr);
|
||||
assert.ok(result.stdout.includes('FAIL'));
|
||||
});
|
||||
|
||||
test('prompt-injection-scan: mixed images+code diff scans the code file (exit 0, clean)', (t) => {
|
||||
const repo = createTempGitProject('gsd-scan-mixed-');
|
||||
t.after(() => cleanup(repo));
|
||||
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
|
||||
fs.writeFileSync(path.join(repo, 'clean.md'), '# Clean docs\n');
|
||||
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
gitOrThrow(['commit', '-m', 'mixed'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
|
||||
const result = runScanner(SCRIPTS.injection, ['--diff', 'base-branch'], { cwd: repo });
|
||||
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
|
||||
});
|
||||
|
||||
test('--file / --dir / --stdin controls still scan and pass on clean content', (t) => {
|
||||
const repo = createTempGitProject('gsd-scan-modes-');
|
||||
t.after(() => cleanup(repo));
|
||||
const cleanFile = path.join(repo, 'clean.md');
|
||||
fs.writeFileSync(cleanFile, '# Clean docs\n');
|
||||
assert.equal(runScanner(SCRIPTS.injection, ['--file', cleanFile]).exitCode, 0);
|
||||
assert.equal(runScanner(SCRIPTS.injection, ['--dir', repo]).exitCode, 0);
|
||||
const stdinResult = runScanner(SCRIPTS.injection, ['--stdin'], { input: '# clean\n' });
|
||||
assert.equal(stdinResult.exitCode, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('--dir unreadable -> UNAVAILABLE', () => {
|
||||
let parent, locked;
|
||||
before(() => {
|
||||
parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-unreadable-'));
|
||||
locked = path.join(parent, 'locked');
|
||||
fs.mkdirSync(locked);
|
||||
fs.chmodSync(locked, 0o000);
|
||||
});
|
||||
after(() => {
|
||||
try { fs.chmodSync(locked, 0o755); } catch { /* best effort, for cleanup() below */ }
|
||||
cleanup(parent);
|
||||
});
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, (t) => {
|
||||
// Root (and some CI/Docker images running as root) bypasses mode
|
||||
// bits entirely — a bare `return` here would be a silent PASS, so
|
||||
// this is an explicit t.skip() instead.
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
t.skip('running as root — mode bits do not restrict access');
|
||||
return;
|
||||
}
|
||||
const result = runScanner(scriptPath, ['--dir', locked]);
|
||||
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('missing exit-codes.sh -> loud non-zero, never 0', () => {
|
||||
// Isolated copy of the scanner in a throwaway tree whose gsd-core/bin/
|
||||
// shared/ directory has no exit-codes.sh — never touches the real
|
||||
// committed file, so this is safe under parallel test-file execution.
|
||||
function isolatedCopyWithNoRegistry(scriptPath) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-noregistry-'));
|
||||
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'gsd-core', 'bin', 'shared'), { recursive: true });
|
||||
const dest = path.join(root, 'scripts', path.basename(scriptPath));
|
||||
fs.copyFileSync(scriptPath, dest);
|
||||
fs.chmodSync(dest, 0o755);
|
||||
return { root, dest };
|
||||
}
|
||||
|
||||
for (const [name, scriptPath] of SCANNERS) {
|
||||
test(name, (t) => {
|
||||
const { root, dest } = isolatedCopyWithNoRegistry(scriptPath);
|
||||
t.after(() => cleanup(root));
|
||||
const result = runScanner(dest, ['--diff', 'origin/next']);
|
||||
assert.ok(Number.isInteger(result.exitCode), `expected a numeric exit code, got ${result.exitCode}`);
|
||||
assert.notEqual(result.exitCode, 0, 'a missing exit-code registry must never silently exit 0');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user