Skip to content

Commit 6b4f700

Browse files
donislawdevclaude
andcommitted
ci: every checkout turns the job's token off, and a guard asks each one
actions/checkout leaves the token it cloned with in .git/config, and the steps after it run go test over the pull request's own code, which can read that file. An outside review of #117 named it on the one job it was reading, and #117 turned the token off there. A fix at one job of twenty four was an inconsistency (O230). Checked per step before turning off all of them: no step in these workflows pushes, fetches or commits after a checkout. The only git commands are diff, cat-file and rev-parse, all local. The release, the attestation and the pages talk to GitHub through gh with a token in the environment, or through actions that carry their own, and none of that reads .git/config. The weight is small - a pull request's token from a fork is read only and the workflows ask for contents: read - and the class is real on every checkout alike. The guard reads each workflow through the YAML parser rather than the line under uses:, because in pages.yml the with block sits under a comment and the key can sit anywhere inside it. It self tests its predicate on six shapes the tree does not contain, since every checkout is off now and a rule that weakened would find nothing to let through. It counts what it saw and refuses a walk that found far fewer than the twenty four measured. Two mutations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ae37148 commit 6b4f700

9 files changed

Lines changed: 173 additions & 5 deletions

File tree

‎.github/workflows/attest-release.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ jobs:
4545
attestations: write
4646
steps:
4747
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48+
with:
49+
persist-credentials: false
4850

4951
- name: fetch what the maintainer signed
5052
shell: bash

‎.github/workflows/ci.yml‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ jobs:
6767
# holds this job to setting it and the imports job to not setting it.
6868
TFG_IMPORT_TABLE_JOB: "1"
6969
steps:
70+
# Every checkout in these workflows turns the token off. The checkout
71+
# keeps the job's token in .git/config unless told not to, and the jobs
72+
# go on to run code from the pull request under test. Nothing after a
73+
# checkout here pushes or fetches - the release and the pages talk to
74+
# GitHub through gh and through actions that carry their own token - so
75+
# the credential has no use once the tree is on disk. A guard asks each
76+
# checkout (checkoutcredentials_test.go), so a new one cannot forget.
7077
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
78+
with:
79+
persist-credentials: false
7180

7281
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
7382
with:
@@ -353,11 +362,6 @@ jobs:
353362
timeout-minutes: 15
354363
steps:
355364
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
356-
# The checkout keeps the job's token in .git/config unless told not
357-
# to, and this job goes on to run go test over the pull request's
358-
# own code. Nothing here pushes, so the token has no use after the
359-
# checkout. Asked for by an outside review of #117, and the other
360-
# checkouts in these workflows are O230.
361365
with:
362366
persist-credentials: false
363367

@@ -383,6 +387,8 @@ jobs:
383387
timeout-minutes: 15
384388
steps:
385389
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
390+
with:
391+
persist-credentials: false
386392

387393
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
388394
with:
@@ -425,6 +431,8 @@ jobs:
425431
timeout-minutes: 15
426432
steps:
427433
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
434+
with:
435+
persist-credentials: false
428436

429437
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
430438
with:
@@ -468,6 +476,8 @@ jobs:
468476
timeout-minutes: 15
469477
steps:
470478
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
479+
with:
480+
persist-credentials: false
471481

472482
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
473483
with:
@@ -512,6 +522,8 @@ jobs:
512522
timeout-minutes: 15
513523
steps:
514524
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
525+
with:
526+
persist-credentials: false
515527

516528
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
517529
with:
@@ -576,6 +588,8 @@ jobs:
576588
timeout-minutes: 20
577589
steps:
578590
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
591+
with:
592+
persist-credentials: false
579593

580594
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
581595
with:
@@ -646,6 +660,7 @@ jobs:
646660
steps:
647661
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
648662
with:
663+
persist-credentials: false
649664
# The comparison needs the earlier commit, and the default checkout
650665
# fetches one.
651666
fetch-depth: 0
@@ -742,6 +757,8 @@ jobs:
742757
CGO_ENABLED: "1"
743758
steps:
744759
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
760+
with:
761+
persist-credentials: false
745762

746763
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
747764
with:
@@ -817,6 +834,8 @@ jobs:
817834
CGO_ENABLED: "0"
818835
steps:
819836
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
837+
with:
838+
persist-credentials: false
820839

821840
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
822841
with:
@@ -893,6 +912,8 @@ jobs:
893912
CGO_ENABLED: "0"
894913
steps:
895914
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
915+
with:
916+
persist-credentials: false
896917

897918
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
898919
with:
@@ -948,6 +969,8 @@ jobs:
948969
CGO_ENABLED: "0"
949970
steps:
950971
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
972+
with:
973+
persist-credentials: false
951974

952975
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
953976
with:

‎.github/workflows/dependency-review.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
timeout-minutes: 10
2727
steps:
2828
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29+
with:
30+
persist-credentials: false
2931

3032
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
3133
with:

‎.github/workflows/dev-build.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ jobs:
6969
CGO_ENABLED: "0"
7070
steps:
7171
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
72+
with:
73+
persist-credentials: false
7274

7375
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
7476
with:
@@ -153,6 +155,8 @@ jobs:
153155
shell: bash
154156
steps:
155157
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
158+
with:
159+
persist-credentials: false
156160

157161
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
158162
with:

‎.github/workflows/pages.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
# nosemgrep: yaml.github-actions.security.workflow-run-target-code-checkout.workflow-run-target-code-checkout
6666
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6767
with:
68+
persist-credentials: false
6869
# The commit CI passed on, not whatever main happens to be now. On a
6970
# dispatch there is no such commit and the default ref is right.
7071
ref: ${{ github.event.workflow_run.head_sha || github.ref }}

‎.github/workflows/release.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ jobs:
4242
CGO_ENABLED: "0"
4343
steps:
4444
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45+
with:
46+
persist-credentials: false
4547

4648
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
4749
with:
@@ -151,6 +153,8 @@ jobs:
151153
CGO_ENABLED: "0"
152154
steps:
153155
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
156+
with:
157+
persist-credentials: false
154158

155159
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
156160
with:
@@ -258,6 +262,8 @@ jobs:
258262
shell: bash
259263
steps:
260264
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
265+
with:
266+
persist-credentials: false
261267

262268
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
263269
with:
@@ -363,6 +369,8 @@ jobs:
363369
attestations: write
364370
steps:
365371
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
372+
with:
373+
persist-credentials: false
366374

367375
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
368376
with:

‎.github/workflows/tool-versions.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ jobs:
3939
timeout-minutes: 10
4040
steps:
4141
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
42+
with:
43+
persist-credentials: false
4244

4345
- name: compare each pin with its latest release
4446
env:

‎.github/workflows/verify-release.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ jobs:
4141
contents: read
4242
steps:
4343
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44+
with:
45+
persist-credentials: false
4446

4547
- name: which release this is
4648
id: which
@@ -151,6 +153,8 @@ jobs:
151153
contents: read
152154
steps:
153155
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
156+
with:
157+
persist-credentials: false
154158

155159
- name: which release this is
156160
id: which
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package guard
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/goccy/go-yaml"
10+
)
11+
12+
// Every checkout in a workflow turns the job's token off once the tree is on
13+
// disk.
14+
//
15+
// actions/checkout writes the token it cloned with into .git/config and leaves
16+
// it there, so that a later step can push. No step in these workflows pushes:
17+
// the release, the attestation and the pages talk to GitHub through gh with a
18+
// token in the environment, or through actions that carry their own. What the
19+
// jobs do after a checkout is run go test over the code of the pull request
20+
// under test - and that code can read .git/config. The token of a pull request
21+
// from a fork is read only, so the class is real and the weight is small, and
22+
// it is the same class on every one of the twenty four checkouts, which is why
23+
// the answer is every one of them and a guard, not a fix at the job somebody
24+
// happened to review (O230).
25+
//
26+
// Asked through the YAML parser rather than by searching the text, because
27+
// `with:` can sit under a comment block and the key can sit anywhere inside
28+
// it - a regular expression tying the key to the line after `uses:` would read
29+
// pages.yml wrong today.
30+
func TestEveryCheckoutTurnsItsTokenOff(t *testing.T) {
31+
// Asking the predicate about shapes the tree does not currently contain.
32+
// Every checkout DOES turn its token off today, so a change that weakened
33+
// the rule would find nothing to let through and stay green. These cases
34+
// keep a hold on the rule itself.
35+
for _, c := range []struct {
36+
with map[string]any
37+
off bool
38+
why string
39+
}{
40+
{map[string]any{"persist-credentials": false}, true, "the key is there and false"},
41+
{map[string]any{"persist-credentials": "false"}, true, "the action reads its inputs as strings, so a quoted false is the same answer"},
42+
{nil, false, "no with block at all means the default, which keeps the token"},
43+
{map[string]any{"fetch-depth": 0}, false, "a with block that says nothing about the token keeps it"},
44+
{map[string]any{"persist-credentials": true}, false, "the key is there and true"},
45+
{map[string]any{"persist-credentials": "no"}, false, "a word that is not false is not false"},
46+
} {
47+
if tokenTurnedOff(c.with) != c.off {
48+
t.Errorf("tokenTurnedOff(%v) should be %v, because %s", c.with, c.off, c.why)
49+
}
50+
}
51+
52+
dir := filepath.Join(repoRoot(t), ".github", "workflows")
53+
entries, err := os.ReadDir(dir)
54+
if err != nil {
55+
t.Skipf("the workflows are not here: %v", err)
56+
}
57+
58+
seen := 0
59+
for _, e := range entries {
60+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yml") {
61+
continue
62+
}
63+
body, err := os.ReadFile(filepath.Join(dir, e.Name()))
64+
if err != nil {
65+
t.Fatalf("reading %s: %v", e.Name(), err)
66+
}
67+
var workflow struct {
68+
Jobs map[string]struct {
69+
Steps []struct {
70+
Uses string `yaml:"uses"`
71+
With map[string]any `yaml:"with"`
72+
} `yaml:"steps"`
73+
} `yaml:"jobs"`
74+
}
75+
if err := yaml.Unmarshal(body, &workflow); err != nil {
76+
t.Fatalf("reading %s: %v", e.Name(), err)
77+
}
78+
for jobName, job := range workflow.Jobs {
79+
for _, step := range job.Steps {
80+
if !strings.HasPrefix(step.Uses, "actions/checkout@") {
81+
continue
82+
}
83+
seen++
84+
if tokenTurnedOff(step.With) {
85+
continue
86+
}
87+
t.Errorf("%s, job %q checks out with the token left in .git/config, and the steps "+
88+
"after it run the pull request's own code. Nothing in these workflows pushes, "+
89+
"so turn it off:\n"+
90+
" with:\n"+
91+
" persist-credentials: false",
92+
e.Name(), jobName)
93+
}
94+
}
95+
}
96+
97+
// Measured 2026-09-22: twenty four checkouts across eight workflows. A
98+
// walk that found far fewer would report a clean tree while reading
99+
// nothing - a renamed key, a changed suffix, a parser that stopped seeing
100+
// steps - which is the way this guard is most likely to break.
101+
if seen < 20 {
102+
t.Errorf("only %d checkouts were found under %s, and there are twenty four. Either the "+
103+
"workflows moved or the way this reads them stopped working, and this guard "+
104+
"checked nothing", seen, dir)
105+
}
106+
}
107+
108+
// tokenTurnedOff reports whether a checkout's with block says
109+
// persist-credentials: false.
110+
//
111+
// A bare false parses as a boolean and a quoted one as a string, and the
112+
// action reads either as false, so both are accepted. Anything else - the key
113+
// missing, true, or a word - leaves the token where the action puts it.
114+
func tokenTurnedOff(with map[string]any) bool {
115+
switch v := with["persist-credentials"].(type) {
116+
case bool:
117+
return !v
118+
case string:
119+
return v == "false"
120+
}
121+
return false
122+
}

0 commit comments

Comments
 (0)