diff --git a/free/ci/internal/authz/promote.go b/free/ci/internal/authz/promote.go index 180b7b123..fd5455d89 100644 --- a/free/ci/internal/authz/promote.go +++ b/free/ci/internal/authz/promote.go @@ -9,7 +9,7 @@ package authz // Outputs: Promotable. // Constraints: pure. The gate class is forced to release, self-reported // evidence is never admitted and selection must be full, whatever the caller's -// gate said, and the trust floor is "internal". An artifact of another repo or +// gate said, and the trust floor is at least "internal" (an unknown floor is refused, not raised). An artifact of another repo or // revision is refused, and so is one whose SHA-256 the evidence does not list // as a committed artifact: a fork build of the same commit is not the release. @@ -42,7 +42,9 @@ func Promotable(a Artifact, ev model.Evidence, g attest.Gate) (bool, []attest.Re g.Class = attest.ClassRelease g.FullSelection = true g.AllowSelfReported = false - if trust.RankTrust(g.MinTrust) < trust.RankTrust(promoteMinTrust) { + // An unknown floor (ranks -1) is left as is so GateAccept rejects it; raising it + // to the default would let a typo'd owner floor weaken to internal. + if r := trust.RankTrust(g.MinTrust); r >= 0 && r < trust.RankTrust(promoteMinTrust) { g.MinTrust = promoteMinTrust } reasons := attest.GateAccept(ev, g) diff --git a/free/ci/internal/authz/promote_test.go b/free/ci/internal/authz/promote_test.go index e9559d5da..f9e547484 100644 --- a/free/ci/internal/authz/promote_test.go +++ b/free/ci/internal/authz/promote_test.go @@ -96,3 +96,25 @@ func TestPromotable_TrustFloor(t *testing.T) { } } } + +// An unknown floor (a typo'd "owner") is refused, not raised to internal, even +// when the evidence would have met internal. +func TestPromotable_UnknownFloorIsRefused(t *testing.T) { + for _, floor := range []model.TrustClass{"", "owner ", "Owner", "unknown"} { + ev := goodEvidence() + ev.SourceTrust = "internal" + g := goodGate(ev) + g.MinTrust = floor + ok, reasons := authz.Promotable(goodArtifact(), ev, g) + if ok { + t.Fatalf("floor %q promoted", floor) + } + found := false + for _, r := range reasons { + found = found || r == attest.ReasonTrustBelowMin + } + if !found { + t.Fatalf("floor %q: want %s in %v", floor, attest.ReasonTrustBelowMin, reasons) + } + } +} diff --git a/free/ci/internal/config/load.go b/free/ci/internal/config/load.go index ea64b1e4d..6e8e01ab7 100644 --- a/free/ci/internal/config/load.go +++ b/free/ci/internal/config/load.go @@ -122,7 +122,7 @@ func Parse(name string, data []byte) (*Loaded, error) { } return fail(Problem{File: name, Line: line, Message: "more than one YAML document"}) } - w := &walker{file: name, lines: map[string]int{}, budget: maxNodes, bytes: maxBytes} + w := &walker{file: name, lines: map[string]int{}, budget: maxNodes, bytes: maxBytes, ptrs: maxPtrBytes} value := w.value(doc.Content[0], "", 0) lines := w.lines if len(w.probs) > 0 { diff --git a/free/ci/internal/config/load_test.go b/free/ci/internal/config/load_test.go index 27d7f957e..82f7fad25 100644 --- a/free/ci/internal/config/load_test.go +++ b/free/ci/internal/config/load_test.go @@ -293,3 +293,17 @@ func TestWarningNeverContainsAValue(t *testing.T) { t.Fatalf("value echoed or accepted: %v", err) } } + +// Every node keeps its full JSON pointer, so a long key above many items is +// refused instead of retaining len(key) bytes per item (a sub-1MiB file could +// otherwise allocate tens of GiB). +func TestLongKeyPointersAreBudgeted(t *testing.T) { + doc := "version: 1\npolicy:\n ? " + strings.Repeat("k", 16<<10) + "\n : [" + strings.Repeat("0,", 10_000) + "0]\n" + if len(doc) >= MaxFileBytes { + t.Fatalf("document is %d bytes, not a sub-limit file", len(doc)) + } + _, err := Parse("ci.yaml", []byte(doc)) + if err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("long-pointer document accepted: %v", err) + } +} diff --git a/free/ci/internal/config/walk.go b/free/ci/internal/config/walk.go index f86405146..6eba00f0f 100644 --- a/free/ci/internal/config/walk.go +++ b/free/ci/internal/config/walk.go @@ -24,6 +24,10 @@ const ( maxDepth = 64 maxNodes = 200_000 maxBytes = 4 << 20 // scalar bytes after alias expansion + // maxPtrBytes bounds the JSON pointer strings retained in walker.lines: every + // node keeps its full pointer, so a long key above many items would otherwise + // cost len(key) per item, far past what the node and scalar budgets allow. + maxPtrBytes = 16 << 20 ) type walker struct { @@ -32,6 +36,17 @@ type walker struct { probs []Problem budget int bytes int + ptrs int // pointer bytes still available +} + +// child returns the pointer of a member of ptr, charging its length to the +// pointer budget. ok is false (and the limit reported) when the budget is spent. +func (w *walker) child(n *yaml.Node, ptr, seg string) (string, bool) { + if w.ptrs -= len(ptr) + 1 + len(seg); w.ptrs < 0 { + w.overLimit(n, ptr) + return "", false + } + return ptr + "/" + seg, true } func (w *walker) bad(n *yaml.Node, ptr, msg string) { @@ -75,7 +90,10 @@ func (w *walker) value(n *yaml.Node, ptr string, depth int) any { w.overLimit(k, ptr) return m } - child := ptr + "/" + escape(k.Value) + child, ok := w.child(k, ptr, escape(k.Value)) + if !ok { + return m + } if _, dup := m[k.Value]; dup { w.bad(k, child, "duplicate key") continue @@ -87,7 +105,11 @@ func (w *walker) value(n *yaml.Node, ptr string, depth int) any { case yaml.SequenceNode: s := make([]any, len(n.Content)) for i, c := range n.Content { - s[i] = w.value(c, ptr+"/"+strconv.Itoa(i), depth+1) + child, ok := w.child(c, ptr, strconv.Itoa(i)) + if !ok { + return s + } + s[i] = w.value(c, child, depth+1) } return s case yaml.ScalarNode: