Skip to content

Commit e4a48ea

Browse files
authored
Add codemod to enforce persist-credentials: false on actions/checkout steps (#31478)
1 parent 79139ac commit e4a48ea

5 files changed

Lines changed: 523 additions & 0 deletions
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/github/gh-aw/pkg/logger"
8+
)
9+
10+
var checkoutPersistCredentialsFalseCodemodLog = logger.New("cli:codemod_checkout_persist_credentials_false")
11+
12+
// getCheckoutPersistCredentialsFalseCodemod ensures checkout steps set with.persist-credentials: false.
13+
func getCheckoutPersistCredentialsFalseCodemod() Codemod {
14+
return Codemod{
15+
ID: "checkout-persist-credentials-false",
16+
Name: "Add persist-credentials: false to checkout steps",
17+
Description: "Ensures actions/checkout steps set with.persist-credentials: false in steps-like sections for strict-mode safety.",
18+
IntroducedIn: "1.0.44",
19+
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
20+
sections := []string{"pre-steps", "steps", "post-steps", "pre-agent-steps"}
21+
hasTargetSection := false
22+
for _, section := range sections {
23+
if _, ok := frontmatter[section]; ok {
24+
hasTargetSection = true
25+
break
26+
}
27+
}
28+
if !hasTargetSection {
29+
return content, false, nil
30+
}
31+
32+
newContent, applied, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
33+
modified := false
34+
current := lines
35+
for _, section := range sections {
36+
var sectionChanged bool
37+
current, sectionChanged = transformSectionCheckoutPersistCredentials(current, section)
38+
modified = modified || sectionChanged
39+
}
40+
return current, modified
41+
})
42+
if applied {
43+
checkoutPersistCredentialsFalseCodemodLog.Print("Added persist-credentials: false to actions/checkout step with blocks")
44+
}
45+
return newContent, applied, err
46+
},
47+
}
48+
}
49+
50+
func transformSectionCheckoutPersistCredentials(lines []string, sectionName string) ([]string, bool) {
51+
sectionStart := -1
52+
sectionIndent := ""
53+
for i, line := range lines {
54+
trimmed := strings.TrimSpace(line)
55+
if isTopLevelKey(line) && strings.HasPrefix(trimmed, sectionName+":") {
56+
sectionStart = i
57+
sectionIndent = getIndentation(line)
58+
break
59+
}
60+
}
61+
if sectionStart == -1 {
62+
return lines, false
63+
}
64+
65+
sectionEnd := len(lines) - 1
66+
for i := sectionStart + 1; i < len(lines); i++ {
67+
trimmed := strings.TrimSpace(lines[i])
68+
if len(trimmed) == 0 || strings.HasPrefix(trimmed, "#") {
69+
continue
70+
}
71+
if len(getIndentation(lines[i])) <= len(sectionIndent) {
72+
sectionEnd = i - 1
73+
break
74+
}
75+
}
76+
77+
sectionLines := lines[sectionStart : sectionEnd+1]
78+
updatedSection, changed := transformCheckoutWithinSection(sectionLines, sectionIndent)
79+
if !changed {
80+
return lines, false
81+
}
82+
83+
result := make([]string, 0, len(lines))
84+
result = append(result, lines[:sectionStart]...)
85+
result = append(result, updatedSection...)
86+
result = append(result, lines[sectionEnd+1:]...)
87+
return result, true
88+
}
89+
90+
func transformCheckoutWithinSection(sectionLines []string, sectionIndent string) ([]string, bool) {
91+
result := make([]string, 0, len(sectionLines))
92+
modified := false
93+
94+
for i := 0; i < len(sectionLines); {
95+
line := sectionLines[i]
96+
trimmed := strings.TrimSpace(line)
97+
indent := getIndentation(line)
98+
99+
if strings.HasPrefix(trimmed, "- ") && len(indent) > len(sectionIndent) {
100+
stepStart := i
101+
stepIndent := indent
102+
stepEnd := len(sectionLines) - 1
103+
for j := i + 1; j < len(sectionLines); j++ {
104+
t := strings.TrimSpace(sectionLines[j])
105+
if len(t) == 0 {
106+
continue
107+
}
108+
jIndent := getIndentation(sectionLines[j])
109+
if strings.HasPrefix(t, "- ") && len(jIndent) == len(stepIndent) {
110+
stepEnd = j - 1
111+
break
112+
}
113+
}
114+
115+
chunk := append([]string(nil), sectionLines[stepStart:stepEnd+1]...)
116+
updatedChunk, changed := ensureStepCheckoutPersistCredentials(chunk, stepIndent)
117+
modified = modified || changed
118+
result = append(result, updatedChunk...)
119+
i = stepEnd + 1
120+
continue
121+
}
122+
123+
result = append(result, line)
124+
i++
125+
}
126+
127+
return result, modified
128+
}
129+
130+
func ensureStepCheckoutPersistCredentials(stepLines []string, stepIndent string) ([]string, bool) {
131+
usesIdx := -1
132+
usesIndent := ""
133+
isUsesInline := false
134+
withStart := -1
135+
withEnd := -1
136+
withIndent := ""
137+
withKeyIndentLen := 0
138+
persistIdx := -1
139+
140+
for i := 0; i < len(stepLines); i++ {
141+
line := stepLines[i]
142+
trimmed := strings.TrimSpace(line)
143+
indent := getIndentation(line)
144+
145+
usesMatch, usesValue, _ := parseStepKeyLine(trimmed, indent, stepIndent, "uses")
146+
if usesMatch && isCheckoutUsesValue(usesValue) {
147+
usesIdx = i
148+
isUsesInline = strings.HasPrefix(trimmed, "- uses:") && len(indent) == len(stepIndent)
149+
if isUsesInline {
150+
usesIndent = stepIndent + " "
151+
} else {
152+
usesIndent = indent
153+
}
154+
}
155+
156+
withMatch, withValue, currentWithKeyIndentLen := parseStepKeyLine(trimmed, indent, stepIndent, "with")
157+
if withMatch {
158+
if withValue != "" && hasPersistKey(withValue) {
159+
if persistExplicitTrue(withValue) {
160+
checkoutPersistCredentialsFalseCodemodLog.Print("Skipping checkout step update: explicit with.persist-credentials: true found")
161+
}
162+
return stepLines, false
163+
}
164+
withStart = i
165+
withEnd = i
166+
withIndent = indent
167+
withKeyIndentLen = currentWithKeyIndentLen
168+
for j := i + 1; j < len(stepLines); j++ {
169+
t := strings.TrimSpace(stepLines[j])
170+
if len(t) == 0 {
171+
withEnd = j
172+
continue
173+
}
174+
if effectiveStepLineIndentLen(t, getIndentation(stepLines[j]), stepIndent) <= withKeyIndentLen {
175+
break
176+
}
177+
withEnd = j
178+
if parseYAMLMapKey(t) == "persist-credentials" {
179+
persistIdx = j
180+
}
181+
}
182+
}
183+
}
184+
185+
if usesIdx == -1 {
186+
return stepLines, false
187+
}
188+
189+
if persistIdx != -1 {
190+
persistLine := strings.TrimSpace(stepLines[persistIdx])
191+
if persistExplicitTrue(persistLine) {
192+
checkoutPersistCredentialsFalseCodemodLog.Print("Skipping checkout step update: explicit with.persist-credentials: true found")
193+
}
194+
return stepLines, false
195+
}
196+
197+
if withStart != -1 {
198+
insertAt := withEnd + 1
199+
insertLine := fmt.Sprintf("%spersist-credentials: false", withIndent+" ")
200+
updated := append([]string{}, stepLines[:insertAt]...)
201+
updated = append(updated, insertLine)
202+
updated = append(updated, stepLines[insertAt:]...)
203+
return updated, true
204+
}
205+
206+
if usesIndent == "" {
207+
usesIndent = stepIndent + " "
208+
}
209+
insertLines := []string{
210+
usesIndent + "with:",
211+
usesIndent + " persist-credentials: false",
212+
}
213+
insertAt := usesIdx + 1
214+
updated := append([]string{}, stepLines[:insertAt]...)
215+
updated = append(updated, insertLines...)
216+
updated = append(updated, stepLines[insertAt:]...)
217+
return updated, true
218+
}
219+
220+
func isCheckoutUsesValue(raw string) bool {
221+
value := strings.TrimSpace(raw)
222+
value = strings.Trim(value, "\"'")
223+
value = strings.ToLower(value)
224+
return strings.HasPrefix(value, "actions/checkout@") || value == "actions/checkout"
225+
}
226+
227+
func hasPersistKey(raw string) bool {
228+
return extractPersistCredentialsValue(raw) != ""
229+
}
230+
231+
func persistExplicitTrue(raw string) bool {
232+
return strings.EqualFold(extractPersistCredentialsValue(raw), "true")
233+
}
234+
235+
func extractPersistCredentialsValue(raw string) string {
236+
lower := strings.ToLower(raw)
237+
idx := strings.Index(lower, "persist-credentials:")
238+
if idx == -1 {
239+
return ""
240+
}
241+
rest := strings.TrimSpace(raw[idx+len("persist-credentials:"):])
242+
if rest == "" {
243+
return ""
244+
}
245+
246+
rest = strings.SplitN(rest, "#", 2)[0]
247+
rest = strings.SplitN(rest, ",", 2)[0]
248+
rest = strings.SplitN(rest, "}", 2)[0]
249+
return strings.TrimSpace(strings.Trim(rest, `"'`))
250+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//go:build !integration
2+
3+
package cli
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
"testing"
9+
)
10+
11+
const maxCheckoutRefLength = 16
12+
13+
func FuzzCheckoutPersistCredentialsFalseCodemod(f *testing.F) {
14+
f.Add(true, "@v5", false, false, false, false, uint8(0))
15+
f.Add(true, "", true, false, false, false, uint8(1))
16+
f.Add(true, "@v4", true, true, true, false, uint8(2))
17+
f.Add(true, "@v4", true, true, false, false, uint8(3))
18+
f.Add(false, "@v4", false, false, false, false, uint8(0))
19+
20+
f.Fuzz(func(t *testing.T, isCheckout bool, ref string, hasWith bool, hasPersist bool, persistTrue bool, inlineUses bool, sectionSelector uint8) {
21+
codemod := getCheckoutPersistCredentialsFalseCodemod()
22+
section := []string{"pre-steps", "steps", "post-steps", "pre-agent-steps"}[int(sectionSelector)%4]
23+
24+
ref = sanitizeCheckoutRef(ref)
25+
uses := "actions/cache@v4"
26+
if isCheckout {
27+
if ref == "" {
28+
uses = "actions/checkout"
29+
} else {
30+
uses = "actions/checkout" + ref
31+
}
32+
}
33+
34+
step := map[string]any{}
35+
if inlineUses {
36+
step["uses"] = uses
37+
} else {
38+
step["name"] = "checkout-step"
39+
step["uses"] = uses
40+
}
41+
42+
if hasWith {
43+
with := map[string]any{"fetch-depth": 0}
44+
if hasPersist {
45+
with["persist-credentials"] = persistTrue
46+
}
47+
step["with"] = with
48+
}
49+
50+
frontmatter := map[string]any{
51+
"on": "push",
52+
section: []any{step},
53+
"workflow": "fuzz",
54+
}
55+
56+
content := buildCheckoutFuzzContent(section, uses, hasWith, hasPersist, persistTrue, inlineUses)
57+
58+
result, applied, err := codemod.Apply(content, frontmatter)
59+
if err != nil {
60+
t.Fatalf("unexpected error: %v", err)
61+
}
62+
63+
expectMutation := isCheckout && (!hasWith || !hasPersist)
64+
65+
if expectMutation {
66+
if !applied {
67+
t.Fatalf("expected codemod to apply; section=%s uses=%s", section, uses)
68+
}
69+
if !strings.Contains(result, "persist-credentials: false") {
70+
t.Fatalf("expected result to include persist-credentials: false")
71+
}
72+
return
73+
}
74+
75+
if applied {
76+
t.Fatalf("expected codemod not to apply; section=%s uses=%s", section, uses)
77+
}
78+
if result != content {
79+
t.Fatalf("unexpected content mutation when codemod not applied")
80+
}
81+
})
82+
}
83+
84+
func buildCheckoutFuzzContent(section, uses string, hasWith, hasPersist, persistTrue, inlineUses bool) string {
85+
usesLine := fmt.Sprintf(" uses: %s", uses)
86+
if inlineUses {
87+
usesLine = fmt.Sprintf(" - uses: %s", uses)
88+
}
89+
90+
lines := []string{
91+
"---",
92+
"on: push",
93+
section + ":",
94+
}
95+
if !inlineUses {
96+
lines = append(lines, " - name: checkout-step")
97+
}
98+
lines = append(lines, usesLine)
99+
100+
if hasWith {
101+
lines = append(lines, " with:", " fetch-depth: 0")
102+
if hasPersist {
103+
value := "false"
104+
if persistTrue {
105+
value = "true"
106+
}
107+
lines = append(lines, " persist-credentials: "+value)
108+
}
109+
}
110+
111+
lines = append(lines, "---")
112+
return strings.Join(lines, "\n") + "\n"
113+
}
114+
115+
func sanitizeCheckoutRef(ref string) string {
116+
if ref == "" {
117+
return ""
118+
}
119+
if len(ref) > maxCheckoutRefLength {
120+
ref = ref[:maxCheckoutRefLength]
121+
}
122+
var b strings.Builder
123+
for _, r := range ref {
124+
switch {
125+
case r >= 'a' && r <= 'z':
126+
b.WriteRune(r)
127+
case r >= 'A' && r <= 'Z':
128+
b.WriteRune(r)
129+
case r >= '0' && r <= '9':
130+
b.WriteRune(r)
131+
case r == '.', r == '-', r == '_':
132+
b.WriteRune(r)
133+
}
134+
}
135+
clean := b.String()
136+
if clean == "" {
137+
return ""
138+
}
139+
if clean[0] != '@' {
140+
return "@" + clean
141+
}
142+
return clean
143+
}

0 commit comments

Comments
 (0)