From 3ffba5e53f942383d1171009da1746d735d03d32 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 17:00:14 -0700 Subject: [PATCH] Authenticate live GitHub policy checks in security audit --- .github/audit/security.md | 6 ++++++ .github/workflows/security-audit.yml | 11 +++++++--- scripts/security-audit.test.mjs | 31 +++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/audit/security.md b/.github/audit/security.md index f0bdcf1..338a1ad 100644 --- a/.github/audit/security.md +++ b/.github/audit/security.md @@ -42,6 +42,12 @@ The remaining bullets are read rather than run: Check the triggers, the job names, `persist-credentials: false`, the permission blocks, the reporting step's verdict grammar, and that the redaction step is present and covers every file the archive step publishes. +- The environment deployment-branch policy and the active rules for `main` + require live GitHub API reads, not just workflow inspection. Run both `gh api` + commands in `SECURITY.md` with `--paginate`; CI supplies `GH_TOKEN` and the + existing `actions: read` permission, while local runs use your `gh` login. + If either request fails, record it as `UNVERIFIABLE` with the API error; + never infer the live settings from the workflow's comments. - The `Packed provenance` bullets are read over `scripts/build-packages.ts`, `scripts/pack-packages.ts` and `scripts/verify-packages.ts`, with `pnpm packages:verify` as the evidence. diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index c1bb6e3..3cf5801 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -22,7 +22,7 @@ on: permissions: contents: read # checkout - actions: read # deep-link this run's transcript artifact from the issue + actions: read # inspect deployment branch policies and link the transcript artifact issues: write # file, append to and close the failure issue jobs: @@ -94,6 +94,10 @@ jobs: # `.github/audit/` so CI and the local runner cannot disagree. - name: Audit against SECURITY.md env: + # SECURITY.md checks live deployment branch policies (actions: read) + # and branch rules (implicit metadata: read). Without this, gh cannot + # authenticate and shared-runner API limits leave them UNVERIFIABLE. + GH_TOKEN: ${{ github.token }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_VERSION: 2.1.278 # Process-wide cap on a single Bash call. The default is two minutes — @@ -144,6 +148,7 @@ jobs: - name: Redact secrets from agent output if: always() env: + GH_TOKEN: ${{ github.token }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} run: | TRANSCRIPT="$RUNNER_TEMP/claude-execution-output.json" @@ -153,7 +158,7 @@ jobs: for (const p of process.argv.slice(1)) { if (!fs.existsSync(p)) continue; let s = fs.readFileSync(p, "utf8"); - for (const name of ["CLAUDE_CODE_OAUTH_TOKEN"]) { + for (const name of ["CLAUDE_CODE_OAUTH_TOKEN", "GH_TOKEN"]) { const v = process.env[name]; if (!v || v.length < 8) continue; const parts = s.split(v); @@ -163,7 +168,7 @@ jobs: fs.writeFileSync(p, s); } if (hits > 0) { - console.log("::warning::Redacted " + hits + " literal secret occurrence(s) from agent output. A secret reached a file in cleartext — rotate CLAUDE_CODE_OAUTH_TOKEN."); + console.log("::warning::Redacted " + hits + " literal secret occurrence(s) from agent output. A secret reached a file in cleartext — rotate CLAUDE_CODE_OAUTH_TOKEN if exposed; GH_TOKEN expires with this job."); } else { console.log("No literal secret occurrences found."); } diff --git a/scripts/security-audit.test.mjs b/scripts/security-audit.test.mjs index 6431227..83b6a39 100644 --- a/scripts/security-audit.test.mjs +++ b/scripts/security-audit.test.mjs @@ -112,6 +112,7 @@ function fixture(t) { GITHUB_REPOSITORY: 'fixture/repo', GITHUB_RUN_ID: '123', CLAUDE_CODE_OAUTH_TOKEN: 'fixture-oauth-token', + GH_TOKEN: 'fixture-github-token', }; return { dir, env }; } @@ -337,7 +338,7 @@ test('redaction covers every published sink', (t) => { for (const sink of publishedSinks) writeFileSync( join(dir, sink), - `before ${env.CLAUDE_CODE_OAUTH_TOKEN} after`, + `before ${env.CLAUDE_CODE_OAUTH_TOKEN} between ${env.GH_TOKEN} after`, ); const result = spawnSync( 'bash', @@ -354,10 +355,13 @@ test('redaction covers every published sink', (t) => { `expected every sink, got ${publishedSinks.join(', ')}`, ); for (const sink of publishedSinks) - assert.equal(readFileSync(join(dir, sink), 'utf8'), 'before *** after'); + assert.equal( + readFileSync(join(dir, sink), 'utf8'), + 'before *** between *** after', + ); assert.match( result.stdout, - /::warning::Redacted 2 literal secret occurrence/, + /::warning::Redacted 4 literal secret occurrence/, ); }); @@ -477,3 +481,24 @@ test('the workflow keeps the triggers and the job name a consumer reads', () => 'the audit must not cancel a superseded run', ); }); + +test('the auditor can authenticate the live GitHub policy checks and redact its token', () => { + const lines = workflow.split('\n'); + for (const stepName of [ + 'Audit against SECURITY.md', + 'Redact secrets from agent output', + ]) { + const { at } = findStep(lines, stepName); + const runAt = lines.findIndex( + (line, i) => i > at && line.trim() === 'run: |', + ); + assert.match( + lines.slice(at, runAt).join('\n'), + /^ {10}GH_TOKEN: \$\{\{ github.token \}\}$/m, + `${stepName} needs the workflow token`, + ); + } + // Listing deployment branch policies needs Actions read; the branch rules + // endpoint needs only the token's implicit Metadata read permission. + assert.match(workflow.split('\njobs:')[0], /^ {2}actions: read\b/m); +});