Repository navigation
827 lines (786 loc) · 46.9 KB
/
Copy pathsecurity-audit.yaml
File metadata and controls
827 lines (786 loc) · 46.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
name: security-audit
# Audits this repo against the security specs (docs/specs/security*.md). Runs nightly via the schedule
# trigger and on-demand via workflow_dispatch. release.yml dispatches it
# on the release tag and gates publishing on the result — it dispatches
# rather than calling this workflow with `uses:` because a tag `push`
# would propagate as event_name `push`, which claude-code-action rejects.
# The nightly schedule runs about 21h after workflow-audit.yaml's "13 7 * * *": Check
# GitHub state judges that run's age against `liveness` in
# .github/audit/expected-github-state.json (fail past 48h, INFO past 27h), so
# one skipped run reports INFO without failing only while this offset stays
# between about 3h and 24h. Move both crons together.
#
# The second schedule is the weekly canary (docs/specs/security-audit.md ->
# "Canary recall"). A schedule carries no inputs, so `run-name` and both jobs'
# `if:` tell it apart by `github.event.schedule`, each spelling this cron
# literally; scripts/security-audit-canary.test.mjs fails if they drift apart.
on:
schedule:
- cron: "21 4 * * *"
- cron: "21 6 * * 0"
workflow_dispatch:
inputs:
# A canary run measures the audit rather than auditing: it seeds known
# vulnerabilities into a throwaway checkout and counts how many the code
# domains catch (docs/specs/security-audit.md -> "Canary recall"). It runs
# the `canary` job alone, on `main` alone, and files and gates nothing.
canary:
description: "Measure recall on seeded vulnerabilities instead of auditing (main only; files and gates nothing)"
type: boolean
default: false
# The release gate in release.yml picks its run by this title, so a canary can
# never be the run a release waits on.
run-name: ${{ (inputs.canary || github.event.schedule == '21 6 * * 0') && 'security-audit canary (recall measurement, not an audit)' || 'security-audit' }}
# Each job declares its own.
permissions: {}
jobs:
audit:
# Every run but a canary: the nightly schedule, the release gate's
# dispatch, and a plain dispatch.
if: ${{ !(inputs.canary || github.event.schedule == '21 6 * * 0') }}
# One run at a time, so filing and publishing never overlap: `File
# embargoed findings` creates against one listing of the open ledger, and
# `Publish the audit status` against one listing of disclosures, so two
# runs at once could file one key twice or disclose one ledger issue twice.
# The group sits on this whole job rather than on a job of those two steps
# because what they file is the composed report, plaintext that leaves this
# runner only for the private tracker; handing it to another job would
# take an artifact, and this repository's artifacts are public. One fixed
# name for every ref and event, since every run shares the one ledger.
# `queue: max` keeps every pending run where GitHub's default keeps one and
# cancels the older, so the release gate's run is never dropped, only
# delayed behind the run ahead of it; `cancel-in-progress: false` never
# stops a run mid-filing (the two cannot be combined in any case). The
# canary files nothing and stays outside the group.
concurrency:
group: security-audit-ledger
queue: max
cancel-in-progress: false
runs-on: ubuntu-latest
# No `id-token`: with it, claude-code-action exchanges OIDC for the Claude
# App's installation token, which can push and file, and hands it to the
# agent. The action runs on this job's token instead, passed below, so the
# agent holds `issues: write` (the reporting steps need it) and no more.
permissions:
contents: read
actions: read
issues: write
env:
# The per-domain fragment filenames, then the deterministic
# GitHub-state check's, in one place. They are
# named by the redactor's argv, its fail-closed `rm -f`, the
# encrypted archive, the reporting step's guard loop, and the public
# body builder, and drift between two of those lists is precisely
# what the `FAIL IF` on the redactor exists to catch — a sink that
# stops being redacted but keeps being published. Every use
# word-splits this deliberately, so the names must stay
# space-separated and shell-safe.
AUDIT_FRAGMENTS: audit-supply-chain.md audit-ci-secrets.md audit-remote.md audit-local.md audit-hosted.md audit-github-state.md
# Must stay well above the orchestrator's own wait deadline in
# `.github/audit/orchestrator.md` (32 minutes, counted from the first
# time its wait loop runs — i.e. after checkout, setup-node, the
# install, and the spawn — and persisted across the loop's
# re-issues so it accumulates rather than restarting).
# At 20 minutes the runner cancelled the job before that deadline
# could ever fire, so the graceful "give up and report what the
# domains found" path was unreachable and every overrun landed as
# INCONCLUSIVE — the exact failure mode the deadline exists to
# prevent. The slack also covers the merge, verdict, redact, upload,
# and reporting steps that run after the wait.
timeout-minutes: 40
# The AUDIT_PAT (read-only Administration + Secrets + Environments)
# lives in this environment, whose deployment-branch-policy admits
# only `main` and `v*` tags — refs that the `Tag operations` and
# `Merge access` rulesets reserve to admins. A bot-pushed feature branch cannot reach this job at all
# (GitHub rejects the run before any step starts), so the PAT
# cannot be exfiltrated through a hand-authored workflow on a
# non-admin-gated ref.
environment:
name: security-audit
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: package.json
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Verify AUDIT_PAT is provisioned
env:
AUDIT_PAT: ${{ secrets.AUDIT_PAT }}
run: |
[ -n "$AUDIT_PAT" ] && exit 0
echo "**FAIL** — \`AUDIT_PAT\` is not present in the \`security-audit\` environment. See [security-audit.md > Environment and AUDIT_PAT](https://github.com/$GITHUB_REPOSITORY/blob/main/docs/specs/security-audit.md#environment-and-audit_pat) for provisioning." > audit-report.md
echo "FAIL" > audit-status.txt
echo "::error::AUDIT_PAT secret is not set."
exit 1
# The deterministic half: every `FAIL IF` that GitHub state answers,
# read live and judged by a script against
# `.github/audit/expected-github-state.json` rather than re-derived by
# a model each night (docs/specs/security-audit.md -> "Deterministic
# checks"). Its fragment rides `AUDIT_FRAGMENTS` like a domain's, so a
# FAIL here fails the run through the reporting steps below. It must
# not stop the domains: a crash leaves no fragment, which the
# reporting step's guard reports as an unfinished audit.
- name: Check GitHub state
id: ghstate
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.AUDIT_PAT }}
# Workflow runs, the tagger App, and the tend tag are public; the
# workflow token reads them without spending the PAT's scope.
WORKFLOW_TOKEN: ${{ github.token }}
run: node scripts/github-state-check.mjs --out audit-github-state.md
# A scheduled run whose commit and GitHub-state hash both match the last
# completed run on `main`, which passed, skips the model-run domains
# (docs/specs/security-audit.md -> "Skipping an unchanged audit"). A
# dispatch, the release gate's included, never skips. A crash here
# leaves `skip` unset, which runs the domains.
- name: Skip the domains if nothing changed
id: plan
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
STATE_HASH: ${{ steps.ghstate.outputs.hash }}
run: node scripts/security-audit-plan.mjs
# What this run audited, for the next run's skip decision: the commit,
# the state hash, and when the domains last ran. Public like every
# artifact here, and it carries nothing a finding could.
- name: Upload the audited state
if: always() && steps.plan.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: audit-state
path: ${{ runner.temp }}/audit-state/audit-state.json
retention-days: 14
if-no-files-found: error
# The open findings ledger, as the checklist each domain re-verifies
# before it passes a rule one cites (docs/specs/security-audit.md ->
# "Findings ledger"). One of three steps that hold `EMBARGO_TOKEN`, and
# like the others it runs only `gh` and shell builtins. It writes the
# open ledger titles, keys and summaries alone, to
# `audit-open-findings.txt` for the agent to read, and logs only their
# count, which the public status issue already carries. A failure
# writes no file, and every domain then computes INCONCLUSIVE.
- name: List open findings
if: steps.plan.outputs.skip != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.EMBARGO_TOKEN }}
EMBARGO_REPO: diffplug/dormouse-embargo
run: |
set -uo pipefail
[ -n "${GH_TOKEN:-}" ] ||
{ echo "::error::EMBARGO_TOKEN is not set; the domains have no open findings to re-verify."; exit 1; }
GH_CONFIG_DIR="$RUNNER_TEMP/gh-findings-$GITHUB_RUN_ID-$$"
[ ! -e "$GH_CONFIG_DIR" ] ||
{ echo "::error::A gh configuration directory already exists at the fresh path."; exit 1; }
export GH_CONFIG_DIR GH_HOST=github.com
# `--limit 1000`, not `gh`'s default of 30, which once left an
# older open finding unreconciled.
OPEN=$(gh issue list --repo "$EMBARGO_REPO" --state open --limit 1000 \
--json number,title \
--jq '.[] | select(.title | startswith("[audit-finding ")) | "\(.number) \(.title)"') ||
{ echo "::error::Reading the findings ledger failed; the domains have no open findings to re-verify."; exit 1; }
COUNT=0
while read -r row; do
[ -n "$row" ] || continue
printf '%s\n' "${row#* }"
COUNT=$((COUNT + 1))
done <<< "$OPEN" > audit-open-findings.txt
echo "$COUNT open finding(s) for the domains to re-verify."
- name: Audit against the security specs
if: steps.plan.outputs.skip != 'true'
uses: anthropics/claude-code-action@db77d4d3c98f3f6380e7cd45ffba2342a3dbde3c # v1.0.254
env:
# `claude-code-action` resets `GH_TOKEN` to its own internal
# workflow token, so setting `GH_TOKEN` at this scope is
# silently overridden. Expose AUDIT_PAT under its own name
# and have the prompt instruct Claude to prefix `gh api`
# calls with `GH_TOKEN=$AUDIT_PAT`.
AUDIT_PAT: ${{ secrets.AUDIT_PAT }}
# Process-wide: this raises the cap on every Bash call in the
# session, the domain subagents' as well as the
# orchestrator's. What it is for is the orchestrator's wait loop,
# which breaks itself at 540s so a capped call still prints
# `STILL WAITING`; at the two-minute default that call is
# backgrounded before the break can print, leaving no line to act
# on — the run 34581574869 failure. The prompt asks for
# `timeout: 600000` per call, and this makes forgetting it
# harmless rather than fatal. The wider scope is accepted, not
# incidental: a domain's own long commands (`local` and `remote`
# re-run vitest and the repo lints) stop being backgrounded
# mid-run, and the cost is that a subagent that blocks with no
# self-break loses ten minutes rather than two.
BASH_DEFAULT_TIMEOUT_MS: "600000"
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Skips the OIDC exchange for the Claude App's installation token,
# which can write; the agent's `gh` gets this job's token.
github_token: ${{ github.token }}
# release.yml dispatches this workflow with the default
# GITHUB_TOKEN, so the actor is `github-actions[bot]`. The
# action refuses bot-initiated runs unless the bot is allow-
# listed. This is safe here: the prompt below is hardcoded (no
# comment/PR/issue injection vector) and the job only runs on
# admin-gated refs behind the `security-audit` environment. The
# nightly `schedule` run has a human actor and is unaffected.
allowed_bots: "github-actions"
# Without an explicit allowlist the action defaults to a
# restrictive set that excludes Bash and Write, so an
# auditing prompt that wants to run `gh api` and produce a
# report file racks up permission denials and exits without
# writing audit-status.txt.
#
# `--allowed-tools` only auto-approves; it removes nothing. Run
# 32618922852 proved that the hard way: it passed all 21
# mechanical checks, handed the qualitative pass to two
# background subagents, then ended its turn to "wait for the
# completion notification" — which in a headless SDK run
# terminates the session, discarding the subagents and leaving
# both output files unwritten. A clean audit blocked the
# release gate for $5 and no verdict.
#
# The cause was ending the turn, not delegating. `Task` is
# therefore allowed on purpose — the audit fans out to
# scoped domain agents — and the orchestrator prompt carries
# the rule that makes it safe: block in a Bash `until` loop on
# the fragment files, never end the turn to wait. `Workflow`
# stays denied because nothing here should be spawning one.
# Two of the domains are mechanical — run a generator, read
# `gh api`, compare a pin — and the session default (Sonnet)
# does them well. `remote`, `local`, and `hosted` are the
# ones that read code adversarially, and their findings have
# been the ones that needed real reasoning: tracing a
# relay-minted `clientId` to a keystroke-injection path, or
# working out that an 8-char fingerprint carries ~40 bits
# because a P-256 point's leading byte is constant. They run on
# Opus; the orchestrator and the two mechanical domains stay on
# the cheaper default, so the cost lands only where the depth
# does.
#
# Each agent's prompt is a pointer, not a copy — the content
# stays in `.github/audit/` so CI and
# `scripts/security-audit-local.sh` cannot drift apart.
claude_args: >-
--model sonnet
--allowed-tools "Read,Write,Edit,Bash,Grep,Glob,Task,Agent"
--disallowed-tools "Workflow"
--agents '{"supply-chain":{"description":"Audits docs/specs/security-supply-chain.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/supply-chain.md`, and follow them exactly."},"ci-and-secrets":{"description":"Audits docs/specs/security.md, docs/specs/security-ci.md, and docs/specs/security-audit.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/ci-and-secrets.md`, and follow them exactly."},"remote":{"description":"Audits docs/specs/security-remote.md: remote control.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/remote.md`, and follow them exactly.","model":"opus"},"local":{"description":"Audits docs/specs/security-local.md and the desktop shells, and sweeps the rest of the product code.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/local.md`, and follow them exactly.","model":"opus"},"hosted":{"description":"Audits docs/specs/security-hosted.md: Hosted accounts and the pgstencil provenance link.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/hosted.md`, and follow them exactly.","model":"opus"}}'
# The prompts live in `.github/audit/`, not inline here: they
# are long enough to need real diffs in review, they must be
# runnable locally against the same text CI uses
# (`scripts/security-audit-local.sh`), and each domain's scope
# — the bullet list of security specs under its `**Scope` line
# — is what `scripts/spec-lint.mjs` (check 16) reads to prove
# every `docs/specs/security*.md` has exactly one owner. That
# lint cannot read YAML block scalars, and when the scopes were
# inline here the ownership check was a grep that wrapping
# once broke silently.
prompt: |
You are the orchestrator of this repository's nightly
security audit.
Read `.github/audit/orchestrator.md` and follow it exactly.
It tells you which subagents to spawn, which prompt files to
give them, how to wait for them without ending your turn,
and how to write `audit-report.md` and `audit-status.txt`.
# Everything the agent wrote leaves the runner by two routes only: an
# age-encrypted archive (`Encrypt the audit transcript`) and the
# private embargo issue (`File embargoed findings`). The public status
# issue is built from verdicts and counts and carries none of it. See
# docs/specs/security-audit.md -> "Embargo".
#
# Both routes still get the redactor. The prompt only passes
# `$AUDIT_PAT` through an unexpanded `GH_TOKEN=` prefix, but that is
# a convention, not a control: the agent holds unrestricted Bash and
# audits code that touches secrets, so one `printenv` or one `set -x`
# lands a PAT in the SDK transcript, which serializes every Bash
# `tool_result` verbatim. `***` masking applies to step logs, not to
# files. Stripping the values does not stop an agent that encodes a
# secret, but it closes the accidental-print path, which is the one
# that actually happens.
#
# Fail closed: on any error, delete every sink instead, so neither
# route ships the raw file. Deleted rather than truncated — `: >` has
# to open the file and so fails on exactly the unreadable file that
# made the redactor throw, whereas `rm` needs only the directory. A
# nonzero count is not routine — it means a secret reached a file in
# cleartext — so it surfaces as an annotation rather than a log line,
# and the right response is to rotate.
- name: Redact secrets from agent output
if: always()
env:
AUDIT_PAT: ${{ secrets.AUDIT_PAT }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
run: |
TRANSCRIPT="$RUNNER_TEMP/claude-execution-output.json"
# Every sink the encrypted archive or the private issue carries
# has to be in the argument list, and the fail-closed `rm -f` has
# to delete the same set — docs/specs/security-audit.md names
# `audit-report.md`, the per-domain fragments, and the
# transcript. The fragments are
# agent output like any other: a subagent that shells out and
# prints an environment lands the value in its own fragment,
# not in the merged report. The fragment names come from the
# job-level `AUDIT_FRAGMENTS` so this list and the guard loop in
# the reporting step cannot drift; both expansions below are
# unquoted on purpose (shellcheck SC2086) because the value is a
# filename list. Keep every shell comment outside the
# single-quoted script — inside it, Node parses it as
# JavaScript and the step fails closed on every run, which
# `bash -n` does not catch.
# shellcheck disable=SC2086
node -e '
const fs = require("fs");
let hits = 0;
for (const p of process.argv.slice(1)) {
if (!fs.existsSync(p)) continue;
let s = fs.readFileSync(p, "utf8");
for (const name of ["AUDIT_PAT", "CLAUDE_CODE_OAUTH_TOKEN"]) {
const v = process.env[name];
// Guard against a short or empty value turning into a
// global replace of something innocuous.
if (!v || v.length < 8) continue;
const parts = s.split(v);
hits += parts.length - 1;
s = parts.join("***");
}
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 AUDIT_PAT and CLAUDE_CODE_OAUTH_TOKEN.");
} else {
console.log("No literal secret occurrences found.");
}
' "$TRANSCRIPT" audit-report.md $AUDIT_FRAGMENTS \
|| { rm -f "$TRANSCRIPT" audit-report.md $AUDIT_FRAGMENTS; exit 1; }
# The SDK transcript is the only record of what the audit actually
# did: `claude-code-action` keeps tool output out of the logs on
# purpose, and the runner is ephemeral, so a run that finishes
# without a verdict is undiagnosable without it. The fragments ride
# along so an orchestrator that dies mid-merge still ships what the
# domains found. Archived on every outcome — a PASS transcript is the
# baseline you compare a bad one against.
#
# This repository is public and so is every artifact it uploads, so
# the archive goes up as age ciphertext only, to the maintainer key
# in `.github/audit/transcript-recipient.txt`. Written to a partial
# name and renamed once age exits zero, so a failed encryption leaves
# nothing at the path the upload names, and the upload is gated on
# this step succeeding as well. `age` comes from the Ubuntu archive
# the runner image already trusts, as in `hosted-production.yml`.
- name: Encrypt the audit transcript
id: encrypt
if: always()
run: |
set -euo pipefail
OUT="$RUNNER_TEMP/audit-transcript.tar.gz.age"
rm -f "$OUT" "$OUT.partial"
sudo apt-get update -qq && sudo apt-get install -y -qq age
STAGE=$(mktemp -d)
# Unquoted on purpose: `AUDIT_FRAGMENTS` is a filename list. A sink
# a domain never wrote is simply absent from the archive.
# shellcheck disable=SC2086
for f in "$RUNNER_TEMP/claude-execution-output.json" audit-report.md $AUDIT_FRAGMENTS; do
if [ -f "$f" ]; then cp "$f" "$STAGE/"; fi
done
tar -C "$STAGE" -czf - . |
age -R .github/audit/transcript-recipient.txt -o "$OUT.partial" ||
{ rm -f "$OUT.partial"; rm -rf "$STAGE"; echo "::error::Encrypting the audit transcript failed; nothing is uploaded."; exit 1; }
mv "$OUT.partial" "$OUT"
rm -rf "$STAGE"
- name: Archive audit transcript
if: always() && steps.encrypt.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: audit-transcript
# Ciphertext only: one `.age` file, never a plaintext sink.
path: ${{ runner.temp }}/audit-transcript.tar.gz.age
retention-days: 14
if-no-files-found: error
# Decides the verdict and composes the private report and the findings
# ledger. It files nothing: `File embargoed findings` takes both to the
# private tracker and `Publish the audit status` rewrites the public
# status issue, each reading what this step hands on in its outputs.
#
# The verdict is computed, not read. `scripts/security-audit-report.mjs`
# derives each domain's verdict from its result and finding lines against
# the rules its specs carry, and a `VERDICT:` line or `audit-status.txt`
# that disagrees is reported as an anomaly rather than believed: on
# 2026-10-07 a fragment said `VERDICT: PASS` over its own `UNVERIFIABLE`
# line. See docs/specs/security-audit.md -> "Outcomes and reporting".
- name: Compose the audit report
id: compose
if: always()
env:
GH_TOKEN: ${{ github.token }}
# A skipped run has only the deterministic fragment to read.
AUDIT_FRAGMENTS: ${{ steps.plan.outputs.fragments || env.AUDIT_FRAGMENTS }}
run: |
set -o pipefail
RUN_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# Deep-link the encrypted transcript so the issue points at the
# evidence rather than at a run page the reader has to dig through.
# The id lookup needs `actions: read`, which this job holds. Left
# empty when there is no artifact — a run cancelled on
# `timeout-minutes` never writes the execution file, and those are
# exactly the runs a link to the run page would dead-end on.
ART_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" \
--jq '.artifacts[] | select(.name == "audit-transcript") | .id' \
2>/dev/null | head -n1 || true)
TRANSCRIPT_URL=""
[ -n "$ART_ID" ] && TRANSCRIPT_URL="$RUN_URL/artifacts/$ART_ID"
# The builder writes its outputs to a file of its own, appended only
# when it succeeds, so a builder that dies half-way can hand on no
# status but the fail-closed one below.
OUTPUTS=$(mktemp)
if node scripts/security-audit-report.mjs compose \
--run-url "$RUN_URL" --transcript-url "$TRANSCRIPT_URL" --outputs "$OUTPUTS"; then
cat "$OUTPUTS" >> "$GITHUB_OUTPUT"
STATUS=$(sed -n 's/^status=//p' "$OUTPUTS")
else
echo "::error::The report builder failed; the run is reported INCONCLUSIVE with no detail."
STATUS=MISSING
echo "status=MISSING" >> "$GITHUB_OUTPUT"
rm -rf audit-ledger
printf '%s\n\n%s\n' "Audit reached no usable verdict: the report builder failed. [Run]($RUN_URL)" \
"Read the run's log and the encrypted \`audit-transcript\` artifact." > audit-private.md
fi
rm -f "$OUTPUTS"
[ "$STATUS" = "PASS" ] && exit 0
# Split rather than truncate: the private issue carries the report
# whole, as its body and comments. Non-fatal: on a helper failure
# the body goes up as one part and the tracker takes it or refuses
# it.
node scripts/clamp-issue-body.mjs audit-private.md --split \
--note "The full report is in this run's encrypted \`audit-transcript\` artifact${TRANSCRIPT_URL:+ ([download]($TRANSCRIPT_URL))}." \
|| { echo "clamp-issue-body.mjs failed; filing audit-private.md as one part." >&2; cp audit-private.md audit-private-part-01.md; }
# A step that holds `EMBARGO_TOKEN` and runs no repository code: only
# `gh` and shell builtins, on the files `Compose the audit report` wrote.
# The agent step never has the token in its environment; see
# docs/specs/security-audit.md -> "Embargo" for what that does and does
# not stop.
#
# Every run files its ledger findings, a PASS's WARNINGs included. A
# non-PASS run also files its report as one private issue, split across
# the body and comments so nothing is truncated away.
#
# Any failure — no token, no report, a refused call — fails this step
# loudly and records why for the public step, which says so in public
# without the detail. Never a fallback that puts the detail anywhere
# else.
- name: File embargoed findings
id: embargo
if: always()
env:
GH_TOKEN: ${{ secrets.EMBARGO_TOKEN }}
EMBARGO_REPO: diffplug/dormouse-embargo
STATUS: ${{ steps.compose.outputs.status }}
RUN_DATE: ${{ steps.compose.outputs.date }}
run: |
set -uo pipefail
fail() {
echo "result=$1" >> "$GITHUB_OUTPUT"
echo "::error::$2 The audit's findings were not filed privately; the public issue says so without them."
exit 1
}
[ -n "${GH_TOKEN:-}" ] ||
fail missing-token "EMBARGO_TOKEN is not set in the security-audit environment."
# A config directory nothing else wrote, so no leftover `gh`
# configuration decides where the token is sent. Named rather than
# made with `mktemp`, so this step runs no program but `gh`.
GH_CONFIG_DIR="$RUNNER_TEMP/gh-embargo-$GITHUB_RUN_ID-$$"
[ ! -e "$GH_CONFIG_DIR" ] || fail failed "A gh configuration directory already exists at the fresh path."
export GH_CONFIG_DIR GH_HOST=github.com
RUN_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# The ledger as it stood before this run: every open issue whose
# title carries an `[audit-finding <key>]` prefix.
[ -f audit-ledger/index.tsv ] ||
fail no-report "No findings ledger was composed."
# `--limit 1000`, not `gh`'s default of 30, which once left an
# older open finding unreconciled.
OPEN=$(gh issue list --repo "$EMBARGO_REPO" --state open --limit 1000 \
--json number,title \
--jq '.[] | select(.title | startswith("[audit-finding ")) | "\(.number) \(.title)"') ||
fail failed "Reading the findings ledger failed."
OPEN_COUNT=0
while read -r row; do [ -n "$row" ] && OPEN_COUNT=$((OPEN_COUNT + 1)); done <<< "$OPEN"
# Per-run report, for a run that did not pass.
if [ "${STATUS:-}" != "PASS" ]; then
shopt -s nullglob
PARTS=(audit-private-part-*.md)
[ "${#PARTS[@]}" -gt 0 ] ||
fail no-report "No private report was composed."
case "${STATUS:-}" in FAIL) VERDICT=FAIL ;; *) VERDICT=INCONCLUSIVE ;; esac
DAY="an unknown day"
[[ "${RUN_DATE:-}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z$ ]] && DAY="${RUN_DATE%%T*}"
TITLE="[security-audit] $VERDICT on $DAY at ${GITHUB_SHA:0:7}"
URL=$(gh issue create --repo "$EMBARGO_REPO" --title "$TITLE" --body-file "${PARTS[0]}") ||
fail failed "Filing the private issue failed."
for part in "${PARTS[@]:1}"; do
gh issue comment "$URL" --body-file "$part" >/dev/null ||
fail failed "The private issue was filed but $part could not be added to it."
done
echo "Filed ${#PARTS[@]} part(s) in the private tracker."
fi
# One ledger issue per finding key: opened if none is open, a
# comment if one is. Never closed here — a human closes it, citing
# the fix. Row i's title and body are `audit-ledger/<i>.title` and
# `audit-ledger/<i>.md`, named here rather than read from the index.
ROW=0; CREATED=0; SEEN=0
while IFS=$'\t' read -r KEY SEVERITY; do
ROW=$((ROW + 1))
[[ "$KEY" =~ ^[0-9a-f]{12}$ ]] || fail failed "Ledger row $ROW has a malformed key."
case "$SEVERITY" in FAIL|BLOCKER|WARNING) ;; *) fail failed "Ledger row $ROW has a malformed severity." ;; esac
NUMBER=""
while read -r row; do
case "${row#* }" in "[audit-finding $KEY]"*) NUMBER="${row%% *}"; break ;; esac
done <<< "$OPEN"
if [ -n "$NUMBER" ]; then
gh issue comment "$NUMBER" --repo "$EMBARGO_REPO" \
--body "Seen again as $SEVERITY in [run $GITHUB_RUN_ID]($RUN_URL) at \`${GITHUB_SHA:0:7}\`." >/dev/null ||
fail failed "Updating ledger issue #$NUMBER failed."
SEEN=$((SEEN + 1))
else
TITLE=$(< "audit-ledger/$ROW.title")
gh issue create --repo "$EMBARGO_REPO" --title "$TITLE" \
--body-file "audit-ledger/$ROW.md" >/dev/null ||
fail failed "Opening a ledger issue failed."
CREATED=$((CREATED + 1))
fi
done < audit-ledger/index.tsv
echo "result=filed" >> "$GITHUB_OUTPUT"
echo "Ledger: $CREATED opened, $SEEN seen again, $OPEN_COUNT open before this run."
# What the status issue and the disclosures need from the ledger, read
# after this run filed its findings. The third step that holds
# `EMBARGO_TOKEN`, and like the others it runs only `gh` and shell
# builtins. Its filter is the embargo: from each `[audit-finding …]`
# issue it keeps the number, state, close reason, and dates; the key and
# severity from the title; the fixed-form metadata line that opens the
# body (docs/specs/security-audit.md -> "Findings ledger"); and from
# maintainers' comments only a `Fixed in` citation's PR and a
# `## Public summary`. The ledger's prose never leaves it. The snapshot
# goes to one file for the public step; the log gets a count. No file
# means the ledger could not be read, which the public step reports and
# a PASS fails on.
- name: Read the findings ledger
if: always()
env:
GH_TOKEN: ${{ secrets.EMBARGO_TOKEN }}
EMBARGO_REPO: diffplug/dormouse-embargo
run: |
set -uo pipefail
[ -n "${GH_TOKEN:-}" ] ||
{ echo "::error::EMBARGO_TOKEN is not set; the status issue cannot list the open findings."; exit 1; }
GH_CONFIG_DIR="$RUNNER_TEMP/gh-ledger-$GITHUB_RUN_ID-$$"
[ ! -e "$GH_CONFIG_DIR" ] ||
{ echo "::error::A gh configuration directory already exists at the fresh path."; exit 1; }
export GH_CONFIG_DIR GH_HOST=github.com
# `--limit 1000`, not `gh`'s default of 30, which once left an
# older open finding unreconciled.
SNAPSHOT=$(gh issue list --repo "$EMBARGO_REPO" --state all --limit 1000 \
--json number,title,state,stateReason,createdAt,closedAt,body,comments \
--jq '.[] | select(.title | startswith("[audit-finding ")) | {
number, state, stateReason, createdAt, closedAt,
title: (first(.title | capture("\\A\\[audit-finding (?<key>[A-Za-z0-9._-]+)\\] (?<severity>FAIL|BLOCKER|WARNING)\\b")) // null),
meta: ((try first(.body | capture("\\A<!-- audit-ledger (?<m>\\{[^\\n]*\\}) -->") | .m | fromjson | objects | {key, severity, rule, spec, heading, file})) // null),
fixed: [.comments[] | select(.authorAssociation | IN("OWNER", "MEMBER", "COLLABORATOR")) | .body
| capture("\\AFixed (?:in|by) (?:(?<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#|https://github\\.com/(?<url>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/pull/)(?<pr>[0-9]+)\\b")
| {repo: (.repo // .url), pr}],
summary: ([.comments[] | select(.authorAssociation | IN("OWNER", "MEMBER", "COLLABORATOR")) | .body
| select(test("\\A## Public summary\\r?\\n")) | sub("\\A## Public summary\\r?\\n"; "")] | last)
} | tojson') ||
{ echo "::error::Reading the findings ledger failed; the status issue cannot list the open findings."; exit 1; }
COUNT=0
while read -r row; do [ -n "$row" ] && COUNT=$((COUNT + 1)); done <<< "$SNAPSHOT"
printf '%s\n' "$SNAPSHOT" > audit-ledger-snapshot.jsonl
echo "Read $COUNT ledger issue(s)."
# The public record: the pinned status issue, rewritten every run, and a
# disclosure issue per ledger finding once it is fixed, dismissed, or
# past its deadline. Built by `scripts/security-audit-public-body.mjs`
# from verdicts, counts, validated metadata, and maintainers' public
# summaries — no fragment, report, or ledger text — on the workflow token,
# which cannot reach the private tracker. A run that did not pass, or a
# PASS whose findings were not filed or whose ledger could not be read,
# exits non-zero, which is what holds the release gate in `release.yml`
# shut.
- name: Publish the audit status
if: always()
env:
GH_TOKEN: ${{ github.token }}
AUDIT_FRAGMENTS: ${{ steps.plan.outputs.fragments || env.AUDIT_FRAGMENTS }}
STATUS: ${{ steps.compose.outputs.status }}
RUN_DATE: ${{ steps.compose.outputs.date }}
EMBARGO_RESULT: ${{ steps.embargo.outputs.result }}
SKIPPED: ${{ steps.plan.outputs.skip }}
FULL_RUN_AT: ${{ steps.plan.outputs.full_run_at }}
run: |
set -o pipefail
# A compose step that died before deciding hands on nothing, and
# nothing is not a pass.
case "${STATUS:-}" in PASS|FAIL|MISSING) ;; *) STATUS=MISSING ;; esac
# The compose step's timestamp, so both trackers name the same day.
DATE="${RUN_DATE:-}"
[[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z$ ]] || DATE=$(date -u +%Y-%m-%dT%H:%MZ)
RUN_URL="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
OUTPUTS=$(mktemp)
if node scripts/security-audit-public-body.mjs \
--status "$STATUS" --date "$DATE" --run-url "$RUN_URL" --commit "$GITHUB_SHA" \
--embargo-result "${EMBARGO_RESULT:-}" --skipped "${SKIPPED:-}" --full-run-at "${FULL_RUN_AT:-}" \
--ledger audit-ledger-snapshot.jsonl --outputs "$OUTPUTS"; then
PASSED=$(sed -n 's/^passed=//p' "$OUTPUTS")
rm -f "$OUTPUTS"
[ "$PASSED" = true ] && exit 0
exit 1
fi
# A builder that throws gets a fixed sentence, never a fallback to
# the report, and the run fails.
rm -f "$OUTPUTS"
echo "::error::The status builder failed; the status issue was not rewritten."
ISSUE=$(gh issue list --repo "$GITHUB_REPOSITORY" --label security-audit-status --state open --limit 100 \
--json number --jq 'map(.number) | min // empty') || ISSUE=""
[ -z "$ISSUE" ] || gh issue comment "$ISSUE" --repo "$GITHUB_REPOSITORY" \
--body "The audit run at $DATE could not publish its status, so the body above is stale. [Run]($RUN_URL)" || true
exit 1
# Recall, not an audit (docs/specs/security-audit.md -> "Canary recall"): the
# code domains audit a checkout seeded with known vulnerabilities from
# `.github/audit/canaries/`, and a script counts the seeds their lines catch.
# It holds no `AUDIT_PAT` and no `EMBARGO_TOKEN`, writes to nothing but this
# run's artifacts, and leaves the skip decision and the release gate alone:
# it uploads no `audit-state`, and release.yml never picks a run by its title.
canary:
# A dispatch with `canary: true`, or the weekly schedule.
if: ${{ inputs.canary || github.event.schedule == '21 6 * * 0' }}
runs-on: ubuntu-latest
# Above the orchestrator's 32-minute wait, as the audit job's is.
timeout-minutes: 45
# No `issues: write`, no `contents: write`, and no `id-token`: without it
# claude-code-action cannot mint its GitHub App token, and runs on the
# read-only workflow token passed below instead.
permissions:
contents: read
# For `CLAUDE_CODE_OAUTH_TOKEN`, which is env-scoped. The environment also
# admits `v*` tags; the first step refuses them.
environment:
name: security-audit
env:
# Two rounds dealt across the four code domains; each keeps at least
# three seeds in the pool, so raise that floor before a third round.
CANARY_SEEDS: "8"
CANARY_FRAGMENTS: audit-supply-chain.md audit-remote.md audit-local.md audit-hosted.md
steps:
- name: Refuse any ref but main
run: |
[ "$GITHUB_REF" = refs/heads/main ] && exit 0
echo "::error::A canary runs on main only."
exit 1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
# No token left in `.git/config` for the agent to push with.
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: package.json
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
# In this runner's checkout only: the seeds, then one fresh root commit in
# place of the history, so no diff tells the auditor which changes are
# seeds. The seeds applied go to the stash, outside the checkout; the log
# carries their count alone. `ci-and-secrets` is not run, so its fragment
# is written finished here for the orchestrator's wait. The open-findings
# list is handed over empty: the ledger is real findings on `main`, which
# a canary neither reads nor scores.
- name: Seed the checkout
id: seed
run: |
node scripts/security-audit-canary.mjs seed --key "$GITHUB_RUN_ID" \
--count "$CANARY_SEEDS" --stash "$RUNNER_TEMP/canary-stash"
printf 'VERDICT: INCONCLUSIVE\n\nThis run does not audit ci-and-secrets.\n\n<!-- END OF REPORT -->\n' > audit-ci-secrets.md
: > audit-open-findings.txt
# The audit job's agent step, less `ci-and-secrets`: the same pinned
# action, prompts, tools, and model split.
- name: Audit the seeded checkout
uses: anthropics/claude-code-action@db77d4d3c98f3f6380e7cd45ffba2342a3dbde3c # v1.0.254
env:
BASH_DEFAULT_TIMEOUT_MS: "600000"
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Skips the OIDC exchange for the Claude App's installation token,
# which can write; the agent's `gh` gets this job's token.
github_token: ${{ github.token }}
claude_args: >-
--model sonnet
--allowed-tools "Read,Write,Edit,Bash,Grep,Glob,Task,Agent"
--disallowed-tools "Workflow"
--agents '{"supply-chain":{"description":"Audits docs/specs/security-supply-chain.md.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/supply-chain.md`, and follow them exactly."},"remote":{"description":"Audits docs/specs/security-remote.md: remote control.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/remote.md`, and follow them exactly.","model":"opus"},"local":{"description":"Audits docs/specs/security-local.md and the desktop shells, and sweeps the rest of the product code.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/local.md`, and follow them exactly.","model":"opus"},"hosted":{"description":"Audits docs/specs/security-hosted.md: Hosted accounts and the pgstencil provenance link.","prompt":"Read `.github/audit/_preamble.md` and then `.github/audit/hosted.md`, and follow them exactly.","model":"opus"}}'
prompt: |
You are the orchestrator of this repository's security audit.
Read `.github/audit/orchestrator.md` and follow it exactly, with one
change: this run audits the code domains. Spawn only `supply-chain`,
`remote`, `local`, and `hosted`. `audit-ci-secrets.md` is
already written and finished, so do not spawn `ci-and-secrets`.
# As the audit job's redactor: every sink the archive carries, deleted
# rather than kept when the redactor throws. Before scoring, so the
# scorecard quotes redacted lines.
- name: Redact secrets from canary output
if: always()
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
run: |
TRANSCRIPT="$RUNNER_TEMP/claude-execution-output.json"
# shellcheck disable=SC2086
node scripts/security-audit-canary.mjs redact "$TRANSCRIPT" audit-report.md $CANARY_FRAGMENTS \
|| { rm -f "$TRANSCRIPT" audit-report.md $CANARY_FRAGMENTS; exit 1; }
# The public half is counts alone: the step summary's one line and the
# `canary-recall` artifact. Which seeds ran, and which were caught by
# what, stay in the scorecard, which leaves only encrypted.
- name: Score the canary
id: score
if: always() && steps.seed.outcome == 'success'
run: |
node scripts/security-audit-canary.mjs score --stash "$RUNNER_TEMP/canary-stash" \
--run-url "https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--scorecard "$RUNNER_TEMP/canary-stash/scorecard.json" \
--public "$RUNNER_TEMP/canary-recall/canary-recall.json" --summary "$GITHUB_STEP_SUMMARY"
# As the audit job's transcript: age ciphertext to the maintainer key,
# written to a partial name and renamed once age exits zero.
- name: Encrypt the canary transcript
id: encrypt
if: always()
run: |
set -euo pipefail
OUT="$RUNNER_TEMP/canary-transcript.tar.gz.age"
rm -f "$OUT" "$OUT.partial"
sudo apt-get update -qq && sudo apt-get install -y -qq age
STAGE=$(mktemp -d)
# shellcheck disable=SC2086
for f in "$RUNNER_TEMP/claude-execution-output.json" audit-report.md $CANARY_FRAGMENTS \
"$RUNNER_TEMP/canary-stash/seeds.json" "$RUNNER_TEMP/canary-stash/scorecard.json"; do
if [ -f "$f" ]; then cp "$f" "$STAGE/"; fi
done
tar -C "$STAGE" -czf - . |
age -R .github/audit/transcript-recipient.txt -o "$OUT.partial" ||
{ rm -f "$OUT.partial"; rm -rf "$STAGE"; echo "::error::Encrypting the canary transcript failed; nothing is uploaded."; exit 1; }
mv "$OUT.partial" "$OUT"
rm -rf "$STAGE"
- name: Archive the canary transcript
if: always() && steps.encrypt.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: canary-transcript
# Ciphertext only: one `.age` file, never a plaintext sink.
path: ${{ runner.temp }}/canary-transcript.tar.gz.age
retention-days: 14
if-no-files-found: error
- name: Upload the canary recall
if: always() && steps.score.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: canary-recall
path: ${{ runner.temp }}/canary-recall/canary-recall.json
retention-days: 90
if-no-files-found: error