diff --git a/.github/wiki/guides/ci/Trust-Security-and-Privacy.md b/.github/wiki/guides/ci/Trust-Security-and-Privacy.md index 9f0f99161..e2b3ff74f 100644 --- a/.github/wiki/guides/ci/Trust-Security-and-Privacy.md +++ b/.github/wiki/guides/ci/Trust-Security-and-Privacy.md @@ -289,12 +289,12 @@ A missing fact takes its untrusted value. A first-time contributor's run needs a **A local run** is classified from git, by provenance and never by author name or email (anyone can write your email into a commit). Git runs with `--no-replace-objects`. -- `owner`: HEAD is on the `origin` default branch; or the reflogs show that every commit that branch does not contain was made here. The oldest reflog entry of a commit decides: `commit`, `commit (amend)`, a rebase step that rewrites a commit (also under `pull --rebase`), a merge commit, `cherry-pick` and `revert` count; `checkout`, `fetch`, `pull` that fast-forwards, `branch: Created from`, `rebase (finish)` and anything that fast-forwards do not. Pushing your own commits changes nothing: you still made them. A stranger's commit you fetched or checked out has an arrival entry, and a branch you fetched back from a peer keeps your `commit` entries. +- `owner`: HEAD is on the `origin` default branch; or the reflogs show that every commit that branch does not contain was made here. The oldest reflog entry of a commit decides: `commit`, `commit (amend)`, a rebase step that rewrites a commit (also under `pull --rebase`), a merge commit, `cherry-pick` and `revert` count; `checkout`, `fetch`, `pull` that fast-forwards, `branch: Created from`, `rebase (finish)` and anything that fast-forwards do not. Pushing your own commits changes nothing: you still made them. A stranger's commit you fetched or checked out has an arrival entry, and a branch you fetched back from a peer keeps your `commit` entries. A rewrite (a rebase step, `commit (amend)`, `cherry-pick`) counts only when its change has a source made here: its patch (added and removed lines, whitespace and positions ignored) equals that of a commit made here (or one on the default branch), or, for an amend, the commit it amended (its older reflog neighbour with the same parents) counts. A rewrite of someone else's commit never counts, whether or not anything still refers to the original. - `collaborator`: HEAD is on another branch of `origin`. Pull-request namespaces (`refs/remotes/origin/pr/*`, `pull/*`, `mr/*` and any refspec that maps something other than `refs/heads/*`) do not count as branches. - `untrusted`: anything else, including a stranger's pull request checked out with `gh pr checkout`, an unfinished merge, squash, cherry-pick, revert or rebase (the tree holds content no commit accounts for), and any case where git could not answer. - A repository with no remote, a directory that is not a git repository, and a repository without a commit are your own directory: `owner`. A remote that is configured but never fetched is judged by the reflog like a branch with no default. -Limits, so you can judge them: the reflog expires and is read for the last 5000 entries, so an old branch (or more than 500 commits beyond the default branch) may fall to `untrusted`; run `git remote set-head origin -a` so the default branch is known when it is not `main` or `master`. A commit you rebase, amend or cherry-pick is a new commit made here, even when its content came from someone else's branch, so rebasing a stranger's pull request before you test it makes it `owner` (patch-id matching against remote commits is the planned fix, see the debt register). Uncommitted changes count as yours. A repository you extracted from an archive, or cloned from a stranger, is whatever its `origin` says it is. Run `nself ci --trust owner` when you know better, and use an isolated runner for code you did not write. +Limits, so you can judge them: the reflog expires and is read for the last 5000 entries, so an old branch (or more than 500 commits beyond the default branch) may fall to `untrusted`; run `git remote set-head origin -a` so the default branch is known when it is not `main` or `master`. Rebasing, amending or cherry-picking a stranger's pull request does not make it `owner`: the rewritten commit is reported with `local.rewrite_not_made_here`. Fail-closed costs: a rebase step whose conflicts you resolved, a squash or fixup, your own commits rewritten on another machine, and an amend whose original entry has expired have no source made here and need `--trust owner`. A grafts file (`.git/info/grafts` or `$GIT_GRAFT_FILE`) can rewrite parents, so a checkout that has one is `untrusted` (`local.history_grafted`); a fetch refspec that maps anything but `refs/heads/*` into `refs/remotes/origin/` (a glob anywhere in its destination) is never an origin branch. Content you commit yourself (a `cherry-pick -n` then `commit`, a `merge --squash` then `commit`) counts as yours. Uncommitted changes count as yours. A repository you extracted from an archive, or cloned from a stranger, is whatever its `origin` says it is. Run `nself ci --trust owner` when you know better, and use an isolated runner for code you did not write. **`--trust owner`** forces `owner` and writes one audit row (`trust.override`) that names the revision, the verdict it replaced and its reasons. If the row cannot be written the run is refused (`E625`). `--trust` with a lower class always works; it never raises a class except to `owner`. diff --git a/free/ci/internal/run/trust_git.go b/free/ci/internal/run/trust_git.go index 6c2c85e1e..4290d97c3 100644 --- a/free/ci/internal/run/trust_git.go +++ b/free/ci/internal/run/trust_git.go @@ -7,8 +7,10 @@ package run // Constraints: git runs with --no-replace-objects (a `git replace` ref must not rewrite the history // under test); only refs/remotes/origin/ names the default branch; every FETCH_HEAD line counts, // not just the first; an unfinished merge, squash, cherry-pick, revert or rebase is reported; -// a git failure is listed in Missing, never read as an answer; the reflog is read from every -// log (a linked worktree has a HEAD log of its own) and each commit keeps its OLDEST entry. +// a git failure is listed in Missing, never read as an answer; a grafts file (info/grafts or +// $GIT_GRAFT_FILE) rewrites parents, so its presence is MissingHistory; the reflog is read from every +// log (a linked worktree has a HEAD log of its own) and each commit keeps its OLDEST entry; a +// rewritten commit's patch is compared with the commits that arrived (trust_git_patch.go). import ( "context" @@ -67,6 +69,7 @@ func (h SourceTrust) gather(ctx context.Context, root, rev string) source.GitFac var symref string var refsOK, remotesOK, pathsOK bool var entries []source.ReflogEntry + var amends map[string][]string run(func() { out, exit := git("for-each-ref", "--format=%(refname)", "refs/remotes", "refs/pull") refs, refsOK = lines(out), exit == 0 @@ -79,15 +82,15 @@ func (h SourceTrust) gather(ctx context.Context, root, rev string) source.GitFac }) run(func() { out, exit := git("rev-parse", "--git-path", "FETCH_HEAD", "--git-path", "SQUASH_MSG", "--git-path", "MERGE_HEAD", "--git-path", "CHERRY_PICK_HEAD", - "--git-path", "REVERT_HEAD", "--git-path", "rebase-merge", "--git-path", "rebase-apply") - paths, pathsOK = lines(out), exit == 0 && len(lines(out)) == 7 + "--git-path", "REVERT_HEAD", "--git-path", "rebase-merge", "--git-path", "rebase-apply", "--git-path", "info/grafts") + paths, pathsOK = lines(out), exit == 0 && len(lines(out)) == 8 }) run(func() { out, exit := git("reflog", "show", "--all", "--date=unix", "--format=%H%x09%gd%x09%gs", "-n", "5000") if exit != 0 { miss(source.MissingReflog) } - entries = reflogEntries(out) + entries, amends = reflogEntries(out), amendPreImages(out) }) wg.Wait() if g.NotARepo { @@ -101,11 +104,14 @@ func (h SourceTrust) gather(ctx context.Context, root, rev string) source.GitFac if !ok { pathsOK = false } - for _, m := range paths[1:] { + for _, m := range paths[1:7] { if _, err := os.Stat(rootPath(root, m)); err == nil { g.InProgress = true } } + if h.grafted(root, paths[7]) { + miss(source.MissingHistory) + } } if !pathsOK || !refsOK || !remotesOK { miss(source.MissingRemoteRefs) @@ -154,6 +160,7 @@ func (h SourceTrust) gather(ctx context.Context, root, rev string) source.GitFac run(func() { g.OffDefault = listed("rev-list", "-n", "501", rev) }) } wg.Wait() + replayedPatches(git, &g, amends) return g } @@ -172,7 +179,8 @@ func defaultRef(symref string, refs []string) string { } // excludedRemoteDirs lists the refs/remotes/origin/ prefixes that hold pull requests: the usual names -// and every destination the origin's fetch refspecs map from something other than refs/heads/*. +// and the fixed part (up to the first glob) of every destination the origin's fetch refspecs map from +// something other than refs/heads/* (D-1431: refs/remotes/origin/gh-pr/*/head). func excludedRemoteDirs(refspecs []string) []string { var skip []string for _, d := range prDirs { @@ -181,7 +189,8 @@ func excludedRemoteDirs(refspecs []string) []string { for _, spec := range refspecs { src, dst, ok := strings.Cut(strings.TrimPrefix(spec, "+"), ":") if ok && !strings.HasPrefix(src, "refs/heads/") { - skip = append(skip, strings.TrimSuffix(dst, "*")) + prefix, _, _ := strings.Cut(dst, "*") // a glob anywhere in the destination: everything below its fixed part + skip = append(skip, prefix) } } return skip diff --git a/free/ci/internal/run/trust_git_patch.go b/free/ci/internal/run/trust_git_patch.go new file mode 100644 index 000000000..4d97e0fa1 --- /dev/null +++ b/free/ci/internal/run/trust_git_patch.go @@ -0,0 +1,222 @@ +package run + +// Purpose: the patch half of local provenance (D-1322): a rewritten commit of a run (oldest reflog entry a +// rebase step, an amend or a cherry-pick) is created here only when its change has a source made here, so a +// replay of someone else's change is never counted as created here, even when nothing refers to the +// original any more (a deleted branch, an overwritten FETCH_HEAD, an expired reflog). +// Inputs: the gathered GitFacts (OffDefault, ReflogCreated, DefaultRemoteRef, Head) and the amend pre-images +// read from the reflog (amendPreImages). +// Outputs: GitFacts.ReplayedPatch, or MissingPatches when the comparison could not be made; grafted. +// Constraints: a source made here is a commit whose oldest entry is a creation other than a rewrite, a +// rewrite already counted as made here, or a commit of the default branch that Head does not have; +// a rewrite counts when its patch equals a source's, or (an amend) when the commit it amended +// counts; a commit with no change lines adds no code and counts. The patch id ignores line numbers, +// context and whitespace, so a replay onto a moved base still matches. Known cost, fail closed: a +// rebase step whose conflicts you resolved, or a squash, changes the patch and is not counted. +// More than maxPatchCommits commits on a side, or a git failure, is MissingPatches. + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "slices" + "strconv" + "strings" + + "github.com/nself-org/plugins/free/ci/internal/trust/source" +) + +// maxPatchCommits bounds the commits whose patches are compared on one side. +const maxPatchCommits = 5000 + +// patchLog is the log format both sides are read with: one NUL-led line with the commit id and its parents, +// then its patch. +var patchLog = []string{"-p", "--no-color", "--full-index", "--no-ext-diff", "--no-textconv", "--no-renames", "--format=%x00%H%x09%P"} + +type gitFn func(args ...string) (string, int) + +// replayedPatches fills g.ReplayedPatch with the rewritten commits of g.OffDefault that have no source made here. +func replayedPatches(git gitFn, g *source.GitFacts, amends map[string][]string) { + var rewritten, made []string + for _, c := range g.OffDefault { + if source.Rewrite(g.ReflogCreated[c.SHA]) { + rewritten = append(rewritten, c.SHA) + } + } + if len(rewritten) == 0 { + return + } + for sha, subject := range g.ReflogCreated { + if source.MadeHere(subject) { + made = append(made, sha) + } + } + slices.Sort(made) // a stable argv: map order is random + ids, parents, ok := logPatches(git, maxPatchCommits, append([]string{"--ignore-missing", "--no-walk=unsorted"}, made...)) + for _, sha := range rewritten { + if _, have := ids[sha]; !have { + ok = false + } + } + if !ok { + g.Missing = append(g.Missing, source.MissingPatches) + return + } + own, created := map[string]bool{}, map[string]bool{} + for _, sha := range made { + if !source.Rewrite(g.ReflogCreated[sha]) { + created[sha], own[ids[sha]] = true, true + } + } + settle := func() bool { // true when every rewritten commit of the run counts as made here + for changed := true; changed; { + changed = false + for _, sha := range made { + subject, id := g.ReflogCreated[sha], ids[sha] + if created[sha] || !source.Rewrite(subject) { + continue + } + if id == "" || own[id] || amendedOwn(subject, sha, amends, parents, created) { + created[sha], own[id], changed = true, true, true + } + } + } + return !slices.ContainsFunc(rewritten, func(sha string) bool { return !created[sha] }) + } + if !settle() && g.DefaultRemoteRef != "" { // a backport: the default branch's own changes are sources too + def, _, fine := logPatches(git, maxPatchCommits, []string{"--no-merges", g.DefaultRemoteRef, "--not", g.Head}) + if !fine { + g.Missing = append(g.Missing, source.MissingPatches) + return + } + for _, id := range def { + own[id] = true + } + settle() + } + for _, sha := range rewritten { + if !created[sha] { + if g.ReplayedPatch == nil { + g.ReplayedPatch = map[string]bool{} + } + g.ReplayedPatch[sha] = true + } + } +} + +func amended(subject string) bool { return strings.HasPrefix(subject, "commit (amend)") } + +// amendedOwn: sha is an amend of a commit counted as made here. The pre-image is the older neighbour in the +// reflog; an amend keeps the parents, so a neighbour with other parents (its own entry was deleted or +// expired) is not the pre-image and does not count. +func amendedOwn(subject, sha string, amends map[string][]string, parents map[string]string, created map[string]bool) bool { + if !amended(subject) { + return false + } + for _, p := range amends[sha] { + if pp, known := parents[p]; known && created[p] && pp == parents[sha] { + return true + } + } + return false +} + +// amendPreImages maps each commit an amend made to the candidates for the commit it replaced: the entry just +// older in each log that records the amend. Each log is listed newest first; the logs are grouped by ref +// here, whatever order they come in. +func amendPreImages(out string) map[string][]string { + type entry struct{ sha, subject string } + logs := map[string][]entry{} + for _, l := range strings.Split(out, "\n") { + f := strings.SplitN(strings.TrimSpace(l), "\t", 3) + if len(f) != 3 || !fullSHA.MatchString(f[0]) { + continue + } + ref := f[1] + if at := strings.LastIndex(ref, "@{"); at >= 0 { + ref = ref[:at] + } + logs[ref] = append(logs[ref], entry{f[0], f[2]}) + } + pre := map[string][]string{} + for _, es := range logs { + for i := 0; i+1 < len(es); i++ { + if amended(es[i].subject) { + pre[es[i].sha] = append(pre[es[i].sha], es[i+1].sha) + } + } + } + return pre +} + +// grafted reports whether a grafts file may rewrite the parents git reports: info/grafts of the repository +// or $GIT_GRAFT_FILE. A file that cannot be checked counts as present. +func (h SourceTrust) grafted(root, gitPath string) bool { + for _, p := range []string{rootPath(root, gitPath), h.getenv("GIT_GRAFT_FILE")} { + if p == "" { + continue + } + if _, err := os.Stat(p); err == nil || !errors.Is(err, fs.ErrNotExist) { + return true + } + } + return false +} + +// logPatches runs `git log` with patchLog over args and returns the patch id and the parents of each +// commit; ok is false when git failed or more than limit commits came back. +func logPatches(git gitFn, limit int, args []string) (ids, parents map[string]string, ok bool) { + out, exit := git(append(append([]string{"log", "-n", strconv.Itoa(limit + 1)}, patchLog...), args...)...) + ids, parents = patchIDs(out) + return ids, parents, exit == 0 && len(ids) <= limit +} + +// patchIDs splits patchLog output into a patch id per commit ("" for a commit with no change lines) and +// its parents. +func patchIDs(out string) (ids, parents map[string]string) { + ids, parents = map[string]string{}, map[string]string{} + for _, block := range strings.Split(out, "\x00")[1:] { + head, diff, _ := strings.Cut(block, "\n") + sha, ps, _ := strings.Cut(strings.TrimSpace(head), "\t") + if fullSHA.MatchString(sha) { + ids[sha], parents[sha] = patchID(diff), strings.TrimSpace(ps) + } + } + return ids, parents +} + +// patchID hashes the changes of one commit the way `git patch-id --stable` treats them, and looser: per file +// its header lines and its added and removed lines with all whitespace removed (no hunk positions, no index +// line, no context), the file hashes sorted, so file order and a moved base do not change it. +func patchID(diff string) string { + var files []string + var cur []string + flush := func() { + if len(cur) > 1 { + sum := sha256.Sum256([]byte(strings.Join(cur, "\n"))) + files = append(files, hex.EncodeToString(sum[:])) + } + cur = nil + } + for _, l := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(l, "diff --git "): + flush() + cur = []string{l} + case cur == nil, l == "", strings.HasPrefix(l, "index "), strings.HasPrefix(l, "@@"), strings.HasPrefix(l, " "): + case strings.HasPrefix(l, "+++ "), strings.HasPrefix(l, "--- "): + cur = append(cur, l) + default: // + and - lines, mode lines, "Binary files ... differ" + cur = append(cur, strings.Join(strings.Fields(l), "")) + } + } + flush() + if len(files) == 0 { + return "" + } + slices.Sort(files) + sum := sha256.Sum256([]byte(strings.Join(files, "\n"))) + return hex.EncodeToString(sum[:]) +} diff --git a/free/ci/internal/trust/source/facts.go b/free/ci/internal/trust/source/facts.go index f1b022b57..1485486b1 100644 --- a/free/ci/internal/trust/source/facts.go +++ b/free/ci/internal/trust/source/facts.go @@ -44,6 +44,8 @@ const ( ReasonLocalInProgress = "local.operation_in_progress" ReasonLocalIncomplete = "local.facts_incomplete" ReasonLocalOverride = "local.trust_owner_override" + ReasonLocalReplayed = "local.rewrite_not_made_here" + ReasonLocalGrafted = "local.history_grafted" ) // Result is a classification: the class, why, and the side facts the callers need. @@ -64,6 +66,8 @@ const ( MissingRemoteRefs = "remote_refs" MissingCommits = "commits" MissingReflog = "reflog" + MissingPatches = "patches" + MissingHistory = "history" // a grafts file may rewrite the parents git reports ) // GitFacts are the git facts of the checkout a local run is bound to. @@ -92,6 +96,11 @@ type GitFacts struct { // ReflogCreated maps a commit to the subject of its OLDEST reflog entry in any log (OldestEntries), // the way it first reached this repository, for example "commit: fix" or "branch: Created from FETCH_HEAD". ReflogCreated map[string]string + // ReplayedPatch marks the commits of OffDefault whose oldest entry is a rewrite (Rewrite: a rebase step, + // an amend, a cherry-pick) and whose change has no source made here: no commit made here (or on the + // default branch) carries its patch, and (an amend) the commit it amended was not made here. Such a + // commit replays someone else's change, or one nothing here can account for (D-1322). + ReplayedPatch map[string]bool // Missing lists the facts that could not be gathered (the Missing* keys). Missing []string } diff --git a/free/ci/internal/trust/source/local.go b/free/ci/internal/trust/source/local.go index 64ebe4864..4eb9b5113 100644 --- a/free/ci/internal/trust/source/local.go +++ b/free/ci/internal/trust/source/local.go @@ -8,7 +8,9 @@ package source // here (commit, commit (amend), rebase, merge, cherry-pick; never checkout, fetch, pull, a // fast-forward). A pushed commit of yours still counts: D17 asks for "on no ref of any remote", // but an arrival entry is the proof of a stranger's commit and the push test made `nself ci` -// refuse your own pushed branch (lead ruling, TRUST EPIC Invalidations 2026-10-19). +// refuse your own pushed branch (lead ruling, TRUST EPIC Invalidations 2026-10-19). A rewrite +// (rebase step, amend, cherry-pick) is a creation only when its change has a source made here +// (D-1322); a grafts file makes the history unknown, so the checkout is untrusted (D-1431). // Otherwise collaborator when HEAD is on a branch of the origin remote, else untrusted. A fact // that could not be gathered closes the path that needs it. A repository with no remote at // all has nothing foreign to classify, so it is the operator's own (local.no_remote). @@ -36,6 +38,9 @@ func ClassifyLocal(g GitFacts) Result { if g.InProgress { return done(untrusted, ReasonLocalInProgress) } + if g.missing(MissingHistory) { + return done(untrusted, ReasonLocalGrafted) // reachability and every commit list may be the grafts file's + } if g.NoRemotes && len(g.Missing) == 0 { return done(owner, ReasonLocalNoRemote) } @@ -45,6 +50,9 @@ func ClassifyLocal(g GitFacts) Result { if createdHere(g) { return done(owner, ReasonLocalCreated) } + if replayed(g) { + r.Reasons = append(r.Reasons, ReasonLocalReplayed) + } if len(g.Missing) > 0 { r.Reasons = append(r.Reasons, ReasonLocalIncomplete) } @@ -67,13 +75,39 @@ func createdHere(g GitFacts) bool { } } for _, c := range g.OffDefault { - if !MadeHere(g.ReflogCreated[c.SHA]) { + subject := g.ReflogCreated[c.SHA] + if !MadeHere(subject) { return false } + if Rewrite(subject) && (g.missing(MissingPatches) || g.ReplayedPatch[c.SHA]) { + return false // a change with no source made here, or the patches could not be compared + } } return true } +// replayed reports whether a commit of Head is a rewrite with no source made here. +func replayed(g GitFacts) bool { + for _, c := range g.OffDefault { + if g.ReplayedPatch[c.SHA] { + return true + } + } + return false +} + +// Rewrite reports whether a creation subject (MadeHere) rewrites an existing commit rather than making a +// new change: a rebase step (also under `pull --rebase`), `commit (amend)` and `cherry-pick`. Such a +// commit is new here only when its change has a source made here (GitFacts.ReplayedPatch). +func Rewrite(subject string) bool { + if !MadeHere(subject) || strings.Contains(subject, "Merge made by") { + return false + } + kind, _, _ := strings.Cut(subject, ":") + kind = strings.TrimSpace(kind) + return kind == "commit (amend)" || strings.HasPrefix(kind, "rebase") || strings.HasPrefix(kind, "pull") || strings.HasPrefix(kind, "cherry-pick") +} + // MadeHere reports whether a reflog subject records a commit created on this machine: `commit`, // `commit (amend)`, a rebase step that rewrites a commit (also under `pull --rebase`), `cherry-pick`, // `revert` and a merge commit (also under `pull`). Anything that moves a branch to a commit someone diff --git a/free/ci/internal/trust/source/replay_test.go b/free/ci/internal/trust/source/replay_test.go new file mode 100644 index 000000000..8090e5059 --- /dev/null +++ b/free/ci/internal/trust/source/replay_test.go @@ -0,0 +1,226 @@ +package source_test + +// D-1322: a rebase, amend or cherry-pick of someone else's commit makes a new commit here, but it replays +// their patch, so it is not created here. Pure facts first, then real git: the stranger's replays stay below +// owner, and the operator's own rewrites (pushed or not) stay owner. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nself-org/plugins/free/ci/internal/model" + "github.com/nself-org/plugins/free/ci/internal/run" + "github.com/nself-org/plugins/free/ci/internal/trust/source" +) + +func TestClassifyRewrite(t *testing.T) { + for subject, want := range map[string]bool{ + "commit (amend): w": true, "rebase (pick): w": true, "rebase -i (pick): w": true, "rebase (squash): w": true, "rebase (continue): w": true, + "pull -q --rebase origin main (pick): w": true, "cherry-pick: w": true, "cherry-pick (continue): w": true, + "commit: w": false, "commit (initial): w": false, "commit (merge): w": false, "revert: Revert \"w\"": false, + "merge feat: Merge made by the 'ort' strategy.": false, "pull origin main: Merge made by the 'ort' strategy.": false, + "cherry-pick: fast-forward": false, "rebase (finish): refs/heads/x onto 0123": false, "checkout: moving from a to b": false, "": false, + } { + if got := source.Rewrite(subject); got != want { + t.Errorf("Rewrite(%q) = %v", subject, got) + } + } +} + +func TestClassifyLocalReplayedPatch(t *testing.T) { + const def = "refs/remotes/origin/main" + rewritten := map[string]string{"a": "commit: base", "b": "rebase (pick): w"} + for _, tc := range []struct { + name string + g source.GitFacts + want model.TrustClass + reason string + }{ + {"a rewrite of an own patch", source.GitFacts{Head: "b", DefaultRemoteRef: def, OffDefault: c("a", "b"), ReflogCreated: rewritten}, "owner", source.ReasonLocalCreated}, + {"a rewrite of an arrived patch", source.GitFacts{Head: "b", DefaultRemoteRef: def, OffDefault: c("a", "b"), ReflogCreated: rewritten, ReplayedPatch: map[string]bool{"b": true}}, + "untrusted", source.ReasonLocalReplayed}, + {"a replayed patch pushed to origin", source.GitFacts{Head: "b", DefaultRemoteRef: def, OnOriginRef: true, OffDefault: c("a", "b"), ReflogCreated: rewritten, + ReplayedPatch: map[string]bool{"b": true}}, "collaborator", source.ReasonLocalOnOrigin}, + {"patches not compared closes the owner path for a rewrite", source.GitFacts{Head: "b", DefaultRemoteRef: def, OffDefault: c("a", "b"), ReflogCreated: rewritten, + Missing: []string{source.MissingPatches}}, "untrusted", source.ReasonLocalIncomplete}, + {"a grafts file closes every path, reachability included", source.GitFacts{Head: "a", DefaultRemoteRef: def, ReachableFromDefault: true, + Missing: []string{source.MissingHistory}}, "untrusted", source.ReasonLocalGrafted}, + {"patches not compared do not matter without a rewrite", source.GitFacts{Head: "a", DefaultRemoteRef: def, OffDefault: c("a"), ReflogCreated: rewritten, + Missing: []string{source.MissingPatches}}, "owner", source.ReasonLocalCreated}, + } { + got := source.ClassifyLocal(tc.g) + if got.Class != tc.want || !contains(got.Reasons, tc.reason) { + t.Errorf("%s: %s %v, want %s with %s", tc.name, got.Class, got.Reasons, tc.want, tc.reason) + } + } +} + +// advance pushes one commit by someone else to origin/main and fetches it, so a replay is not a fast-forward. +func (x *fx) advance() { + x.t.Helper() + other := filepath.Join(x.tmp, "other") + git(x.t, x.tmp, "clone", "-q", x.origin, other) + git(x.t, other, "config", "user.name", "Other") + git(x.t, other, "config", "user.email", "other@example.test") + git(x.t, other, "config", "commit.gpgsign", "false") + x.commit(other, "newer.txt") + git(x.t, other, "push", "-q", "origin", "main") + git(x.t, x.op, "fetch", "-q", "origin") +} + +func (x *fx) fetchStranger() string { + x.t.Helper() + git(x.t, x.op, "fetch", "-q", x.stranger, "pr") + return git(x.t, x.op, "rev-parse", "FETCH_HEAD") +} + +func TestClassifyGitReplays(t *testing.T) { + check := func(name string, setup func(*fx), want model.TrustClass, reason string) { + t.Run(name, func(t *testing.T) { + t.Parallel() + x := newFx(t) + setup(x) + got := x.decide("", run.SourceTrust{}) + if got.Class != want || !contains(got.Reasons, reason) { + t.Fatalf("%s %v, want %s with %s", got.Class, got.Reasons, want, reason) + } + }) + } + // The stranger's patch, replayed by the operator. + check("stranger branch rebased onto newer main", func(x *fx) { + sha := x.fetchStranger() + x.advance() + git(x.t, x.op, "checkout", "-q", "-b", "x", sha) + git(x.t, x.op, "rebase", "-q", "origin/main") + }, "untrusted", source.ReasonLocalReplayed) + check("stranger commit amended (message only)", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "pr", x.fetchStranger()) + git(x.t, x.op, "commit", "-q", "--amend", "-m", "tidy message") + }, "untrusted", source.ReasonLocalReplayed) + check("stranger commit cherry-picked while FETCH_HEAD holds it", func(x *fx) { + sha := x.fetchStranger() + git(x.t, x.op, "checkout", "-q", "-b", "y") + x.commit(x.op, "mine.txt") + git(x.t, x.op, "cherry-pick", sha) + }, "untrusted", source.ReasonLocalReplayed) + check("stranger commit cherry-picked after FETCH_HEAD moved on", func(x *fx) { + sha := x.fetchStranger() + x.advance() + git(x.t, x.op, "checkout", "-q", "-b", "y", "origin/main") + git(x.t, x.op, "cherry-pick", sha) + }, "untrusted", source.ReasonLocalReplayed) + check("a collaborator's origin branch rebased onto newer main", func(x *fx) { + git(x.t, x.stranger, "push", "-q", "origin", "pr") + x.advance() + git(x.t, x.op, "checkout", "-q", "-b", "pr", "origin/pr") + git(x.t, x.op, "rebase", "-q", "origin/main") + }, "untrusted", source.ReasonLocalReplayed) + check("stranger commit rebased, then nothing refers to it any more", func(x *fx) { + sha := x.fetchStranger() + x.advance() + git(x.t, x.op, "checkout", "-q", "-b", "x", sha) + git(x.t, x.op, "rebase", "-q", "origin/main") + git(x.t, x.op, "reflog", "expire", "--expire-unreachable=now", "--all") + git(x.t, x.op, "fetch", "-q", "origin") + }, "untrusted", source.ReasonLocalReplayed) + check("stranger commit amended with a content change, then nothing refers to it any more", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "pr", x.fetchStranger()) + x.commitAmend("extra.txt") + // drop the entries that name the stranger's commit, keep the amend's own: the older neighbour of the + // amend in HEAD's log is then the operator's base commit, which must not pass for the pre-image + git(x.t, x.op, "reflog", "delete", "refs/heads/pr@{1}") + git(x.t, x.op, "reflog", "delete", "HEAD@{1}") + git(x.t, x.op, "fetch", "-q", "origin") + }, "untrusted", source.ReasonLocalReplayed) + // The operator's own work, rewritten: still created here. + check("own pushed branch rebased onto newer main", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "feat") + x.commit(x.op, "one.txt") + git(x.t, x.op, "push", "-q", "-u", "origin", "feat") + x.advance() + git(x.t, x.op, "rebase", "-q", "origin/main") + }, "owner", source.ReasonLocalCreated) + check("own commit amended with a content change", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "feat") + x.commit(x.op, "one.txt") + x.commit(x.op, "two.txt") + git(x.t, x.op, "reset", "-q", "--soft", "HEAD~1") + git(x.t, x.op, "commit", "-q", "--amend", "-m", "one and two") + }, "owner", source.ReasonLocalCreated) + check("own commit amended twice with content changes, then rebased", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "feat") + x.commit(x.op, "one.txt") + x.commitAmend("two.txt") + x.commitAmend("three.txt") + x.advance() + git(x.t, x.op, "rebase", "-q", "origin/main") + }, "owner", source.ReasonLocalCreated) + check("own commit cherry-picked to another branch", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "feat") + sha := x.commit(x.op, "one.txt") + git(x.t, x.op, "checkout", "-q", "-b", "other", "origin/main") + git(x.t, x.op, "cherry-pick", sha) + }, "owner", source.ReasonLocalCreated) + check("a default-branch commit cherry-picked onto an older branch", func(x *fx) { + git(x.t, x.op, "checkout", "-q", "-b", "release") + x.commit(x.op, "rel.txt") + x.advance() + git(x.t, x.op, "cherry-pick", "origin/main") + }, "owner", source.ReasonLocalCreated) +} + +// commitAmend adds file to the last commit (an amend that changes its content). +func (x *fx) commitAmend(file string) { + x.t.Helper() + if err := os.WriteFile(filepath.Join(x.op, file), []byte(file+"\n"), 0o644); err != nil { + x.t.Fatal(err) + } + git(x.t, x.op, "add", file) + git(x.t, x.op, "commit", "-q", "--amend", "-m", "amended with "+file) +} + +// D-1431 (D04, D06): a pull request refspec with a glob in the middle of its destination is not an origin +// branch, and a grafts file (the repository's or $GIT_GRAFT_FILE) cannot make a stranger's commit reachable. +func TestClassifyGitRefspecsAndGrafts(t *testing.T) { + t.Run("mid-path glob refspec", func(t *testing.T) { + t.Parallel() + x := newFx(t) + git(t, x.stranger, "push", "-q", "origin", "pr:refs/pull/7/head") + git(t, x.op, "config", "--add", "remote.origin.fetch", "+refs/pull/*/head:refs/remotes/origin/gh-pr/*/head") + git(t, x.op, "fetch", "-q", "origin") + git(t, x.op, "checkout", "-q", "-b", "prx", "origin/gh-pr/7/head") + if got := x.decide("", run.SourceTrust{}); got.Class != "untrusted" || !contains(got.Reasons, source.ReasonLocalForeign) { + t.Fatalf("%s %v, want untrusted", got.Class, got.Reasons) + } + }) + grafted := func(t *testing.T, viaEnv bool) { + t.Parallel() + x := newFx(t) + sha := x.fetchStranger() + tip := git(t, x.op, "rev-parse", "origin/main") + graft := filepath.Join(x.op, ".git", "info", "grafts") + h := run.SourceTrust{} + if viaEnv { + graft = filepath.Join(x.tmp, "grafts") + h.Getenv = func(k string) string { + if k == "GIT_GRAFT_FILE" { + return graft + } + return "" + } + } + if err := os.MkdirAll(filepath.Dir(graft), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(graft, []byte(tip+" "+sha+"\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, x.op, "checkout", "-q", "--detach", sha) + if got := x.decide("", h); got.Class != "untrusted" || !contains(got.Reasons, source.ReasonLocalGrafted) { + t.Fatalf("%s %v, want untrusted with %s", got.Class, got.Reasons, source.ReasonLocalGrafted) + } + } + t.Run("info/grafts", func(t *testing.T) { grafted(t, false) }) + t.Run("GIT_GRAFT_FILE", func(t *testing.T) { grafted(t, true) }) +} diff --git a/free/ci/test/security/redteam/d_prov_local_test.go b/free/ci/test/security/redteam/d_prov_local_test.go index 53de42603..c1fd8b8fa 100644 --- a/free/ci/test/security/redteam/d_prov_local_test.go +++ b/free/ci/test/security/redteam/d_prov_local_test.go @@ -106,9 +106,6 @@ func (x *dFx) fetchStranger() string { // D01: the operator replays the stranger's single commit (rebase onto newer main). The tree content // is still the stranger's, so the class must stay below owner. func TestD01_RebaseOfStrangerCommitStaysForeign(t *testing.T) { - if !openFinding(t, "D01") { - return - } x := newDFx(t) sha := x.fetchStranger() x.advanceMain() @@ -121,9 +118,6 @@ func TestD01_RebaseOfStrangerCommitStaysForeign(t *testing.T) { // D02: the operator amends the stranger's single commit (a message tidy-up). Same content, new SHA. func TestD02_AmendOfStrangerCommitStaysForeign(t *testing.T) { - if !openFinding(t, "D01") { - return - } x := newDFx(t) dGit(t, x.op, "checkout", "-q", "-b", "pr", x.fetchStranger()) dGit(t, x.op, "commit", "-q", "--amend", "-m", "tidy message") @@ -134,9 +128,6 @@ func TestD02_AmendOfStrangerCommitStaysForeign(t *testing.T) { // D03: the operator cherry-picks the stranger's commit onto a branch of main. func TestD03_CherryPickOfStrangerCommitStaysForeign(t *testing.T) { - if !openFinding(t, "D01") { - return - } x := newDFx(t) sha := x.fetchStranger() x.advanceMain() @@ -150,9 +141,6 @@ func TestD03_CherryPickOfStrangerCommitStaysForeign(t *testing.T) { // D04: fetch refspec maps pull requests to refs/remotes/origin/gh-pr//head, a glob in the middle // of the destination. A stranger's PR must not read as a branch of the origin remote. func TestD04_PullRefspecWithMidPathGlobIsNotAnOriginBranch(t *testing.T) { - if !openFinding(t, "D04") { - return - } x := newDFx(t) dGit(t, x.stranger, "push", "-q", "origin", "pr:refs/pull/7/head") dGit(t, x.op, "config", "--add", "remote.origin.fetch", "+refs/pull/*/head:refs/remotes/origin/gh-pr/*/head") @@ -180,9 +168,6 @@ func TestD05_RefsNamedLikeOwnerRefs(t *testing.T) { // D06: .git/info/grafts claims the stranger commit is the parent of origin/main, so it would look // reachable from the default branch. func TestD06_GraftsFileCannotMakeStrangerReachable(t *testing.T) { - if !openFinding(t, "D06") { - return - } x := newDFx(t) sha := x.fetchStranger() tip := dGit(t, x.op, "rev-parse", "origin/main")