From be42e4bc4caeea4db25f764446e0fe6d6718d914 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:21:16 -0400 Subject: [PATCH 1/2] fix(ci): a rebase, amend or cherry-pick of someone else's commit is not created here (D-1322) Local source trust read every rewrite (rebase step, commit --amend, cherry-pick) as a commit created on this machine, so rebasing or amending a stranger's pull request, or cherry-picking one of its commits, classified the run owner. - A rewritten commit is a replay when its patch (added and removed lines, whitespace and hunk positions ignored, file order free) equals a commit that arrived here: reachable from a remote-tracking ref, refs/pull/*, a FETCH_HEAD line or a reflog arrival entry, off the default branch, and not made here. - A cherry-pick counts as made here only when its patch equals a commit made here (not itself a cherry-pick) or one on the default branch, which covers a source commit nothing refers to any more. - A replay keeps the owner path closed and is reported as local.replayed_arrived_patch; patches that could not be compared close it too. - Red team D01-D03 run as regular tests; real-git fixtures cover the stranger's replays and the operator's own rebased, amended and cherry-picked commits (pushed or not), which stay owner. --- .../guides/ci/Trust-Security-and-Privacy.md | 4 +- free/ci/internal/run/trust_git.go | 4 +- free/ci/internal/run/trust_git_patch.go | 176 ++++++++++++++++++ free/ci/internal/trust/source/facts.go | 7 + free/ci/internal/trust/source/local.go | 35 +++- free/ci/internal/trust/source/replay_test.go | 143 ++++++++++++++ .../security/redteam/d_prov_local_test.go | 9 - 7 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 free/ci/internal/run/trust_git_patch.go create mode 100644 free/ci/internal/trust/source/replay_test.go diff --git a/.github/wiki/guides/ci/Trust-Security-and-Privacy.md b/.github/wiki/guides/ci/Trust-Security-and-Privacy.md index 9f0f99161..8fcc72abb 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 it does not replay someone else's patch: its patch (added and removed lines, whitespace and positions ignored) must not equal a commit that arrived here (reachable from a remote-tracking ref, `refs/pull/*`, a `FETCH_HEAD` line or an arrival entry, off the default branch, and not made here), and a cherry-pick's patch must equal a commit made here or one on the default branch. - `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 replayed commit is reported with `local.replayed_arrived_patch`. Your own commits rewritten on another machine and fetched here count as arrived too. Residual: a rebase or amend of a commit nothing here refers to any more (its branch deleted, its `FETCH_HEAD` overwritten, its reflog expired), or one whose patch you changed while rewriting it, still counts as made here. 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..92757a8ba 100644 --- a/free/ci/internal/run/trust_git.go +++ b/free/ci/internal/run/trust_git.go @@ -8,7 +8,8 @@ package run // 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. +// 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" @@ -154,6 +155,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, fetched) return g } 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..c0267e848 --- /dev/null +++ b/free/ci/internal/run/trust_git_patch.go @@ -0,0 +1,176 @@ +package run + +// Purpose: the patch half of local provenance (D-1322): which rewritten commits of a run (oldest reflog entry +// a rebase step, an amend or a cherry-pick) replay a change someone else made, so they are not counted as +// created here: a rewrite whose patch arrived from elsewhere, and a cherry-pick whose patch was not made here. +// Inputs: the gathered GitFacts (OffDefault, ReflogCreated, DefaultRemoteRef) and the FETCH_HEAD commits. +// Outputs: GitFacts.ReplayedPatch, or MissingPatches when the comparison could not be made. +// Constraints: the arrived side is every commit reachable from a remote-tracking ref, refs/pull/*, a FETCH_HEAD +// line or a commit whose oldest reflog entry is an arrival, minus the default branch, minus every +// commit whose own oldest entry is a creation (your own commit, pushed or not, is not arrived); the +// patch id ignores line numbers, context lines and whitespace, so a replay onto a moved base still +// matches; a cherry-pick counts as made here only when its patch is that of a commit made here (not a +// cherry-pick) or of the default branch; more than maxPatchCommits commits on a side, or a git +// failure, is MissingPatches. Residual: a rebase or amend of a commit nothing refers to any more. + +import ( + "crypto/sha256" + "encoding/hex" + "slices" + "strconv" + "strings" + + "github.com/nself-org/plugins/free/ci/internal/trust/source" +) + +// maxPatchCommits bounds the arrived commits whose patches are compared. +const maxPatchCommits = 5000 + +// patchLog is the log format both sides are read with: one NUL-led commit id line, then its patch. +var patchLog = []string{"-p", "--no-color", "--full-index", "--no-ext-diff", "--no-textconv", "--no-renames", "--format=%x00%H"} + +// replayedPatches fills g.ReplayedPatch for the rewritten commits of g.OffDefault: a rewrite whose patch +// arrived from elsewhere, and a cherry-pick whose patch is neither one made here nor one on the default +// branch (its source may be a commit nothing refers to any more, such as an overwritten FETCH_HEAD). +func replayedPatches(git func(args ...string) (string, int), g *source.GitFacts, fetched []string) { + var rewritten []string + picks := map[string]bool{} + for _, c := range g.OffDefault { + if subject := g.ReflogCreated[c.SHA]; source.Rewrite(subject) { + rewritten = append(rewritten, c.SHA) + picks[c.SHA] = cherryPick(subject) + } + } + if len(rewritten) == 0 { + return + } + mine, ok := logPatches(git, len(rewritten), append([]string{"--no-walk=unsorted"}, rewritten...)) + arrived, ok2 := arrivedPatches(git, g, fetched) + own, ok3 := map[string]bool{}, true + if slices.ContainsFunc(rewritten, func(sha string) bool { return picks[sha] }) { + own, ok3 = ownPatches(git, g) + } + if !ok || !ok2 || !ok3 || len(mine) != len(rewritten) { + g.Missing = append(g.Missing, source.MissingPatches) + return + } + for _, sha := range rewritten { + if id := mine[sha]; id != "" && (arrived[id] || picks[sha] && !own[id]) { + if g.ReplayedPatch == nil { + g.ReplayedPatch = map[string]bool{} + } + g.ReplayedPatch[sha] = true + } + } +} + +// arrivedPatches are the patch ids of the commits that reached this repository from elsewhere and were not +// made here, off the default branch. +func arrivedPatches(git func(args ...string) (string, int), g *source.GitFacts, fetched []string) (map[string]bool, bool) { + var tips []string + for sha, subject := range g.ReflogCreated { + if !source.MadeHere(subject) { + tips = append(tips, sha) + } + } + slices.Sort(tips) // a stable argv: map order is random + // --ignore-missing: a FETCH_HEAD commit gc has pruned is no longer anything to compare with + args := append(append([]string{"--ignore-missing", "--no-merges", "--remotes", "--glob=refs/pull/*"}, fetched...), tips...) + if g.DefaultRemoteRef != "" { + args = append(args, "--not", g.DefaultRemoteRef) + } + ids, ok := logPatches(git, maxPatchCommits, args) + arrived := map[string]bool{} + for sha, id := range ids { + if id != "" && !source.MadeHere(g.ReflogCreated[sha]) { + arrived[id] = true + } + } + return arrived, ok +} + +// ownPatches are the patch ids a cherry-pick may come from and still be created here: a commit whose oldest +// entry is a creation other than a cherry-pick, and a commit of the default branch that Head does not have. +func ownPatches(git func(args ...string) (string, int), g *source.GitFacts) (map[string]bool, bool) { + var made []string + for sha, subject := range g.ReflogCreated { + if source.MadeHere(subject) && !cherryPick(subject) { + made = append(made, sha) + } + } + slices.Sort(made) + own, ok := map[string]bool{}, true + add := func(ids map[string]string, fine bool) { + ok = ok && fine + for _, id := range ids { + if id != "" { + own[id] = true + } + } + } + if len(made) > 0 { + add(logPatches(git, maxPatchCommits, append([]string{"--ignore-missing", "--no-walk=unsorted"}, made...))) + } + if g.DefaultRemoteRef != "" { + add(logPatches(git, maxPatchCommits, []string{"--no-merges", g.DefaultRemoteRef, "--not", g.Head})) + } + return own, ok +} + +// logPatches runs `git log` with patchLog over args and returns the patch id of each commit; ok is false when +// git failed or more than limit commits came back. +func logPatches(git func(args ...string) (string, int), limit int, args []string) (map[string]string, bool) { + out, exit := git(append(append([]string{"log", "-n", strconv.Itoa(limit + 1)}, patchLog...), args...)...) + ids := patchIDs(out) + return ids, exit == 0 && len(ids) <= limit +} + +func cherryPick(subject string) bool { + return strings.HasPrefix(strings.TrimSpace(subject), "cherry-pick") +} + +// patchIDs splits patchLog output into a patch id per commit ("" for a commit with no change lines). +func patchIDs(out string) map[string]string { + ids := map[string]string{} + for _, block := range strings.Split(out, "\x00")[1:] { + sha, diff, _ := strings.Cut(block, "\n") + if sha = strings.TrimSpace(sha); fullSHA.MatchString(sha) { + ids[sha] = patchID(diff) + } + } + return ids +} + +// 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..87c1c23b9 100644 --- a/free/ci/internal/trust/source/facts.go +++ b/free/ci/internal/trust/source/facts.go @@ -44,6 +44,7 @@ const ( ReasonLocalInProgress = "local.operation_in_progress" ReasonLocalIncomplete = "local.facts_incomplete" ReasonLocalOverride = "local.trust_owner_override" + ReasonLocalReplayed = "local.replayed_arrived_patch" ) // Result is a classification: the class, why, and the side facts the callers need. @@ -64,6 +65,7 @@ const ( MissingRemoteRefs = "remote_refs" MissingCommits = "commits" MissingReflog = "reflog" + MissingPatches = "patches" ) // GitFacts are the git facts of the checkout a local run is bound to. @@ -92,6 +94,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 patch equals that of a commit that arrived here and was not made + // here (reachable from a remote-tracking ref, refs/pull/*, a FETCH_HEAD line or a reflog arrival entry). + // Such a commit is someone else's change replayed, not created here (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..63142ab78 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) whose patch equals a commit that arrived from elsewhere is a +// replay of that commit, not a creation (D-1322). // 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). @@ -45,6 +47,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 +72,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 // someone else's patch replayed here, or the patches could not be compared + } } return true } +// replayed reports whether a commit of Head is a replay of a patch that arrived 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 patch is not one that arrived from elsewhere (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..db955700b --- /dev/null +++ b/free/ci/internal/trust/source/replay_test.go @@ -0,0 +1,143 @@ +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 ( + "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}, + {"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) + // 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 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) +} 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..9119d3edc 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() From e43d80626150bbaeb921895f3e998f50a54941b0 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:58:41 -0400 Subject: [PATCH 2/2] fix(ci): a rewrite needs a source made here; PR refspec globs and grafts never raise trust (D-1322, D-1431) - A rebase step, amend or cherry-pick counts as created here only when its patch equals a commit made here (or on the default branch), or, for an amend, the commit it amended counts (older reflog neighbour, same parents). A rewrite of a stranger's commit no longer reads as owner when nothing refers to the original any more. Reason: local.rewrite_not_made_here. - D04: a fetch refspec destination with a glob anywhere (gh-pr/*/head) is excluded up to its first glob, so a mapped pull request is not an origin branch. - D06: a grafts file (info/grafts or $GIT_GRAFT_FILE) makes the history unknown: untrusted with local.history_grafted, before the reachability path. - Red team D04 and D06 run as regular tests; new fixtures cover a rebased or amended stranger commit with its original gone, own amends chained into a rebase, the mid-path glob and both grafts sources. --- .../guides/ci/Trust-Security-and-Privacy.md | 4 +- free/ci/internal/run/trust_git.go | 23 +- free/ci/internal/run/trust_git_patch.go | 220 +++++++++++------- free/ci/internal/trust/source/facts.go | 10 +- free/ci/internal/trust/source/local.go | 13 +- free/ci/internal/trust/source/replay_test.go | 83 +++++++ .../security/redteam/d_prov_local_test.go | 6 - 7 files changed, 247 insertions(+), 112 deletions(-) diff --git a/.github/wiki/guides/ci/Trust-Security-and-Privacy.md b/.github/wiki/guides/ci/Trust-Security-and-Privacy.md index 8fcc72abb..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. A rewrite (a rebase step, `commit (amend)`, `cherry-pick`) counts only when it does not replay someone else's patch: its patch (added and removed lines, whitespace and positions ignored) must not equal a commit that arrived here (reachable from a remote-tracking ref, `refs/pull/*`, a `FETCH_HEAD` line or an arrival entry, off the default branch, and not made here), and a cherry-pick's patch must equal a commit made here or one on the default branch. +- `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`. Rebasing, amending or cherry-picking a stranger's pull request does not make it `owner`: the replayed commit is reported with `local.replayed_arrived_patch`. Your own commits rewritten on another machine and fetched here count as arrived too. Residual: a rebase or amend of a commit nothing here refers to any more (its branch deleted, its `FETCH_HEAD` overwritten, its reflog expired), or one whose patch you changed while rewriting it, still counts as made here. 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 92757a8ba..4290d97c3 100644 --- a/free/ci/internal/run/trust_git.go +++ b/free/ci/internal/run/trust_git.go @@ -7,7 +7,8 @@ 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 +// 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). @@ -68,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 @@ -80,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 { @@ -102,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) @@ -155,7 +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, fetched) + replayedPatches(git, &g, amends) return g } @@ -174,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 { @@ -183,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 index c0267e848..4d97e0fa1 100644 --- a/free/ci/internal/run/trust_git_patch.go +++ b/free/ci/internal/run/trust_git_patch.go @@ -1,21 +1,26 @@ package run -// Purpose: the patch half of local provenance (D-1322): which rewritten commits of a run (oldest reflog entry -// a rebase step, an amend or a cherry-pick) replay a change someone else made, so they are not counted as -// created here: a rewrite whose patch arrived from elsewhere, and a cherry-pick whose patch was not made here. -// Inputs: the gathered GitFacts (OffDefault, ReflogCreated, DefaultRemoteRef) and the FETCH_HEAD commits. -// Outputs: GitFacts.ReplayedPatch, or MissingPatches when the comparison could not be made. -// Constraints: the arrived side is every commit reachable from a remote-tracking ref, refs/pull/*, a FETCH_HEAD -// line or a commit whose oldest reflog entry is an arrival, minus the default branch, minus every -// commit whose own oldest entry is a creation (your own commit, pushed or not, is not arrived); the -// patch id ignores line numbers, context lines and whitespace, so a replay onto a moved base still -// matches; a cherry-pick counts as made here only when its patch is that of a commit made here (not a -// cherry-pick) or of the default branch; more than maxPatchCommits commits on a side, or a git -// failure, is MissingPatches. Residual: a rebase or amend of a commit nothing refers to any more. +// 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" @@ -23,39 +28,76 @@ import ( "github.com/nself-org/plugins/free/ci/internal/trust/source" ) -// maxPatchCommits bounds the arrived commits whose patches are compared. +// 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 commit id line, then its patch. -var patchLog = []string{"-p", "--no-color", "--full-index", "--no-ext-diff", "--no-textconv", "--no-renames", "--format=%x00%H"} +// 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"} -// replayedPatches fills g.ReplayedPatch for the rewritten commits of g.OffDefault: a rewrite whose patch -// arrived from elsewhere, and a cherry-pick whose patch is neither one made here nor one on the default -// branch (its source may be a commit nothing refers to any more, such as an overwritten FETCH_HEAD). -func replayedPatches(git func(args ...string) (string, int), g *source.GitFacts, fetched []string) { - var rewritten []string - picks := map[string]bool{} +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 subject := g.ReflogCreated[c.SHA]; source.Rewrite(subject) { + if source.Rewrite(g.ReflogCreated[c.SHA]) { rewritten = append(rewritten, c.SHA) - picks[c.SHA] = cherryPick(subject) } } if len(rewritten) == 0 { return } - mine, ok := logPatches(git, len(rewritten), append([]string{"--no-walk=unsorted"}, rewritten...)) - arrived, ok2 := arrivedPatches(git, g, fetched) - own, ok3 := map[string]bool{}, true - if slices.ContainsFunc(rewritten, func(sha string) bool { return picks[sha] }) { - own, ok3 = ownPatches(git, g) + 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 || !ok2 || !ok3 || len(mine) != len(rewritten) { + 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 id := mine[sha]; id != "" && (arrived[id] || picks[sha] && !own[id]) { + if !created[sha] { if g.ReplayedPatch == nil { g.ReplayedPatch = map[string]bool{} } @@ -64,81 +106,85 @@ func replayedPatches(git func(args ...string) (string, int), g *source.GitFacts, } } -// arrivedPatches are the patch ids of the commits that reached this repository from elsewhere and were not -// made here, off the default branch. -func arrivedPatches(git func(args ...string) (string, int), g *source.GitFacts, fetched []string) (map[string]bool, bool) { - var tips []string - for sha, subject := range g.ReflogCreated { - if !source.MadeHere(subject) { - tips = append(tips, sha) - } - } - slices.Sort(tips) // a stable argv: map order is random - // --ignore-missing: a FETCH_HEAD commit gc has pruned is no longer anything to compare with - args := append(append([]string{"--ignore-missing", "--no-merges", "--remotes", "--glob=refs/pull/*"}, fetched...), tips...) - if g.DefaultRemoteRef != "" { - args = append(args, "--not", g.DefaultRemoteRef) - } - ids, ok := logPatches(git, maxPatchCommits, args) - arrived := map[string]bool{} - for sha, id := range ids { - if id != "" && !source.MadeHere(g.ReflogCreated[sha]) { - arrived[id] = 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 arrived, ok + return false } -// ownPatches are the patch ids a cherry-pick may come from and still be created here: a commit whose oldest -// entry is a creation other than a cherry-pick, and a commit of the default branch that Head does not have. -func ownPatches(git func(args ...string) (string, int), g *source.GitFacts) (map[string]bool, bool) { - var made []string - for sha, subject := range g.ReflogCreated { - if source.MadeHere(subject) && !cherryPick(subject) { - made = append(made, sha) +// 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]}) } - slices.Sort(made) - own, ok := map[string]bool{}, true - add := func(ids map[string]string, fine bool) { - ok = ok && fine - for _, id := range ids { - if id != "" { - own[id] = true + 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) } } } - if len(made) > 0 { - add(logPatches(git, maxPatchCommits, append([]string{"--ignore-missing", "--no-walk=unsorted"}, made...))) - } - if g.DefaultRemoteRef != "" { - add(logPatches(git, maxPatchCommits, []string{"--no-merges", g.DefaultRemoteRef, "--not", g.Head})) - } - return own, ok + return pre } -// logPatches runs `git log` with patchLog over args and returns the patch id of each commit; ok is false when -// git failed or more than limit commits came back. -func logPatches(git func(args ...string) (string, int), limit int, args []string) (map[string]string, bool) { - out, exit := git(append(append([]string{"log", "-n", strconv.Itoa(limit + 1)}, patchLog...), args...)...) - ids := patchIDs(out) - return ids, exit == 0 && len(ids) <= limit +// 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 } -func cherryPick(subject string) bool { - return strings.HasPrefix(strings.TrimSpace(subject), "cherry-pick") +// 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). -func patchIDs(out string) map[string]string { - ids := map[string]string{} +// 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:] { - sha, diff, _ := strings.Cut(block, "\n") - if sha = strings.TrimSpace(sha); fullSHA.MatchString(sha) { - ids[sha] = patchID(diff) + 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 + return ids, parents } // patchID hashes the changes of one commit the way `git patch-id --stable` treats them, and looser: per file diff --git a/free/ci/internal/trust/source/facts.go b/free/ci/internal/trust/source/facts.go index 87c1c23b9..1485486b1 100644 --- a/free/ci/internal/trust/source/facts.go +++ b/free/ci/internal/trust/source/facts.go @@ -44,7 +44,8 @@ const ( ReasonLocalInProgress = "local.operation_in_progress" ReasonLocalIncomplete = "local.facts_incomplete" ReasonLocalOverride = "local.trust_owner_override" - ReasonLocalReplayed = "local.replayed_arrived_patch" + ReasonLocalReplayed = "local.rewrite_not_made_here" + ReasonLocalGrafted = "local.history_grafted" ) // Result is a classification: the class, why, and the side facts the callers need. @@ -66,6 +67,7 @@ const ( 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. @@ -95,9 +97,9 @@ type GitFacts struct { // 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 patch equals that of a commit that arrived here and was not made - // here (reachable from a remote-tracking ref, refs/pull/*, a FETCH_HEAD line or a reflog arrival entry). - // Such a commit is someone else's change replayed, not created here (D-1322). + // 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 63142ab78..4eb9b5113 100644 --- a/free/ci/internal/trust/source/local.go +++ b/free/ci/internal/trust/source/local.go @@ -9,8 +9,8 @@ package source // 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). A rewrite -// (rebase step, amend, cherry-pick) whose patch equals a commit that arrived from elsewhere is a -// replay of that commit, not a creation (D-1322). +// (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). @@ -38,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) } @@ -77,13 +80,13 @@ func createdHere(g GitFacts) bool { return false } if Rewrite(subject) && (g.missing(MissingPatches) || g.ReplayedPatch[c.SHA]) { - return false // someone else's patch replayed here, or the patches could not be compared + 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 replay of a patch that arrived here. +// 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] { @@ -95,7 +98,7 @@ func replayed(g GitFacts) bool { // 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 patch is not one that arrived from elsewhere (GitFacts.ReplayedPatch). +// 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 diff --git a/free/ci/internal/trust/source/replay_test.go b/free/ci/internal/trust/source/replay_test.go index db955700b..8090e5059 100644 --- a/free/ci/internal/trust/source/replay_test.go +++ b/free/ci/internal/trust/source/replay_test.go @@ -5,6 +5,7 @@ package source_test // owner, and the operator's own rewrites (pushed or not) stay owner. import ( + "os" "path/filepath" "testing" @@ -43,6 +44,8 @@ func TestClassifyLocalReplayedPatch(t *testing.T) { 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}, } { @@ -113,6 +116,23 @@ func TestClassifyGitReplays(t *testing.T) { 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") @@ -128,6 +148,14 @@ func TestClassifyGitReplays(t *testing.T) { 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") @@ -141,3 +169,58 @@ func TestClassifyGitReplays(t *testing.T) { 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 9119d3edc..c1fd8b8fa 100644 --- a/free/ci/test/security/redteam/d_prov_local_test.go +++ b/free/ci/test/security/redteam/d_prov_local_test.go @@ -141,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") @@ -171,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")