Files
at-container-registry/scanner/internal/scan/worker_skip_test.go
T
Evan JarrettandClaude Opus 5 a63f668de0 scanner: fix five crash and halt classes found by a pipeline audit
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
2026-09-05 15:01:10 -05:00

172 lines
5.5 KiB
Go

package scan
import (
"strings"
"testing"
scanner "atcr.io/scanner"
)
// hexDigest builds a well-formed sha256 digest from a one-character seed.
// skipReason validates every digest it will use, so a placeholder like
// "sha256:config" is now itself a skip and would mask what these cases test.
func hexDigest(seed rune) string {
return "sha256:" + strings.Repeat(string(seed), scanner.HexLen)
}
// TestSkipReason covers the artifact shapes the scanner must refuse before it
// spends a download on them. The attestation case is the one that reached
// production: an in-toto SLSA provenance manifest carries an ordinary image
// config, so the config media type check alone waved it through and Syft was
// handed an OCI layout with no layers in it.
func TestSkipReason(t *testing.T) {
tests := []struct {
name string
configType string
layerTypes []string
wantSkip bool
}{
{
name: "container image",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.oci.image.layer.v1.tar+gzip"},
},
{
name: "docker image",
configType: "application/vnd.docker.container.image.v1+json",
layerTypes: []string{"application/vnd.docker.image.rootfs.diff.tar.gzip"},
},
{
name: "layer media type absent",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{""},
},
{
name: "helm chart",
configType: "application/vnd.cncf.helm.config.v1+json",
layerTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
wantSkip: true,
},
{
name: "in-toto attestation with an image config",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.in-toto+json"},
wantSkip: true,
},
{
name: "dsse envelope layer",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.dsse.envelope.v1+json"},
wantSkip: true,
},
{
name: "mixed layers keep the scannable one",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.in-toto+json", "application/vnd.oci.image.layer.v1.tar"},
},
{
name: "no layers at all is left to the pipeline",
configType: "application/vnd.oci.image.config.v1+json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
job := &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: tt.configType, Digest: hexDigest('c')},
}
for i, mt := range tt.layerTypes {
job.Layers = append(job.Layers, scanner.BlobDescriptor{
MediaType: mt,
Digest: hexDigest(rune('a' + i)),
})
}
reason := skipReason(job)
if got := reason != ""; got != tt.wantSkip {
t.Errorf("skipReason = %q, wantSkip=%v", reason, tt.wantSkip)
}
})
}
}
// TestSkipReasonRejectsMalformedDigests pins the other half of skipReason: a
// digest that is not a digest is a permanent property of the record, so the
// job is refused here rather than failing later as a retryable error.
//
// A malformed digest on a layer the layout would drop anyway is not a reason
// to refuse the image, and the last case pins that: the check walks exactly the
// blobs referencedBlobs will fetch.
func TestSkipReasonRejectsMalformedDigests(t *testing.T) {
const layerType = "application/vnd.oci.image.layer.v1.tar+gzip"
const configType = "application/vnd.oci.image.config.v1+json"
tests := []struct {
name string
job *scanner.ScanJob
wantSkip bool
}{
{
name: "path traversal in a layer digest",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: hexDigest('c')},
Layers: []scanner.BlobDescriptor{{MediaType: layerType, Digest: "sha256:../../../escaped"}},
},
wantSkip: true,
},
{
name: "path traversal in the config digest",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: "sha256:../../../escaped"},
Layers: []scanner.BlobDescriptor{{MediaType: layerType, Digest: hexDigest('a')}},
},
wantSkip: true,
},
{
name: "no algorithm prefix",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: hexDigest('c')},
Layers: []scanner.BlobDescriptor{{MediaType: layerType, Digest: strings.Repeat("ef", 32)}},
},
wantSkip: true,
},
{
name: "unsupported algorithm",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: hexDigest('c')},
Layers: []scanner.BlobDescriptor{{MediaType: layerType, Digest: "sha512:" + strings.Repeat("cd", 64)}},
},
wantSkip: true,
},
{
name: "an empty layer digest is dropped, not refused",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: hexDigest('c')},
Layers: []scanner.BlobDescriptor{
{MediaType: layerType, Digest: ""},
{MediaType: layerType, Digest: hexDigest('a')},
},
},
},
{
name: "a dropped non-tar layer's digest is not checked",
job: &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: configType, Digest: hexDigest('c')},
Layers: []scanner.BlobDescriptor{
{MediaType: "application/vnd.oci.image.layer.v1.zstd+odd", Digest: "not a digest"},
{MediaType: layerType, Digest: hexDigest('a')},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reason := skipReason(tt.job)
if got := reason != ""; got != tt.wantSkip {
t.Errorf("skipReason = %q, wantSkip=%v", reason, tt.wantSkip)
}
})
}
}