From 38f2a53e963e97766ddf10e662afd33ac9994fc0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 12 Oct 2026 20:48:49 -0400 Subject: [PATCH] fix(ci): adopt frozen classifier capability and own fetch fixture event tuple --- .../public-workflow-executable-allowlist.json | 10 +- .github/workflows/policytool/main.go | 229 +++++++++++- .github/workflows/policytool/main_test.go | 330 ++++++++++++++++++ scripts/check-public-workflow-policy.sh | 4 +- scripts/test-check-public-workflow-policy.sh | 68 +++- 5 files changed, 607 insertions(+), 34 deletions(-) diff --git a/.github/public-workflow-executable-allowlist.json b/.github/public-workflow-executable-allowlist.json index b54c30b0..80cb2804 100644 --- a/.github/public-workflow-executable-allowlist.json +++ b/.github/public-workflow-executable-allowlist.json @@ -12,7 +12,7 @@ "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", "state": "active", - "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c", + "sha256": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", "rationale": "Enforces the credential-free public workflow boundary in CI." }, { @@ -20,7 +20,7 @@ "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", "state": "active", - "sha256": "7d209bb35ff3a7a46b22f9a10a920eb30218c3403726d1dac72e0bffd1d914a4", + "sha256": "9b857524e8b736a104e1aa65a3fa5edeaf76a3787ba8942871992675440f550b", "rationale": "Exercises fail-closed public workflow policy mutations in CI." }, { @@ -36,7 +36,7 @@ "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "37b7935261b56568daee7ad04ebb647f5a53bef336b5da0e8efb5df5e666e06a", "state": "staged", - "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c", + "sha256": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", "rationale": "Staged v0.86 CI authority. Enforces the credential-free public workflow boundary in CI." }, { @@ -52,7 +52,7 @@ "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "37b7935261b56568daee7ad04ebb647f5a53bef336b5da0e8efb5df5e666e06a", "state": "staged", - "sha256": "7d209bb35ff3a7a46b22f9a10a920eb30218c3403726d1dac72e0bffd1d914a4", + "sha256": "9b857524e8b736a104e1aa65a3fa5edeaf76a3787ba8942871992675440f550b", "rationale": "Staged v0.86 CI authority. Exercises fail-closed public workflow policy mutations in CI." }, { @@ -83,7 +83,7 @@ "contextSHA256": "d804de818453d79cf9b6434ea2780dac8a4434b4dd32bd9d46be17620fc25066", "path": "scripts/check-public-workflow-policy.sh", "rationale": "Stage the exact approved Go1.27.2 canonical policy context. Existing executable/statement/action authority is preserved; adoption and promotion remain separate.", - "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c", + "sha256": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", "state": "active", "workflowPath": ".github/workflows/public-workflow-policy.yml" }, diff --git a/.github/workflows/policytool/main.go b/.github/workflows/policytool/main.go index 94123cf9..14a316dd 100644 --- a/.github/workflows/policytool/main.go +++ b/.github/workflows/policytool/main.go @@ -499,6 +499,15 @@ func validateTrustTransition(base, candidate trustPolicy, baseContexts, candidat findings = append(findings, "public workflow secret manifest must remain exactly empty") } if trustPoliciesEqual(base, candidate) { + for _, group := range base.Presence { + if group.State == "staged" && realizesTrustGroup(group, baseContexts, basePresent) && !realizesTrustGroup(group, candidateContexts, candidatePresent) { + findings = append(findings, "realized staged trust context cannot be reversed before promotion") + } + } + sort.Strings(findings) + return findings + } + if unusedStagedExecutableCorrection(base, candidate, baseContexts, candidateContexts, basePresent, candidatePresent) { return findings } @@ -506,40 +515,209 @@ func validateTrustTransition(base, candidate trustPolicy, baseContexts, candidat executableAdditions, executableRemoved := semanticAdditions(base.Executables, candidate.Executables) commandAdditions, commandRemoved := semanticAdditions(base.Commands, candidate.Commands) actionAdditions, actionRemoved := semanticAdditions(base.Actions, candidate.Actions) - if !presenceRemoved && !executableRemoved && !commandRemoved && !actionRemoved && len(presenceAdditions) == 1 { - staged := presenceAdditions[0] - additions := trustPolicy{Executables: executableAdditions, Commands: commandAdditions, Actions: actionAdditions} - var active trustGroup - for _, group := range base.Presence { - if group.Path == staged.Path && group.State == "active" { - active = group - } + if !presenceRemoved && !executableRemoved && !commandRemoved && !actionRemoved && len(presenceAdditions) > 0 { + additions := trustPolicy{Presence: presenceAdditions, Executables: executableAdditions, Commands: commandAdditions, Actions: actionAdditions} + if validTrustStage(base, candidate, additions, baseContexts, candidateContexts, basePresent, candidatePresent) { + return findings } - if validTrustGroupShape(staged) && staged.State == "staged" && active.State == "active" && stagedAuthorityMatches(staged, additions) && - realizesTrustGroup(active, baseContexts, basePresent) && - !(active.Presence == staged.Presence && active.ContextSHA256 == staged.ContextSHA256) { - if realizesTrustGroup(staged, candidateContexts, candidatePresent) { + for _, staged := range presenceAdditions { + if staged.State == "staged" && realizesTrustGroup(staged, candidateContexts, candidatePresent) { findings = append(findings, "trust context cannot be staged and adopted in the same pull request") - } else if realizesTrustGroup(active, candidateContexts, candidatePresent) { - return findings } } } + expected := base + promotions := 0 for _, staged := range base.Presence { - if staged.State != "staged" || !realizesTrustGroup(staged, baseContexts, basePresent) || !realizesTrustGroup(staged, candidateContexts, candidatePresent) { + if staged.State != "staged" || !validTrustGroupShape(staged) || !realizesTrustGroup(staged, baseContexts, basePresent) || !realizesTrustGroup(staged, candidateContexts, candidatePresent) { continue } - expected := promotedTrustPolicy(base, staged) - if trustPoliciesEqual(expected, candidate) { - return findings + _, retainedStage, baseValid := trustStatesForPath(base, staged.Path) + active, candidateStage, candidateValid := trustStatesForPath(candidate, staged.Path) + promoted := staged + promoted.State = "active" + if baseValid && retainedStage == staged && candidateValid && candidateStage.State == "" && active == promoted { + expected = promotedTrustPolicy(expected, staged) + promotions++ } } - findings = append(findings, "trust manifest change does not match unchanged, stage-only, or promotion-only lifecycle") + if promotions > 0 && trustPoliciesEqual(expected, candidate) { + return findings + } + findings = append(findings, "trust manifest change does not match unchanged, unused staged executable SHA correction, stage-only, or promotion-only lifecycle") sort.Strings(findings) return findings } +func trustStatesForPath(policy trustPolicy, workflowPath string) (active, staged trustGroup, valid bool) { + valid = true + for _, group := range policy.Presence { + if group.Path != workflowPath { + continue + } + if !validTrustGroupShape(group) { + return active, staged, false + } + switch group.State { + case "active": + if active.State != "" { + return active, staged, false + } + active = group + case "staged": + if staged.State != "" { + return active, staged, false + } + staged = group + } + } + return active, staged, valid && active.State == "active" +} + +func trustRowsForGroup(policy trustPolicy, group trustGroup) trustPolicy { + rows := trustPolicy{} + for _, entry := range policy.Executables { + if entry.WorkflowPath == group.Path && entry.ContextSHA256 == group.ContextSHA256 { + rows.Executables = append(rows.Executables, entry) + } + } + for _, entry := range policy.Commands { + if entry.Path == group.Path && entry.ContextSHA256 == group.ContextSHA256 { + rows.Commands = append(rows.Commands, entry) + } + } + for _, entry := range policy.Actions { + if entry.Path == group.Path && entry.ContextSHA256 == group.ContextSHA256 { + rows.Actions = append(rows.Actions, entry) + } + } + return rows +} + +// A correction cannot authorize a new executable, context, metadata field or +// selected execution. Only SHA fields of one retained unused staged group change. +func unusedStagedExecutableCorrection(base, candidate trustPolicy, baseContexts, candidateContexts map[string]string, basePresent, candidatePresent map[string]bool) bool { + if !reflect.DeepEqual(base.Secrets, candidate.Secrets) || !reflect.DeepEqual(base.Presence, candidate.Presence) || !reflect.DeepEqual(base.Commands, candidate.Commands) || !reflect.DeepEqual(base.Actions, candidate.Actions) || len(base.Executables) != len(candidate.Executables) { + return false + } + workflowPath, context := "", "" + changed := false + for index, before := range base.Executables { + after := candidate.Executables[index] + if before == after { + continue + } + if before.State != "staged" || !validSHA256Hex(before.SHA256) || !validSHA256Hex(after.SHA256) { + return false + } + if changed && (workflowPath != before.WorkflowPath || context != before.ContextSHA256) { + return false + } + workflowPath, context, changed = before.WorkflowPath, before.ContextSHA256, true + after.SHA256 = before.SHA256 + if before != after { + return false + } + } + if !changed { + return false + } + active, staged, valid := trustStatesForPath(base, workflowPath) + return valid && staged.State == "staged" && staged.ContextSHA256 == context && staged.Presence == "present" && + realizesTrustGroup(active, baseContexts, basePresent) && realizesTrustGroup(active, candidateContexts, candidatePresent) && + !realizesTrustGroup(staged, baseContexts, basePresent) && !realizesTrustGroup(staged, candidateContexts, candidatePresent) && + stagedAuthorityMatches(staged, trustRowsForGroup(base, staged)) && stagedAuthorityMatches(staged, trustRowsForGroup(candidate, staged)) +} + +func retainsEntryPrefix[T comparable](base, candidate []T) bool { + if len(candidate) < len(base) { + return false + } + for index, entry := range base { + if entry != candidate[index] { + return false + } + } + return true +} + +func validTrustStage(base, candidate, additions trustPolicy, baseContexts, candidateContexts map[string]string, basePresent, candidatePresent map[string]bool) bool { + if len(additions.Presence) > 1 && (!retainsEntryPrefix(base.Presence, candidate.Presence) || !retainsEntryPrefix(base.Executables, candidate.Executables) || !retainsEntryPrefix(base.Commands, candidate.Commands) || !retainsEntryPrefix(base.Actions, candidate.Actions)) { + return false + } + byKey := make(map[string]trustPolicy) + seenPaths := make(map[string]bool) + for _, staged := range additions.Presence { + active, priorStage, valid := trustStatesForPath(base, staged.Path) + if !validTrustGroupShape(staged) || staged.State != "staged" || !valid || priorStage.State != "" || seenPaths[staged.Path] || + !realizesTrustGroup(active, baseContexts, basePresent) || !realizesTrustGroup(active, candidateContexts, candidatePresent) || + (active.Presence == staged.Presence && active.ContextSHA256 == staged.ContextSHA256) || realizesTrustGroup(staged, candidateContexts, candidatePresent) { + return false + } + seenPaths[staged.Path] = true + byKey[staged.Path+"\x00"+staged.ContextSHA256] = trustPolicy{} + } + for _, entry := range additions.Executables { + key := entry.WorkflowPath + "\x00" + entry.ContextSHA256 + rows, ok := byKey[key] + if !ok { + return false + } + rows.Executables = append(rows.Executables, entry) + byKey[key] = rows + } + for _, entry := range additions.Commands { + key := entry.Path + "\x00" + entry.ContextSHA256 + rows, ok := byKey[key] + if !ok { + return false + } + rows.Commands = append(rows.Commands, entry) + byKey[key] = rows + } + for _, entry := range additions.Actions { + key := entry.Path + "\x00" + entry.ContextSHA256 + rows, ok := byKey[key] + if !ok { + return false + } + rows.Actions = append(rows.Actions, entry) + byKey[key] = rows + } + for _, group := range additions.Presence { + if !stagedAuthorityMatches(group, byKey[group.Path+"\x00"+group.ContextSHA256]) { + return false + } + } + return true +} + +func repositoryWorkflowBytes(repoRoot string) (map[string]string, error) { + files := make(map[string]string) + for _, pattern := range []string{"*.yml", "*.yaml"} { + matches, err := filepath.Glob(filepath.Join(repoRoot, ".github", "workflows", pattern)) + if err != nil { + return nil, err + } + for _, file := range matches { + info, err := os.Lstat(file) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("non-regular public workflow %s", filepath.Base(file)) + } + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + files[filepath.Base(file)] = fmt.Sprintf("%o:%x", info.Mode(), sha256.Sum256(data)) + } + } + return files, nil +} + func loadTrustPolicy(repoRoot string) (trustPolicy, error) { policy := trustPolicy{} inputs := []struct { @@ -679,6 +857,19 @@ func validateRepositoryAuthority(repoRoot, scanRoot string, bootstrap bool) ([]s return nil, fmt.Errorf("inspect candidate trust paths: %w", err) } findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) + if !trustPoliciesEqual(basePolicy, candidatePolicy) { + baseFiles, err := repositoryWorkflowBytes(repoRoot) + if err != nil { + return nil, fmt.Errorf("read base public workflow bytes: %w", err) + } + candidateFiles, err := repositoryWorkflowBytes(scanRoot) + if err != nil { + return nil, fmt.Errorf("read candidate public workflow bytes: %w", err) + } + if !reflect.DeepEqual(baseFiles, candidateFiles) { + findings = append(findings, "trust-only transition must preserve every public workflow byte and mode") + } + } sort.Strings(findings) return findings, nil } diff --git a/.github/workflows/policytool/main_test.go b/.github/workflows/policytool/main_test.go index 456b467d..04156183 100644 --- a/.github/workflows/policytool/main_test.go +++ b/.github/workflows/policytool/main_test.go @@ -1,20 +1,283 @@ package main import ( + "context" "encoding/json" "go/ast" "go/parser" "go/token" "os" + "os/exec" "path/filepath" "strconv" "strings" "testing" + "time" "gopkg.in/yaml.v3" "mvdan.cc/sh/v3/syntax" ) +func groupedClonePolicy(t *testing.T, policy trustPolicy) trustPolicy { + t.Helper() + data, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + var result trustPolicy + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + return result +} + +func groupedPolicyFixtures() (trustPolicy, trustPolicy, trustPolicy, map[string]string, map[string]string, map[string]bool) { + base := trustPolicy{} + oldContexts, newContexts, present := map[string]string{}, map[string]string{}, map[string]bool{} + paths := []string{".github/workflows/ci.yml", ".github/workflows/benchmark.yml"} + for index, workflow := range paths { + oldContext := strings.Repeat(strconv.Itoa(index*2+1), 64) + oldContexts[workflow], present[workflow] = oldContext, true + newContexts[workflow] = strings.Repeat(strconv.Itoa(index*2+2), 64) + base.Presence = append(base.Presence, trustGroup{workflow, oldContext, "active", "present"}) + base.Executables = append(base.Executables, executableEntry{"scripts/check-" + strconv.Itoa(index) + ".sh", workflow, oldContext, strings.Repeat("a", 64), "active", "Reviewed executable."}) + base.Commands = append(base.Commands, commandEntry{workflow, "go", strings.Repeat("b", 64), oldContext, "active", "Reviewed command."}) + base.Actions = append(base.Actions, actionEntry{workflow, "actions/checkout@" + strings.Repeat("c", 40), strings.Repeat("d", 64), oldContext, "active", "Reviewed action."}) + } + staged := trustPolicy{Presence: append([]trustGroup(nil), base.Presence...), Executables: append([]executableEntry(nil), base.Executables...), Commands: append([]commandEntry(nil), base.Commands...), Actions: append([]actionEntry(nil), base.Actions...)} + for index, workflow := range paths { + context := newContexts[workflow] + staged.Presence = append(staged.Presence, trustGroup{workflow, context, "staged", "present"}) + staged.Executables = append(staged.Executables, executableEntry{"scripts/check-" + strconv.Itoa(index) + ".sh", workflow, context, strings.Repeat("e", 64), "staged", "Replacement executable."}) + staged.Commands = append(staged.Commands, commandEntry{workflow, "go", strings.Repeat("f", 64), context, "staged", "Replacement command."}) + staged.Actions = append(staged.Actions, actionEntry{workflow, "actions/checkout@" + strings.Repeat("c", 40), strings.Repeat("9", 64), context, "staged", "Replacement action."}) + } + promoted := staged + for _, group := range staged.Presence { + if group.State == "staged" { + promoted = promotedTrustPolicy(promoted, group) + } + } + return base, staged, promoted, oldContexts, newContexts, present +} + +func TestGroupedTrustLifecycle(t *testing.T) { + base, staged, promoted, oldContexts, newContexts, present := groupedPolicyFixtures() + for _, tc := range []struct { + name string + base, candidate trustPolicy + before, after map[string]string + reject bool + }{ + {"complete distinct-path stage", base, staged, oldContexts, oldContexts, false}, + {"complete adoption", staged, staged, oldContexts, newContexts, false}, + {"complete realized promotion", staged, promoted, newContexts, newContexts, false}, + {"stage and adopt", base, staged, oldContexts, newContexts, true}, + {"premature promotion", staged, promoted, oldContexts, oldContexts, true}, + {"drop unused stage", staged, base, oldContexts, oldContexts, true}, + {"revert adopted bytes before promotion", staged, staged, newContexts, oldContexts, true}, + } { + t.Run(tc.name, func(t *testing.T) { + findings := validateTrustTransition(tc.base, tc.candidate, tc.before, tc.after, present, present) + if (len(findings) > 0) != tc.reject { + t.Fatalf("findings=%v reject=%v", findings, tc.reject) + } + }) + } + for _, tc := range []struct { + name string + mutate func(*trustPolicy) + }{ + {"duplicate staged path", func(p *trustPolicy) { p.Presence = append(p.Presence, p.Presence[2]) }}, + {"existing unused stage", func(p *trustPolicy) { + p.Presence = append(p.Presence, trustGroup{p.Presence[2].Path, strings.Repeat("a", 64), "staged", "present"}) + }}, + {"drops active row", func(p *trustPolicy) { p.Executables = p.Executables[1:] }}, + {"changes retained rationale", func(p *trustPolicy) { p.Actions[0].Rationale = "Different metadata." }}, + {"orphan executable", func(p *trustPolicy) { p.Executables[2].WorkflowPath = ".github/workflows/other.yml" }}, + {"mixed context", func(p *trustPolicy) { p.Commands[2].ContextSHA256 = strings.Repeat("a", 64) }}, + {"invalid executable digest", func(p *trustPolicy) { p.Executables[2].SHA256 = "bad" }}, + {"duplicate executable", func(p *trustPolicy) { p.Executables = append(p.Executables, p.Executables[2]) }}, + {"mutable action", func(p *trustPolicy) { p.Actions[2].Uses = "actions/checkout@main" }}, + {"categorical command", func(p *trustPolicy) { p.Commands[2].Command = "eval" }}, + {"nonempty secrets", func(p *trustPolicy) { + p.Secrets = []allowEntry{{Path: p.Presence[2].Path, Secret: "TOKEN", State: "staged"}} + }}, + {"missing group with retained rows", func(p *trustPolicy) { p.Presence = p.Presence[:3] }}, + {"already active context", func(p *trustPolicy) { p.Presence[2].ContextSHA256 = p.Presence[0].ContextSHA256 }}, + } { + t.Run(tc.name, func(t *testing.T) { + candidate := groupedClonePolicy(t, staged) + tc.mutate(&candidate) + if findings := validateTrustTransition(base, candidate, oldContexts, oldContexts, present, present); len(findings) == 0 { + t.Fatal("malformed batch accepted") + } + }) + } + t.Run("partial promotion rows", func(t *testing.T) { + candidate := groupedClonePolicy(t, promoted) + candidate.Executables[0].State = "staged" + if findings := validateTrustTransition(staged, candidate, newContexts, newContexts, present, present); len(findings) == 0 { + t.Fatal("partial row promotion accepted") + } + }) +} + +func TestUnusedStagedExecutableCorrection(t *testing.T) { + _, staged, _, oldContexts, newContexts, present := groupedPolicyFixtures() + corrected := groupedClonePolicy(t, staged) + corrected.Executables[2].SHA256 = strings.Repeat("7", 64) + t.Run("one unused context SHA only", func(t *testing.T) { + if findings := validateTrustTransition(staged, corrected, oldContexts, oldContexts, present, present); len(findings) != 0 { + t.Fatalf("correction rejected: %v", findings) + } + }) + for _, tc := range []struct { + name string + mutate func(*trustPolicy) + }{ + {"active row", func(p *trustPolicy) { p.Executables[0].SHA256 = strings.Repeat("7", 64) }}, + {"two contexts", func(p *trustPolicy) { p.Executables[3].SHA256 = strings.Repeat("7", 64) }}, + {"rationale drift", func(p *trustPolicy) { p.Executables[2].Rationale = "Drift." }}, + {"row order", func(p *trustPolicy) { p.Executables[2], p.Executables[3] = p.Executables[3], p.Executables[2] }}, + {"membership deletion", func(p *trustPolicy) { p.Executables = p.Executables[:3] }}, + {"membership addition", func(p *trustPolicy) { p.Executables = append(p.Executables, p.Executables[2]) }}, + {"path drift", func(p *trustPolicy) { p.Executables[2].Path = "scripts/new.sh" }}, + {"state drift", func(p *trustPolicy) { p.Executables[2].State = "active" }}, + {"context drift", func(p *trustPolicy) { p.Executables[2].ContextSHA256 = strings.Repeat("8", 64) }}, + {"invalid digest", func(p *trustPolicy) { p.Executables[2].SHA256 = "bad" }}, + {"action drift", func(p *trustPolicy) { p.Actions[2].NodeSHA256 = strings.Repeat("7", 64) }}, + {"command drift", func(p *trustPolicy) { p.Commands[2].StatementSHA256 = strings.Repeat("7", 64) }}, + {"presence drift", func(p *trustPolicy) { p.Presence[2].Presence = "absent"; p.Presence[2].ContextSHA256 = "" }}, + {"secret drift", func(p *trustPolicy) { p.Secrets = []allowEntry{{Secret: "TOKEN"}} }}, + } { + t.Run(tc.name, func(t *testing.T) { + candidate := groupedClonePolicy(t, corrected) + tc.mutate(&candidate) + if findings := validateTrustTransition(staged, candidate, oldContexts, oldContexts, present, present); len(findings) == 0 { + t.Fatal("non-SHA-only correction accepted") + } + }) + } + t.Run("selected staged context", func(t *testing.T) { + if findings := validateTrustTransition(staged, corrected, newContexts, newContexts, present, present); len(findings) == 0 { + t.Fatal("realized staged executable correction accepted") + } + }) + t.Run("simultaneous adoption", func(t *testing.T) { + if findings := validateTrustTransition(staged, corrected, oldContexts, newContexts, present, present); len(findings) == 0 { + t.Fatal("correction and adoption accepted") + } + }) +} + +func groupedRepositoryFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + inputs := filepath.Join("..", "..", "..") + var manifest authorityManifest + data, err := os.ReadFile(filepath.Join(inputs, ".github/public-workflow-authority.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + paths := map[string]bool{} + for _, bundle := range manifest.Bundles { + for _, file := range bundle.Files { + paths[file.Path] = true + } + } + for _, pattern := range []string{".github/workflows/*.yml", ".github/workflows/*.yaml", ".github/public-workflow-*.json"} { + matches, err := filepath.Glob(filepath.Join(inputs, pattern)) + if err != nil { + t.Fatal(err) + } + for _, file := range matches { + relative, err := filepath.Rel(inputs, file) + if err != nil { + t.Fatal(err) + } + paths[relative] = true + } + } + for name := range paths { + data, err := os.ReadFile(filepath.Join(inputs, name)) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(inputs, name)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, data, info.Mode().Perm()); err != nil { + t.Fatal(err) + } + } + inventory, err := authorityInventory(root) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(authorityManifest{Version: 1, Bundles: []authorityBundle{{State: "active", Files: inventory}}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".github/public-workflow-authority.json"), encoded, 0600); err != nil { + t.Fatal(err) + } + return root +} + +func TestTrustOnlyTransitionPreservesRawWorkflowBoundary(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(string) error + reject bool + }{ + {"unchanged bytes", func(string) error { return nil }, false}, + {"comment drift", func(root string) error { + path := filepath.Join(root, ".github/workflows/ci.yml") + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(path, append(data, []byte("\n# unrelated drift\n")...), 0644) + }, true}, + {"mode drift", func(root string) error { return os.Chmod(filepath.Join(root, ".github/workflows/ci.yml"), 0755) }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + base, candidate := groupedRepositoryFixture(t), groupedRepositoryFixture(t) + policy, err := loadTrustPolicy(candidate) + if err != nil { + t.Fatal(err) + } + policy.Presence = append(policy.Presence, trustGroup{Path: ".github/workflows/public-workflow-policy.yml", ContextSHA256: strings.Repeat("7", 64), State: "staged", Presence: "present"}) + data, err := json.Marshal(policy.Presence) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(candidate, ".github/public-workflow-presence-allowlist.json"), data, 0600); err != nil { + t.Fatal(err) + } + if err := tc.mutate(candidate); err != nil { + t.Fatal(err) + } + findings, err := validateRepositoryAuthority(base, candidate, false) + if err != nil { + t.Fatal(err) + } + if (len(findings) > 0) != tc.reject { + t.Fatalf("findings=%v reject=%v", findings, tc.reject) + } + }) + } +} + func TestSHA256HexPatternIsCompiledOnce(t *testing.T) { file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) if err != nil { @@ -1447,3 +1710,70 @@ func TestAuthorityInventoryRejectsSymlinkedFixedAncestor(t *testing.T) { t.Fatalf("symlinked fixed-file ancestor error = %v", err) } } + +// Run only the real dependency-fixture function. Calling the full harness from +// its own policy-tool test would recurse into go test. +func TestPolicyFetchFixtureOwnsEventBoundary(t *testing.T) { + root := filepath.Join("..", "..", "..") + harness, err := os.ReadFile(filepath.Join(root, "scripts", "test-check-public-workflow-policy.sh")) + if err != nil { + t.Fatalf("read policy mutation harness: %v", err) + } + const signature = "\nrun_candidate_fetch() {\n" + if strings.Count(string(harness), signature) != 1 { + t.Fatal("policy mutation harness must define one fetch fixture") + } + start := strings.Index(string(harness), signature) + 1 + end := strings.Index(string(harness)[start:], "\n}\n") + if end < 0 { + t.Fatal("fetch fixture closing boundary missing") + } + fixture := string(harness)[start : start+end+3] + bash, err := exec.LookPath("bash") + if err != nil { + t.Fatalf("find bash: %v", err) + } + const probe = `set -euo pipefail +[[ "${EVENT_NAME}:${BASE_REF}:${EVENT_REF}" == pull_request_target:main:refs/heads/main ]] || exit 1 +[[ "$CANDIDATE_REPOSITORY" == example/repo ]] || exit 1 +[[ "$CANDIDATE_SHA" == 0123456789012345678901234567890123456789 ]] || exit 1 +[[ "$GIT_ASKPASS" == /bin/false ]] || exit 1 +[[ "$GIT_CONFIG_GLOBAL" == /dev/null ]] || exit 1 +[[ "$GIT_CONFIG_NOSYSTEM" == 1 ]] || exit 1 +[[ "$GIT_TERMINAL_PROMPT" == 0 ]] || exit 1 +printf 'fixture-event-boundary-ok\n' +` + for _, tc := range []struct { + name string + env []string + }{ + {name: "unset event variables"}, + {name: "hostile inherited event", env: []string{ + "EVENT_NAME=workflow_run", "BASE_REF=develop", "EVENT_REF=refs/tags/v0.0.0", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + leaf := t.TempDir() + probePath := filepath.Join(leaf, "fetch-boundary.sh") + if err := os.WriteFile(probePath, []byte(probe), 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(leaf, "fake-bin"), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + body := "set -euo pipefail\n" + fixture + ` +fake_bin="$1/fake-bin" +candidate_fetch_script="$2" +run_candidate_fetch "$1/work" example/repo 0123456789012345678901234567890123456789 "$1/archive" +` + cmd := exec.CommandContext(ctx, bash, "-c", body, "fetch-fixture-test", leaf, probePath) + cmd.Env = append([]string{"PATH=/usr/bin:/bin"}, tc.env...) + out, err := cmd.CombinedOutput() + if err != nil || string(out) != "fixture-event-boundary-ok\n" { + t.Fatalf("fetch fixture must own its event tuple: err=%v output=%q", err, out) + } + }) + } +} diff --git a/scripts/check-public-workflow-policy.sh b/scripts/check-public-workflow-policy.sh index 0012f58b..cdf9fa13 100755 --- a/scripts/check-public-workflow-policy.sh +++ b/scripts/check-public-workflow-policy.sh @@ -74,8 +74,8 @@ verify_policytool_file() { verify_policytool() { verify_policytool_layout - verify_policytool_file main.go 8dba6ad341fa07644868fd43717aff162c4feae1694ee9a122ef4cd401214a00 - verify_policytool_file main_test.go a88f4ec70a2736e54bf887594da855fb6d25d2cecb35321cd5fa8a2455304212 + verify_policytool_file main.go 9c38f6e1a92d34d31cd73ba2bd2b092216ab9c115af79a0ef5ec13343c4f4d2d + verify_policytool_file main_test.go fadde1edd916918a69f912177a74565a6138ed935aae3509d7c6082924467778 verify_policytool_file go.mod 0a2d135980310757f36e733161b94498b4e0a1b6ac8c6de8a799f542d6c9ddb8 verify_policytool_file go.sum 790ef858e5aeed12269a69e764ac69c02c3877678b0e7d9384ad3728b6e09f6c } diff --git a/scripts/test-check-public-workflow-policy.sh b/scripts/test-check-public-workflow-policy.sh index a0b878b0..c3fac57f 100755 --- a/scripts/test-check-public-workflow-policy.sh +++ b/scripts/test-check-public-workflow-policy.sh @@ -89,6 +89,9 @@ cleanup() { } trap cleanup EXIT +# Validate the real input before any external mutation fixture is constructed. +"${checker_binary}" --scan-root "${production_repo_root}" >/dev/null + # BEGIN root-module-graph-check root_module_graph="$(GOWORK=off GOFLAGS=-mod=readonly go list -m all)" if grep -Eq '^mvdan\.cc/sh/v3 ' <<<"${root_module_graph}"; then @@ -1524,6 +1527,9 @@ run_candidate_fetch() { ( cd "${workdir}" PATH="${fake_bin}:$PATH" \ + EVENT_NAME=pull_request_target \ + BASE_REF=main \ + EVENT_REF=refs/heads/main \ CANDIDATE_REPOSITORY="${repository}" \ CANDIDATE_SHA="${sha}" \ GIT_ASKPASS=/bin/false \ @@ -2259,15 +2265,42 @@ active_authority="${tmp_dir}/authority-active.json" active_verifier="${tmp_dir}/authority-active-verifier.sh" staged_authority="${tmp_dir}/authority-staged.json" future_verifier="${tmp_dir}/authority-future-verifier.sh" -cp "${candidate_authority}" "${active_authority}" -cp "${candidate_verifier}" "${active_verifier}" -cp "${candidate_verifier}" "${future_verifier}" +original_candidate_authority="${tmp_dir}/authority-original-candidate.json" +original_archive_authority="${tmp_dir}/authority-original-archive.json" +original_archive_verifier="${tmp_dir}/authority-original-archive-verifier.sh" +cp -p "${candidate_authority}" "${original_candidate_authority}" +cp -p "${archive_authority}" "${original_archive_authority}" +cp -p "${candidate_verifier}" "${active_verifier}" +cp -p "${archive_verifier}" "${original_archive_verifier}" + +# Only this external archive uses a synthetic active baseline. The real checker +# must verify the complete inventory, not just a matching subset of file hashes. +realized_bundle_count=0 +bundle_count="$(jq -er '.bundles | length' "${original_archive_authority}")" +for ((bundle_index=0; bundle_index"${archive_authority}" + if "${archive_checker}" --scan-root "${archive_repo}" --bootstrap-authority \ + >"${tmp_dir}/authority-realized-${bundle_index}.log" 2>&1; then + realized_bundle_count=$((realized_bundle_count + 1)) + cp -p "${archive_authority}" "${active_authority}" + fi +done +if [[ "${realized_bundle_count}" -ne 1 ]]; then + echo "synthetic authority fixture requires exactly one complete realized bundle" >&2 + exit 1 +fi +cp "${active_authority}" "${archive_authority}" +cp "${active_authority}" "${candidate_authority}" +"${archive_checker}" --scan-root "${candidate_root}" >/dev/null +cp -p "${candidate_verifier}" "${future_verifier}" printf '\n# authority lifecycle fixture\n' >>"${future_verifier}" future_verifier_sha="$(sha256_file "${future_verifier}")" jq --arg path "${authority_path}" --arg sha "${future_verifier_sha}" ' .bundles += [{ state: "staged", - files: (.bundles[0].files | map(if .path == $path then .sha256 = $sha else . end)) + files: (.bundles[] | select(.state == "active") | .files | map(if .path == $path then .sha256 = $sha else . end)) }] ' "${active_authority}" >"${staged_authority}" @@ -2275,6 +2308,21 @@ jq --arg path "${authority_path}" --arg sha "${future_verifier_sha}" ' cp "${staged_authority}" "${candidate_authority}" "${checker_binary}" --scan-root "${candidate_root}" >/dev/null +# Synthetic isolation must not weaken the production one-staged-bundle rule. +jq '.bundles += [(.bundles[] | select(.state == "staged"))]' \ + "${staged_authority}" >"${candidate_authority}" +set +e +two_staged_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +two_staged_status=$? +set -e +if [[ "${two_staged_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority manifest may contain at most one staged bundle" <<<"${two_staged_output}"; then + echo "two staged authority bundles were not explicitly rejected" >&2 + printf '%s\n' "${two_staged_output}" >&2 + exit 1 +fi +cp "${staged_authority}" "${candidate_authority}" + # The same pull request cannot both introduce and realize the staged bundle. cp "${future_verifier}" "${candidate_verifier}" set +e @@ -2298,10 +2346,14 @@ jq '.bundles = [(.bundles[] | select(.state == "staged") | .state = "active")]' "${staged_authority}" >"${candidate_authority}" "${archive_checker}" --scan-root "${candidate_root}" >/dev/null -cp "${active_authority}" "${candidate_authority}" -cp "${active_verifier}" "${candidate_verifier}" -cp "${active_authority}" "${archive_authority}" -cp "${active_verifier}" "${archive_verifier}" +cp -p "${original_candidate_authority}" "${candidate_authority}" +cp -p "${active_verifier}" "${candidate_verifier}" +cp -p "${original_archive_authority}" "${archive_authority}" +cp -p "${original_archive_verifier}" "${archive_verifier}" +assert_file_unchanged "candidate authority manifest" "${original_candidate_authority}" "${candidate_authority}" +assert_file_unchanged "archive authority manifest" "${original_archive_authority}" "${archive_authority}" +assert_file_unchanged "candidate verifier" "${active_verifier}" "${candidate_verifier}" +assert_file_unchanged "archive verifier" "${original_archive_verifier}" "${archive_verifier}" (cd "${policytool}" && \ "${checker_binary}" --scan-root "${candidate_root}" \