mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-27 20:54:20 +00:00
Nothing limited how long one job could take. The worker's context was the process's, buildOCILayout took none, and blob downloads used a package-level client whose five-minute timeout is per request with no context, so a 19-layer image had a hundred-minute worst case on downloads alone and cancellation could not touch it. At the default single worker, one wedged job stopped that scanner entirely. scanner.job_timeout, default 8m, against the hold's 10m scanning timeout. Both clocks start at the same instant: the worker sends "started" on dequeue and derives the job context on the next line, so the scanner loses by two minutes, which is enough for its terminal message to cross the socket and be recorded. If the hold wins instead it re-dispatches while this scanner is still working, which is duplicate work recorded under a generic reason. A scanner cannot read the hold's config, so the relation is a mirrored constant used only for a boot-time warning, and the same warning fires if the deadline is disabled. What is actually bounded, since a deadline the code cannot honour is worse than none: presign, download, stereoscope's Provide, Syft's CreateSBOM, and Grype, which does have FindMatchesContext even though FindMatches does not. stereoscope's img.Read takes no context and is 81% of a scan, so it is checked either side rather than interrupted. Abandoning it on a goroutine would trade a bounded overrun for one writing gigabytes into a directory the caller has already deleted. max_image_size remains the real bound on that stage. A timeout reports error, not skipped. It describes this host at this moment, a contended CPU or a slow bucket, not the image, and skips are never retried, so one bad afternoon would retire an image permanently with nothing in the record to say why. Retry cost is bounded on the other side by max_image_size and by the stale-scan schedule. The classification asks the job context rather than the error, because several stages replace the cause and the uninterruptible one knows nothing about the deadline, and a job that finishes after an overrun still reports its real result rather than throwing away completed work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
897 lines
31 KiB
Go
897 lines
31 KiB
Go
package e2e
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
scanner "atcr.io/scanner"
|
|
"atcr.io/scanner/internal/client"
|
|
"atcr.io/scanner/internal/config"
|
|
"atcr.io/scanner/internal/mockhold"
|
|
"atcr.io/scanner/internal/queue"
|
|
"atcr.io/scanner/internal/scan"
|
|
)
|
|
|
|
// This file covers the blob download and OCI layout construction stages:
|
|
// what the scanner does when a blob is gone, truncated, corrupt, slow, or
|
|
// named by a digest a user made up.
|
|
//
|
|
// Historical note on the shape of these scenarios. worker.go used to
|
|
// dereference result.Summary unconditionally, and Summary is only populated
|
|
// when cfg.Vuln.Enabled; with Grype off in the harness, a *successful* scan
|
|
// panicked and took the test binary with it, so no e2e test could observe
|
|
// anything but a terminal "error" or "skipped". That is fixed, and
|
|
// TestZeroSizeLayerProducesACleanScan now runs a success through to the end.
|
|
// Scenarios below that would otherwise succeed (a duplicated layer, a lying
|
|
// size) still break one blob deliberately, but now only to keep the assertion
|
|
// on the download stage rather than on Syft's verdict.
|
|
//
|
|
// A second move since: the scenarios that pinned the *absence* of digest
|
|
// validation and content verification (the traversal digest, unverified bytes,
|
|
// a short layer, malformed digests, the claimed-bytes ceiling) now live in
|
|
// blob_integrity_test.go, where they assert the checks that replaced them.
|
|
// What is left here is the transport: what happens when a blob is gone, slow,
|
|
// redirected, duplicated, or served with a body the transport itself rejects.
|
|
|
|
// --- local harness ---------------------------------------------------------
|
|
|
|
// startScanner is Start with the mock hold supplied by the caller. Start builds
|
|
// its own Hold and so cannot install a presign or blob-response hook, and every
|
|
// transport fault here needs one. Everything else matches Start, including the
|
|
// TMPDIR restore and the shortened cooldown.
|
|
func startScanner(t *testing.T, hold *mockhold.Hold, opts ...Option) *Harness {
|
|
t.Helper()
|
|
t.Cleanup(hold.Close)
|
|
|
|
cfg := config.DefaultConfig()
|
|
cfg.Hold.URL = hold.URL()
|
|
cfg.Hold.Secret = testSecret
|
|
cfg.Scanner.Workers = 1
|
|
cfg.Vuln.Enabled = false
|
|
cfg.Vuln.TmpDir = t.TempDir()
|
|
|
|
origTmpDir, hadTmpDir := os.LookupEnv("TMPDIR")
|
|
t.Cleanup(func() {
|
|
if hadTmpDir {
|
|
os.Setenv("TMPDIR", origTmpDir)
|
|
return
|
|
}
|
|
os.Unsetenv("TMPDIR")
|
|
})
|
|
for _, opt := range opts {
|
|
opt(cfg)
|
|
}
|
|
|
|
restoreCooldown := scan.JobCooldown
|
|
scan.JobCooldown = testJobCooldown
|
|
|
|
q := queue.NewJobQueue(cfg.Scanner.QueueSize)
|
|
c := client.NewHoldClient(cfg.Hold.URL, cfg.Hold.Secret, q)
|
|
pool := scan.NewWorkerPool(cfg, q, c)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
pool.Start(ctx)
|
|
go c.Connect()
|
|
|
|
// Teardown waits for the worker to actually exit before restoring
|
|
// JobCooldown. Start does not, and the race detector sees it: a worker from
|
|
// the finished test is still reading the package variable while the next
|
|
// test's cleanup writes it. Waiting also keeps one test's in-flight scan
|
|
// from writing into the next test's temp directory.
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
c.Close()
|
|
q.Close()
|
|
pool.Wait()
|
|
scan.JobCooldown = restoreCooldown
|
|
})
|
|
|
|
if err := hold.WaitForScanner(10 * time.Second); err != nil {
|
|
t.Fatalf("scanner never connected: %v", err)
|
|
}
|
|
return &Harness{Hold: hold, Queue: q, Client: c, Cfg: cfg}
|
|
}
|
|
|
|
// newHold builds a mock hold with the harness secret plus any hooks.
|
|
func newHold(blobs mockhold.BlobSource, opts ...mockhold.Option) *mockhold.Hold {
|
|
return mockhold.New(blobs, append([]mockhold.Option{mockhold.WithSecret(testSecret)}, opts...)...)
|
|
}
|
|
|
|
// --- synthetic blobs -------------------------------------------------------
|
|
|
|
const (
|
|
layerType = "application/vnd.oci.image.layer.v1.tar+gzip"
|
|
configType = "application/vnd.oci.image.config.v1+json"
|
|
)
|
|
|
|
func digestOf(b []byte) string {
|
|
return fmt.Sprintf("sha256:%x", sha256.Sum256(b))
|
|
}
|
|
|
|
// gzTar builds a one-file gzipped tar, the shape of a real image layer.
|
|
func gzTar(t *testing.T, name string, content []byte) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(zw)
|
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
|
|
t.Fatalf("tar header: %v", err)
|
|
}
|
|
if _, err := tw.Write(content); err != nil {
|
|
t.Fatalf("tar write: %v", err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("tar close: %v", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("gzip close: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// unparseableConfig is a config blob that is not JSON at all. Scenarios that
|
|
// would otherwise scan clean through use it to stop the pipeline at Syft, so
|
|
// the assertion stays on the download stage (see the file comment).
|
|
func unparseableConfig() []byte { return []byte("{ this is not a container config") }
|
|
|
|
// validConfig is a well-formed OCI image config, for scenarios where the
|
|
// *layer* is the thing under test and the config must not be what fails.
|
|
func validConfig(t *testing.T, diffIDs ...string) []byte {
|
|
t.Helper()
|
|
cfg := map[string]any{
|
|
"architecture": "amd64",
|
|
"os": "linux",
|
|
"config": map[string]any{},
|
|
"rootfs": map[string]any{"type": "layers", "diff_ids": diffIDs},
|
|
}
|
|
b, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("marshal config: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func desc(digest string, size int64, mediaType string) scanner.BlobDescriptor {
|
|
return scanner.BlobDescriptor{Digest: digest, Size: size, MediaType: mediaType}
|
|
}
|
|
|
|
// jobFor assembles a scan job from raw descriptors. HoldEndpoint is left empty
|
|
// so SendJob points it at the mock.
|
|
func jobFor(cfg scanner.BlobDescriptor, layers ...scanner.BlobDescriptor) *scanner.ScanJob {
|
|
return &scanner.ScanJob{
|
|
ManifestDigest: "sha256:" + strings.Repeat("ab", 32),
|
|
Repository: "edge-case",
|
|
Tag: "latest",
|
|
UserDID: "did:plc:testuser",
|
|
UserHandle: "test.example",
|
|
HoldDID: "did:web:hold.test",
|
|
Tier: "deckhand",
|
|
Config: cfg,
|
|
Layers: layers,
|
|
}
|
|
}
|
|
|
|
// assertNoLeakedScanDirs checks the scanner's tmp dir is empty. The OCI layout
|
|
// lives in tmpDir/scan-*, and stereoscope's extraction lands in the same
|
|
// directory because WorkerPool.Start points TMPDIR at it, so a leak on any
|
|
// error path shows up here.
|
|
func assertNoLeakedScanDirs(t *testing.T, h *Harness) {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(h.Cfg.Vuln.TmpDir)
|
|
if err != nil {
|
|
t.Fatalf("read tmp dir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
t.Errorf("leftover in scanner tmp dir after failure: %s", e.Name())
|
|
}
|
|
}
|
|
|
|
func blobDigests(h *Harness) []string {
|
|
var out []string
|
|
for _, r := range h.Hold.BlobRequests() {
|
|
out = append(out, r.Digest)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func countOf(list []string, want string) int {
|
|
n := 0
|
|
for _, s := range list {
|
|
if s == want {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// --- blob transport --------------------------------------------------------
|
|
|
|
// TestMissingBlobIsRetryableError covers a layer garbage collected out from
|
|
// under a queued job: getBlob answers 404 and there is nothing to download,
|
|
// ever. The scanner reports "error", the hold records the scan as failed, and
|
|
// failed records are re-offered by the stale-scan loop on every pass. Nothing
|
|
// about the outcome can change, so this is an unbounded retry loop for a
|
|
// permanently unscannable manifest.
|
|
func TestMissingBlobIsRetryableError(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().Add(cfgDigest, cfgBytes)
|
|
|
|
var hold *mockhold.Hold
|
|
hold = newHold(blobs, mockhold.WithPresignHook(func(digest string) (string, bool) {
|
|
if digest == layerDigest {
|
|
return "", false // garbage collected
|
|
}
|
|
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(layerDigest, int64(len(layer)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want the current retryable-error behaviour, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, "404") {
|
|
t.Errorf("error does not mention the 404: %q", msg.Error)
|
|
}
|
|
t.Logf("missing blob -> retryable error: %q", msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestGetBlobNon200 points the job at a hold endpoint whose getBlob is broken
|
|
// in two ways a real hold can be: a 5xx, and a 200 carrying something that is
|
|
// not the expected JSON. Both surface as retryable errors, which is right for
|
|
// the 5xx and wrong-but-harmless for the malformed body.
|
|
func TestGetBlobNon200(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
want string
|
|
}{
|
|
{
|
|
name: "503 from getBlob",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "hold is restarting", http.StatusServiceUnavailable)
|
|
},
|
|
want: "status 503",
|
|
},
|
|
{
|
|
name: "200 with a non-JSON body",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("<html>proxy error</html>"))
|
|
},
|
|
want: "failed to decode response",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
broken := httptest.NewServer(http.HandlerFunc(tc.handler))
|
|
defer broken.Close()
|
|
|
|
h := startScanner(t, newHold(mockhold.NewMemory()))
|
|
|
|
cfgBytes := unparseableConfig()
|
|
job := jobFor(desc(digestOf(cfgBytes), int64(len(cfgBytes)), configType))
|
|
job.HoldEndpoint = broken.URL
|
|
|
|
seq, err := h.Hold.SendJob(job)
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, tc.want) {
|
|
t.Errorf("error %q does not mention %q", msg.Error, tc.want)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExpiredPresignedURL stands in for an S3 URL whose 15 minute window closed
|
|
// while the job sat in the queue: getBlob succeeds, the download 403s.
|
|
//
|
|
// The assertion worth keeping is the second one. DownloadBlob reports only the
|
|
// status code and drops the body, so the S3 <Error><Code>AccessDenied</Code>
|
|
// explanation never reaches the hold's scan record: an operator sees
|
|
// "download returned status 403" with no indication of which blob, from where,
|
|
// or why. GetBlobPresignedURL includes the body in its error; DownloadBlob does
|
|
// not.
|
|
func TestExpiredPresignedURL(t *testing.T) {
|
|
s3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`<Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>`))
|
|
}))
|
|
defer s3.Close()
|
|
|
|
hold := newHold(mockhold.NewMemory(), mockhold.WithPresignHook(func(digest string) (string, bool) {
|
|
return s3.URL + "/bucket/blob", true
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
cfgBytes := unparseableConfig()
|
|
seq, err := h.Hold.SendJob(jobFor(desc(digestOf(cfgBytes), int64(len(cfgBytes)), configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, "403") {
|
|
t.Errorf("error does not mention the 403: %q", msg.Error)
|
|
}
|
|
if strings.Contains(msg.Error, "AccessDenied") || strings.Contains(msg.Error, "expired") {
|
|
t.Errorf("DownloadBlob now includes the response body; update this test and the finding: %q", msg.Error)
|
|
}
|
|
t.Logf("expired presigned URL -> %q (body discarded)", msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestTruncatedBodyIsDetected sends fewer bytes than the declared
|
|
// Content-Length. Go's client turns that into an unexpected EOF on the body, so
|
|
// io.Copy does notice and the blob never reaches Syft. This is the transport
|
|
// fault the code handles correctly, and it is here so a regression is visible.
|
|
func TestTruncatedBodyIsDetected(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", bytes.Repeat([]byte("payload"), 4096))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().Add(cfgDigest, cfgBytes)
|
|
hold := newHold(blobs, mockhold.WithBlobResponseHook(func(w http.ResponseWriter, r *http.Request, digest string) bool {
|
|
if digest != mockhold.DigestHex(layerDigest) {
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(layer)))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(layer[:len(layer)/4])
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
return true // hang up short of Content-Length
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
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, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, "failed to write blob") {
|
|
t.Errorf("truncation was not caught by io.Copy; error was %q", msg.Error)
|
|
}
|
|
t.Logf("short body vs Content-Length -> %q", msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestStalledDownloadIsWaitedOutWithinTheJobBudget holds a response body open
|
|
// and shows that a stall shorter than the job's deadline is simply waited out.
|
|
// There is no per-blob impatience: a slow hold is not a broken one, and the
|
|
// only thing that ends a download early is the job budget running out.
|
|
//
|
|
// The stall here is deliberately short, and the harness runs the shipped
|
|
// job_timeout, so nothing here fires. What happens when the budget does run out
|
|
// is TestJobDeadlineFailsAStalledBlobBody in deadline_test.go.
|
|
func TestStalledDownloadIsWaitedOutWithinTheJobBudget(t *testing.T) {
|
|
const stall = 2 * time.Second
|
|
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
hold := newHold(mockhold.NewMemory(), mockhold.WithBlobResponseHook(func(w http.ResponseWriter, r *http.Request, digest string) bool {
|
|
w.Header().Set("Content-Length", "4096")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("partial"))
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
time.Sleep(stall)
|
|
return true
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
started := time.Now()
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, int64(len(cfgBytes)), configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 60*time.Second)
|
|
elapsed := time.Since(started)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if elapsed < stall {
|
|
t.Errorf("scanner gave up after %s, before the stall ended; a shorter deadline now exists", elapsed)
|
|
}
|
|
t.Logf("stalled body held the worker for %s, then: %q", elapsed.Round(100*time.Millisecond), msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestDownloadFollowsRedirectToAnotherHost shows the scanner will fetch blob
|
|
// bytes from wherever the getBlob response, or any redirect from it, points.
|
|
// DownloadBlob sends no credentials, so nothing leaks; what it means is that
|
|
// the hold (or anything able to answer as the hold, or an open redirect on the
|
|
// presigned URL's host) chooses which server the scanner talks to. That is
|
|
// acceptable now for the reason B-10 gave: the bytes are verified against the
|
|
// digest on arrival, so where they came from does not decide what they are.
|
|
func TestDownloadFollowsRedirectToAnotherHost(t *testing.T) {
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
var elsewhereHits atomic.Int32
|
|
var sawAuth atomic.Bool
|
|
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
elsewhereHits.Add(1)
|
|
if r.Header.Get("Authorization") != "" {
|
|
sawAuth.Store(true)
|
|
}
|
|
w.Write(cfgBytes)
|
|
}))
|
|
defer elsewhere.Close()
|
|
|
|
hold := newHold(mockhold.NewMemory(), mockhold.WithBlobResponseHook(func(w http.ResponseWriter, r *http.Request, digest string) bool {
|
|
http.Redirect(w, r, elsewhere.URL+"/somewhere-else", http.StatusFound)
|
|
return true
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, int64(len(cfgBytes)), configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error (the redirected bytes are an unparseable config), got %s: %s%s",
|
|
msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if elsewhereHits.Load() == 0 {
|
|
t.Error("redirect to a different host was not followed")
|
|
}
|
|
if sawAuth.Load() {
|
|
t.Error("Authorization header was sent to the redirect target")
|
|
}
|
|
t.Logf("cross-host redirect followed %d time(s), no credentials attached", elsewhereHits.Load())
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestRedirectChainStopsAtTen pins the only bound on a redirect chain: Go's
|
|
// default client policy. The scanner sets no policy of its own, so a hold
|
|
// pointing a blob at a redirect loop costs eleven round trips per blob before
|
|
// erroring, and the error names the URL rather than the blob.
|
|
func TestRedirectChainStopsAtTen(t *testing.T) {
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
var hold *mockhold.Hold
|
|
hold = newHold(mockhold.NewMemory(), mockhold.WithBlobResponseHook(func(w http.ResponseWriter, r *http.Request, digest string) bool {
|
|
http.Redirect(w, r, hold.URL()+"/blobs/"+digest, http.StatusFound)
|
|
return true
|
|
}))
|
|
h := startScanner(t, hold)
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, int64(len(cfgBytes)), configType)))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, "redirect") {
|
|
t.Errorf("error does not mention redirects: %q", msg.Error)
|
|
}
|
|
if n := len(h.Hold.BlobRequests()); n < 10 {
|
|
t.Errorf("redirect loop cost %d requests, expected the client's 10-hop limit", n)
|
|
}
|
|
t.Logf("redirect loop: %d requests, then %q", len(h.Hold.BlobRequests()), msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// --- layout construction ---------------------------------------------------
|
|
|
|
// TestDuplicateLayerIsDownloadedTwice feeds the same layer digest twice, which
|
|
// a hand-written manifest record can do. Both copies are fetched over the
|
|
// network and written to the same path, and the layout's manifest lists the
|
|
// layer twice.
|
|
//
|
|
// The config is deliberately unparseable so the run ends at Syft rather than
|
|
// in a scan verdict; the download accounting is what this asserts.
|
|
func TestDuplicateLayerIsDownloadedTwice(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := unparseableConfig()
|
|
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),
|
|
desc(layerDigest, int64(len(layer)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if n := countOf(blobDigests(h), mockhold.DigestHex(layerDigest)); n != 2 {
|
|
t.Errorf("duplicate layer fetched %d times, want the current behaviour of 2", n)
|
|
}
|
|
t.Logf("duplicate layer digest fetched %d times", countOf(blobDigests(h), mockhold.DigestHex(layerDigest)))
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestUnparseableConfigBlobIsRetryableError states outright what several
|
|
// scenarios above lean on: a config blob whose bytes are not JSON fails inside
|
|
// generateSBOM and comes back as "error". That is a permanent property of the
|
|
// image, so the hold's stale-scan loop will offer it again on every pass and
|
|
// get the same answer forever. The same is true of a layer whose bytes are not
|
|
// the tar its media type claims (TestDigestIsNeverVerified).
|
|
func TestUnparseableConfigBlobIsRetryableError(t *testing.T) {
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
layerDigest := digestOf(layer)
|
|
|
|
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 != "error" {
|
|
t.Fatalf("want the current retryable-error behaviour, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
t.Logf("unparseable config -> retryable error: %q", msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestEmptyLayerDigestIsSkippedNotFetched pins the one malformed-digest case
|
|
// the layout builder does handle: an empty digest among valid ones is dropped
|
|
// from both the download loop and the layout manifest.
|
|
//
|
|
// The surviving layer is served as garbage so the run ends in an error, which
|
|
// keeps the assertion on which blobs were fetched.
|
|
func TestEmptyLayerDigestIsSkipped(t *testing.T) {
|
|
layer := []byte("not actually a gzip stream")
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := unparseableConfig()
|
|
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("", 0, layerType), // empty digest
|
|
desc(layerDigest, int64(len(layer)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if n := len(h.Hold.BlobRequests()); n != 2 {
|
|
t.Errorf("fetched %d blobs, want config + one real layer: %v", n, blobDigests(h))
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestZeroSizeLayerIsFetched covers a descriptor claiming size 0 whose blob is
|
|
// genuinely empty. The size field is not consulted at download time, so the
|
|
// blob is fetched and an empty file is written into the layout.
|
|
func TestZeroSizeLayerIsFetched(t *testing.T) {
|
|
empty := []byte{}
|
|
emptyDigest := digestOf(empty)
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(emptyDigest, empty)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(emptyDigest, 0, layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if n := countOf(blobDigests(h), mockhold.DigestHex(emptyDigest)); n != 1 {
|
|
t.Errorf("zero-size layer fetched %d times, want 1", n)
|
|
}
|
|
t.Logf("zero-size layer -> %q", msg.Error)
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// TestZeroSizeLayerProducesACleanScan is the same image with a config the
|
|
// scanner can actually read, and it is the cheapest end-to-end proof that a
|
|
// successful scan no longer kills the process.
|
|
//
|
|
// stereoscope accepts a zero-byte layer as a valid, empty layer, so the scan
|
|
// succeeds with an SBOM containing no packages and, with Grype off, no
|
|
// vulnerability summary. That combination used to reach the unconditional
|
|
// result.Summary dereference in worker.go and panic the test binary. It is
|
|
// worth keeping for a second reason too: an image whose layer descriptors are
|
|
// all zero-size blobs comes back as a clean scan rather than a failure, and
|
|
// nothing in the scanner distinguishes "no vulnerabilities" from "nothing was
|
|
// there".
|
|
func TestZeroSizeLayerProducesACleanScan(t *testing.T) {
|
|
empty := []byte{}
|
|
emptyDigest := digestOf(empty)
|
|
cfgBytes := validConfig(t, emptyDigest)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(emptyDigest, empty)
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(emptyDigest, 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("want result, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if msg.Summary != nil {
|
|
t.Errorf("vuln scanning is disabled but the result carried a summary: %+v", msg.Summary)
|
|
}
|
|
t.Logf("zero-byte layer scanned clean, SBOM %d bytes", len(msg.SBOM))
|
|
}
|
|
|
|
// TestManyLayersAreFetchedSequentially runs an image as wide as the widest
|
|
// manifest in the corpus (19 layers) through the download stage. Each layer is
|
|
// one sequential HTTP round trip with its own 5 minute ceiling, so the
|
|
// worst-case time for this one job is not five minutes but ninety-five, and
|
|
// nothing caps it.
|
|
//
|
|
// The width comes from the corpus but the blobs are synthesised, because every
|
|
// blob is now verified against its digest and no fixture can serve bytes that
|
|
// hash to a real registry's digests. The layer count is what this measures.
|
|
func TestManyLayersAreFetchedSequentially(t *testing.T) {
|
|
width := widestCorpusWidth(t)
|
|
if width < 10 {
|
|
t.Skipf("corpus has no wide manifest; widest is %d layers", width)
|
|
}
|
|
|
|
blobs := mockhold.NewMemory()
|
|
layers := make([]scanner.BlobDescriptor, 0, width)
|
|
diffIDs := make([]string, 0, width)
|
|
for i := 0; i < width; i++ {
|
|
b := gzTar(t, fmt.Sprintf("usr/bin/app%d", i), []byte(fmt.Sprintf("layer %d", i)))
|
|
d := digestOf(b)
|
|
blobs.Add(d, b)
|
|
layers = append(layers, desc(d, int64(len(b)), layerType))
|
|
diffIDs = append(diffIDs, d)
|
|
}
|
|
cfgBytes := validConfig(t, diffIDs...)
|
|
cfgDigest := digestOf(cfgBytes)
|
|
blobs.Add(cfgDigest, cfgBytes)
|
|
|
|
h := startScanner(t, newHold(blobs))
|
|
|
|
send := func() int64 {
|
|
t.Helper()
|
|
seq, err := h.Hold.SendJob(jobFor(desc(cfgDigest, int64(len(cfgBytes)), configType), layers...))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
return seq
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, send(), 60*time.Second)
|
|
if msg.Type != "result" {
|
|
t.Fatalf("want result, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
reqs := h.Hold.BlobRequests()
|
|
if len(reqs) != width+1 {
|
|
t.Errorf("fetched %d blobs, want %d (config + %d layers)", len(reqs), width+1, width)
|
|
}
|
|
// Sequential, not concurrent: no two fetches overlap.
|
|
for i := 1; i < len(reqs); i++ {
|
|
if reqs[i].At.Before(reqs[i-1].At) {
|
|
t.Errorf("blob requests are out of order at %d", i)
|
|
}
|
|
}
|
|
t.Logf("%d-layer manifest: %d sequential fetches", width, len(reqs))
|
|
|
|
// Per-layer resource accounting: downloadBlob opens a file and a response
|
|
// body per layer. Run the same job twice more and require the process's
|
|
// file descriptor count to hold steady, which is what rules out a per-layer
|
|
// handle leak (a leak would grow by ~20 per run).
|
|
if fds, ok := openFDs(); ok {
|
|
for i := 0; i < 2; i++ {
|
|
h.AwaitTerminal(t, send(), 60*time.Second)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
after, _ := openFDs()
|
|
if after > fds+8 {
|
|
t.Errorf("file descriptors grew from %d to %d across two more %d-layer scans",
|
|
fds, after, width)
|
|
}
|
|
t.Logf("file descriptors: %d after one scan, %d after three", fds, after)
|
|
}
|
|
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// widestCorpusWidth reports the layer count of the widest manifest in the
|
|
// corpus, so the shape under test stays tied to what a hold really holds.
|
|
func widestCorpusWidth(t *testing.T) int {
|
|
t.Helper()
|
|
all, err := mockhold.Corpus()
|
|
if err != nil {
|
|
t.Fatalf("load corpus: %v", err)
|
|
}
|
|
widest := 0
|
|
for _, m := range all {
|
|
if len(m.Layers) > widest {
|
|
widest = len(m.Layers)
|
|
}
|
|
}
|
|
return widest
|
|
}
|
|
|
|
// openFDs returns the number of open file descriptors, and whether the count
|
|
// was available (it is not on platforms without /proc).
|
|
func openFDs() (int, bool) {
|
|
entries, err := os.ReadDir("/proc/self/fd")
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return len(entries), true
|
|
}
|
|
|
|
// --- size guard ------------------------------------------------------------
|
|
|
|
// TestMaxImageSizeZeroDisablesTheGuard confirms the documented "0 = no limit"
|
|
// reading of the config. The guard now spends a budget down as bytes arrive,
|
|
// so the case worth pinning is that a zero limit spends nothing: an image far
|
|
// larger than any of the other scenarios here transfers in full.
|
|
//
|
|
// The descriptors are honest, because a manifest that lies about its size is
|
|
// now rejected by the descriptor check rather than by the ceiling, which would
|
|
// prove nothing about the ceiling.
|
|
func TestMaxImageSizeZeroDisablesTheGuard(t *testing.T) {
|
|
noise := make([]byte, 64*1024)
|
|
rand.New(rand.NewSource(2)).Read(noise)
|
|
big := gzTar(t, "usr/bin/app", noise)
|
|
bigDigest := digestOf(big)
|
|
|
|
// Unparseable on purpose: the run ends at Syft, so the assertion stays on
|
|
// the download stage rather than on a scan verdict.
|
|
cfgBytes := unparseableConfig()
|
|
cfgDigest := digestOf(cfgBytes)
|
|
|
|
blobs := mockhold.NewMemory().
|
|
Add(cfgDigest, cfgBytes).
|
|
Add(bigDigest, big)
|
|
h := startScanner(t, newHold(blobs), WithMaxImageSize(0))
|
|
|
|
seq, err := h.Hold.SendJob(jobFor(
|
|
desc(cfgDigest, int64(len(cfgBytes)), configType),
|
|
desc(bigDigest, int64(len(big)), layerType),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("send job: %v", err)
|
|
}
|
|
|
|
msg := h.AwaitTerminal(t, seq, 30*time.Second)
|
|
if msg.Type != "error" {
|
|
t.Fatalf("want error from Syft, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if strings.Contains(msg.Error, "too large") || strings.Contains(msg.Reason, "too large") {
|
|
t.Fatalf("MaxImageSize=0 rejected a job; the 0-means-unlimited contract changed: %q%q",
|
|
msg.Error, msg.Reason)
|
|
}
|
|
if n := len(h.Hold.BlobRequests()); n != 2 {
|
|
t.Errorf("fetched %d blobs, want config + layer with no ceiling in force", n)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|
|
|
|
// --- cleanup ---------------------------------------------------------------
|
|
|
|
// TestTmpDirIsCleanAfterAFailureInsideSyft covers the one cleanup path that is
|
|
// not a direct return from buildOCILayout: the layout is built, downloads
|
|
// succeed, and generateSBOM fails. processJob's deferred cleanup is responsible
|
|
// for the directory, and stereoscope is responsible for its own extraction
|
|
// scratch space under the same TMPDIR.
|
|
func TestTmpDirIsCleanAfterAFailureInsideSyft(t *testing.T) {
|
|
layer := gzTar(t, "usr/bin/app", []byte("hello"))
|
|
layerDigest := digestOf(layer)
|
|
cfgBytes := unparseableConfig()
|
|
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 != "error" {
|
|
t.Fatalf("want error, got %s: %s%s", msg.Type, msg.Error, msg.Reason)
|
|
}
|
|
if !strings.Contains(msg.Error, "SBOM") && !strings.Contains(msg.Error, "OCI image") {
|
|
t.Logf("failure came from somewhere other than Syft: %q", msg.Error)
|
|
}
|
|
assertNoLeakedScanDirs(t, h)
|
|
}
|