mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
feat(ci): supply-chain hygiene — secret gate on every PR diff, dependency review, OSV, dependabot, evidence-bar PR template
The repo owned a redaction engine and had zero CI-side secret scanning. quality-gate.yml now pipes every PR diff's ADDED lines through our own bin/gstack-redact (gate-secret-scan.mjs, taken from the fork — it dogfoods the engine): HIGH findings fail the check, MEDIUM prints an advisory count only (no human in CI to confirm), planted-bug fixtures excluded by pathspec. Live-verified both directions: PEM key fails, clean diff and MEDIUM shapes pass; ShellCheck (errors) covers the setup/build shell boundary and passes today; bun audit gates critical advisories. Trigger is pull_request, never pull_request_target. dependency-review.yml adopts the hardened never-merged prior-art branch (fail-on-severity high, workflow paths watched, tight perms) — verify the dependency graph parses bun.lock with a canary bump before trusting the gate. dependabot: weekly, grouped per ecosystem, capped PR counts; and evals.yml image build/push now skips dependabot actors, whose read-only GITHUB_TOKEN made every lockfile bump a permanently red check. OSV scans weekly with a reasoned ignore file. All new workflow actions SHA-pinned. Scorecard deliberately not taken (no consumer for the score). The PR template front-loads the evidence bar (live proof, liveness screenshot, no-ETHOS/voice-changes checklist); the unenforced DCO line is dropped. bin/gstack-verify-gate ships OPT-IN (never registered by ./setup — a Stop hook running the project's verify command after every turn is the user's call), with the fork's tests adapted to pin exactly that. Ported from time-attack/gstack (GStack 2) + our own prior-art branch. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Sina Matian
Claude Fable 5
parent
3aba218303
commit
8c75496391
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* CI secret gate contract (R4/R9, fork port wave 2).
|
||||
*
|
||||
* .github/scripts/gate-secret-scan.mjs pipes a unified diff's ADDED lines
|
||||
* into bin/gstack-redact and enforces: HIGH fails (exit 1), MEDIUM is an
|
||||
* advisory count only (no human in CI to confirm, so it must never fail
|
||||
* the check), clean passes. The workflow-level pathspec excludes keep the
|
||||
* planted-bug fixtures out of the diff entirely; this pins the script's
|
||||
* own exit contract with live subprocess runs.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
|
||||
|
||||
function scan(diff: string): { code: number; out: string } {
|
||||
const res = spawnSync("node", [SCRIPT], {
|
||||
cwd: ROOT,
|
||||
input: diff,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
|
||||
}
|
||||
|
||||
describe("gate-secret-scan.mjs exit contract", () => {
|
||||
test("clean added lines pass", () => {
|
||||
const r = scan("+const x = 1;\n+++ b/file.ts\n+// harmless\n");
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toContain("0 high");
|
||||
});
|
||||
|
||||
test("a HIGH credential in an added line fails the gate", () => {
|
||||
const r = scan(
|
||||
"+-----BEGIN RSA PRIVATE KEY-----\n+MIIEowIBAAKCAQEA\n+-----END RSA PRIVATE KEY-----\n",
|
||||
);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toContain("1 high");
|
||||
});
|
||||
|
||||
test("removed lines and context are ignored — only additions are scanned", () => {
|
||||
const r = scan(
|
||||
"------BEGIN RSA PRIVATE KEY-----\n-MIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n+just an addition\n",
|
||||
);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
test("MEDIUM findings are advisory only — never fail CI", () => {
|
||||
// A Stripe publishable-key shape sits at MEDIUM in the taxonomy
|
||||
// (context-variable; a human confirms interactively, CI cannot).
|
||||
const r = scan(`+const key = "pk_live_${"a".repeat(24)}";\n`);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/\d+ advisory/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* gstack-verify-gate — Stop-hook enforcement tier.
|
||||
*
|
||||
* Pins the three behaviours the gate exists for:
|
||||
* block — declared check fails, exit 2, turn cannot end.
|
||||
* allow — declared check passes, exit 0.
|
||||
* fail open — nothing declared, exit 0. Absence never blocks.
|
||||
*
|
||||
* Plus the two safety branches: the Stop re-entry guard, and the static
|
||||
* opt-in contract (our adaptation): ./setup never registers the gate; the
|
||||
* settings-hook helper.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate');
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Declare a verification command in the project's CLAUDE.md. */
|
||||
function declareCheck(command: string): void {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n<!-- gstack:verify: ${command} -->\n`);
|
||||
}
|
||||
|
||||
/** Write the check script the declaration points at. */
|
||||
function check(exitCode: number, message: string): void {
|
||||
const script = path.join(dir, 'check.sh');
|
||||
fs.writeFileSync(script, `#!/bin/sh\necho "${message}"\nexit ${exitCode}\n`);
|
||||
fs.chmodSync(script, 0o755);
|
||||
}
|
||||
|
||||
function runGate(stopHookActive = false): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(GATE, {
|
||||
cwd: dir,
|
||||
input: JSON.stringify({ stop_hook_active: stopHookActive }),
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
describe('gstack-verify-gate', () => {
|
||||
test('blocks the turn when the declared check fails', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'totals mismatch');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('FAILED');
|
||||
expect(r.stderr).toContain('totals mismatch');
|
||||
});
|
||||
|
||||
test('allows the turn when the declared check passes', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(0, 'all good');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('passed');
|
||||
});
|
||||
|
||||
test('fails open when CLAUDE.md declares no check', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
test('fails open when there is no CLAUDE.md at all', () => {
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('no CLAUDE.md');
|
||||
});
|
||||
|
||||
test('never invents a command: an empty declaration fails open', () => {
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
|
||||
|
||||
const r = runGate();
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
|
||||
});
|
||||
|
||||
test('re-entry guard: a failing check does not block twice in one turn', () => {
|
||||
declareCheck('./check.sh');
|
||||
check(1, 'still failing');
|
||||
|
||||
const r = runGate(true);
|
||||
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('already ran');
|
||||
});
|
||||
});
|
||||
|
||||
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
|
||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
const gate = fs.readFileSync(GATE, 'utf-8');
|
||||
|
||||
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
|
||||
expect(setup).not.toContain('verify-gate');
|
||||
});
|
||||
|
||||
test('the bin documents its own registration and removal commands', () => {
|
||||
expect(gate).toContain('remove-source --source verify-gate');
|
||||
expect(gate).toContain('gstack:verify:');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user