From 95d05a237f8d1807b0a88b70111b038537c03ac6 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:11:25 -0400 Subject: [PATCH 1/7] fix(ci): scan compressed and wide-character secret content (D-1427) --- free/ci/internal/artifacts/records/collect.go | 1 + free/ci/internal/artifacts/records/scan.go | 31 +++-- .../artifacts/records/scan_archive.go | 45 ++++++++ free/ci/internal/authz/scan_content.go | 107 ++++++++++++++++++ free/ci/internal/authz/scan_content_test.go | 98 ++++++++++++++++ free/ci/internal/cache/specs/engine_save.go | 4 +- free/ci/internal/cache/specs/inflate.go | 102 +---------------- free/ci/internal/cache/specs/pack.go | 36 +++--- free/ci/internal/cache/specs/scan.go | 2 +- free/ci/internal/cache/specs/spec.go | 3 +- .../redteam/b_artifacts_collect_test.go | 8 +- 11 files changed, 299 insertions(+), 138 deletions(-) create mode 100644 free/ci/internal/artifacts/records/scan_archive.go create mode 100644 free/ci/internal/authz/scan_content.go create mode 100644 free/ci/internal/authz/scan_content_test.go diff --git a/free/ci/internal/artifacts/records/collect.go b/free/ci/internal/artifacts/records/collect.go index c8c8c4caa..69c837af9 100644 --- a/free/ci/internal/artifacts/records/collect.go +++ b/free/ci/internal/artifacts/records/collect.go @@ -202,6 +202,7 @@ func (c Collector) store(ctx context.Context, root *os.Root, realm cas.Realm, p } limit, reason := streamBudget(c.Policy, c.Usage, streamed) sr := newScanReader(file, c.Scanner, f.rel, limit) + defer sr.Close() sr.reason = reason digest, size, err := c.Blobs.Put(ctx, realm, sr) if err != nil { diff --git a/free/ci/internal/artifacts/records/scan.go b/free/ci/internal/artifacts/records/scan.go index 89bcdc1f5..3eb503320 100644 --- a/free/ci/internal/artifacts/records/scan.go +++ b/free/ci/internal/artifacts/records/scan.go @@ -14,6 +14,7 @@ package records import ( "errors" "io" + "os" "github.com/nself-org/plugins/free/ci/internal/authz" ) @@ -24,16 +25,17 @@ const ( ) type scanReader struct { - src io.Reader - sc authz.ValueScanner - name string - max int64 - reason string // the refusal when the stream outgrows max - read int64 - tail []byte - out []byte - err error - matched bool + src io.Reader + sc authz.ValueScanner + name string + max int64 + reason string // the refusal when the stream outgrows max + read int64 + tail []byte + out []byte + err error + compressed *os.File + matched bool } func newScanReader(src io.Reader, sc authz.ValueScanner, name string, max int64) *scanReader { @@ -69,6 +71,9 @@ func (r *scanReader) fill() { return } if len(chunk) == 0 { + if err := r.scanCompressed(nil); err != nil { + r.err = err + } return } if r.sc == nil { @@ -76,10 +81,14 @@ func (r *scanReader) fill() { return } window := append(append(make([]byte, 0, len(r.tail)+len(chunk)), r.tail...), chunk...) - if len(r.sc.Scan(window)) != 0 { + if !authz.CleanCandidates(r.sc, window) { r.err, r.matched = failf(ReasonSecretMatch, "%s holds a registered secret value; it is not stored", r.name), true return } + if err := r.scanCompressed(chunk); err != nil { + r.err = err + return + } keep := min(scanOverlap, len(window)) r.tail = append(r.tail[:0], window[len(window)-keep:]...) r.out = chunk diff --git a/free/ci/internal/artifacts/records/scan_archive.go b/free/ci/internal/artifacts/records/scan_archive.go new file mode 100644 index 000000000..4211f58da --- /dev/null +++ b/free/ci/internal/artifacts/records/scan_archive.go @@ -0,0 +1,45 @@ +package records + +import ( + "errors" + "github.com/nself-org/plugins/free/ci/internal/authz" + "io" + "os" +) + +// Keep exactly the compressed bytes streamed to the blob; scan before its EOF commits it. +func (r *scanReader) scanCompressed(chunk []byte) error { + if r.compressed == nil && r.read == int64(len(chunk)) && authz.Compressed(chunk) { + f, err := os.CreateTemp("", "nself-artifact-scan-*") + if err != nil { + return err + } + r.compressed = f + } + if r.compressed == nil { + return nil + } + if _, err := r.compressed.Write(chunk); err != nil { + return err + } + if r.err != io.EOF { + return nil + } + budget := int64(8 << 30) + err := authz.ScanArchive(r.compressed, r.read, r.sc, &budget) + if errors.Is(err, authz.ErrSecretContent) { + r.matched = true + return failf(ReasonSecretMatch, "%s holds a registered secret value; it is not stored", r.name) + } + if err != nil { + return failf(ReasonUnreadable, "%s could not be scanned", r.name) + } + return nil +} + +func (r *scanReader) Close() { + if r.compressed != nil { + _ = r.compressed.Close() + _ = os.Remove(r.compressed.Name()) + } +} diff --git a/free/ci/internal/authz/scan_content.go b/free/ci/internal/authz/scan_content.go new file mode 100644 index 000000000..c46e9ac62 --- /dev/null +++ b/free/ci/internal/authz/scan_content.go @@ -0,0 +1,107 @@ +package authz + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "io" + "unicode/utf16" +) + +var ErrSecretContent = errors.New("content holds a registered secret") +var ErrScanBudget = errors.New("compressed content exceeds scan budget") + +// CleanCandidates checks raw, NUL-stripped and both alignments of UTF-16 text. +func CleanCandidates(s ValueScanner, b []byte) bool { + if !Clean(s, b) { + return false + } + if bytes.IndexByte(b, 0) >= 0 && !Clean(s, bytes.ReplaceAll(b, []byte{0}, nil)) { + return false + } + for _, big := range []bool{false, true} { + for offset := 0; offset < 2; offset++ { + units := make([]uint16, 0, len(b)/2) + for i := offset; i+1 < len(b); i += 2 { + a, c := b[i], b[i+1] + if big { + a, c = c, a + } + units = append(units, uint16(a)|uint16(c)<<8) + } + if !Clean(s, []byte(string(utf16.Decode(units)))) { + return false + } + } + } + return true +} + +// Compressed reports gzip or ZIP magic without trusting a file extension. +func Compressed(b []byte) bool { + return len(b) >= 2 && b[0] == 0x1f && b[1] == 0x8b || len(b) >= 4 && b[0] == 'P' && b[1] == 'K' && (b[2] == 3 && b[3] == 4 || b[2] == 5 && b[3] == 6 || b[2] == 7 && b[3] == 8) +} + +// ScanArchive scans all gzip members or ZIP entries, sharing an expanded-byte budget. +// The caller scans raw bytes separately; malformed gzip is scanned up to its break. +func ScanArchive(src io.ReaderAt, size int64, s ValueScanner, left *int64) error { + var magic [2]byte + if _, err := src.ReadAt(magic[:], 0); err != nil { + return nil + } + if magic == [2]byte{0x1f, 0x8b} { + gz, err := gzip.NewReader(io.NewSectionReader(src, 0, size)) + if err != nil { + return nil + } + defer func() { _ = gz.Close() }() + return scanExpanded(gz, s, left, true) + } + if magic != [2]byte{'P', 'K'} { + return nil + } + zr, err := zip.NewReader(src, size) + if err != nil { + return err + } + for _, entry := range zr.File { + r, err := entry.Open() + if err != nil { + return err + } + err = scanExpanded(r, s, left, false) + closeErr := r.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func scanExpanded(r io.Reader, s ValueScanner, left *int64, tolerateCorrupt bool) error { + const window = 1 << 20 + var tail []byte + for { + chunk := make([]byte, min(int64(window), *left+1)) + n, err := io.ReadFull(r, chunk) + *left -= int64(n) + if *left < 0 { + return ErrScanBudget + } + data := append(tail, chunk[:n]...) + if !CleanCandidates(s, data) { + return ErrSecretContent + } + tail = append([]byte(nil), data[max(0, len(data)-window):]...) + if err != nil { + if !tolerateCorrupt && err != io.EOF && err != io.ErrUnexpectedEOF { + return err + } + return nil + } + } +} diff --git a/free/ci/internal/authz/scan_content_test.go b/free/ci/internal/authz/scan_content_test.go new file mode 100644 index 000000000..bcf9a283d --- /dev/null +++ b/free/ci/internal/authz/scan_content_test.go @@ -0,0 +1,98 @@ +package authz_test + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "encoding/binary" + "errors" + "testing" + "unicode/utf16" + + "github.com/nself-org/plugins/free/ci/internal/authz" + "github.com/nself-org/plugins/free/ci/internal/redact" +) + +func TestScanContentEncodings(t *testing.T) { + const secret = "synthetic-secret-with-雪-123456" + r := redact.New() + if err := r.Register([]byte(secret)); err != nil { + t.Fatal(err) + } + forms := map[string][]byte{"raw": []byte(secret), "nul": bytes.Join(bytes.Split([]byte(secret), nil), []byte{0})} + for _, order := range []struct { + name string + order binary.ByteOrder + }{{"le", binary.LittleEndian}, {"be", binary.BigEndian}} { + var b bytes.Buffer + for _, u := range utf16.Encode([]rune(secret)) { + if err := binary.Write(&b, order.order, u); err != nil { + t.Fatal(err) + } + } + forms[order.name] = b.Bytes() + } + for name, b := range forms { + t.Run(name, func(t *testing.T) { + if authz.CleanCandidates(r, append([]byte{'!'}, b...)) { + t.Fatal("encoded secret missed") + } + }) + } + if !authz.CleanCandidates(r, []byte("clean content")) { + t.Fatal("clean text refused") + } +} + +func TestScanArchiveAllMembersAndBudget(t *testing.T) { + const secret = "synthetic-archive-secret-123456" + r := redact.New() + if err := r.Register([]byte(secret)); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"gzip", "zip"} { + for _, tc := range []struct { + name, payload string + budget int64 + want error + }{ + {"secret", secret, 1 << 20, authz.ErrSecretContent}, + {"clean", "clean data", 1 << 20, nil}, + {"budget", "clean data", 1, authz.ErrScanBudget}, + } { + t.Run(kind+"/"+tc.name, func(t *testing.T) { + var b bytes.Buffer + if kind == "gzip" { + for _, text := range []string{"clean first member", tc.payload} { + w := gzip.NewWriter(&b) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + } + } else { + w := zip.NewWriter(&b) + for i, text := range []string{"clean first entry", tc.payload} { + f, err := w.Create(string(rune('a' + i))) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(text)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + } + left := tc.budget + err := authz.ScanArchive(bytes.NewReader(b.Bytes()), int64(b.Len()), r, &left) + if !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) + } + }) + } + } +} diff --git a/free/ci/internal/cache/specs/engine_save.go b/free/ci/internal/cache/specs/engine_save.go index 35257abda..7770c14d9 100644 --- a/free/ci/internal/cache/specs/engine_save.go +++ b/free/ci/internal/cache/specs/engine_save.go @@ -140,8 +140,8 @@ func (c *capWriter) Write(p []byte) (int, error) { // putDir streams dir through tar and scan into the blob store. The local blob // is a plain tar: disk is cheap and a restore is on a job's critical path, so a // copy beats an inflate (D-1008). Compress only when a blob leaves the host. -// The scan also looks inside gzip and ZIP payloads for every spec. -func (e *Engine) putDir(ctx context.Context, base *os.Root, realm cas.Realm, rel string, budget int64, _ bool) (string, int64, PackStats, error) { +// Every save also scans gzip and ZIP payloads inside the files. +func (e *Engine) putDir(ctx context.Context, base *os.Root, realm cas.Realm, rel string, budget int64, inflate bool) (string, int64, PackStats, error) { pr, pw := io.Pipe() var st PackStats done := make(chan struct{}) diff --git a/free/ci/internal/cache/specs/inflate.go b/free/ci/internal/cache/specs/inflate.go index f7b23a680..6fb631ab1 100644 --- a/free/ci/internal/cache/specs/inflate.go +++ b/free/ci/internal/cache/specs/inflate.go @@ -1,31 +1,10 @@ package specs -// Purpose: scan inside gzip and ZIP payloads of every spec for registered -// values (npm's _cacache keeps its tarballs gzipped, so the scan of the tar -// stream sees only compressed bytes there). -// Inputs: the raw bytes of one compressed file. -// Outputs: inflater, ErrSecretMatch, ErrTooLarge. -// Constraints: every member of the payload is inflated and scanned with the -// same windows as the tar stream; the inflated bytes of one save are capped -// (inflateBudget) and a payload that would pass it is refused as too large, -// because it cannot be shown clean. A stream that stops being valid gzip is -// scanned up to the break and then ignored: the tool that reads the file back -// fails on it too, and the raw bytes were scanned by the tar-stream scan. One -// level only: a gzip inside a gzip is scanned as inflated bytes. +// Every spec scans gzip and ZIP payloads. Share an expanded-byte budget across +// all files in a directory, so compression cannot bypass the save-time cap. -import ( - "archive/zip" - "bufio" - "bytes" - "compress/gzip" - "errors" - "fmt" - "io" +import "github.com/nself-org/plugins/free/ci/internal/authz" - "github.com/nself-org/plugins/free/ci/internal/authz" -) - -// inflateBudget caps the inflated bytes scanned over one directory (8 GiB). const inflateBudget = 8 << 30 type inflater struct { @@ -34,78 +13,3 @@ type inflater struct { } func newInflater(sc authz.ValueScanner) *inflater { return &inflater{sc: sc, left: inflateBudget} } - -// isCompressed detects gzip or ZIP magic without consuming it. -func isCompressed(r *bufio.Reader) bool { - head, _ := r.Peek(4) - return len(head) >= 2 && head[0] == 0x1f && head[1] == 0x8b || len(head) == 4 && bytes.Equal(head, []byte{'P', 'K', 3, 4}) -} - -// scan reads r to its end. It returns ErrSecretMatch or ErrTooLarge, or nil. -func (in *inflater) scan(r io.Reader) error { - br := bufio.NewReader(r) - head, _ := br.Peek(4) - if len(head) == 4 && bytes.Equal(head, []byte{'P', 'K', 3, 4}) { - return in.zip(br) - } - if err := in.inflate(br); err != nil { - return err // the caller closes the pipe with it: the file copy stops - } - _, _ = io.Copy(io.Discard, br) // never leave the file copy blocked on the pipe - return nil -} - -func (in *inflater) inflate(r io.Reader) error { - gz, err := gzip.NewReader(r) - if err != nil { - return nil - } - sw := newScanWriter(io.Discard, in.sc) - n, err := io.Copy(sw, io.LimitReader(gz, in.left+1)) - switch { - case errors.Is(err, ErrSecretMatch): - return err - case n > in.left: - return fmt.Errorf("%w: the gzip payloads inflate past %d bytes", ErrTooLarge, int64(inflateBudget)) - } - in.left -= n - return sw.Close() -} - -// ZIP needs random access to its central directory. Bound its compressed size -// as well as the shared inflation budget, refusing payloads we cannot inspect. -const zipScanLimit = 64 << 20 - -func (in *inflater) zip(r io.Reader) error { - data, err := io.ReadAll(io.LimitReader(r, zipScanLimit+1)) - if err != nil { - return err - } - if len(data) > zipScanLimit { - return fmt.Errorf("%w: zip scan limit", ErrTooLarge) - } - zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return fmt.Errorf("specs: invalid zip payload: %w", err) - } - for _, f := range zr.File { - rc, err := f.Open() - if err != nil { - return err - } - sw := newScanWriter(io.Discard, in.sc) - n, err := io.Copy(sw, io.LimitReader(rc, in.left+1)) - _ = rc.Close() - if err != nil { - return err - } - if n > in.left { - return fmt.Errorf("%w: zip inflation budget", ErrTooLarge) - } - in.left -= n - if err := sw.Close(); err != nil { - return err - } - } - return nil -} diff --git a/free/ci/internal/cache/specs/pack.go b/free/ci/internal/cache/specs/pack.go index 23d566540..35fa2f739 100644 --- a/free/ci/internal/cache/specs/pack.go +++ b/free/ci/internal/cache/specs/pack.go @@ -46,7 +46,7 @@ func Pack(ctx context.Context, base *os.Root, rel string, w io.Writer, lim authz return pack(ctx, base, rel, w, lim, nil) } -// pack is Pack with an optional inflater that scans compressed payloads. +// pack is Pack with an optional compressed-content scanner. func pack(ctx context.Context, base *os.Root, rel string, w io.Writer, lim authz.Limits, inf *inflater) (PackStats, error) { var st PackStats root, err := base.OpenRoot(rel) @@ -157,7 +157,7 @@ func header(root *os.Root, p string, info fs.FileInfo) (*tar.Header, bool, error // copyFile copies exactly size bytes of p; a file that shrank, or that is no // longer a regular file once opened, is an error. With an inflater, a file that -// starts like gzip or ZIP is inflated on the way and scanned (the same bytes, so +// starts like gzip or ZIP is also expanded and scanned (the same bytes, so // the file cannot change between the copy and the scan). func copyFile(ctx context.Context, root *os.Root, p string, w io.Writer, size int64, inf *inflater) error { f, err := root.OpenFile(p, os.O_RDONLY|syscall.O_NONBLOCK, 0) @@ -169,22 +169,26 @@ func copyFile(ctx context.Context, root *os.Root, p string, w io.Writer, size in return fmt.Errorf("specs: %q is no longer a regular file", p) } src := bufio.NewReader(&ctxReader{ctx, f}) - if inf == nil || !isCompressed(src) { + head, _ := src.Peek(4) + if inf == nil || !authz.Compressed(head) { return copyN(w, src, p, size) } - pr, pw := io.Pipe() - scanned := make(chan error, 1) - go func() { - err := inf.scan(pr) - _ = pr.CloseWithError(err) // a refusal reaches the copy at its next write - scanned <- err - }() - cerr := copyN(io.MultiWriter(w, pw), src, p, size) - _ = pw.Close() - if serr := <-scanned; serr != nil { - return serr - } - return cerr + tmp, err := os.CreateTemp("", "nself-cache-scan-*") + if err != nil { + return err + } + defer func() { _ = tmp.Close(); _ = os.Remove(tmp.Name()) }() + if err := copyN(io.MultiWriter(w, tmp), src, p, size); err != nil { + return err + } + err = authz.ScanArchive(tmp, size, inf.sc, &inf.left) + if errors.Is(err, authz.ErrSecretContent) { + return ErrSecretMatch + } + if errors.Is(err, authz.ErrScanBudget) { + return ErrTooLarge + } + return err } func copyN(w io.Writer, r io.Reader, p string, size int64) error { diff --git a/free/ci/internal/cache/specs/scan.go b/free/ci/internal/cache/specs/scan.go index d6c74adb8..97b66dbc4 100644 --- a/free/ci/internal/cache/specs/scan.go +++ b/free/ci/internal/cache/specs/scan.go @@ -72,7 +72,7 @@ func (w *scanWriter) release(chunk []byte) error { return ErrSecretMatch } window := append(append(make([]byte, 0, len(w.tail)+len(chunk)), w.tail...), chunk...) - if len(w.sc.Scan(window)) != 0 { + if !authz.CleanCandidates(w.sc, window) { return ErrSecretMatch } w.tail = append(w.tail[:0], window[max(0, len(window)-scanOverlap):]...) diff --git a/free/ci/internal/cache/specs/spec.go b/free/ci/internal/cache/specs/spec.go index e99c9fdde..adcb79622 100644 --- a/free/ci/internal/cache/specs/spec.go +++ b/free/ci/internal/cache/specs/spec.go @@ -45,8 +45,7 @@ type Spec struct { // Lockfiles are the detector lockfile managers whose digests key the spec. Lockfiles []string Probes []Probe - // InflateScan is retained for spec compatibility. Every save now scans - // gzip and ZIP payloads, regardless of this legacy flag. + // InflateScan is retained for compatibility; every spec now scans compressed content. InflateScan bool // ContainerOnly specs apply to container-level jobs only. ContainerOnly bool diff --git a/free/ci/test/security/redteam/b_artifacts_collect_test.go b/free/ci/test/security/redteam/b_artifacts_collect_test.go index 47c07c3e7..16e5823b9 100644 --- a/free/ci/test/security/redteam/b_artifacts_collect_test.go +++ b/free/ci/test/security/redteam/b_artifacts_collect_test.go @@ -207,6 +207,7 @@ func TestB_Artifacts_SecretHiddenFromScanner(t *testing.T) { "url-all": percentAll(fakeSecret), "utf16le": toUTF16LE(fakeSecret), "gzip": gz.String(), + "zip": string(zipBytes(filler + "token=" + fakeSecret + filler)), "split": fakeSecret[:12] + "\n" + fakeSecret[12:], "interleaf": strings.Join(strings.Split(fakeSecret, ""), "\x00"), } @@ -218,13 +219,6 @@ func TestB_Artifacts_SecretHiddenFromScanner(t *testing.T) { e.write("out/f.bin", content) _, err := e.collect("out/f.bin") if err == nil { - switch name { - case "gzip", "utf16le", "interleaf": - t.Logf("%s: collected", name) - if !openFinding(t, "B06") { - continue - } - } t.Errorf("%s-encoded secret was collected into an artifact", name) } } From 64c8e66500637b5b2fb12bbd2e11224652bcb68f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:11:38 -0400 Subject: [PATCH 2/7] fix(ci): scrub publication diagnostics and exotic addresses (D-1428) --- free/ci/internal/attest/legacy_addr.go | 44 ++++++++++++++ free/ci/internal/attest/profile.go | 6 +- free/ci/internal/attest/publish.go | 12 ++++ free/ci/internal/attest/publish_debt_test.go | 58 +++++++++++++++++++ free/ci/internal/attest/scrub.go | 3 +- .../test/security/redteam/b_publish_test.go | 6 -- 6 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 free/ci/internal/attest/legacy_addr.go create mode 100644 free/ci/internal/attest/publish_debt_test.go diff --git a/free/ci/internal/attest/legacy_addr.go b/free/ci/internal/attest/legacy_addr.go new file mode 100644 index 000000000..9fcb2167c --- /dev/null +++ b/free/ci/internal/attest/legacy_addr.go @@ -0,0 +1,44 @@ +package attest + +import ( + "net/netip" + "regexp" + "strconv" + "strings" +) + +var legacyIPv4 = regexp.MustCompile(`\b(?:0[xX][0-9a-fA-F]+|[0-9]+)(?:\.(?:0[xX][0-9a-fA-F]+|[0-9]+)){0,3}\b`) + +// inet-style IPv4 permits one to four components, including hex and octal. +func parseLegacyIPv4(s string) (netip.Addr, bool) { + parts := strings.Split(s, ".") + if len(parts) > 4 { + return netip.Addr{}, false + } + var value uint64 + for i, part := range parts { + bits := 8 + if i == len(parts)-1 { + bits = 8 * (5 - len(parts)) + } + base := 10 + if strings.HasPrefix(part, "0x") || strings.HasPrefix(part, "0X") || len(part) > 1 && part[0] == '0' { + base = 0 + } + n, err := strconv.ParseUint(part, base, bits) + if err != nil { + return netip.Addr{}, false + } + value = value<> 24), byte(value >> 16), byte(value >> 8), byte(value)}), true +} + +func (p Profile) scrubLegacyIPv4(s string) string { + return legacyIPv4.ReplaceAllStringFunc(s, func(m string) string { + if a, ok := parseLegacyIPv4(m); ok && (privateAddr(a) || p.knownAddr(a)) { + return redactedIP + } + return m + }) +} diff --git a/free/ci/internal/attest/profile.go b/free/ci/internal/attest/profile.go index c7572ab40..0f14bb610 100644 --- a/free/ci/internal/attest/profile.go +++ b/free/ci/internal/attest/profile.go @@ -210,17 +210,17 @@ func parseLooseAddr(s string) (netip.Addr, bool) { } parts := strings.Split(s, ".") if len(parts) != 4 { - return netip.Addr{}, false + return parseLegacyIPv4(s) } var b [4]byte for i, part := range parts { if part == "" || len(part) > 3 { - return netip.Addr{}, false + return parseLegacyIPv4(s) } n := 0 for _, c := range part { if c < '0' || c > '9' { - return netip.Addr{}, false + return parseLegacyIPv4(s) } n = n*10 + int(c-'0') } diff --git a/free/ci/internal/attest/publish.go b/free/ci/internal/attest/publish.go index 4e0e64043..5afb66cda 100644 --- a/free/ci/internal/attest/publish.go +++ b/free/ci/internal/attest/publish.go @@ -37,6 +37,11 @@ func PublishWith(ev model.Evidence, p Profile) model.Evidence { out.Runner.Provider = structured.Text(ev.Runner.Provider) out.Runner.Platform = structured.Text(ev.Runner.Platform) out.Pipeline.Name = structured.Text(ev.Pipeline.Name) + if ev.Binding.Reason != nil { + reason := model.BindingReason(p.Text(string(*ev.Binding.Reason))) + out.Binding.Reason = &reason + } + out.Selection.Reason = model.Reason(p.Text(string(ev.Selection.Reason))) out.PlacementRef = scrubPtr(p, ev.PlacementRef) // every other free string is scrubbed too: a hostile or careless producer can // put a path or an address in any of them @@ -70,6 +75,12 @@ func PublishWith(ev model.Evidence, p Profile) model.Evidence { c.Tool = p.Text(c.Tool) c.ToolVersion = scrubPtr(p, c.ToolVersion) c.Excerpt = p.Text(c.Excerpt) + c.Diagnosis, c.Remediation = p.Text(c.Diagnosis), p.Text(c.Remediation) + if c.Flaky != nil { + flaky := *c.Flaky + flaky.Source = p.Text(flaky.Source) + c.Flaky = &flaky + } c.LogRef = scrubPtr(p, c.LogRef) out.Checks[i] = c } @@ -81,6 +92,7 @@ func PublishWith(ev model.Evidence, p Profile) model.Evidence { out.Retries = make([]model.Retry, len(ev.Retries)) for i, r := range ev.Retries { r.JobID = p.Text(r.JobID) + r.Diagnosis, r.Excerpt = p.Text(r.Diagnosis), p.Text(r.Excerpt) out.Retries[i] = r } reason := "publication profile applied; recompute from the inputs" diff --git a/free/ci/internal/attest/publish_debt_test.go b/free/ci/internal/attest/publish_debt_test.go new file mode 100644 index 000000000..91d1365e5 --- /dev/null +++ b/free/ci/internal/attest/publish_debt_test.go @@ -0,0 +1,58 @@ +package attest_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/nself-org/plugins/free/ci/internal/attest" + "github.com/nself-org/plugins/free/ci/internal/model" +) + +func TestPublicationDiagnosticFields(t *testing.T) { + const marker = "10.1.2.3" + for _, field := range []string{"diagnosis", "remediation", "flaky source", "retry diagnosis", "retry excerpt"} { + t.Run(field, func(t *testing.T) { + ev := leaky() + ev.Checks = []model.Check{{}} + ev.Retries = []model.Retry{{}} + switch field { + case "diagnosis": + ev.Checks[0].Diagnosis = marker + case "remediation": + ev.Checks[0].Remediation = marker + case "flaky source": + ev.Checks[0].Flaky = &model.Flaky{Source: marker} + case "retry diagnosis": + ev.Retries[0].Diagnosis = marker + case "retry excerpt": + ev.Retries[0].Excerpt = marker + } + before, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + after, err := json.Marshal(attest.Publish(ev, attest.Public)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(after), marker) { + t.Fatal("private diagnostic published") + } + unchanged, err := json.Marshal(ev) + if err != nil || string(unchanged) != string(before) { + t.Fatal("input modified") + } + }) + } +} + +func TestPublicationExoticAddresses(t *testing.T) { + for _, marker := range []string{"0x0a000001", "167772161", "012.0.0.1", "10.1", "::ffff:10.0.0.9", "/private/var/folders/zz"} { + t.Run(marker, func(t *testing.T) { + if got := (attest.Profile{}).Text("address=" + marker); strings.Contains(got, marker) { + t.Fatal("private address published") + } + }) + } +} diff --git a/free/ci/internal/attest/scrub.go b/free/ci/internal/attest/scrub.go index 56ced4826..abd3a6a58 100644 --- a/free/ci/internal/attest/scrub.go +++ b/free/ci/internal/attest/scrub.go @@ -31,7 +31,7 @@ var ( urlRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s"'<>\x60)\]}]+`) ipv4Re = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`) ipv6Re = regexp.MustCompile(`[0-9A-Fa-f:]*:[0-9A-Fa-f:]*:[0-9A-Fa-f:.]*`) - homeRe = regexp.MustCompile(`(/Users|/home)/([^/\s:"'\x60?#&]+)|/root\b|[A-Za-z]:\\Users\\([^\\\s"'?#&]+)`) + homeRe = regexp.MustCompile(`/private/var/folders/[^\s:"'\x60?#&]+|(/Users|/home)/([^/\s:"'\x60?#&]+)|/root\b|[A-Za-z]:\\Users\\([^\\\s"'?#&]+)`) userHostRe = regexp.MustCompile(`([A-Za-z0-9._-]+)@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})`) localHost = regexp.MustCompile(`(?i)\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.(?:` + privateSuffixAlternation() + `)\b`) ) @@ -111,6 +111,7 @@ func (p Profile) scrub(s string, seen []string) string { } return m }) + s = p.scrubLegacyIPv4(s) s = ipv6Re.ReplaceAllStringFunc(s, p.redactIPv6Spans) for lit, repl := range p.aliases { s = replaceToken(s, lit, repl) diff --git a/free/ci/test/security/redteam/b_publish_test.go b/free/ci/test/security/redteam/b_publish_test.go index 3fd3c9aa4..0c2140728 100644 --- a/free/ci/test/security/redteam/b_publish_test.go +++ b/free/ci/test/security/redteam/b_publish_test.go @@ -154,12 +154,6 @@ func leakingIn(t *testing.T, id string, markers []string, allFreeText bool) { keys = append(keys, k) } sort.Strings(keys) - if !openFinding(t, id) { - for _, k := range keys { - t.Logf("leaks %s: %v", k, leaking[k]) - } - return - } for _, k := range keys { t.Errorf("published document keeps private data from %s: %v", k, leaking[k]) } From 4cf5b55382210b4e737024a1f0c3f1e4f3494fae Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:11:39 -0400 Subject: [PATCH 3/7] fix(ci): refuse node public keys bound to another node (D-1429) --- free/ci/internal/pki/nodekey.go | 16 +++++++- free/ci/internal/pki/nodekey_reuse_test.go | 37 ++++++++++++++++++ free/ci/internal/pki/nodekey_test.go | 2 + free/ci/internal/store/node_pki.go | 8 ---- free/ci/internal/store/node_pki_pin.go | 39 +++++++++++++++++++ .../test/security/redteam/c_pairing_test.go | 3 -- 6 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 free/ci/internal/pki/nodekey_reuse_test.go diff --git a/free/ci/internal/pki/nodekey.go b/free/ci/internal/pki/nodekey.go index 762d7fe5d..251b4b7f8 100644 --- a/free/ci/internal/pki/nodekey.go +++ b/free/ci/internal/pki/nodekey.go @@ -16,6 +16,8 @@ import ( "crypto/subtle" "encoding/base64" "encoding/hex" + "errors" + "github.com/nself-org/plugins/free/ci/internal/store" "regexp" ) @@ -47,7 +49,7 @@ func (c *CA) PinNodeKey(ctx context.Context, nodeID string, pub ed25519.PublicKe if !ValidNodeKey(pub) { return fail(ReasonBadCSR, "node key must be an Ed25519 public key of full order") } - return c.st.PKINodeKeyPut(ctx, nodeID, NodeKeyID(pub), base64.StdEncoding.EncodeToString(pub)) + return nodeKeyBindingError(c.st.PKINodeKeyPut(ctx, nodeID, NodeKeyID(pub), base64.StdEncoding.EncodeToString(pub))) } // RepinNodeKey finalizes the pin of a re-pairing node after CheckPinnedNodeKey passed: a compare-and-set @@ -69,12 +71,15 @@ func (c *CA) RepinNodeKey(ctx context.Context, nodeID string, pub ed25519.Public if err == nil && !swapped { return fail(ReasonNodeKeyChanged, "node %s changed its node key pin during the enrollment", nodeID) } - return err + return nodeKeyBindingError(err) } // CheckNodeKey compares a hello's node key with the pin. With no pin it pins (an SSH node's first // hello). A pending pin (after rotate) accepts exactly the key id the operator named. func (c *CA) CheckNodeKey(ctx context.Context, nodeID string, pub ed25519.PublicKey) error { + if err := c.st.PKINodeKeyExclusive(ctx, nodeID, NodeKeyID(pub), base64.StdEncoding.EncodeToString(pub)); err != nil { + return nodeKeyBindingError(err) + } keyID, pinned, ok, err := c.st.PKINodeKeyGet(ctx, nodeID) if err != nil { return err @@ -116,3 +121,10 @@ func (c *CA) RotateNodeKey(ctx context.Context, actor, nodeID, keyID string) err } return c.st.PKINodeKeyRotate(ctx, actor, nodeID, keyID) } + +func nodeKeyBindingError(err error) error { + if errors.Is(err, store.ErrNodeKeyBound) { + return fail(ReasonNodeKeyChanged, "node key is already bound to another node") + } + return err +} diff --git a/free/ci/internal/pki/nodekey_reuse_test.go b/free/ci/internal/pki/nodekey_reuse_test.go new file mode 100644 index 000000000..ade72b1e4 --- /dev/null +++ b/free/ci/internal/pki/nodekey_reuse_test.go @@ -0,0 +1,37 @@ +package pki + +import ( + "context" + "testing" +) + +func TestNodeKeyCannotBeShared(t *testing.T) { + for _, method := range []string{"pin", "hello", "pending hello"} { + t.Run(method, func(t *testing.T) { + r := newRig(t) + ctx := context.Background() + pub := edPub(t) + if err := r.ca.PinNodeKey(ctx, "victim", pub); err != nil { + t.Fatal(err) + } + var err error + switch method { + case "pin": + err = r.ca.PinNodeKey(ctx, "attacker", pub) + case "hello": + err = r.ca.CheckNodeKey(ctx, "attacker", pub) + case "pending hello": + if err = r.ca.RotateNodeKey(ctx, "operator", "attacker", NodeKeyID(pub)); err != nil { + t.Fatal(err) + } + err = r.ca.CheckNodeKey(ctx, "attacker", pub) + } + if err == nil { + t.Fatal("accepted another node's key") + } + if err = r.ca.CheckNodeKey(ctx, "victim", pub); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/free/ci/internal/pki/nodekey_test.go b/free/ci/internal/pki/nodekey_test.go index bf1c40b63..c5e4de496 100644 --- a/free/ci/internal/pki/nodekey_test.go +++ b/free/ci/internal/pki/nodekey_test.go @@ -94,6 +94,7 @@ func TestCheckNodeKey(t *testing.T) { wantReason(t, r.ca.CheckNodeKey(ctx, "fresh", make(ed25519.PublicKey, 5)), ReasonBadCSR) }) t.Run("pending pin after rotate", func(t *testing.T) { + pub, other := edPub(t), edPub(t) if err := r.ca.PinNodeKey(ctx, "n9", pub); err != nil { t.Fatal(err) } @@ -126,6 +127,7 @@ func TestCheckNodeKey(t *testing.T) { wantReason(t, r.ca.CheckNodeKey(ctx, "n10", short), ReasonNodeKeyChanged) }) t.Run("pending pin on a node with no earlier pin", func(t *testing.T) { + pub := edPub(t) if err := r.ca.RotateNodeKey(ctx, "cli:admin", "n11", NodeKeyID(pub)); err != nil { t.Fatal(err) } diff --git a/free/ci/internal/store/node_pki.go b/free/ci/internal/store/node_pki.go index d51b336a7..40d951794 100644 --- a/free/ci/internal/store/node_pki.go +++ b/free/ci/internal/store/node_pki.go @@ -154,14 +154,6 @@ func (s *Store) PKIIsDenied(ctx context.Context, serial string) (bool, error) { return n > 0, err } -// PKINodeKeyPut pins a node's Ed25519 key, replacing any earlier pin. -func (s *Store) PKINodeKeyPut(ctx context.Context, nodeID, keyID, public string) error { - return s.write(ctx, func(tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO pki_node_key(node_id,key_id,public,pinned_at) VALUES(?,?,?,?)`, nodeID, keyID, public, s.now().UnixNano()) - return err - }) -} - // PKINodeKeyGet returns the pin (ok false when none). func (s *Store) PKINodeKeyGet(ctx context.Context, nodeID string) (keyID, public string, ok bool, err error) { err = s.readers.QueryRowContext(ctx, `SELECT key_id,public FROM pki_node_key WHERE node_id=?`, nodeID).Scan(&keyID, &public) diff --git a/free/ci/internal/store/node_pki_pin.go b/free/ci/internal/store/node_pki_pin.go index 6782aa985..4905ec520 100644 --- a/free/ci/internal/store/node_pki_pin.go +++ b/free/ci/internal/store/node_pki_pin.go @@ -7,6 +7,7 @@ package store import ( "context" "database/sql" + "errors" ) // PKINodeKeyPutIf is PKINodeKeyPut as a compare-and-set: it writes only while the node still has a pin @@ -15,6 +16,9 @@ import ( func (s *Store) PKINodeKeyPutIf(ctx context.Context, nodeID, wantKeyID, keyID, public string) (bool, error) { var swapped bool err := s.write(ctx, func(tx *sql.Tx) error { + if err := refuseSharedNodeKey(ctx, tx, nodeID, keyID, public); err != nil { + return err + } res, err := tx.ExecContext(ctx, `UPDATE pki_node_key SET key_id=?,public=?,pinned_at=? WHERE node_id=? AND key_id=?`, keyID, public, s.now().UnixNano(), nodeID, wantKeyID) if err != nil { return err @@ -25,3 +29,38 @@ func (s *Store) PKINodeKeyPutIf(ctx context.Context, nodeID, wantKeyID, keyID, p }) return swapped, err } + +// PKINodeKeyPut pins a node's Ed25519 key, replacing any earlier pin. +func (s *Store) PKINodeKeyPut(ctx context.Context, nodeID, keyID, public string) error { + return s.write(ctx, func(tx *sql.Tx) error { + if err := refuseSharedNodeKey(ctx, tx, nodeID, keyID, public); err != nil { + return err + } + _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO pki_node_key(node_id,key_id,public,pinned_at) VALUES(?,?,?,?)`, nodeID, keyID, public, s.now().UnixNano()) + return err + }) +} + +// ErrNodeKeyBound refuses a key used by another node without exposing either key. +var ErrNodeKeyBound = errors.New("node key is already bound to another node") + +type nodeKeyQuery interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func refuseSharedNodeKey(ctx context.Context, q nodeKeyQuery, nodeID, keyID, public string) error { + var count int + err := q.QueryRowContext(ctx, `SELECT count(*) FROM pki_node_key WHERE node_id<>? AND public<>'' AND (key_id=? OR public=?)`, nodeID, keyID, public).Scan(&count) + if err != nil { + return err + } + if count != 0 { + return ErrNodeKeyBound + } + return nil +} + +// PKINodeKeyExclusive also rejects duplicate bindings left by older coordinators. +func (s *Store) PKINodeKeyExclusive(ctx context.Context, nodeID, keyID, public string) error { + return refuseSharedNodeKey(ctx, s.readers, nodeID, keyID, public) +} diff --git a/free/ci/test/security/redteam/c_pairing_test.go b/free/ci/test/security/redteam/c_pairing_test.go index 71f826597..75c2765b8 100644 --- a/free/ci/test/security/redteam/c_pairing_test.go +++ b/free/ci/test/security/redteam/c_pairing_test.go @@ -87,9 +87,6 @@ func TestC39CodeIsSingleUseUnderRace(t *testing.T) { // to node b, neither by the enrollment pin nor by the first-hello pin (the evidence verifier, the // revocation set and the quarantine all key on the key id). func TestC39ReusedPublicKeyIsRefused(t *testing.T) { - if !openFinding(t, "C03") { - return - } r := cNewRig(t) ctx := context.Background() pub, _, _ := ed25519.GenerateKey(rand.Reader) From ac8f42c73e127cfbc2e50ac0211625a727cb65be Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:11:39 -0400 Subject: [PATCH 4/7] fix(ci): apply machine write cap to spend and provider keys (D-1430) --- free/ci/internal/policy/machine_spend_test.go | 36 +++++++++++++++++++ free/ci/internal/policy/write.go | 4 +-- free/ci/internal/policy/write_rows.go | 2 +- .../ci/test/security/redteam/c_policy_test.go | 16 ++++----- .../redteam/f_attack_authorize_test.go | 3 -- 5 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 free/ci/internal/policy/machine_spend_test.go diff --git a/free/ci/internal/policy/machine_spend_test.go b/free/ci/internal/policy/machine_spend_test.go new file mode 100644 index 000000000..c9e4dc6d6 --- /dev/null +++ b/free/ci/internal/policy/machine_spend_test.go @@ -0,0 +1,36 @@ +package policy + +import ( + "context" + "encoding/json" + "testing" + + "github.com/nself-org/plugins/free/ci/internal/roles" +) + +func TestMachineSpendWrites(t *testing.T) { + for _, tc := range []struct { + name, old, next, op string + refused bool + }{ + {"loosen", `"off"`, `"on"`, OpSet, true}, + {"tighten", `"on"`, `"off"`, OpSet, false}, + {"unchanged", `"off"`, `"off"`, OpSet, false}, + {"unset", `"off"`, "", OpUnset, true}, + } { + t.Run(tc.name, func(t *testing.T) { + w, _, actor := testWriter(t) + ctx := context.Background() + change := Change{Op: OpSet, Scope: "user", Key: "economics.paid_enabled", Value: json.RawMessage(tc.old)} + if _, err := w.Apply(ctx, actor, change); err != nil { + t.Fatal(err) + } + w.Machine = true + change.Op, change.Value = tc.op, json.RawMessage(tc.next) + _, err := w.Apply(ctx, actor, change) + if tc.refused && roles.ReasonOf(err) != ReasonMachineInvoked || !tc.refused && err != nil { + t.Fatalf("refused=%v err=%v", tc.refused, err) + } + }) + } +} diff --git a/free/ci/internal/policy/write.go b/free/ci/internal/policy/write.go index 93fe35034..0b9ed98f6 100644 --- a/free/ci/internal/policy/write.go +++ b/free/ci/internal/policy/write.go @@ -156,7 +156,7 @@ func (w *Writer) row(ctx context.Context, keys *Keyset, actor roles.Actor, c Cha if value, err = checkValue(spec, c.Value); err != nil { return Result{}, err } - pw.Op, pw.Value = store.PolicySet, value + pw.Op, pw.Value = store.PolicySet, value res.Value = shown(spec, value) } var branch *trust.Scope @@ -167,7 +167,7 @@ func (w *Writer) row(ctx context.Context, keys *Keyset, actor roles.Actor, c Cha // cannot both pass on a state the other is changing (D-1283). errMachine := errors.New("machine-invoked write refused") check := func(tx *store.Tx) error { - if w.Machine && strings.HasPrefix(c.Key, "trust.") && (c.Op == OpUnset || w.loosens(keys, spec, storedOf(ctx, tx, t, c.Key), value)) { + if w.Machine && (strings.HasPrefix(c.Key, "trust.") || spec.Spend) && (c.Op == OpUnset || w.loosens(keys, spec, storedOf(ctx, tx, t, c.Key), value)) { return errMachine } if c.Op == OpSet { // an unset cannot make the remaining rows loosen a parent diff --git a/free/ci/internal/policy/write_rows.go b/free/ci/internal/policy/write_rows.go index acb75210b..0cd76b294 100644 --- a/free/ci/internal/policy/write_rows.go +++ b/free/ci/internal/policy/write_rows.go @@ -243,7 +243,7 @@ func (w *Writer) loosens(keys *Keyset, spec KeySpec, old, next json.RawMessage) // refuseMachine writes the refusal's audit row and returns the E687 error. A // failed audit write does not turn the refusal into an allow. func (w *Writer) refuseMachine(ctx context.Context, actor roles.Actor, op, target string) error { - refusal := &roles.Error{Reason: ReasonMachineInvoked, Msg: "a machine-invoked caller (NSELF_INVOKED_BY) cannot loosen trust policy: " + op + " " + target} + refusal := &roles.Error{Reason: ReasonMachineInvoked, Msg: "a machine-invoked caller (NSELF_INVOKED_BY) cannot loosen policy: " + op + " " + target} if w.Log == nil { return refusal } diff --git a/free/ci/test/security/redteam/c_policy_test.go b/free/ci/test/security/redteam/c_policy_test.go index 9ccdc9f17..a095ee67d 100644 --- a/free/ci/test/security/redteam/c_policy_test.go +++ b/free/ci/test/security/redteam/c_policy_test.go @@ -228,11 +228,14 @@ func TestC37MachineInvokedCannotLoosen(t *testing.T) { t.Fatalf("control: %v", err) } for name, err := range map[string]error{ - "raise the cap": set(machine, "trust.machine_invoked_cap", `"owner"`), - "raise the cap, same": set(machine, "trust.machine_invoked_cap", `"internal"`), - "lower isolation": set(machine, "trust.isolation_min", `"process"`), - "widen secrets": set(machine, "trust.secrets", `["project","environment","team","release","deploy"]`), - "first-time approval": set(machine, "trust.approval.first_time", `false`), + "enable paid spend": set(machine, "economics.paid_enabled", `true`), + "raise daily budget": set(machine, "economics.budgets.day", `1000000000`), + "raise approval threshold": set(machine, "economics.approval_threshold_micro", `1000000000`), + "raise the cap": set(machine, "trust.machine_invoked_cap", `"owner"`), + "raise the cap, same": set(machine, "trust.machine_invoked_cap", `"internal"`), + "lower isolation": set(machine, "trust.isolation_min", `"process"`), + "widen secrets": set(machine, "trust.secrets", `["project","environment","team","release","deploy"]`), + "first-time approval": set(machine, "trust.approval.first_time", `false`), "unset the cap": func() error { _, e := machine.Apply(ctx, op, policy.Change{Op: policy.OpUnset, Scope: "user", Key: "trust.machine_invoked_cap"}) return e @@ -255,9 +258,6 @@ func TestC37MachineInvokedCannotLoosen(t *testing.T) { // TestC38MachineInvokedCannotEnableSpend (TM-38, C05): the machine-invoked refusal covers trust.* only; // a capped caller can set paid placement and budgets, which loosen policy the same way. func TestC38MachineInvokedCannotEnableSpend(t *testing.T) { - if !openFinding(t, "C05") { - return - } ctx := context.Background() w, op := cMachineWriter(t, true) for key, val := range map[string]string{"economics.paid_enabled": `true`, "economics.budgets.day": `1000000000`, "economics.approval_threshold_micro": `1000000000`} { diff --git a/free/ci/test/security/redteam/f_attack_authorize_test.go b/free/ci/test/security/redteam/f_attack_authorize_test.go index 0d465f7e7..461ff00a0 100644 --- a/free/ci/test/security/redteam/f_attack_authorize_test.go +++ b/free/ci/test/security/redteam/f_attack_authorize_test.go @@ -169,9 +169,6 @@ func TestF05PlantedRevisionPolicy(t *testing.T) { // forgejo URL decide where a token is sent and which variable is read. Setting them as a machine-invoked caller // (a prompt-injected agent) must be refused; today only trust.* keys are checked (the root cause of C05). func TestF06MachineInvokedCannotRepointProviders(t *testing.T) { - if !openFinding(t, "P02") { - return - } b := fNew(t) machine := []string{"NSELF_INVOKED_BY=mcp"} for _, kv := range [][2]string{{"providers.github.api_url", `"https://attacker.example"`}, {"providers.github.token_env", `"AWS_SECRET_ACCESS_KEY"`}, From b7e07c08fbc251a257e9405ee6299adc256c548f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:11:39 -0400 Subject: [PATCH 5/7] fix(ci): reserve node location and pool facts for operators (D-1434) --- free/ci/internal/nodes/registry/intake.go | 38 ++++++++++++++ .../ci/internal/nodes/registry/intake_test.go | 50 +++++++++++++++++++ free/ci/internal/nodes/registry/registry.go | 5 +- .../security/redteam/d_watch_pool_test.go | 6 --- 4 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 free/ci/internal/nodes/registry/intake.go create mode 100644 free/ci/internal/nodes/registry/intake_test.go diff --git a/free/ci/internal/nodes/registry/intake.go b/free/ci/internal/nodes/registry/intake.go new file mode 100644 index 000000000..a47df9f21 --- /dev/null +++ b/free/ci/internal/nodes/registry/intake.go @@ -0,0 +1,38 @@ +package registry + +import ( + "github.com/nself-org/plugins/free/ci/internal/model" + "strings" +) + +// Placement facts come from the operator, regardless of a report's claimed source. +func discoveryPlacement(c model.Capability) model.Capability { + c.Location = model.CapabilityLocation{Kind: assigned[string](nil), Region: assigned[string](nil)} + c.Identity.Labels = reportedLabels(c.Identity.Labels, nil) + return c +} + +func preservePlacement(in, old model.Capability) model.Capability { + in.Location = old.Location + in.Identity.Labels = reportedLabels(in.Identity.Labels, old.Identity.Labels) + return in +} + +func reportedLabels(reported, assigned []string) []string { + out := make([]string, 0, len(reported)+len(assigned)) + for _, label := range reported { + if !strings.HasPrefix(label, "pool.") { + out = append(out, label) + } + } + for _, label := range assigned { + if strings.HasPrefix(label, "pool.") { + out = append(out, label) + } + } + return out +} + +func eligible(c model.Capability) bool { + return c.Separation.UIDSeparation.Value != nil && *c.Separation.UIDSeparation.Value +} diff --git a/free/ci/internal/nodes/registry/intake_test.go b/free/ci/internal/nodes/registry/intake_test.go new file mode 100644 index 000000000..2e6c2f4bd --- /dev/null +++ b/free/ci/internal/nodes/registry/intake_test.go @@ -0,0 +1,50 @@ +package registry + +import ( + "context" + "github.com/nself-org/plugins/free/ci/internal/model" + "reflect" + "testing" +) + +func TestIntakeKeepsOperatorLocationAndPoolLabels(t *testing.T) { + for _, phase := range []string{"register", "update"} { + t.Run(phase, func(t *testing.T) { + r, c := setup(t) + ctx := context.Background() + hosted := "hosted" + c.Location.Kind = model.Fact[string]{Value: &hosted, Source: "assigned", Confidence: "known"} + c.Identity.Labels = append(c.Identity.Labels, "pool.team", "pool.record") + var n Node + var err error + if phase == "register" { + c.Identity.ID += "-forged" + n, err = r.Register(ctx, c, "node") + } else { + n, _, err = r.UpdateCapability(ctx, c.Identity.ID, c, "node") + } + if err != nil { + t.Fatal(err) + } + if n.Capability.Location.Kind.Value != nil && *n.Capability.Location.Kind.Value == "hosted" { + t.Fatal("self-reported hosted location accepted") + } + for _, label := range n.Capability.Identity.Labels { + if label == "pool.team" || label == "pool.record" { + t.Fatal("self-reported pool label accepted") + } + } + if phase == "update" { + old, err := r.Get(ctx, c.Identity.ID) + if err != nil { + t.Fatal(err) + } + c.Location.Region.Value = new(string) + got, _, err := r.UpdateCapability(ctx, c.Identity.ID, c, "node") + if err != nil || !reflect.DeepEqual(got.Capability.Location, old.Capability.Location) { + t.Fatalf("operator location changed: %v", err) + } + } + }) + } +} diff --git a/free/ci/internal/nodes/registry/registry.go b/free/ci/internal/nodes/registry/registry.go index f1df15db6..286e2faf9 100644 --- a/free/ci/internal/nodes/registry/registry.go +++ b/free/ci/internal/nodes/registry/registry.go @@ -36,15 +36,13 @@ func safeTrust() model.CapabilityTrust { f.Confidence = "known" return model.CapabilityTrust{Accepts: f, Isolation: assigned[model.Isolation](nil), Network: assigned[[]model.NetworkScope](nil), SecretClasses: assigned[[]model.SecretClass](nil), PrivacyZone: assigned[model.PrivacyZone](nil)} } -func eligible(c model.Capability) bool { - return c.Separation.UIDSeparation.Value != nil && *c.Separation.UIDSeparation.Value -} // Register records discovery without authorizing a project or accepting node trust. func (r *Registry) Register(ctx context.Context, c model.Capability, actor string) (Node, error) { if c.Identity.ID == "" { return Node{}, invalid("/identity/id") } + c = discoveryPlacement(c) c.Trust = safeTrust() c.Identity.Ownership = assigned[string](nil) c.Identity.AgeRecipient = model.Fact[string]{Source: "self", ObservedAt: time.Now().UTC(), Confidence: "unknown"} @@ -143,6 +141,7 @@ func (r *Registry) UpdateCapability(ctx context.Context, id string, in model.Cap if !reflect.DeepEqual(old.Tools, in.Tools) { drift = append(drift, "/tools") } + in = preservePlacement(in, old) in.Trust = old.Trust in.Lifecycle = old.Lifecycle in.DeployHost = old.DeployHost diff --git a/free/ci/test/security/redteam/d_watch_pool_test.go b/free/ci/test/security/redteam/d_watch_pool_test.go index d52bb819a..4d7756cce 100644 --- a/free/ci/test/security/redteam/d_watch_pool_test.go +++ b/free/ci/test/security/redteam/d_watch_pool_test.go @@ -207,9 +207,6 @@ func TestD41_NodeCannotSelfDeclareHostedToSkipProjectAuthorization(t *testing.T) if dHas(dEligibility(t, n, "personal-proj"), model.NodeDiscovered) { t.Fatal("positive control: the authorized project was refused") } - if !openFinding(t, "D41") { - return - } forged := n.Capability hosted := "hosted" forged.Location.Kind = model.Fact[string]{Value: &hosted, Source: "self", ObservedAt: time.Now().UTC(), Confidence: "known"} @@ -229,9 +226,6 @@ func TestD41_NodeCannotSelfDeclareHostedToSkipProjectAuthorization(t *testing.T) // D42: node-reported labels are kept verbatim, including the pool.* namespace the pool design assigns. func TestD42_NodeCannotAssignItselfPoolLabels(t *testing.T) { r, c := dRegistry(t) - if !openFinding(t, "D42") { - return - } forged := dCapability(t) forged.Identity.Labels = append(append([]string(nil), forged.Identity.Labels...), "pool.team", "pool.record") updated, _, err := r.UpdateCapability(context.Background(), c.Identity.ID, forged, "node") From 5e309adc0669e658222350c4d1160d12646ac862 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:12:05 -0400 Subject: [PATCH 6/7] fix(ci): resolve agent toolchain images and carry network scope (D-1444) --- free/ci/cmd/agent/exec.go | 16 ++-- free/ci/cmd/agent/exec_floor_test.go | 5 ++ free/ci/cmd/agent/exec_image.go | 34 ++++++++ free/ci/cmd/agent/exec_image_test.go | 82 +++++++++++++++++++ free/ci/cmd/agent/serve.go | 2 +- free/ci/internal/dispatch/deliver.go | 3 +- free/ci/internal/dispatch/deliver_test.go | 5 +- free/ci/internal/protocol/messages.go | 25 +++--- free/ci/internal/run/dispatch_hook.go | 2 +- .../ci/schemas/runner-protocol.v1.schema.json | 3 + 10 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 free/ci/cmd/agent/exec_image.go create mode 100644 free/ci/cmd/agent/exec_image_test.go diff --git a/free/ci/cmd/agent/exec.go b/free/ci/cmd/agent/exec.go index e8d4e00d5..f12766861 100644 --- a/free/ci/cmd/agent/exec.go +++ b/free/ci/cmd/agent/exec.go @@ -38,10 +38,11 @@ func init() { // execSpec is the job a hosted workflow or a test hands to `exec`. type execSpec struct { - AttemptID string `json:"attempt_id"` - CoordinatorID string `json:"coordinator_id"` - JobID string `json:"job_id"` - Job model.Job `json:"job"` + NetworkScope model.NetworkScope `json:"network_scope,omitempty"` + AttemptID string `json:"attempt_id"` + CoordinatorID string `json:"coordinator_id"` + JobID string `json:"job_id"` + Job model.Job `json:"job"` // Revision is the full commit the job runs against (40 or 64 hex). Revision string `json:"revision"` SourceDir string `json:"source_dir,omitempty"` @@ -197,7 +198,10 @@ func execute(ctx context.Context, spec execSpec, in jobInputs) (ciexec.AttemptRe if err != nil { return ciexec.AttemptResult{}, "", err } - job := spec.Job + job, err := agentJobImage(ctx, spec, cirun.Hooks{}.Resolved().Image) + if err != nil { + return ciexec.AttemptResult{Attempt: model.Attempt{ID: spec.AttemptID, State: "failed", FailureClass: "infra"}, ExitCode: -1, Started: now, Finished: time.Now(), Err: err}, err.Error(), nil + } job.Env = map[string]string{} for k, v := range spec.Job.Env { job.Env[k] = v @@ -217,7 +221,7 @@ func execute(ctx context.Context, spec execSpec, in jobInputs) (ciexec.AttemptRe } raw := &tailBuffer{max: excerptMax} res := ex.Run(ctx, ciexec.JobSpec{AttemptID: spec.AttemptID, CoordinatorID: agentIncarnation(spec.CoordinatorID), Job: job, Command: job.Run, - Secrets: ex.Secrets, SourceDir: spec.SourceDir, Mounts: in.Mounts, Home: home, Slot: in.Slot, Stdin: in.Stdin, Masks: in.Masks, RunnerRoot: in.RunnerRoot, Entrypoint: in.Entrypoint, + Secrets: ex.Secrets, SourceDir: spec.SourceDir, Network: spec.NetworkScope, Mounts: in.Mounts, Home: home, Slot: in.Slot, Stdin: in.Stdin, Masks: in.Masks, RunnerRoot: in.RunnerRoot, Entrypoint: in.Entrypoint, CPU: jobCPU(in), MemoryMB: jobMemoryMB(in), BeforeJob: cacheBeforeJob(spec, job.Env), AfterJob: agentAfterJob(spec, in), Output: func(b []byte) { raw.Write(b) diff --git a/free/ci/cmd/agent/exec_floor_test.go b/free/ci/cmd/agent/exec_floor_test.go index adebb7c91..aa2ba3d20 100644 --- a/free/ci/cmd/agent/exec_floor_test.go +++ b/free/ci/cmd/agent/exec_floor_test.go @@ -18,9 +18,14 @@ import ( ciexec "github.com/nself-org/plugins/free/ci/internal/exec" "github.com/nself-org/plugins/free/ci/internal/model" + cirun "github.com/nself-org/plugins/free/ci/internal/run" ) func TestRaisedJobWithoutImageIsRefusedNotRunOnTheHost(t *testing.T) { + calls := 0 + old := cirun.Registered.Image + cirun.RegisterImage(missingImageHook{&calls, errors.New("no configured image")}) + t.Cleanup(func() { cirun.RegisterImage(old) }) skipUnixOnly(t) useState(t) dir := t.TempDir() diff --git a/free/ci/cmd/agent/exec_image.go b/free/ci/cmd/agent/exec_image.go new file mode 100644 index 000000000..f5e92e481 --- /dev/null +++ b/free/ci/cmd/agent/exec_image.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + ciexec "github.com/nself-org/plugins/free/ci/internal/exec" + "github.com/nself-org/plugins/free/ci/internal/model" + cirun "github.com/nself-org/plugins/free/ci/internal/run" +) + +// A delivery raised to container isolation uses the local run's pinned-image hook. +func agentJobImage(ctx context.Context, spec execSpec, hook cirun.ContainerImageHook) (model.Job, error) { + job := spec.Job + if job.Isolation != "container" && job.Isolation != "sandboxed-container" { + return job, nil + } + if job.Container != nil && job.Container.Image != "" { + return job, nil + } + var image string + var err error + if hook != nil { + image, err = hook.Image(ctx, cirun.AttemptInfo{AttemptID: spec.AttemptID, JobID: spec.JobID, Revision: spec.Revision, Job: job, Container: true}) + } + if err != nil || image == "" { + return job, &ciexec.Error{Code: "E613", Message: "remote container job needs a pinned toolchain image; configure NSELF_CI_IMAGE on the agent", Err: err} + } + container := model.ContainerConfig{} + if job.Container != nil { + container = *job.Container + } + container.Image = image + job.Container = &container + return job, nil +} diff --git a/free/ci/cmd/agent/exec_image_test.go b/free/ci/cmd/agent/exec_image_test.go new file mode 100644 index 000000000..842a09ee0 --- /dev/null +++ b/free/ci/cmd/agent/exec_image_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "github.com/nself-org/plugins/free/ci/internal/agent/session" + ciexec "github.com/nself-org/plugins/free/ci/internal/exec" + "github.com/nself-org/plugins/free/ci/internal/model" + "github.com/nself-org/plugins/free/ci/internal/protocol" + cirun "github.com/nself-org/plugins/free/ci/internal/run" + "strings" + "testing" +) + +type missingImageHook struct { + calls *int + err error +} + +func (h missingImageHook) Image(context.Context, cirun.AttemptInfo) (string, error) { + *h.calls++ + return "", h.err +} + +func TestRaisedJobResolvesImageBeforeExecution(t *testing.T) { + useState(t) + missing := errors.New("test image unavailable") + for _, level := range []model.Isolation{"container", "sandboxed-container"} { + t.Run(string(level), func(t *testing.T) { + calls := 0 + old := cirun.Registered.Image + cirun.RegisterImage(missingImageHook{&calls, missing}) + t.Cleanup(func() { cirun.RegisterImage(old) }) + spec := execSpec{AttemptID: "raised", Job: model.Job{Isolation: level}} + res, tail, err := execute(context.Background(), spec, jobInputs{}) + if err != nil { + t.Fatal(err) + } + if calls != 1 || !errors.Is(res.Err, missing) || !strings.Contains(tail, "NSELF_CI_IMAGE") { + t.Fatalf("image resolution: calls=%d err=%v tail=%q", calls, res.Err, tail) + } + }) + } +} + +type deliveredSpecObserver struct { + got *ciexec.JobSpec + stop error +} + +func (o deliveredSpecObserver) Prepare(_ context.Context, spec *ciexec.JobSpec) error { + *o.got = *spec + return o.stop +} + +type pinnedImageHook struct{} + +func (pinnedImageHook) Image(context.Context, cirun.AttemptInfo) (string, error) { + return "example.invalid/toolchain@sha256:" + strings.Repeat("a", 64), nil +} + +func TestAgentReceivesImageAndLeaseNetwork(t *testing.T) { + useState(t) + for _, scope := range []model.NetworkScope{"none", "restricted", "internet"} { + t.Run(string(scope), func(t *testing.T) { + old := cirun.Registered + t.Cleanup(func() { cirun.Registered = old }) + var got ciexec.JobSpec + stop := errors.New("stop before runtime") + cirun.RegisterIsolation(deliveredSpecObserver{&got, stop}) + cirun.RegisterImage(pinnedImageHook{}) + runner := leaseRunner{coordinator: "test"} + _, err := runner.Run(context.Background(), session.JobRun{Lease: protocol.Lease{AttemptID: "network", NetworkScope: scope, Checkout: protocol.Checkout{Revision: rev40}, Job: model.Job{Isolation: "container", Run: []string{"true"}}}}) + if err != nil { + t.Fatal(err) + } + if got.Network != scope || got.Job.Container == nil || got.Job.Container.Image == "" || got.Job.Isolation != "container" { + t.Fatalf("delivered scope=%s image=%v isolation=%s", got.Network, got.Job.Container, got.Job.Isolation) + } + }) + } +} diff --git a/free/ci/cmd/agent/serve.go b/free/ci/cmd/agent/serve.go index 922e2b736..82afa4abf 100644 --- a/free/ci/cmd/agent/serve.go +++ b/free/ci/cmd/agent/serve.go @@ -147,7 +147,7 @@ func (r leaseRunner) Run(ctx context.Context, j session.JobRun) (session.JobResu // run on an empty tree would report a pass for code it never saw. return session.JobResult{}, errors.New("the lease has a checkout but no hook staged it") } - spec := execSpec{AttemptID: l.AttemptID, CoordinatorID: r.coordinator, JobID: "job", Job: l.Job, Revision: l.Checkout.Revision, SourceDir: j.Setup.SourceDir} + spec := execSpec{AttemptID: l.AttemptID, CoordinatorID: r.coordinator, JobID: "job", Job: l.Job, NetworkScope: l.NetworkScope, Revision: l.Checkout.Revision, SourceDir: j.Setup.SourceDir} in := jobInputs{Slot: j.Slot, Env: j.Setup.Env, Secrets: j.Setup.Secrets, Mounts: j.Setup.Mounts, Output: j.Output, Tail: j.Tail, Skip: j.Skip} if len(j.Credential) > 0 { if err := wrapCredential(&spec, &in, j); err != nil { diff --git a/free/ci/internal/dispatch/deliver.go b/free/ci/internal/dispatch/deliver.go index 9ab5ddad5..6f9812a1e 100644 --- a/free/ci/internal/dispatch/deliver.go +++ b/free/ci/internal/dispatch/deliver.go @@ -27,6 +27,7 @@ import ( // Delivery is what the carrier needs to start one leased attempt. type Delivery struct { Node model.Capability + Network model.NetworkScope Job model.Job AttemptID string LeaseID string @@ -61,7 +62,7 @@ func (d *Dispatcher) knownTransport(r sched.Runner) bool { // already counted it in d.wg. func (d *Dispatcher) deliver(q Queued, r sched.Runner, a Admitted, pol sched.Policy) { hosted := sched.RunnerHosted(r) - delivery := Delivery{Node: r.Capability, Job: raiseIsolation(q.Job.Model, requiredIsolation(q, r, pol)), AttemptID: a.AttemptID, LeaseID: a.LeaseID, Token: a.Token, Epoch: a.Epoch, Revision: q.Job.Revision, ExpiresAt: d.cfg.Clock().Add(d.cfg.LeaseTTL), Hosted: hosted, + delivery := Delivery{Network: q.Job.Sched.Network, Node: r.Capability, Job: raiseIsolation(q.Job.Model, requiredIsolation(q, r, pol)), AttemptID: a.AttemptID, LeaseID: a.LeaseID, Token: a.Token, Epoch: a.Epoch, Revision: q.Job.Revision, ExpiresAt: d.cfg.Clock().Add(d.cfg.LeaseTTL), Hosted: hosted, Trust: q.Job.Sched.Trust, Runner: runnerFacts(q.Job.Sched, r)} go func() { // admit registered the delivery with the WaitGroup defer d.wg.Done() diff --git a/free/ci/internal/dispatch/deliver_test.go b/free/ci/internal/dispatch/deliver_test.go index 6db4ae6cf..6ec19ae63 100644 --- a/free/ci/internal/dispatch/deliver_test.go +++ b/free/ci/internal/dispatch/deliver_test.go @@ -353,7 +353,8 @@ func TestDispatchRaisesDeliveredIsolationToTheFloor(t *testing.T) { if _, err := d.Submit(context.Background(), j); err != nil { t.Fatal(err) } - if got := wait(t, remote.got).lease.Job.Isolation; got != "sandboxed-container" { - t.Fatalf("an untrusted job declared at process was delivered at %q, want sandboxed-container", got) + lease := wait(t, remote.got).lease + if lease.Job.Isolation != "sandboxed-container" || lease.NetworkScope != "restricted" { + t.Fatalf("delivered isolation=%s network=%s, want sandboxed-container restricted", lease.Job.Isolation, lease.NetworkScope) } } diff --git a/free/ci/internal/protocol/messages.go b/free/ci/internal/protocol/messages.go index 32b891fe2..1810ae052 100644 --- a/free/ci/internal/protocol/messages.go +++ b/free/ci/internal/protocol/messages.go @@ -109,18 +109,19 @@ type LeaseCache struct { Save []CacheSave `json:"save"` } type Lease struct { - LeaseID string `json:"lease_id"` - AttemptID string `json:"attempt_id"` - Epoch uint64 `json:"epoch"` - Token string `json:"token"` - CompatMode CompatMode `json:"compat_mode"` - Job model.Job `json:"job"` - Checkout Checkout `json:"checkout"` - Secrets []SecretRef `json:"secrets"` - Trust model.TrustClass `json:"trust,omitempty"` // the class the job was admitted at; set with Secrets, so a node judges its own eligibility by it - ExpiresAt string `json:"expires_at"` - Cache *LeaseCache `json:"cache,omitempty"` - RunnerCredential *RunnerCredential `json:"runner_credential,omitempty"` + NetworkScope model.NetworkScope `json:"network_scope,omitempty"` + LeaseID string `json:"lease_id"` + AttemptID string `json:"attempt_id"` + Epoch uint64 `json:"epoch"` + Token string `json:"token"` + CompatMode CompatMode `json:"compat_mode"` + Job model.Job `json:"job"` + Checkout Checkout `json:"checkout"` + Secrets []SecretRef `json:"secrets"` + Trust model.TrustClass `json:"trust,omitempty"` // the class the job was admitted at; set with Secrets, so a node judges its own eligibility by it + ExpiresAt string `json:"expires_at"` + Cache *LeaseCache `json:"cache,omitempty"` + RunnerCredential *RunnerCredential `json:"runner_credential,omitempty"` } type Chunk struct { Stream string `json:"stream"` diff --git a/free/ci/internal/run/dispatch_hook.go b/free/ci/internal/run/dispatch_hook.go index 6f36a3ab6..8a27d1cf9 100644 --- a/free/ci/internal/run/dispatch_hook.go +++ b/free/ci/internal/run/dispatch_hook.go @@ -224,7 +224,7 @@ func (a transportAdapter) Deliver(ctx context.Context, d dispatch.Delivery) erro if err != nil { return err } - lease := protocol.Lease{LeaseID: d.LeaseID, AttemptID: d.AttemptID, Epoch: uint64(d.Epoch), Token: d.Token, CompatMode: protocol.Compat15, Job: d.Job, + lease := protocol.Lease{NetworkScope: d.Network, LeaseID: d.LeaseID, AttemptID: d.AttemptID, Epoch: uint64(d.Epoch), Token: d.Token, CompatMode: protocol.Compat15, Job: d.Job, Checkout: protocol.Checkout{Revision: d.Revision}, Secrets: refs, ExpiresAt: d.ExpiresAt.UTC().Format(time.RFC3339)} if len(refs) > 0 { lease.Trust = d.Trust diff --git a/free/ci/schemas/runner-protocol.v1.schema.json b/free/ci/schemas/runner-protocol.v1.schema.json index 5a6f7bf19..889bc5c68 100644 --- a/free/ci/schemas/runner-protocol.v1.schema.json +++ b/free/ci/schemas/runner-protocol.v1.schema.json @@ -672,6 +672,9 @@ "lease_id": { "type": "string" }, + "network_scope": { + "type": "string" + }, "runner_credential": { "anyOf": [ { From b124a771ddd779755db6a887bb6dd95a3258f462 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 12:29:45 -0400 Subject: [PATCH 7/7] fix(ci): adapt zip payload scan test to authz.ScanArchive API --- free/ci/internal/cache/specs/zip_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/free/ci/internal/cache/specs/zip_test.go b/free/ci/internal/cache/specs/zip_test.go index 0fb199e2f..7fd91698b 100644 --- a/free/ci/internal/cache/specs/zip_test.go +++ b/free/ci/internal/cache/specs/zip_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + "github.com/nself-org/plugins/free/ci/internal/authz" "github.com/nself-org/plugins/free/ci/internal/redact" ) @@ -36,9 +37,18 @@ func TestZipPayloadScan(t *testing.T) { }{ {"clean", 1024, nil}, {fakeSecret, 1024, ErrSecretMatch}, {"longer than budget", 3, ErrTooLarge}, } { - inf := newInflater(r) - inf.left = tc.budget - if err := inf.scan(bytes.NewReader(makeZip(tc.text))); !errors.Is(err, tc.want) { + data := makeZip(tc.text) + left := tc.budget + err := authz.ScanArchive(bytes.NewReader(data), int64(len(data)), r, &left) + var got error + if errors.Is(err, authz.ErrSecretContent) { + got = ErrSecretMatch + } else if errors.Is(err, authz.ErrScanBudget) { + got = ErrTooLarge + } else { + got = err + } + if !errors.Is(got, tc.want) { t.Fatalf("budget %d: %v, want %v", tc.budget, err, tc.want) } }