diff --git a/.github/public-workflow-authority.json b/.github/public-workflow-authority.json index a8af778f..cde18e8b 100644 --- a/.github/public-workflow-authority.json +++ b/.github/public-workflow-authority.json @@ -25,11 +25,11 @@ }, { "path": ".github/workflows/policytool/main.go", - "sha256": "9c38f6e1a92d34d31cd73ba2bd2b092216ab9c115af79a0ef5ec13343c4f4d2d" + "sha256": "6db7d0f72db318c232a9f5418e64763c92414d3d45b5b55f041eb332c695198f" }, { "path": ".github/workflows/policytool/main_test.go", - "sha256": "fadde1edd916918a69f912177a74565a6138ed935aae3509d7c6082924467778" + "sha256": "ec415dca7a83c743a16b69636f4fa10134099a828d1f8db01861b47d10114900" }, { "path": ".github/workflows/scripts/verify-public-workflow-branch-protection.sh", @@ -37,7 +37,7 @@ }, { "path": "scripts/check-public-workflow-policy.sh", - "sha256": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a" + "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061" }, { "path": "scripts/fixtures/public-workflow-policy/lifecycle-future.yml", diff --git a/.github/public-workflow-executable-allowlist.json b/.github/public-workflow-executable-allowlist.json index dd9237b8..94f54154 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": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", + "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", "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": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", + "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", "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": "447b640269534fa2108222bfa4e77c885318f92ba206b5aac070646ac910d598", - "sha256": "2cf3f0461ceed4a3e32265ccad7fd3ac1ae4e162d1f2cb19ba9b8737405db02a", + "sha256": "a5173d30a7c231f0361a9d1391248a9c5033fba2594d98f853d958a4f0bca061", "state": "staged", "rationale": "Staged v0.86 CI authority. Enforces the credential-free public workflow boundary in CI." }, diff --git a/.github/workflows/policytool/main.go b/.github/workflows/policytool/main.go index 14a316dd..00eb8193 100644 --- a/.github/workflows/policytool/main.go +++ b/.github/workflows/policytool/main.go @@ -802,76 +802,197 @@ 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, fmt.Errorf("inspect candidate authority manifest: %w", err) + return nil, nil, fmt.Errorf("inspect candidate authority manifest: %w", err) } if err := decodeJSONFile(candidateManifestPath, &candidateManifest); err != nil { - return nil, fmt.Errorf("read candidate authority manifest: %w", err) + return nil, nil, fmt.Errorf("read candidate authority manifest: %w", err) } candidateInventory, err := authorityInventory(scanRoot) if err != nil { - return nil, fmt.Errorf("inventory candidate authority: %w", err) + return nil, nil, fmt.Errorf("inventory candidate authority: %w", err) } if bootstrap { - return validateAuthorityTransition(nil, candidateManifest, nil, candidateInventory, true), nil + return validateAuthorityTransition(nil, candidateManifest, nil, candidateInventory, true), nil, nil } baseManifest := authorityManifest{} baseManifestPath, err := authorityManifestPath(repoRoot) if err != nil { - return nil, fmt.Errorf("inspect base authority manifest: %w", err) + return nil, nil, fmt.Errorf("inspect base authority manifest: %w", err) } if err := decodeJSONFile(baseManifestPath, &baseManifest); err != nil { - return nil, fmt.Errorf("read base authority manifest: %w", err) + return nil, nil, fmt.Errorf("read base authority manifest: %w", err) } baseInventory, err := authorityInventory(repoRoot) if err != nil { - return nil, fmt.Errorf("inventory base authority: %w", err) + return nil, nil, fmt.Errorf("inventory base authority: %w", err) } findings := validateAuthorityTransition(&baseManifest, candidateManifest, baseInventory, candidateInventory, false) basePolicy, err := loadTrustPolicy(repoRoot) if err != nil { - return nil, err + return nil, nil, err } candidatePolicy, err := loadTrustPolicy(scanRoot) if err != nil { - return nil, err + return nil, nil, err } baseContexts, err := repositoryWorkflowContexts(repoRoot) if err != nil { - return nil, fmt.Errorf("read base workflow contexts: %w", err) + return nil, nil, fmt.Errorf("read base workflow contexts: %w", err) } candidateContexts, err := repositoryWorkflowContexts(scanRoot) if err != nil { - return nil, fmt.Errorf("read candidate workflow contexts: %w", err) + return nil, nil, fmt.Errorf("read candidate workflow contexts: %w", err) } basePresent, err := trustPresence(repoRoot, basePolicy, candidatePolicy) if err != nil { - return nil, fmt.Errorf("inspect base trust paths: %w", err) + return nil, nil, fmt.Errorf("inspect base trust paths: %w", err) } candidatePresent, err := trustPresence(scanRoot, basePolicy, candidatePolicy) if err != nil { - return nil, fmt.Errorf("inspect candidate trust paths: %w", err) + 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) } - findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) - if !trustPoliciesEqual(basePolicy, candidatePolicy) { + if !equivalentBudget { + findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) + } + if !equivalentBudget && !trustPoliciesEqual(basePolicy, candidatePolicy) { baseFiles, err := repositoryWorkflowBytes(repoRoot) if err != nil { - return nil, fmt.Errorf("read base public workflow bytes: %w", err) + return nil, 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) + return nil, 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 + if len(findings) == 0 && equivalentBudget { + return findings, &candidatePolicy, nil + } + return findings, nil, nil } func selectTrustGroups(groups []trustGroup, workflowContexts map[string]string, presentPaths map[string]bool) (map[string]bool, []string) { @@ -2325,8 +2446,9 @@ func main() { fmt.Fprintln(os.Stderr, "--bootstrap-authority cannot be combined with custom policy inputs") os.Exit(2) } + var verifiedPolicy *trustPolicy if !customPolicyInputs { - authorityFindings, authorityErr := validateRepositoryAuthority(repoRoot, scanRoot, bootstrapAuthority) + authorityFindings, effectivePolicy, authorityErr := validateRepositoryAuthorityPolicy(repoRoot, scanRoot, bootstrapAuthority) if authorityErr != nil { fmt.Fprintf(os.Stderr, "validate repository authority: %v\n", authorityErr) os.Exit(1) @@ -2337,6 +2459,7 @@ func main() { } os.Exit(1) } + verifiedPolicy = effectivePolicy } if allowPath == "" { allowPath = filepath.Join(repoRoot, ".github", "public-workflow-secret-allowlist.json") @@ -2410,6 +2533,15 @@ 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 04156183..11bb80e0 100644 --- a/.github/workflows/policytool/main_test.go +++ b/.github/workflows/policytool/main_test.go @@ -2,10 +2,13 @@ package main import ( "context" + "crypto/sha256" "encoding/json" + "fmt" "go/ast" "go/parser" "go/token" + "io" "os" "os/exec" "path/filepath" @@ -1777,3 +1780,631 @@ 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 cdf9fa13..0eade9bf 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 9c38f6e1a92d34d31cd73ba2bd2b092216ab9c115af79a0ef5ec13343c4f4d2d - verify_policytool_file main_test.go fadde1edd916918a69f912177a74565a6138ed935aae3509d7c6082924467778 + verify_policytool_file main.go 6db7d0f72db318c232a9f5418e64763c92414d3d45b5b55f041eb332c695198f + verify_policytool_file main_test.go ec415dca7a83c743a16b69636f4fa10134099a828d1f8db01861b47d10114900 verify_policytool_file go.mod 0a2d135980310757f36e733161b94498b4e0a1b6ac8c6de8a799f542d6c9ddb8 verify_policytool_file go.sum 790ef858e5aeed12269a69e764ac69c02c3877678b0e7d9384ad3728b6e09f6c }