mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
An audit of the scan pipeline and the hold side of scanning found several ways scanning stops without saying so. Each fix here was written test-first: a test expressing the wanted behaviour, confirmed failing for the right reason, then the change. A summary-less result crash-looped both processes. worker.go dereferenced result.Summary unconditionally, but processJob only sets it when Grype runs, and SendResult puts the nil on the wire before the scanner dies on it, so handleResult's unguarded log killed the hold too. A nil Summary now means "not scanned for vulnerabilities", deliberately distinct from "scanned, found zero" — inventing a zeroed summary would report every image as clean when Grype never ran. The hold writes a record rather than orphaning the uploaded SBOM, and the appview renders an "SBOM only" state instead of a green Clean badge. The Grype database could wedge with no way back short of a restart. All three throttles in loadVulnDatabase were guarded by vulnDB != nil, so a scanner holding no provider retried a full download on every scan under the exclusive lock. Two earlier attempts at this bug each added one more condition to the same chain; this replaces the chain with a single decision function over a state snapshot, consulted by both call sites so they cannot disagree. That disagreement was itself a bug: the 50-scan reload had never once executed. Two independent halts. An unparseable frame was dropped in silence, stranding a row that held the hold's only dispatch slot forever; it is now answered "skipped" on first delivery. The 10-minute sweep leaked the in-flight digest and wrote no record, permanently retiring one image per timeout. A digest went unvalidated into filepath.Join and os.Create, so a layer digest of sha256:../../../x wrote outside the scan directory, and nothing verified that downloaded bytes hashed to the digest naming them. Digests come from records in a user's own PDS. Both are fixed together: verification is what makes an escaping write self-defeating. Concurrency did not work on either axis. The proactive capacity gate was depth-one hold-wide, so neither extra workers nor extra scanner processes received work. Depth is now the sum of the worker counts scanners advertise on connect, the gate is scoped to proactive work, and dispatch prefers the least-loaded scanner. Disconnects no longer hand a running scan to someone else: a scanner keeps a stable per-process identity and reclaims its own rows within a grace window, while a process that truly restarted returns with a new identity and has its work reclaimed, which is correct because the restart did lose it. The hold's scanning deadline measured queueing rather than scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. A new "started" message, sent by the worker that dequeues the job, separates the two budgets. An older scanner never sends it and falls under the queueing budget, which is more forgiving than the deadline it gets today. Adds an in-process mock hold and an e2e harness that runs the real client, queue and worker pool, seeded with 84 real manifest records fetched from a live PDS. Real image layouts and the Grype database are fetched by scripts and gitignored; suites needing them skip cleanly, so the default run stays offline and fast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
423 lines
15 KiB
Go
423 lines
15 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/scanner/internal/mockhold"
|
|
)
|
|
|
|
// This file states what the scanner must do with blob bytes and blob names it
|
|
// did not choose. Both come from an io.atcr.manifest record in a user's own
|
|
// PDS, so both are attacker-controlled text, and the hold's dispatch guards
|
|
// check neither.
|
|
//
|
|
// Two invariants, and everything here is one of them:
|
|
//
|
|
// 1. A digest names a blob and a file. It is only ever "sha256:<64 hex>";
|
|
// anything else is rejected before it reaches a path or the network.
|
|
// 2. The bytes that land on disk are the bytes the digest names, in the
|
|
// amount the descriptor claims, within the budget the config allows.
|
|
//
|
|
// A violation of either is a permanent property of the record or of the stored
|
|
// blob, so it must come back as "skipped" (which the hold records once) rather
|
|
// than "error" (which the stale-scan loop re-offers forever).
|
|
|
|
// --- invariant 1: a digest is a name, never a path -------------------------
|
|
|
|
// TestTraversalDigestNeverBecomesAPath is the security case. A layer digest of
|
|
// "sha256:../../../escaped-marker" used to be split on ":" and filepath.Joined
|
|
// onto blobs/sha256, which is exactly three levels below the scanner's tmp
|
|
// dir, so the downloaded bytes landed in the tmp dir itself: outside the scan
|
|
// directory, beyond the reach of cleanup, and over whatever file was already
|
|
// there (os.Create truncates).
|
|
//
|
|
// The escape is aimed at the harness's own t.TempDir so the test proves the
|
|
// boundary holds without writing anywhere real.
|
|
func TestTraversalDigestNeverBecomesAPath(t *testing.T) {
|
|
payload := []byte("bytes the scanner must never place here")
|
|
traversal := "sha256:../../../escaped-marker"
|
|
|
|
cfgBytes := validConfig(t)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add("payload", payload)
|
|
|
|
// A hold willing to serve a 200 for the traversal digest, which is the
|
|
// precondition the old write needed.
|
|
var hold *mockhold.Hold
|
|
hold = newHold(blobs, mockhold.WithPresignHook(func(digest string) (string, bool) {
|
|
if digest == traversal {
|
|
return hold.URL() + "/blobs/payload", true
|
|
}
|
|
return hold.URL() + "/blobs/" + mockhold.DigestHex(digest), true
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(traversal, int64(len(payload)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped (a malformed digest can never succeed), got %s: %s%s",
|
|
msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, "digest") {
|
|
t.Errorf("skip reason %q does not name the digest as the problem", msg.Reason)
|
|
}
|
|
|
|
// Nothing may have been written outside the scan directory. The tmp dir is
|
|
// the escape's target, and after a scan it must hold nothing at all.
|
|
escaped := filepath.Join(h.Cfg.Vuln.TmpDir, "escaped-marker")
|
|
if got, err := os.ReadFile(escaped); err == nil {
|
|
os.Remove(escaped)
|
|
t.Fatalf("the traversal still wrote %d bytes to %s", len(got), escaped)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
|
|
// And the rejection happens before any network call: a digest that cannot
|
|
// name a file must not be sent to the hold as a blob name either.
|
|
if n := len(h.Hold.BlobRequests()); n != 0 {
|
|
t.Errorf("scanner made %d blob requests for a job it must reject up front: %v",
|
|
n, blobDigests(h))
|
|
}
|
|
}
|
|
|
|
// TestMalformedDigestsAreSkippedBeforeAnyDownload walks the digest shapes a
|
|
// hand-written manifest record can carry. Every one is permanently
|
|
// unscannable, so every one is a skip, and none of them costs a round trip.
|
|
func TestMalformedDigestsAreSkippedBeforeAnyDownload(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
|
|
cases := []struct {
|
|
name string
|
|
digest string
|
|
}{
|
|
{"no algorithm prefix", strings.Repeat("ef", 32)},
|
|
{"unsupported algorithm", "sha512:" + strings.Repeat("cd", 64)},
|
|
{"uppercase hex", "sha256:" + strings.ToUpper(strings.Repeat("ab", 32))},
|
|
{"truncated hex", "sha256:abcd"},
|
|
{"path traversal", "sha256:../../../escaped-marker"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfgBytes := validConfig(t)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(tc.digest, layer)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(tc.digest, int64(len(layer)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if n := len(h.Hold.BlobRequests()); n != 0 {
|
|
t.Errorf("made %d blob requests before rejecting a malformed digest: %v",
|
|
n, blobDigests(h))
|
|
}
|
|
t.Logf("%s -> skipped: %s", tc.name, msg.Reason)
|
|
assertNoLeakedScanDirs(t, h)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMalformedConfigDigestIsSkipped covers the same validation on the config
|
|
// descriptor, which reaches downloadBlob by a separate call site.
|
|
func TestMalformedConfigDigestIsSkipped(t *testing.T) {
|
|
h := startScanner(t, newHold(mockhold.NewMemory()))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(desc("sha256:../../../escaped-config", 10, configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(h.Cfg.Vuln.TmpDir, "escaped-config")); err == nil {
|
|
os.Remove(filepath.Join(h.Cfg.Vuln.TmpDir, "escaped-config"))
|
|
t.Fatalf("config traversal wrote %d bytes outside the scan directory", len(got))
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// --- invariant 2: the bytes are the bytes ----------------------------------
|
|
|
|
// TestDigestMismatchIsDetectedAndSkipped serves bytes that do not hash to the
|
|
// digest naming them, which is what a mixed-up S3 key, a poisoned cache or a
|
|
// compromised BYOS hold produces. A compliant OCI client verifies on pull and
|
|
// refuses these bytes; the scanner is the one consumer in the system that used
|
|
// to trust them, and then vouched for them in a scan record.
|
|
//
|
|
// The failure must name the digest, not surface as whatever stereoscope makes
|
|
// of a stream it cannot parse.
|
|
func TestDigestMismatchIsDetectedAndSkipped(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("real content"))
|
|
layerDigest := digestOf(layer)
|
|
imposter := []byte("this is not a gzip stream at all")
|
|
|
|
cfgBytes := validConfig(t, digestOf(layer))
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(layerDigest, imposter) // wrong bytes under the right name
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(layerDigest, int64(len(imposter)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, "digest mismatch") {
|
|
t.Errorf("skip reason %q does not say the bytes failed their digest", msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, mockhold.DigestHex(layerDigest)[:16]) {
|
|
t.Errorf("skip reason %q does not name the offending blob", msg.Reason)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestConfigDigestMismatchIsDetectedAndSkipped is the same check on the config
|
|
// blob, whose download is a separate call site from the layer loop.
|
|
func TestConfigDigestMismatchIsDetectedAndSkipped(t *testing.T) {
|
|
cfgBytes := validConfig(t)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
imposter := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers"}}`)
|
|
|
|
blobs := mockhold.NewMemory().Add(cfgDigest, imposter)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, int64(len(imposter)), configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, "digest mismatch") {
|
|
t.Errorf("skip reason %q does not say the bytes failed their digest", msg.Reason)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestShortLayerIsRejectedByTheDescriptorCheck is the truncation the transport
|
|
// cannot see: a body that is internally consistent (Content-Length matches
|
|
// what is sent) but shorter than the size the descriptor declares.
|
|
//
|
|
// Both the digest and the size disagree with the bytes here, which is the
|
|
// point: descriptor.Size is attacker-supplied too, so the right verdict is
|
|
// that the descriptor as a whole does not describe the blob, and the message
|
|
// says so rather than picking one field to blame.
|
|
func TestShortLayerIsRejectedByTheDescriptorCheck(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", bytes.Repeat([]byte("payload"), 4096))
|
|
layerDigest := digestOf(layer)
|
|
short := layer[:len(layer)/4]
|
|
|
|
cfgBytes := validConfig(t, digestOf(layer))
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(layerDigest, short)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(layerDigest, int64(len(layer)), layerType), // declares the full size
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, fmt.Sprintf("%d", len(short))) {
|
|
t.Errorf("skip reason %q does not report the %d bytes actually received", msg.Reason, len(short))
|
|
}
|
|
if !strings.Contains(msg.Reason, fmt.Sprintf("%d", len(layer))) {
|
|
t.Errorf("skip reason %q does not report the %d bytes claimed", msg.Reason, len(layer))
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestHonestBlobsStillScan is the control. Verification must not reject an
|
|
// image whose bytes are what they say they are: config, layer, digests and
|
|
// sizes all agree, and the scan runs through to a result.
|
|
func TestHonestBlobsStillScan(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello from a real layer"))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := validConfig(t, digestOf(layer))
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(layerDigest, layer)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(layerDigest, int64(len(layer)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 60*time.Second)
|
|
if msg.Type != "result" {
|
|
t.Fatalf("verification rejected an honest image: %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestUndeclaredSizeIsAcceptedWhenTheDigestHolds pins the one relaxation in
|
|
// the size check. A descriptor whose Size is zero has not claimed a length, so
|
|
// the digest alone decides. Real records do carry sizes; this keeps a record
|
|
// that omits one scannable rather than making the weaker field authoritative.
|
|
func TestUndeclaredSizeIsAcceptedWhenTheDigestHolds(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := validConfig(t, digestOf(layer))
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(layerDigest, layer)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, 0, configType),
|
|
desc(layerDigest, 0, layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 60*time.Second)
|
|
if msg.Type != "result" {
|
|
t.Fatalf("a size-less descriptor with correct bytes was rejected: %s: %s%s",
|
|
msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// --- the size budget is measured, not claimed ------------------------------
|
|
|
|
// TestMaxImageSizeCountsTransferredBytes closes the ceiling bypass. The guard
|
|
// summed BlobDescriptor.Size, which comes from the same user-writable record
|
|
// as the digests, so a manifest claiming one byte per blob passed any limit
|
|
// and the scanner then wrote the real bytes to its tmp volume: 65,696 bytes
|
|
// measured against a 1,024 byte limit. The layer below declares one byte and
|
|
// the hold offers 65,696.
|
|
//
|
|
// The claimed-size pre-check stays (it is still worth refusing an honestly
|
|
// large image before a byte moves), but the budget is now enforced against
|
|
// what actually arrives.
|
|
func TestMaxImageSizeCountsTransferredBytes(t *testing.T) {
|
|
const limit = 1024
|
|
|
|
noise := make([]byte, 64*1024)
|
|
rand.New(rand.NewSource(1)).Read(noise)
|
|
big := gzTar(t, "usr/bin/app", noise)
|
|
bigDigest := digestOf(big)
|
|
cfgBytes := validConfig(t, digestOf(big))
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
var served atomic.Int64
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(bigDigest, big)
|
|
|
|
hold := newHold(blobs, mockhold.WithBlobResponseHook(func(w http.ResponseWriter, r *http.Request, digest string) bool {
|
|
if digest != mockhold.DigestHex(bigDigest) {
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(big)))
|
|
n, _ := w.Write(big)
|
|
served.Add(int64(n))
|
|
return true
|
|
}))
|
|
h := startScanner(t, hold, WithMaxImageSize(limit))
|
|
|
|
// The layer lies about its size, the way a hand-written record can. The
|
|
// config is honest, so the job reaches the download stage and the budget
|
|
// is what has to stop it rather than the descriptor check.
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(bigDigest, 1, layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped (an image over the ceiling can never be scanned), got %s: %s%s",
|
|
msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Reason, "too large") {
|
|
t.Errorf("skip reason %q does not name the size ceiling", msg.Reason)
|
|
}
|
|
t.Logf("layer declared 1 byte against a %d byte limit, hold offered %d: %s",
|
|
limit, served.Load(), msg.Reason)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestHonestlyOversizedImageIsSkipped keeps the cheap pre-check honest: a
|
|
// record that declares its real, over-limit size is refused before a byte is
|
|
// downloaded, and refused permanently.
|
|
func TestHonestlyOversizedImageIsSkipped(t *testing.T) {
|
|
cfgBytes := validConfig(t)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().Add(cfgDigest, cfgBytes)
|
|
h := startScanner(t, newHold(blobs), WithMaxImageSize(1024))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, 1<<20, configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "skipped" {
|
|
t.Fatalf("want skipped, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if n := len(h.Hold.BlobRequests()); n != 0 {
|
|
t.Errorf("downloaded %d blobs for an image refused by the pre-check", n)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|