diff --git a/.github/public-workflow-executable-allowlist.json b/.github/public-workflow-executable-allowlist.json index c73efc03..40cd7a43 100644 --- a/.github/public-workflow-executable-allowlist.json +++ b/.github/public-workflow-executable-allowlist.json @@ -27,7 +27,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": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", + "sha256": "48ab26cade210aad60b5fab5bf49c2faad89c8b5ea291b34830b28a50512822e", "state": "active", "workflowPath": ".github/workflows/public-workflow-policy.yml" }, @@ -51,7 +51,7 @@ "path": "scripts/check-public-workflow-policy.sh", "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "cd51e6225760668a51b92c71f4b40d362fe0a957b7aae04d7421a1c1282ee386", - "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", + "sha256": "48ab26cade210aad60b5fab5bf49c2faad89c8b5ea291b34830b28a50512822e", "state": "active", "rationale": "Staged v0.86 CI authority. Enforces the credential-free public workflow boundary in CI." }, @@ -83,7 +83,7 @@ "path": "scripts/check-public-workflow-policy.sh", "workflowPath": ".github/workflows/ci.yml", "contextSHA256": "a6e499f0785275675c67d7d8705484e2a1fed72e333c40e01301f161581ce6f5", - "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", + "sha256": "48ab26cade210aad60b5fab5bf49c2faad89c8b5ea291b34830b28a50512822e", "state": "staged", "rationale": "Staged v0.86 CI authority. Enforces the credential-free public workflow boundary in CI." }, diff --git a/.github/workflows/policytool/go.mod b/.github/workflows/policytool/go.mod index 9bb28e4e..6195995f 100644 --- a/.github/workflows/policytool/go.mod +++ b/.github/workflows/policytool/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/workflow/.github/workflows/policytool -go 1.26.5 +go 1.27.2 require ( gopkg.in/yaml.v3 v3.0.1 diff --git a/.github/workflows/policytool/main.go b/.github/workflows/policytool/main.go index 00eb8193..14a316dd 100644 --- a/.github/workflows/policytool/main.go +++ b/.github/workflows/policytool/main.go @@ -802,197 +802,76 @@ func authorityManifestPath(repoRoot string) (string, error) { return manifestPath, nil } -// equivalentStagedNativeBudget admits one exact, already reviewed CI budget. -// Only the position of -timeout=15m changes; no authority is staged anew. -// Authority validation and the complete candidate scanner remain mandatory. -func equivalentStagedNativeBudget(repoRoot, scanRoot string, base, candidate trustPolicy, baseInventory, candidateInventory []authorityFile, baseContexts, candidateContexts map[string]string) (bool, error) { - const workflow = ".github/workflows/ci.yml" - const activeContext = "cd51e6225760668a51b92c71f4b40d362fe0a957b7aae04d7421a1c1282ee386" - const priorContext = "447b640269534fa2108222bfa4e77c885318f92ba206b5aac070646ac910d598" - const nextContext = "a6e499f0785275675c67d7d8705484e2a1fed72e333c40e01301f161581ce6f5" - const priorStatement = "fa9dfdc59736a4c3cfa84b50ab1dbb642e3acdf8597adbc4f08c16c31139853d" - const nextStatement = "ff839b699b98d3bc674dfc9c04ee51600dec68f4b3dff7292589be885cbe6813" - if base.Secrets == nil || candidate.Secrets == nil || len(base.Secrets) != 0 || len(candidate.Secrets) != 0 || - !authorityFilesEqual(baseInventory, candidateInventory) || baseContexts[workflow] != activeContext || candidateContexts[workflow] != nextContext { - return false, nil - } - active, staged, valid := trustStatesForPath(base, workflow) - candidateActive, candidateStaged, candidateValid := trustStatesForPath(candidate, workflow) - if !valid || !candidateValid || active != candidateActive || active.Presence != "present" || active.ContextSHA256 != activeContext || - staged != (trustGroup{Path: workflow, ContextSHA256: priorContext, State: "staged", Presence: "present"}) || - candidateStaged != (trustGroup{Path: workflow, ContextSHA256: nextContext, State: "staged", Presence: "present"}) || - !stagedAuthorityMatches(staged, trustRowsForGroup(base, staged)) { - return false, nil - } - expected := trustPolicy{Secrets: base.Secrets, - Presence: append([]trustGroup(nil), base.Presence...), Executables: append([]executableEntry(nil), base.Executables...), - Commands: append([]commandEntry(nil), base.Commands...), Actions: append([]actionEntry(nil), base.Actions...)} - groups, executables, commands, actions, statements := 0, 0, 0, 0, 0 - for i, entry := range expected.Presence { - if entry == staged { - expected.Presence[i].ContextSHA256 = nextContext - groups++ - } - } - for i, entry := range expected.Executables { - if entry.WorkflowPath == workflow && entry.ContextSHA256 == priorContext { - expected.Executables[i].ContextSHA256 = nextContext - executables++ - } - } - for i, entry := range expected.Commands { - if entry.Path == workflow && entry.ContextSHA256 == priorContext { - expected.Commands[i].ContextSHA256 = nextContext - commands++ - if entry.StatementSHA256 == priorStatement && entry.Command == "go" { - expected.Commands[i].StatementSHA256 = nextStatement - statements++ - } - } - } - for i, entry := range expected.Actions { - if entry.Path == workflow && entry.ContextSHA256 == priorContext { - expected.Actions[i].ContextSHA256 = nextContext - actions++ - } - } - if groups != 1 || executables != 4 || commands != 63 || actions != 11 || statements != 1 || !reflect.DeepEqual(expected, candidate) { - return false, nil - } - baseFiles, err := repositoryWorkflowBytes(repoRoot) - if err != nil { - return false, err - } - candidateFiles, err := repositoryWorkflowBytes(scanRoot) - if err != nil { - return false, err - } - if baseFiles["ci.yml"] != "644:6ecc07031dd1ad63df7e48bdc490fd13e7e9cb1b6c5073c9f492547a8f0b7851" || - candidateFiles["ci.yml"] != "644:9456c3e65ad04f227c34dc80cf6e094d4f2cc89c0dd5484c0fc7e28ae0edee6c" { - return false, nil - } - candidateFiles["ci.yml"] = baseFiles["ci.yml"] - if !reflect.DeepEqual(baseFiles, candidateFiles) { - return false, nil - } - for _, relative := range append([]string{".github/public-workflow-authority.json"}, authorityPaths(baseInventory)...) { - before := filepath.Join(repoRoot, filepath.FromSlash(relative)) - after := filepath.Join(scanRoot, filepath.FromSlash(relative)) - beforeData, err := os.ReadFile(before) - if err != nil { - return false, err - } - afterData, err := os.ReadFile(after) - if err != nil { - return false, err - } - beforeInfo, err := os.Lstat(before) - if err != nil { - return false, err - } - afterInfo, err := os.Lstat(after) - if err != nil { - return false, err - } - if !beforeInfo.Mode().IsRegular() || !afterInfo.Mode().IsRegular() || beforeInfo.Mode() != afterInfo.Mode() || !bytes.Equal(beforeData, afterData) { - return false, nil - } - } - return true, nil -} - -func authorityPaths(files []authorityFile) []string { - paths := make([]string, 0, len(files)) - for _, file := range files { - paths = append(paths, file.Path) - } - return paths -} - func validateRepositoryAuthority(repoRoot, scanRoot string, bootstrap bool) ([]string, error) { - findings, _, err := validateRepositoryAuthorityPolicy(repoRoot, scanRoot, bootstrap) - return findings, err -} - -func validateRepositoryAuthorityPolicy(repoRoot, scanRoot string, bootstrap bool) ([]string, *trustPolicy, error) { candidateManifest := authorityManifest{} candidateManifestPath, err := authorityManifestPath(scanRoot) if err != nil { - return nil, nil, fmt.Errorf("inspect candidate authority manifest: %w", err) + return nil, fmt.Errorf("inspect candidate authority manifest: %w", err) } if err := decodeJSONFile(candidateManifestPath, &candidateManifest); err != nil { - return nil, nil, fmt.Errorf("read candidate authority manifest: %w", err) + return nil, fmt.Errorf("read candidate authority manifest: %w", err) } candidateInventory, err := authorityInventory(scanRoot) if err != nil { - return nil, nil, fmt.Errorf("inventory candidate authority: %w", err) + return nil, fmt.Errorf("inventory candidate authority: %w", err) } if bootstrap { - return validateAuthorityTransition(nil, candidateManifest, nil, candidateInventory, true), nil, nil + return validateAuthorityTransition(nil, candidateManifest, nil, candidateInventory, true), nil } baseManifest := authorityManifest{} baseManifestPath, err := authorityManifestPath(repoRoot) if err != nil { - return nil, nil, fmt.Errorf("inspect base authority manifest: %w", err) + return nil, fmt.Errorf("inspect base authority manifest: %w", err) } if err := decodeJSONFile(baseManifestPath, &baseManifest); err != nil { - return nil, nil, fmt.Errorf("read base authority manifest: %w", err) + return nil, fmt.Errorf("read base authority manifest: %w", err) } baseInventory, err := authorityInventory(repoRoot) if err != nil { - return nil, nil, fmt.Errorf("inventory base authority: %w", err) + return nil, fmt.Errorf("inventory base authority: %w", err) } findings := validateAuthorityTransition(&baseManifest, candidateManifest, baseInventory, candidateInventory, false) basePolicy, err := loadTrustPolicy(repoRoot) if err != nil { - return nil, nil, err + return nil, err } candidatePolicy, err := loadTrustPolicy(scanRoot) if err != nil { - return nil, nil, err + return nil, err } baseContexts, err := repositoryWorkflowContexts(repoRoot) if err != nil { - return nil, nil, fmt.Errorf("read base workflow contexts: %w", err) + return nil, fmt.Errorf("read base workflow contexts: %w", err) } candidateContexts, err := repositoryWorkflowContexts(scanRoot) if err != nil { - return nil, nil, fmt.Errorf("read candidate workflow contexts: %w", err) + return nil, fmt.Errorf("read candidate workflow contexts: %w", err) } basePresent, err := trustPresence(repoRoot, basePolicy, candidatePolicy) if err != nil { - return nil, nil, fmt.Errorf("inspect base trust paths: %w", err) + return nil, fmt.Errorf("inspect base trust paths: %w", err) } candidatePresent, err := trustPresence(scanRoot, basePolicy, candidatePolicy) if err != nil { - return nil, nil, fmt.Errorf("inspect candidate trust paths: %w", err) - } - equivalentBudget, err := equivalentStagedNativeBudget(repoRoot, scanRoot, basePolicy, candidatePolicy, baseInventory, candidateInventory, baseContexts, candidateContexts) - if err != nil { - return nil, nil, fmt.Errorf("validate equivalent staged native budget: %w", err) + return nil, fmt.Errorf("inspect candidate trust paths: %w", err) } - if !equivalentBudget { - findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) - } - if !equivalentBudget && !trustPoliciesEqual(basePolicy, candidatePolicy) { + findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) + if !trustPoliciesEqual(basePolicy, candidatePolicy) { baseFiles, err := repositoryWorkflowBytes(repoRoot) if err != nil { - return nil, nil, fmt.Errorf("read base public workflow bytes: %w", err) + return nil, fmt.Errorf("read base public workflow bytes: %w", err) } candidateFiles, err := repositoryWorkflowBytes(scanRoot) if err != nil { - return nil, nil, fmt.Errorf("read candidate public workflow bytes: %w", err) + 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) - if len(findings) == 0 && equivalentBudget { - return findings, &candidatePolicy, nil - } - return findings, nil, nil + return findings, nil } func selectTrustGroups(groups []trustGroup, workflowContexts map[string]string, presentPaths map[string]bool) (map[string]bool, []string) { @@ -2446,9 +2325,8 @@ func main() { fmt.Fprintln(os.Stderr, "--bootstrap-authority cannot be combined with custom policy inputs") os.Exit(2) } - var verifiedPolicy *trustPolicy if !customPolicyInputs { - authorityFindings, effectivePolicy, authorityErr := validateRepositoryAuthorityPolicy(repoRoot, scanRoot, bootstrapAuthority) + authorityFindings, authorityErr := validateRepositoryAuthority(repoRoot, scanRoot, bootstrapAuthority) if authorityErr != nil { fmt.Fprintf(os.Stderr, "validate repository authority: %v\n", authorityErr) os.Exit(1) @@ -2459,7 +2337,6 @@ func main() { } os.Exit(1) } - verifiedPolicy = effectivePolicy } if allowPath == "" { allowPath = filepath.Join(repoRoot, ".github", "public-workflow-secret-allowlist.json") @@ -2533,15 +2410,6 @@ func main() { fmt.Fprintf(os.Stderr, "read presence allowlist: %v\n", err) os.Exit(1) } - // Only this mechanically validated equivalent policy may replace base inputs. - // Candidate files are never selected wholesale as scanner input paths. - if verifiedPolicy != nil { - allowlist = verifiedPolicy.Secrets - executableList = verifiedPolicy.Executables - commandList = verifiedPolicy.Commands - actionList = verifiedPolicy.Actions - groups = verifiedPolicy.Presence - } presentTrustPaths := make(map[string]bool) for _, group := range groups { candidatePath := filepath.Join(scanRoot, filepath.FromSlash(group.Path)) diff --git a/.github/workflows/policytool/main_test.go b/.github/workflows/policytool/main_test.go index 11bb80e0..04156183 100644 --- a/.github/workflows/policytool/main_test.go +++ b/.github/workflows/policytool/main_test.go @@ -2,13 +2,10 @@ package main import ( "context" - "crypto/sha256" "encoding/json" - "fmt" "go/ast" "go/parser" "go/token" - "io" "os" "os/exec" "path/filepath" @@ -1780,631 +1777,3 @@ run_candidate_fetch "$1/work" example/repo 0123456789012345678901234567890123456 }) } } - -// These are authority-boundary tests, not native test acceptance evidence. -const equivalentBudgetBaselineSHA256 = "6ecc07031dd1ad63df7e48bdc490fd13e7e9cb1b6c5073c9f492547a8f0b7851" -const equivalentBudgetSuffixSHA256 = "9456c3e65ad04f227c34dc80cf6e094d4f2cc89c0dd5484c0fc7e28ae0edee6c" - -// Reuse the reviewed YAML as data rather than duplicating its audit patterns in -// Go source. Unknown bytes fail closed; only the exact reviewed suffix form is -// normalized, and its result must reproduce the original fixture byte for byte. -func readEquivalentBudgetFixtureCI(repository string) (string, error) { - root, err := os.OpenRoot(repository) - if err != nil { - return "", err - } - defer root.Close() - const name = ".github/workflows/ci.yml" - info, err := root.Lstat(name) - if err != nil { - return "", err - } - if !info.Mode().IsRegular() { - return "", fmt.Errorf("CI fixture must be a regular file") - } - file, err := root.Open(name) - if err != nil { - return "", err - } - defer file.Close() - info, err = file.Stat() - if err != nil { - return "", err - } - if !info.Mode().IsRegular() { - return "", fmt.Errorf("CI fixture must be a regular file") - } - // The largest reviewed input is 17115 bytes. Bound reads before hashing. - data, err := io.ReadAll(io.LimitReader(file, 17116)) - if err != nil { - return "", err - } - ci := string(data) - switch fmt.Sprintf("%x", sha256.Sum256(data)) { - case equivalentBudgetBaselineSHA256: - return ci, nil - case equivalentBudgetSuffixSHA256: - ci = strings.Replace(ci, "go test -v -race -coverprofile=coverage.out ./... -timeout=15m", "go test -v -race -coverprofile=coverage.out ./...", 1) - if fmt.Sprintf("%x", sha256.Sum256([]byte(ci))) == equivalentBudgetBaselineSHA256 { - return ci, nil - } - } - return "", fmt.Errorf("CI fixture does not match reviewed bytes") -} - -func equivalentBudgetFixtureCI(t *testing.T) string { - t.Helper() - ci, err := readEquivalentBudgetFixtureCI(filepath.Join("..", "..", "..")) - if err != nil { - t.Fatal(err) - } - return ci -} - -func TestEquivalentBudgetFixtureReader(t *testing.T) { - baseline := equivalentBudgetFixtureCI(t) - if got := fmt.Sprintf("%x", sha256.Sum256([]byte(baseline))); got != equivalentBudgetBaselineSHA256 { - t.Fatalf("canonical fixture hash=%s", got) - } - suffix := strings.Replace(baseline, "go test -v -race -coverprofile=coverage.out ./...", "go test -v -race -coverprofile=coverage.out ./... -timeout=15m", 1) - if got := fmt.Sprintf("%x", sha256.Sum256([]byte(suffix))); got != equivalentBudgetSuffixSHA256 { - t.Fatalf("reviewed suffix fixture hash=%s", got) - } - for _, tc := range []struct { - name string - data string - pass bool - }{ - {"exact baseline", baseline, true}, - {"exact reviewed suffix", suffix, true}, - {"empty", "", false}, - {"comment drift", baseline + "\n# unrelated drift\n", false}, - {"whitespace drift", baseline + "\n", false}, - {"unreviewed timeout", strings.Replace(suffix, "-timeout=15m", "-timeout=16m", 1), false}, - {"duplicate suffix", strings.Replace(suffix, "-timeout=15m", "-timeout=15m -timeout=15m", 1), false}, - {"oversized input", strings.Repeat("x", 17117), false}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, ".github", "workflows", "ci.yml") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(tc.data), 0644); err != nil { - t.Fatal(err) - } - got, err := readEquivalentBudgetFixtureCI(root) - if tc.pass { - if err != nil || got != baseline { - t.Fatalf("reviewed fixture changed: err=%v hash=%x", err, sha256.Sum256([]byte(got))) - } - } else if err == nil || got != "" { - t.Fatalf("unreviewed fixture accepted: err=%v", err) - } - }) - } - t.Run("missing root", func(t *testing.T) { - if got, err := readEquivalentBudgetFixtureCI(filepath.Join(t.TempDir(), "missing")); err == nil || got != "" { - t.Fatalf("missing root accepted: err=%v", err) - } - }) - for _, kind := range []string{"missing file", "directory", "escaping file symlink", "escaping ancestor symlink"} { - t.Run(kind, func(t *testing.T) { - root, outside := t.TempDir(), t.TempDir() - path := filepath.Join(root, ".github", "workflows", "ci.yml") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "ci.yml"), []byte(baseline), 0644); err != nil { - t.Fatal(err) - } - switch kind { - case "directory": - if err := os.Mkdir(path, 0755); err != nil { - t.Fatal(err) - } - case "escaping file symlink": - if err := os.Symlink(filepath.Join(outside, "ci.yml"), path); err != nil { - t.Fatal(err) - } - case "escaping ancestor symlink": - if err := os.Remove(filepath.Dir(path)); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Dir(path)); err != nil { - t.Fatal(err) - } - } - if got, err := readEquivalentBudgetFixtureCI(root); err == nil || got != "" { - t.Fatalf("unsafe fixture accepted: err=%v", err) - } - }) - } -} - -func equivalentBudgetFixture(t *testing.T) (string, string) { - t.Helper() - const workflow = ".github/workflows/ci.yml" - const active = "cd51e6225760668a51b92c71f4b40d362fe0a957b7aae04d7421a1c1282ee386" - const prior = "447b640269534fa2108222bfa4e77c885318f92ba206b5aac070646ac910d598" - const next = "a6e499f0785275675c67d7d8705484e2a1fed72e333c40e01301f161581ce6f5" - policy := trustPolicy{Secrets: []allowEntry{}, Presence: []trustGroup{ - {workflow, active, "active", "present"}, {workflow, prior, "staged", "present"}, - {".github/workflows/benchmark.yml", strings.Repeat("8", 64), "active", "present"}, - {".github/workflows/benchmark.yml", strings.Repeat("9", 64), "staged", "present"}, - }} - policy.Executables = []executableEntry{{"scripts/check-public-workflow-policy.sh", workflow, active, strings.Repeat("a", 64), "active", "Active executable."}} - policy.Commands = []commandEntry{{workflow, "go", strings.Repeat("b", 64), active, "active", "Active command."}} - policy.Actions = []actionEntry{{workflow, "actions/checkout@" + strings.Repeat("c", 40), strings.Repeat("d", 64), active, "active", "Active action."}} - for i := 0; i < 4; i++ { - policy.Executables = append(policy.Executables, executableEntry{fmt.Sprintf("scripts/staged-%d.sh", i), workflow, prior, strings.Repeat("e", 64), "staged", "Reviewed executable."}) - } - for i := 0; i < 63; i++ { - statement := fmt.Sprintf("%064x", i+1) - if i == 0 { - statement = "fa9dfdc59736a4c3cfa84b50ab1dbb642e3acdf8597adbc4f08c16c31139853d" - } - policy.Commands = append(policy.Commands, commandEntry{workflow, "go", statement, prior, "staged", "Reviewed command."}) - } - for i := 0; i < 11; i++ { - policy.Actions = append(policy.Actions, actionEntry{workflow, "actions/checkout@" + strings.Repeat("c", 40), fmt.Sprintf("%064x", i+100), prior, "staged", "Reviewed action."}) - } - candidatePolicy := groupedClonePolicy(t, policy) - candidatePolicy.Presence[1].ContextSHA256 = next - for i := 1; i < len(candidatePolicy.Executables); i++ { - candidatePolicy.Executables[i].ContextSHA256 = next - } - for i := 1; i < len(candidatePolicy.Commands); i++ { - candidatePolicy.Commands[i].ContextSHA256 = next - } - candidatePolicy.Commands[1].StatementSHA256 = "ff839b699b98d3bc674dfc9c04ee51600dec68f4b3dff7292589be885cbe6813" - for i := 1; i < len(candidatePolicy.Actions); i++ { - candidatePolicy.Actions[i].ContextSHA256 = next - } - makeRoot := func(candidate bool) string { - root := t.TempDir() - write := func(name string, data []byte, mode os.FileMode) { - t.Helper() - path := filepath.Join(root, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, mode); err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, mode); err != nil { - t.Fatal(err) - } - } - for _, name := range append(append([]string(nil), authorityFixedPaths...), "scripts/fixtures/public-workflow-policy/fixture.yml") { - write(name, []byte("authority fixture\n"), 0644) - } - ci := equivalentBudgetFixtureCI(t) - selected := policy - if candidate { - ci = strings.Replace(ci, "go test -v -race -coverprofile=coverage.out ./...", "go test -v -race -coverprofile=coverage.out ./... -timeout=15m", 1) - selected = candidatePolicy - } - write(workflow, []byte(ci), 0644) - write(".github/workflows/benchmark.yml", []byte("name: benchmark\non: push\njobs: {}\n"), 0644) - writePolicy := func(kind string, value any) { - data, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - write(".github/public-workflow-"+kind+"-allowlist.json", data, 0644) - } - writePolicy("secret", selected.Secrets) - writePolicy("presence", selected.Presence) - writePolicy("executable", selected.Executables) - writePolicy("command", selected.Commands) - writePolicy("action", selected.Actions) - inventory, err := authorityInventory(root) - if err != nil { - t.Fatal(err) - } - staged := append([]authorityFile(nil), inventory...) - staged[0].SHA256 = strings.Repeat("f", 64) - data, err := json.Marshal(authorityManifest{Version: 1, Bundles: []authorityBundle{{State: "active", Files: inventory}, {State: "staged", Files: staged}}}) - if err != nil { - t.Fatal(err) - } - write(".github/public-workflow-authority.json", data, 0644) - return root - } - return makeRoot(false), makeRoot(true) -} - -func TestEquivalentStagedNativeBudgetBoundary(t *testing.T) { - mutatePolicy := func(root string, edit func(*trustPolicy)) { - t.Helper() - p, err := loadTrustPolicy(root) - if err != nil { - t.Fatal(err) - } - edit(&p) - for _, item := range []struct { - name string - value any - }{{"secret", p.Secrets}, {"presence", p.Presence}, {"executable", p.Executables}, {"command", p.Commands}, {"action", p.Actions}} { - data, err := json.Marshal(item.value) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, ".github/public-workflow-"+item.name+"-allowlist.json"), data, 0644); err != nil { - t.Fatal(err) - } - } - } - for _, tc := range []struct { - name string - edit func(string, string) - reject bool - }{ - {"exact reviewed budget equivalent", func(string, string) {}, false}, - {"partial context relabel", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Executables[1].ContextSHA256 = strings.Repeat("1", 64) }) - }, true}, - {"wrong statement", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Commands[1].StatementSHA256 = strings.Repeat("1", 64) }) - }, true}, - {"added command", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Commands = append(p.Commands, p.Commands[1]) }) - }, true}, - {"deleted staged row", func(_, c string) { mutatePolicy(c, func(p *trustPolicy) { p.Actions = p.Actions[:len(p.Actions)-1] }) }, true}, - {"active pin change", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Executables[0].SHA256 = strings.Repeat("1", 64) }) - }, true}, - {"staged executable pin change", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Executables[1].SHA256 = strings.Repeat("1", 64) }) - }, true}, - {"action node change", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Actions[1].NodeSHA256 = strings.Repeat("1", 64) }) - }, true}, - {"rationale change", func(_, c string) { mutatePolicy(c, func(p *trustPolicy) { p.Commands[1].Rationale = "Drift" }) }, true}, - {"row reordering", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Actions[1], p.Actions[2] = p.Actions[2], p.Actions[1] }) - }, true}, - {"promotion", func(_, c string) { mutatePolicy(c, func(p *trustPolicy) { p.Presence[1].State = "active" }) }, true}, - {"second context change", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Presence[3].ContextSHA256 = strings.Repeat("1", 64) }) - }, true}, - {"nonempty secrets", func(_, c string) { - mutatePolicy(c, func(p *trustPolicy) { p.Secrets = []allowEntry{{Secret: "TOKEN"}} }) - }, true}, - {"null secrets", func(_, c string) { mutatePolicy(c, func(p *trustPolicy) { p.Secrets = nil }) }, true}, - {"missing previous stage", func(b, _ string) { - mutatePolicy(b, func(p *trustPolicy) { p.Presence = append(p.Presence[:1], p.Presence[2:]...) }) - }, true}, - {"malformed previous stage", func(b, _ string) { mutatePolicy(b, func(p *trustPolicy) { p.Commands[1].Command = "eval" }) }, true}, - {"base already realizes stage", func(b, _ string) { - budgetEditFixtureFile(t, b, ".github/workflows/ci.yml", "go test -v -race -coverprofile=coverage.out ./...", "go test -v -race -timeout=15m -coverprofile=coverage.out ./...") - }, true}, - {"extra target", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/workflows/ci.yml", "./... -timeout=15m", "./... ./config/... -timeout=15m") - }, true}, - {"different timeout", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/workflows/ci.yml", "-timeout=15m", "-timeout=16m") - }, true}, - {"malformed YAML", func(_, c string) { budgetEditFixtureFile(t, c, ".github/workflows/ci.yml", "name: CI", "name: [") }, true}, - {"CI comment drift", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/workflows/ci.yml", "name: CI", "# extra\nname: CI") - }, true}, - {"other workflow drift", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/workflows/benchmark.yml", "name: benchmark", "name: changed") - }, true}, - {"workflow mode", func(_, c string) { - if err := os.Chmod(filepath.Join(c, ".github/workflows/ci.yml"), 0755); err != nil { - t.Fatal(err) - } - }, true}, - {"authority source drift", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/workflows/policytool/main.go", "authority fixture", "unreviewed source") - }, true}, - {"authority mode", func(_, c string) { - if err := os.Chmod(filepath.Join(c, ".github/workflows/policytool/main.go"), 0755); err != nil { - t.Fatal(err) - } - }, true}, - {"H staged pin change", func(_, c string) { - budgetEditFixtureFile(t, c, ".github/public-workflow-authority.json", strings.Repeat("f", 64), strings.Repeat("e", 64)) - }, true}, - {"H mode", func(_, c string) { - if err := os.Chmod(filepath.Join(c, ".github/public-workflow-authority.json"), 0600); err != nil { - t.Fatal(err) - } - }, true}, - } { - t.Run(tc.name, func(t *testing.T) { - base, candidate := equivalentBudgetFixture(t) - tc.edit(base, candidate) - findings, err := validateRepositoryAuthority(base, candidate, false) - if (err != nil || len(findings) != 0) != tc.reject { - t.Fatalf("findings=%v err=%v reject=%v", findings, err, tc.reject) - } - }) - } -} - -func budgetEditFixtureFile(t *testing.T, root, relative, before, after string) { - t.Helper() - name := filepath.Join(root, filepath.FromSlash(relative)) - data, err := os.ReadFile(name) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), before) { - t.Fatalf("fixture target missing: %s", relative) - } - if err := os.WriteFile(name, []byte(strings.ReplaceAll(string(data), before, after)), 0644); err != nil { - t.Fatal(err) - } - if err := os.Chmod(name, 0644); err != nil { - t.Fatal(err) - } -} - -func TestEquivalentBudgetRetainsIndependentAuthorityFindings(t *testing.T) { - base, candidate := equivalentBudgetFixture(t) - for _, root := range []string{base, candidate} { - budgetEditFixtureFile(t, root, ".github/public-workflow-authority.json", `"version":1`, `"version":2`) - } - bp, err := loadTrustPolicy(base) - if err != nil { - t.Fatal(err) - } - cp, err := loadTrustPolicy(candidate) - if err != nil { - t.Fatal(err) - } - bi, err := authorityInventory(base) - if err != nil { - t.Fatal(err) - } - ci, err := authorityInventory(candidate) - if err != nil { - t.Fatal(err) - } - bc, err := repositoryWorkflowContexts(base) - if err != nil { - t.Fatal(err) - } - cc, err := repositoryWorkflowContexts(candidate) - if err != nil { - t.Fatal(err) - } - accepted, err := equivalentStagedNativeBudget(base, candidate, bp, cp, bi, ci, bc, cc) - if err != nil || !accepted { - t.Fatalf("control did not exercise accepted predicate: accepted=%v err=%v", accepted, err) - } - findings, effective, err := validateRepositoryAuthorityPolicy(base, candidate, false) - if err != nil || len(findings) == 0 || effective != nil { - t.Fatalf("independent invalid authority was lost: findings=%v effective=%v err=%v", findings, effective != nil, err) - } -} - -// Use the actual frozen repository workflow and trust rows for the real CLI -// crossing. The synthetic boundary fixture above cannot prove scanner wiring. -func equivalentBudgetRealRepositoryFixture(t *testing.T) (string, string) { - t.Helper() - const prior = "447b640269534fa2108222bfa4e77c885318f92ba206b5aac070646ac910d598" - const next = "a6e499f0785275675c67d7d8705484e2a1fed72e333c40e01301f161581ce6f5" - roots := []string{groupedRepositoryFixture(t), groupedRepositoryFixture(t)} - for index, root := range roots { - policy, err := loadTrustPolicy(root) - if err != nil { - t.Fatal(err) - } - // The tests can run on either side of the proposed root migration. - for i := range policy.Presence { - if policy.Presence[i].ContextSHA256 == next { - policy.Presence[i].ContextSHA256 = prior - } - } - for i := range policy.Executables { - if policy.Executables[i].ContextSHA256 == next { - policy.Executables[i].ContextSHA256 = prior - } - } - for i := range policy.Commands { - if policy.Commands[i].ContextSHA256 == next { - policy.Commands[i].ContextSHA256 = prior - } - if policy.Commands[i].StatementSHA256 == "ff839b699b98d3bc674dfc9c04ee51600dec68f4b3dff7292589be885cbe6813" { - policy.Commands[i].StatementSHA256 = "fa9dfdc59736a4c3cfa84b50ab1dbb642e3acdf8597adbc4f08c16c31139853d" - } - } - for i := range policy.Actions { - if policy.Actions[i].ContextSHA256 == next { - policy.Actions[i].ContextSHA256 = prior - } - } - ci := equivalentBudgetFixtureCI(t) - if index == 1 { - ci = strings.Replace(ci, "go test -v -race -coverprofile=coverage.out ./...", "go test -v -race -coverprofile=coverage.out ./... -timeout=15m", 1) - for i := range policy.Presence { - if policy.Presence[i].ContextSHA256 == prior { - policy.Presence[i].ContextSHA256 = next - } - } - for i := range policy.Executables { - if policy.Executables[i].ContextSHA256 == prior { - policy.Executables[i].ContextSHA256 = next - } - } - for i := range policy.Commands { - if policy.Commands[i].ContextSHA256 == prior { - policy.Commands[i].ContextSHA256 = next - if policy.Commands[i].StatementSHA256 == "fa9dfdc59736a4c3cfa84b50ab1dbb642e3acdf8597adbc4f08c16c31139853d" { - policy.Commands[i].StatementSHA256 = "ff839b699b98d3bc674dfc9c04ee51600dec68f4b3dff7292589be885cbe6813" - } - } - } - for i := range policy.Actions { - if policy.Actions[i].ContextSHA256 == prior { - policy.Actions[i].ContextSHA256 = next - } - } - } - for _, item := range []struct { - name string - value any - }{{"secret", policy.Secrets}, {"presence", policy.Presence}, {"executable", policy.Executables}, {"command", policy.Commands}, {"action", policy.Actions}} { - data, err := json.Marshal(item.value) - if err != nil { - t.Fatal(err) - } - name := filepath.Join(root, ".github/public-workflow-"+item.name+"-allowlist.json") - if err := os.WriteFile(name, data, 0644); err != nil { - t.Fatal(err) - } - if err := os.Chmod(name, 0644); err != nil { - t.Fatal(err) - } - } - path := filepath.Join(root, ".github/workflows/ci.yml") - if err := os.WriteFile(path, []byte(ci), 0644); err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, 0644); err != nil { - t.Fatal(err) - } - contexts, err := repositoryWorkflowContexts(root) - if err != nil { - t.Fatal(err) - } - present, err := trustPresence(root, policy) - if err != nil { - t.Fatal(err) - } - selected, findings := selectTrustGroups(policy.Presence, contexts, present) - if len(findings) != 0 { - t.Fatalf("real fixture trust groups: %v", findings) - } - // Workflow scanning hashes selected executables even outside H. Copy the - // full selected dependency closure; do not special-case missing scripts. - copied := make(map[string]bool) - for _, entry := range policy.Executables { - if !selected[entry.WorkflowPath+"\x00"+entry.ContextSHA256] || copied[entry.Path] { - continue - } - relative := filepath.Clean(filepath.FromSlash(entry.Path)) - if filepath.IsAbs(relative) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - t.Fatalf("invalid selected fixture path: %s", entry.Path) - } - source := filepath.Join("..", "..", "..", relative) - info, err := os.Lstat(source) - if err != nil || !info.Mode().IsRegular() { - t.Fatalf("selected executable %s: info=%v err=%v", entry.Path, info, err) - } - actual, err := hashAuthorityFile(source) - if err != nil || actual != entry.SHA256 { - t.Fatalf("selected executable %s: sha=%s want=%s err=%v", entry.Path, actual, entry.SHA256, err) - } - data, err := os.ReadFile(source) - if err != nil { - t.Fatal(err) - } - destination := filepath.Join(root, relative) - if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(destination, data, info.Mode().Perm()); err != nil { - t.Fatal(err) - } - if err := os.Chmod(destination, info.Mode().Perm()); err != nil { - t.Fatal(err) - } - copied[entry.Path] = true - } - } - return roots[0], roots[1] -} - -func TestEquivalentStagedBudgetCLICrossing(t *testing.T) { - const marker = "WF_POLICY_EQUIVALENT_BUDGET_CLI_CHILD" - if os.Getenv(marker) == "1" { - os.Args = []string{"policytool", "--repo", os.Getenv("WF_POLICY_EQUIVALENT_BASE"), "--scan-root", os.Getenv("WF_POLICY_EQUIVALENT_CANDIDATE")} - main() - return - } - for _, tc := range []struct { - name string - unrelated bool - authorityDrift bool - tamperedExecutable bool - pass bool - }{ - {"actual full scanner accepts equivalent policy", false, false, false, true}, - {"accepted predicate still runs unrelated policy checks", true, false, false, false}, - {"invalid authority cannot select candidate policy", false, true, false, false}, - {"accepted predicate still hashes selected executables", false, false, true, false}, - } { - t.Run(tc.name, func(t *testing.T) { - base, candidate := equivalentBudgetRealRepositoryFixture(t) - if tc.unrelated { - for _, root := range []string{base, candidate} { - name := filepath.Join(root, ".github/workflows/unreviewed.yml") - if err := os.WriteFile(name, []byte("name: unreviewed\non: push\njobs: {}\n"), 0644); err != nil { - t.Fatal(err) - } - if err := os.Chmod(name, 0644); err != nil { - t.Fatal(err) - } - } - findings, effective, err := validateRepositoryAuthorityPolicy(base, candidate, false) - if err != nil || len(findings) != 0 || effective == nil { - t.Fatalf("control missed accepted predicate: findings=%v effective=%v err=%v", findings, effective != nil, err) - } - } - if tc.tamperedExecutable { - audit := filepath.Join(candidate, "scripts/audit-cloud-symbols.sh") - info, err := os.Stat(audit) - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(audit) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(audit, append(data, []byte("\n# test control: executable byte tamper\n")...), info.Mode().Perm()); err != nil { - t.Fatal(err) - } - if err := os.Chmod(audit, info.Mode().Perm()); err != nil { - t.Fatal(err) - } - findings, effective, err := validateRepositoryAuthorityPolicy(base, candidate, false) - if err != nil || len(findings) != 0 || effective == nil { - t.Fatalf("control missed accepted predicate: findings=%v effective=%v err=%v", findings, effective != nil, err) - } - } - if tc.authorityDrift { - budgetEditFixtureFile(t, candidate, ".github/workflows/policytool/main.go", "package main", "// unexpected source drift\npackage main") - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - command := exec.CommandContext(ctx, executable, "-test.run=^TestEquivalentStagedBudgetCLICrossing$") - command.Env = append(os.Environ(), marker+"=1", "WF_POLICY_EQUIVALENT_BASE="+base, "WF_POLICY_EQUIVALENT_CANDIDATE="+candidate) - output, err := command.CombinedOutput() - if ctx.Err() != nil { - t.Fatalf("CLI crossing deadline: %v", ctx.Err()) - } - if (err == nil) != tc.pass { - t.Fatalf("pass=%v err=%v output=%s", tc.pass, err, output) - } - if tc.pass && !strings.Contains(string(output), "public workflow policy passed") { - t.Fatalf("real scanner success missing: %s", output) - } - if tc.unrelated && !strings.Contains(string(output), "no trust group matches workflow") { - t.Fatalf("unrelated scanner rejection missing: %s", output) - } - if tc.tamperedExecutable && !strings.Contains(string(output), "executable hash mismatch for scripts/audit-cloud-symbols.sh") { - t.Fatalf("executable scanner rejection missing: %s", output) - } - }) - } -} diff --git a/scripts/check-public-workflow-policy.sh b/scripts/check-public-workflow-policy.sh index 0eade9bf..2b76a387 100755 --- a/scripts/check-public-workflow-policy.sh +++ b/scripts/check-public-workflow-policy.sh @@ -74,9 +74,9 @@ verify_policytool_file() { verify_policytool() { verify_policytool_layout - verify_policytool_file main.go 6db7d0f72db318c232a9f5418e64763c92414d3d45b5b55f041eb332c695198f - verify_policytool_file main_test.go ec415dca7a83c743a16b69636f4fa10134099a828d1f8db01861b47d10114900 - verify_policytool_file go.mod 0a2d135980310757f36e733161b94498b4e0a1b6ac8c6de8a799f542d6c9ddb8 + verify_policytool_file main.go 9c38f6e1a92d34d31cd73ba2bd2b092216ab9c115af79a0ef5ec13343c4f4d2d + verify_policytool_file main_test.go fadde1edd916918a69f912177a74565a6138ed935aae3509d7c6082924467778 + verify_policytool_file go.mod 65717c7073e6a6532e6e687ada4bed681081b3c9c565f78b84560e89005353b4 verify_policytool_file go.sum 790ef858e5aeed12269a69e764ac69c02c3877678b0e7d9384ad3728b6e09f6c }