Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/audit/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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"
Expand All @@ -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);
Expand All @@ -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.");
}
Expand Down
31 changes: 28 additions & 3 deletions scripts/security-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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',
Expand All @@ -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/,
);
});

Expand Down Expand Up @@ -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);
});
Loading