From 8c754963913a98cababae705b621a200030fa7e9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 13:18:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(ci):=20supply-chain=20hygiene=20=E2=80=94?= =?UTF-8?q?=20secret=20gate=20on=20every=20PR=20diff,=20dependency=20revie?= =?UTF-8?q?w,=20OSV,=20dependabot,=20evidence-bar=20PR=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Fable 5 --- .github/PULL_REQUEST_TEMPLATE.md | 44 ++++++++ .github/dependabot.yml | 25 +++++ .github/scripts/gate-secret-scan.mjs | 31 ++++++ .github/workflows/dependency-review.yml | 32 ++++++ .github/workflows/evals.yml | 6 ++ .github/workflows/osv-scanner.yml | 26 +++++ .github/workflows/quality-gate.yml | 77 ++++++++++++++ .osv-scanner.toml | 15 +++ bin/gstack-verify-gate | 56 +++++++++++ test/gate-secret-scan.test.ts | 58 +++++++++++ test/verify-gate.test.ts | 127 ++++++++++++++++++++++++ 11 files changed, 497 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/gate-secret-scan.mjs create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/osv-scanner.yml create mode 100644 .github/workflows/quality-gate.yml create mode 100644 .osv-scanner.toml create mode 100755 bin/gstack-verify-gate create mode 100644 test/gate-secret-scan.test.ts create mode 100644 test/verify-gate.test.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..bfcc4a2e2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ + + +## Why (in your own words) + + + +## Live evidence + + + +``` +# what you ran + what it produced +``` + +## Scope + +- **Changed:** +- **Verified live by:** +- **Did NOT test:** + +## Liveness proof (required) + + + +## Checklist + +- [ ] Liveness screenshot attached: `GSTACK PR` typed live into a real surface (not edited onto the image) +- [ ] This is not a generated-file-only diff (I edited the source/template and regenerated) +- [ ] No ETHOS.md edits, and no changes to voice / founder perspective / YC references +- [ ] New public command / external service / host adapter has an accepted issue linked (or N/A) +- [ ] Linked issue or reproduction: # diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d6b53e48d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 + +updates: + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + github-actions: + patterns: + - "*" + open-pull-requests-limit: 2 diff --git a/.github/scripts/gate-secret-scan.mjs b/.github/scripts/gate-secret-scan.mjs new file mode 100644 index 000000000..21014f505 --- /dev/null +++ b/.github/scripts/gate-secret-scan.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; + +const child = spawn("bun", [ + "bin/gstack-redact", + "--repo-visibility", "public", + "--json", + "--max-bytes", "16000000", +], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "inherit"] }); +let diff = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { diff += chunk; }); +process.stdin.once("end", () => { + const additions = diff + .split(/\r?\n/) + .filter((line) => line.startsWith("+") && !line.startsWith("+++")) + .map((line) => line.slice(1)) + .join("\n"); + child.stdin.end(additions); +}); +let stdout = ""; +child.stdout.setEncoding("utf8"); +child.stdout.on("data", (chunk) => { stdout += chunk; }); +child.once("error", (error) => { throw error; }); +child.once("close", (code) => { + const report = JSON.parse(stdout); + const high = Number(report.counts?.HIGH ?? 0); + const medium = Number(report.counts?.MEDIUM ?? 0); + console.log(`credential scan: ${high} high, ${medium} advisory`); + process.exitCode = high > 0 || report.oversize || ![0, 2, 3].includes(code) ? 1 : 0; +}); diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..f36e1f771 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,32 @@ +name: Dependency Review + +on: + pull_request: + paths: + - 'package.json' + - 'bun.lock' + - '**/package.json' + - '**/bun.lock' + - '.github/workflows/**' + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubicloud-standard-8 + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + fail-on-scopes: runtime, development + comment-summary-in-pr: on-failure diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index fa911176d..e0eb0742f 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -15,6 +15,12 @@ env: jobs: # Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change) build-image: + # Dependabot-triggered pull_request runs get a read-only GITHUB_TOKEN, so + # a lockfile bump = new hash = failed ghcr push = permanently red check + # (EV6, fork port wave 2). Skip the build for dependabot; the evals job's + # needs-chain tolerates it because no eval test selects on a lockfile-only + # diff — a maintainer's next push rebuilds the image with real perms. + if: github.actor != 'dependabot[bot]' runs-on: ubicloud-standard-8 permissions: contents: read diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 000000000..22b8ae6eb --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -0,0 +1,26 @@ +name: OSV Scanner + +on: + schedule: + - cron: '23 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: osv-scanner + cancel-in-progress: true + +jobs: + scan: + permissions: + actions: read + contents: read + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8 + with: + scan-args: |- + --include-git-root + --recursive + ./ diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 000000000..af1c6fb12 --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -0,0 +1,77 @@ +# Quality gate (fork port wave 2, adapted from time-attack/gstack GStack 2). +# +# Three generic hygiene checks the repo previously had nowhere in CI: +# 1. Credential scan of the PR diff's ADDED lines through our own +# bin/gstack-redact (HIGH fails the check; MEDIUM is an advisory count — +# there is no human in CI to confirm, so it never fails here). +# 2. bun audit at critical severity. +# 3. ShellCheck (errors only) on the setup/build shell boundary. +# +# Trigger is `pull_request`, NEVER `pull_request_target`: fork PRs must not +# get secret-bearing contexts. Diff excludes cover the planted-bug fixtures +# and eval baselines that intentionally contain credential-shaped strings. +name: Quality gate + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: quality-gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubicloud-standard-8 + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install frozen dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Scan changed text for credentials (added lines, own redact engine) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + BASE_SHA=$(git rev-parse HEAD^) + fi + git diff --unified=0 --no-color "$BASE_SHA" "$HEAD_SHA" -- \ + . \ + ':(exclude)test/fixtures/**' \ + ':(exclude)browse/test/fixtures/**' \ + ':(exclude)docs/evals/**' \ + ':(exclude)test/helpers/security-bench*' \ + | node .github/scripts/gate-secret-scan.mjs + + - name: Gate critical dependency advisories + run: bun audit --audit-level=critical + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + shellcheck --version + + - name: ShellCheck setup and build boundaries + run: >- + shellcheck --severity=error + setup + scripts/build.sh + scripts/build-app.sh + scripts/write-version-files.sh + browse/scripts/build-node-server.sh diff --git a/.osv-scanner.toml b/.osv-scanner.toml new file mode 100644 index 000000000..56ca32123 --- /dev/null +++ b/.osv-scanner.toml @@ -0,0 +1,15 @@ +# OSV-Scanner configuration. +# Direct/transitive dependency versions are pinned to their fixed releases via +# the `overrides` block in package.json; this file only records advisories we +# have assessed as not-reachable or not-fixable without disproportionate risk. + +[[IgnoredVulns]] +id = "GHSA-frvp-7c67-39w9" +# @hono/node-server 1.19.x. Reachable only through @modelcontextprotocol/sdk, +# which is an unused transitive dependency (no source file imports it) and never +# starts a Hono HTTP server, so the advisory's request path is not exercised. +# The only fix is @hono/node-server 2.0.5, a major bump the MCP SDK pins against +# (^1.19.9); forcing it via override risks breaking the SDK at runtime for a +# vulnerability we do not expose. Re-evaluate if the MCP SDK becomes a direct, +# server-hosting dependency. +reason = "Unreachable transitive (unused @modelcontextprotocol/sdk); fix requires a risky major override on a pinned peer dep." diff --git a/bin/gstack-verify-gate b/bin/gstack-verify-gate new file mode 100755 index 000000000..512a16028 --- /dev/null +++ b/bin/gstack-verify-gate @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# gstack-verify-gate — Stop hook. Blocks the turn from ending until the +# project's declared verification command passes. +# +# Declare the command on one line in the project's CLAUDE.md: +# +# +# Read-or-ask: gstack never invents this command. No declaration, no gate. +# Fails open on every absence (no CLAUDE.md, no declaration, empty value). +# +# Exit 0 = allow the turn to end, one-line reason on stdout. +# Exit 2 = block, Claude Code feeds stderr back to the agent. +# +# Remove with: gstack-settings-hook remove-source --source verify-gate +set -uo pipefail + +INPUT="" +[ -t 0 ] || INPUT="$(cat)" + +# Claude Code re-runs Stop hooks after a block. Never gate the same turn twice. +if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + echo "verify-gate: gate already ran this turn, allowing." + exit 0 +fi + +ROOT="${CLAUDE_PROJECT_DIR:-$PWD}" +while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do + ROOT="$(dirname "$ROOT")" +done + +if [ ! -f "$ROOT/CLAUDE.md" ]; then + echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing." + exit 0 +fi + +CMD="$(sed -n 's/^[[:space:]]*\(*}" +CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')" + +if [ -z "$CMD" ]; then + echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing." + exit 0 +fi + +OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)" +STATUS=$? + +if [ "$STATUS" -eq 0 ]; then + echo "verify-gate: declared check passed ($CMD)." + exit 0 +fi + +echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2 +printf '%s\n' "$OUT" | tail -20 >&2 +echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2 +exit 2 diff --git a/test/gate-secret-scan.test.ts b/test/gate-secret-scan.test.ts new file mode 100644 index 000000000..a2832f801 --- /dev/null +++ b/test/gate-secret-scan.test.ts @@ -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/); + }); +}); diff --git a/test/verify-gate.test.ts b/test/verify-gate.test.ts new file mode 100644 index 000000000..776895e02 --- /dev/null +++ b/test/verify-gate.test.ts @@ -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\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'), '\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:'); + }); +}); +