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
237 KiB
Scanner bug report
Findings from an audit of the ATCR vulnerability scanner and the hold side of the scanning system, September 2026, followed by a round of fixes.
Seven agents produced the audit. Five proved behaviour by execution against a
new in-process test harness (scanner/internal/mockhold and
scanner/internal/e2e); two read the code without running it, one over the
scanner module and one over the hold. Five more then fixed the blocker classes,
test-first: each wrote tests expressing the desired behaviour, confirmed they
failed for the right reason, and only then changed production code.
Findings are marked CONFIRMED where someone reproduced them and SUSPECTED where they were reasoned from source. That distinction is load-bearing: treat SUSPECTED items as leads, not facts.
How to read this
Section 1 is the current status: what is fixed and what is still open. Read it first; it is the only part guaranteed to describe the code as it stands.
Sections 2 to 6 are the consolidated analysis, updated as fixes landed.
Sections 7 onward are the original agent reports, preserved unedited as dated evidence. They describe the code as audited, before any fix. Where a finding has since been fixed, the status table in section 1 says so and the analysis sections carry the detail. Do not treat an agent report as a description of current behaviour. A handful of "Reproduce:" lines in them name tests that have since been renamed; the renames are listed in section 6.
Where several agents found the same defect independently, that is noted. It is worth weighting: three agents reached the dead vulnDB reload from different directions, and four reached the hold-side panic.
1. Status
Fixed
| What | Where | |
|---|---|---|
| A | A summary-less result crash-looped both the scanner and the hold. Nil Summary now means "not scanned for vulnerabilities", distinct from "scanned, found zero". The hold writes a record rather than orphaning the SBOM blob, and the appview renders an "SBOM only" state instead of a green Clean badge. |
scan/worker.go, scan_broadcaster.go handleResult, 5 appview files |
| B | The Grype database wedge. All three throttles were guarded by vulnDB != nil, so a scanner with no provider retried a full download on every scan under the exclusive lock, with no route back to health short of a restart. Replaced by one vulnDBDecide policy function over a state snapshot, consulted by both call sites. Reloads are load-then-swap. Also fixed: the 50-scan reload that had never executed, the missing stale-serve ceiling, corrupt-database self-healing, and context cancellation. |
scan/grype.go |
| C | Two independent fleet-wide halts. An unparseable frame was dropped in silence, stranding a row that held the hold's single 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; it is now shaped like handleError. Plus drainPendingJobs sending jobs it had not claimed, and hasActiveJobs failing closed forever on a database error. |
client/hold.go, scan_broadcaster.go |
| S | Digest path traversal (CONFIRMED by execution: 50 bytes written outside the scan directory) and the total absence of content verification. New strict scanner.ParseDigest, validation at three boundaries, streaming hash-and-size verification, and MaxImageSize now enforced against real transferred bytes rather than the sizes a manifest claims. |
scanner/digest.go, scan/extractor.go, client/hold.go |
| N | Concurrency. The proactive capacity gate was depth-one hold-wide, defeating both extra workers and extra scanner processes. Dispatch depth is now the sum of worker counts scanners advertise on connect, the gate is proactive-scoped, and dispatch prefers the least-loaded scanner. Disconnects no longer hand off running work. Terminal handlers check assigned_to. Boot reconciliation added. |
scan_broadcaster.go, scanner/types.go, client/hold.go |
| F4 | The hold's scanning deadline measured queueing, not scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. New started message sent by the worker that dequeues a job, with separate scanning (10m from started_at) and queueing (60m from assigned_at) budgets. |
scanner/types.go, client/hold.go, scan_broadcaster.go |
| D | Nothing bounded one scan job. New scanner.job_timeout (default 8m, under the hold's 10m scanning budget) starts when a worker dequeues, at the same instant the started message goes out. A context now reaches every blob request (http.NewRequestWithContext), Syft's cataloging and Grype's matcher (FindMatchesContext). A timeout is reported as a retryable error, and the scan directory is removed. Stereoscope's img.Read() takes no context and remains uninterruptible; vuln.max_image_size is its bound. |
scan/worker.go, scan/extractor.go, scan/syft.go, scan/grype.go, client/hold.go, internal/config/config.go |
Both modules pass go test -race. Three columns were added to scan_jobs
(origin, started_at, disconnected_at) with per-column migration for
existing holds, plus an origin, status index.
Open, and worth doing next
| Severity | What | Detail |
|---|---|---|
| HIGH | No keepalive, read deadline or read limit on the scanner WebSocket. Now more load-bearing than when audited: a half-open connection holds its advertised capacity out of the dispatch budget and keeps its rows marked as its own until the timeouts fire. | section 4 |
| MEDIUM | Rescanning identical content produces different digests, so every rescan uploads a fresh blob and orphans the previous one. Accumulates on the stale-scan schedule. | section 6 |
| MEDIUM | checkPredecessor caches a false from an unreachable hold forever. pkg/hold/gc/gc.go:2027 already carries the corrected logic for the same problem; the scan broadcaster was never brought along. |
section 5 |
| MEDIUM | handleResult does two S3 uploads and a CAR commit inline on the reader goroutine, on context.Background() with no deadline. That scanner's messages are not read while it runs. |
section 5 |
| MEDIUM | Vertical concurrency is still constrained: a database refresh stalls one process's workers behind the write lock, and the scanner queue has no seq or digest dedupe. Scaling out sidesteps both; scaling up does not. (GOMEMLIMIT is a separate matter, see "Accepted" below.) |
section 4 |
| MEDIUM | The cgroup memory ceiling needs rechecking. See section 3. | section 3 |
| LOW | Reports are ~14.5 KB per rendered row (1.8 MB for 125 matches), parsed on every render. The report descriptor hardcodes Grype v0.107.1 against a v0.118.0 dependency. The report's embedded summary is read by nothing. |
section 6 |
| LOW | pkg/config/viper.go:34 discards ReadInConfig()'s error, so a malformed YAML file is silently ignored whole, in scanner, hold and appview alike. |
section 9 |
| LOW | Shutdown: queue.Close() drains rather than cancels, and HoldClient.Close() panics if called twice. (The blob-download half is fixed: requests now carry the job context, so pool.Wait() returns on cancellation.) |
section 9 |
| LOW | A webhook for a summary-less scan reports all-zero counts, because db.Scan has no status column. Only affects deployments running vuln.enabled: false. |
section 7 |
| LOW | assigned_at is stored as local-offset RFC3339 while created_at normalises to ...Z. Correct today, breaks across a DST transition on a non-UTC host. |
section 12 |
| — | Test infrastructure: e2e.Start writes the package-level scan.JobCooldown, so two harnesses alive in one test race. Bound each harness in a t.Run subtest, or make the cooldown per-pool. |
section 6 |
Accepted, not defects
Severity buckets do not sum to the reported total. Measured: debian:11-slim reports 211 vulnerabilities beside four boxes summing to 105, because Grype's Negligible and Unknown severities have no bucket anywhere in the chain. Section 6 has the full data.
Decision: current behaviour is as intended. Two more colours on the vulnerability strip would clutter the UI, and Negligible and Unknown are not worth flagging in most cases. The four boxes are a deliberate summary of what merits attention, not an exhaustive partition of the total, and the detail table already lists every finding including the unbucketed ones.
Two consequences follow, and both are intended. Recorded here because each looks like a rendering bug from the code alone, and the next reader should not "correct" them.
The headline "N vulnerabilities" beside the four boxes is a larger number than they sum to on multi-severity distros. That is the point: the total is the finding count, the boxes are the ones worth acting on.
An image whose findings are entirely Negligible or Unknown has Total > 0,
so it skips the "Clean" branch and renders a coloured strip reading 0 0 0 0.
This is correct, because "clean" is not the same as "nothing worth
reporting". A clean image has no findings at all; this one has findings, none
of which rise to a flagged severity. Rendering it as Clean would assert
something untrue, and the vulnerabilities tab lists exactly what the strip does
not.
The 512 MiB GOMEMLIMIT does not scale with scanner.workers. Process-wide
rather than per-worker, so it is divided among concurrent scans: +1.7% for a
single scan, +24% time and 5.2x GC cycles at two, +92% and 9.4x at four.
Decision: keep as is for now. It exists as a safeguard for a memory-constrained production host, not as a tuning parameter. The intended direction is to remove the limit once scanners run on their own nodes, rather than to scale it with the worker count. Until then it is doing the job it was added for. See section 3 for the deployment context, and note the separate open question there about the cgroup ceiling, which is a different thing.
Deliberately not fixed
Blob 404 from a garbage-collected layer, a config blob that is not JSON, and a
null config all remain retryable errors rather than permanent skips. They sit
outside the digest and byte-verification boundary, and the 404 case needs a
status-code decision that collides with other pending work. They are still
infinite retry loops; see section 2.
2. Cross-cutting themes
Retryable versus permanent is inverted
The hold's stale loop retries error and never retries skipped. Any
deterministic failure reported as an error is therefore an infinite retry loop
at full scan cost.
Fixed for: malformed digests of every shape, digest mismatch on downloaded
bytes, short layers, images over max_image_size (both the claimed pre-check
and the measured budget), and undecodable job frames.
Still open for: blob 404 from a garbage-collected layer, a config blob that is not JSON, an index manifest with an empty config digest, and a null config.
The inverse also held and is partly improved: "scanner queue full" was the
hold's own overload recorded as a week-long failure against the user's image.
It is now much harder to provoke, since the hold stops dispatching before a
scanner's queue fills, but the classification itself is unchanged.
Deadlines
Fixed at the protocol level: the hold now measures scanning from when a worker reports starting, not from dispatch.
Fixed inside the scanner too. scanner.job_timeout (default 8 minutes) is
started by the worker that dequeues the job, at the same instant it sends
started, so the scanner's budget and the hold's 10-minute one measure the same
interval and the scanner's is the shorter. It reaches the blob requests
(http.NewRequestWithContext on both the presign call and the body), Syft's
cataloging, and Grype's matcher, which turned out to have a FindMatchesContext
alongside the FindMatches that has no cancellation. A timeout is reported as
an error, not a skipped: the cause is the host, not the image, and
max_image_size already refuses the pathological cases before a byte moves.
Threading the context also fixed shutdown, which used to wait out the HTTP
client's five-minute per-request timeout.
One stage remains uninterruptible, and the bound is not honoured across it.
img.Read() is stereoscope's layer extraction, it takes no context, and it is
81% of a scan. The deadline is checked immediately before and after it, so a job
that has already spent its budget does not go on to extract, and an overrun is
noticed as soon as extraction returns — but in between, a pathologically slow
decompression runs to completion and can outlast both the scanner's deadline and
the hold's. Running it on an abandoned goroutine was rejected: it trades a
bounded overrun for a leaked goroutine writing gigabytes into a directory the
caller has already cleaned up. vuln.max_image_size is the real bound on that
stage, which is the argument for keeping it tight on a small host.
Misconfiguration is one-sided and warned about at boot: if job_timeout is set
at or above the hold's scanning timeout, the hold reclaims and re-dispatches a
row this scanner is still working on, which is the duplicate-scan behaviour the
deadline exists to prevent. The scanner cannot read the hold's configuration, so
the check is against the value the hold shipped with and is advisory.
There is still no ping/pong, read deadline or read limit on either side of the scanner WebSocket.
Silence
Mostly closed. Undecodable frames are answered, the timeout sweep writes a record, and disconnects no longer strand work silently.
Still open: results computed while the socket is down are dropped, because
sendJSON guards only conn == nil and conn is never nil'd on disconnect.
SendResult returns no error, so the worker cannot tell.
Correctness of the scan verdict itself
The most serious class in this report after the crashes, and the least fixed.
Content verification landed, so the scanner no longer vouches for bytes a compliant OCI client would reject. But a zero-byte layer still scans clean, producing an empty SBOM and a healthy-looking result.
The severity buckets not accounting for every counted vulnerability was reviewed and accepted as intended; see "Accepted, not defects" in section 1.
Workarounds
Three memory workarounds were measured. The 50-scan vulnDB reload had never
executed and now does. The runtime.GC() before the cooldown was not separately
measured. The 10 second cooldown does not reduce peak memory (800 MiB with,
837 MiB without, across six sustained scans, no ratchet either way) at a cost of
39% of wall time, but see below: it was never a memory workaround.
The inter-job cooldown is not a memory workaround
The comment reads "Cooldown between scans to reduce sustained memory pressure",
which measurement disproves. The author's actual intent was to yield CPU and IO
to co-tenant processes on a shared host, and it also returns idle RSS between
jobs (210 versus 596 MiB). No systemd unit in deploy/upcloud/systemd/ set
CPUWeight, IOWeight or Nice, so before the deployment change in section 3
this sleep was the only mechanism giving the hold breathing room.
Recorded because the comment is actively dangerous: it invites exactly the memory benchmark that was run here, and a reader trusting the result would delete something load-bearing for a reason the comment does not state. The comment should be corrected and the duration made configurable.
3. Deployment and resources
Applied
Unlike the code findings, these were applied during the audit, because they are configuration and the target host is 1 GiB shared with the hold.
deploy/upcloud/configs/scanner.yaml.tmpl: scanner.workers 2 to 1;
vuln.max_image_size added at 512 MiB (it was absent, so production ran the
2 GiB default); a comment recording that tmp_dir must stay off tmpfs given
3.8x extraction amplification.
deploy/upcloud/systemd/scanner.service.tmpl: MemoryHigh=640M,
MemoryMax=768M, CPUWeight=20, IOWeight=20.
The ordering principle behind the numbers: the cheap guard must fire before
the violent one. max_image_size rejects an image before a byte is
downloaded; MemoryMax kills mid-scan, and the hold then returns the row to
pending, the scanner restarts and is re-sent the same job, which is a
permanent crash loop on one image. If the cgroup cap is ever observed firing,
lower max_image_size rather than raising MemoryMax.
Those numbers need rechecking
OPEN. MemoryHigh and MemoryMax were sized from measurements that
excluded Grype entirely. Later measurement with the real database found that
although the database is 2.0 GB on disk it is mmap-backed, and loading it plus
one match takes a process from 59 MiB to roughly 200 MiB resident, with
per-scan peak at 233 to 263 MiB.
Crucially, GOMEMLIMIT cannot see any of this — it bounds the heap, and
mapped file pages are not heap (heap after a scan was 16 to 37 MiB) — but the
cgroup MemoryMax does count resident file pages. So a 561 MiB
node:22-class scan plus 140 to 200 MiB of resident database lands near 700 to
760 MiB against a 768 MiB cap. That is too tight, and it fails as an OOM kill
mid-scan, which is the crash-loop shape above. Lower max_image_size rather
than raising the cap.
When scanners move to their own nodes
The scanner is a pull-based worker: it dials the hold's WebSocket and downloads
blobs via presigned URLs that point straight at S3, so layer bandwidth never
crosses the hold. Moving it to another node is largely a matter of pointing
hold.url at a public wss:// endpoint. Because jobs persist in scan_jobs
and drainPendingJobs hands over everything pending on connect, an
intermittent scanner (a spot instance that drains a backlog and goes away) is a
topology the design already supports.
On that move: remove CPUWeight and IOWeight entirely, since they exist only
to protect a co-tenant; raise MemoryHigh/MemoryMax and max_image_size to
match the new host; and raise scanner.workers, which now genuinely helps.
4. Performance
Measured on AMD 7900X3D, 24 threads, 61 GiB, NVMe. This is a far more capable machine than the production host, so absolute times do not transfer; peak memory, disk amplification and scaling shapes do.
- Stereoscope load and extract dominates: 81% of a
node:22scan, 12.8 to 13.3s of 15.9 to 16.3s. Cataloging 16%, encoding 1.5%, download 1.2% (over loopback, a floor rather than a forecast). Nobody has attacked this. - Grype matching adds 0.35 to 0.5s, roughly 15 to 37% of wall time on small and medium images. Every other number in this report excludes it.
- Opening an already-activated database is sub-second, so a scanner with a good database on disk recovers in under a second. This validates the cold-start backoff policy.
- Concurrency scales near-linearly in throughput (3.77, 7.34, 13.71 scans/min at 1, 2, 4 workers) and linearly in RSS (836, 1414, 3040 MiB). Note these were measured separately from the memory-limit scenario below and the two have not been reconciled.
- The 512 MiB
GOMEMLIMITis free at one worker and expensive beyond: +1.7% for a single scan, +24% time and 5.2x GC cycles at two concurrent, +92% and 9.4x at four. It is process-wide, so it is divided among workers rather than multiplied by them, and it does not scale withscanner.workers. - Disk amplification: 3.8x for
node:22(389 MiB to 1493 MiB), 823x for compressible content. - No goroutine or file descriptor leaks across 200 jobs and 4 reconnects, and temp directory cleanup is reliable on every error path reachable.
Run the perf suite with ATCR_SCANNER_PERF=1; exact commands are in section 10.
5. What this audit did not cover
- Whether the scanner finds the right vulnerabilities. Report verification now exists (section 6) and checks internal consistency, but no test asserts that a given image's findings are complete or correct against ground truth.
- The appview's rendering of scan results, beyond the "SBOM only" state added by fix A and the severity-bucket defect in section 6.
scanner/internal/e2eis not run by CI.make testisgo test ./...from the root module, which excludes theatcr.io/scannermodule entirely. None of this coverage runs until that changes.- Production-class hardware. See section 4.
6. Report verification, and the test suites
Test coverage added
All green, clean under -race. The default suite is offline and fast (~27s).
| Path | Covers |
|---|---|
scanner/internal/mockhold/ |
In-process mock hold: the three endpoints the scanner touches, a BlobSource interface (OCI layout / synthetic / overlay), message transcript, blob request log, connection drop modes, blob fault hooks |
scanner/internal/mockhold/testdata/corpus.json |
84 real io.atcr.manifest records (57 image, 16 attestation, 7 index, 4 helm), fetched anonymously from a live PDS |
scanner/internal/e2e/ |
Pipeline, stuck scans, blob faults, blob integrity, protocol and lifecycle, concurrency, report verification, benchmarks |
scanner/internal/scan/, internal/config/ |
vulnDB lifecycle, skip classification, digest parsing, config validation |
pkg/hold/pds/scan_broadcaster_*_test.go |
Hold-side state machine, stalls, disconnects, concurrency |
Gated suites, both skipping cleanly when their fixtures are absent:
scanner/internal/mockhold/testdata/fetch-blobs.sh # real image layouts (needs buoy.cr creds)
scanner/internal/mockhold/testdata/fetch-vulndb.sh # Grype DB + pinned public images, ~2.0 GB
ATCR_SCANNER_VULNDB=1 go test ./internal/e2e -run TestVulnDB -v
ATCR_SCANNER_PERF=1 go test ./internal/e2e -run TestPerf -v -timeout 40m
fetch-vulndb.sh loads the database through Grype's own curator rather than
curling the archive, because the curator writes the import.json that
ValidateChecksum: true requires; a hand-extracted database reproduces the
corrupt-database finding by accident.
Severity buckets do not account for every vulnerability
MEDIUM / CONFIRMED by execution. scan/grype.go
countVulnerabilitiesBySeverity; scan_broadcaster.go:113-119;
pkg/atproto/lexicon.go:749-753; pkg/appview/src/css/main.css:843-846.
Every match increments Total; only Critical/High/Medium/Low reach a bucket.
Grype also emits Negligible and Unknown, and there is no fifth bucket anywhere
in the chain: not the wire summary, not the io.atcr.hold.scan record, not the
four CSS classes.
| fixture | buckets | total | unaccounted |
|---|---|---|---|
| alpine:3.10 | 125 | 125 | 0 |
| debian:11-slim | 105 | 211 | 106 (Negligible 84, Unknown 22) |
| python (trixie) | 117 | 169 | 52 (Negligible 44, Unknown 8) |
Alpine's secdb resolves every finding to an NVD severity, so a check against Alpine alone reports perfectly consistent. It only appears across ecosystems.
Visible consequence: partials/vuln-details.html:36 prints
{{ .Summary.Total }} vulnerabilities beside four boxes summing to 105 on a
Debian image. Edge case: an image whose findings are entirely Negligible or
Unknown has Total > 0, skips the green "Clean" branch, and renders a coloured
strip reading 0 0 0 0. pkg/appview/handlers/diff.go:296-306 reproduces the
same defect independently, with a comment acknowledging it.
Suggested fix: either carry a fifth count end to end, or keep four buckets and
stop printing a total they do not sum to, deriving the headline from the four
and showing the remainder as an explicit "N other". Whichever is chosen,
addToSevCount in diff.go must move the same way.
Repro: ATCR_SCANNER_VULNDB=1 go test ./internal/e2e -run TestVulnDBSeverityBucketsAccountForEveryMatch -v
Rescanning identical content produces different digests
MEDIUM / CONFIRMED by execution (storage consequence SUSPECTED).
scan/grype.go ("source": s.Source), scan/syft.go
(stereoscopesource.ImageConfig{Reference: ociLayoutDir}).
Both the SBOM and the report embed the source's Name, which is the per-job
temp directory. Rescanning unchanged content therefore yields byte-different
blobs, so sbomDigest and vulnDigest change on every rescan, the hold uploads
a fresh blob, and the previous one is orphaned. The stale loop rescans on a
schedule, so this accumulates.
Suggested fix: set the source reference to the manifest digest rather than the scratch path, which also makes stored reports self-describing.
Reports are large, and the version is wrong
LOW / CONFIRMED. alpine:3.10, 15 packages and 125 matches, produces a
1,812,438-byte report: match.Match is marshalled whole, so each row ships
the full CVE description, every CVSS vector, every CPE and the full
pkg.Package. The appview reads six fields per match and parses this on every
render. Changing the shape breaks every stored report, so it needs a version
marker.
The report descriptor hardcodes "version": "v0.107.1" against a v0.118.0
dependency, now observed in real stored reports. Read it from
debug.ReadBuildInfo().
The report's embedded "summary" is read by nothing: the hold treats the report
as an opaque string and takes counts from the WebSocket summary field.
The report's JSON shape is odd and worth pinning
Top-level matches is lowercase (a map literal in grype.go) while
Vulnerability, Package, ID, Metadata and Fix are PascalCase, because
match.Match has no JSON tags. Adding tags upstream, or switching to Grype's
presenter model, would silently blank every stored report. A test now pins this.
Renamed tests
Agent reports in sections 7 onward cite some test names that no longer exist:
| Cited | Now |
|---|---|
TestUnparseableFramesAreDroppedInSilence |
TestUnparseableFramesAreAnsweredWithSkipped, plus TestFramesWithNoUsableSeqAreDroppedSilently |
TestScanProcessingTimeout_LeavesTheDigestInFlightForever |
TestScanProcessingTimeout_RetiresTheJobProperly |
TestScanDispatchQueue_BuriesJobsWhenTheScannerBufferFills |
TestScanDispatchQueue_ReturnsUndeliverableJobsToPending |
TestScanAck_DoesNotRestartTheProcessingClock |
TestScanAck_DoesNotStartTheScanningClock |
TestScanUnsubscribe_UnassignsJobsOnce |
TestScanUnsubscribe_MarksItsOwnJobsOnce |
TestScanUnsubscribe_ReoffersAJobTheScannerIsStillRunning |
TestScanUnsubscribe_HoldsAJobForAReconnectingScanner |
TestScanHasActiveJobs_* |
TestScanActiveProactiveJobs_* |
TestStalledDownloadHasNoShortDeadline |
TestStalledDownloadIsWaitedOutWithinTheJobBudget |
TestShutdownDoesNotInterruptInFlightDownload |
TestShutdownAbortsAnInFlightDownload (inverted; now in deadline_test.go) |
The blob edge cases that pinned the absence of digest validation moved from
blob_edge_test.go to blob_integrity_test.go, where they assert the fix.
The sections below are the original agent reports, preserved unedited as dated evidence. They describe the code as audited, before any fix was applied. Section 1 is the authority on current status.
7. Grype database refresh (execution)
Scanner vulnerability-database refresh: findings
Scope: scanner/internal/scan/grype.go, scanner/internal/scan/worker.go, and the
vendored github.com/anchore/grype@v0.118.0 code they call.
Tests added (all green, go test -race ./internal/scan/ passes):
scanner/internal/scan/vulndb_refresh_test.go.
No e2e file was added; see "Seam needed" at the end for why.
Every claim below was either executed or traced to an exact line in the module
cache. No test performs a network fetch: the two tests that call
grype.LoadVulnerabilityDB pass update=false and read a synthetic on-disk
database built with the exported v6.NewLowLevelDB writer.
0. Ground truth: what the user's error actually is
Not a finding, but it anchors the rest.
The message users report as "grype is out of date, please update the database"
is Grype's own text from
grype/db/v6/installation/curator.go:613:
the vulnerability database was built %s ago (max allowed age is %s)
Reproduced with no network in
TestGrype_ExpiredOnDiskDBIsAHardError:
grype: the vulnerability database was built 2 weeks ago (max allowed age is 2 weeks)
no import metadata file at: .../6/import.json
Path into it, with grypeDBConfig's ValidateAge:true, MaxAllowedBuiltAge:14d
(grype.go:153-160) and update=true:
curator.Update()reads the on-disk description,validateAgefails, so it setscurrent = nil(curator.go:219-224) and always attempts a download — an expired DB is self-healing if the upstream is reachable.- If the upstream check or download fails,
Updatereturns an error.distribution.DefaultConfig()hasRequireUpdateCheck:false, soLoadVulnerabilityDBonly logs it (load_vulnerability_db.go:24-30) and continues. c.Status()then runsvalidateAgeagain on the unchanged on-disk DB andLoadVulnerabilityDBreturns that error with no provider (load_vulnerability_db.go:36-40).
So an expired DB plus a failing upstream is a hard load failure, and everything below is about what the scanner does with that.
The scanner's own wrapping doubles the phrase, which is worth cleaning up while
someone is in here. Observed in TestWorkerPool_EveryScanFailsWhileTheDBIsUnloadable:
failed to scan vulnerabilities: failed to load vulnerability database: failed to load vulnerability database: upstream unreachable
(grype.go:59 wraps grype.go:241, which already says the same words.)
1. The 50-scan periodic reload is unreachable and has never run
Severity: HIGH — CONFIRMED
scanner/internal/scan/grype.go:193-205 (branch), grype.go:181 (the check
that makes it unreachable)
What happens. The periodic reload exists to "close and reopen DB every 50
scans to flush SQLite's page cache and mmap region". It sits under the write
lock guarded by vulnDB != nil && time.Since(vulnDBBuilt) < vulnDBRefreshAge
(line 193). The read-lock fast path at line 181 returns on exactly that same
condition:
if vulnDB != nil && (time.Since(vulnDBBuilt) < vulnDBRefreshAge || ...) {
vulnDBLock.RUnlock()
return vulnDB, nil // fresh DB always exits here
}
A fresh database therefore never reaches the write lock, vulnDBScans is never
incremented, and the reload never fires. The only way into the branch is a race:
a caller must pass the fast path while the DB is stale and then find it fresh a
few instructions later because another goroutine completed a reload in the gap
between RUnlock() and Lock().
This was true from the moment the branch was introduced (136c0a0) — the fast
path had the same freshness test then — so the memory-pressure mitigation the
scanner is documented to have has never operated in production. If the scanner
is being restarted for memory growth, this is why.
How to reproduce. TestPeriodicReload_NeverRuns: 200 loadVulnDatabase
calls against a fresh provider leave vulnDBScans == 0 and the loader
uncalled, where the code's comment implies four reloads.
Suggested fix. Move the scan counter to where scans actually pass —
increment it on the read-lock fast path (it is already atomic.Int64, so no
lock upgrade is needed), and let the fast path fall through to the write lock
when n%50 == 0. Do not do this without fix 2 below: making the branch
reachable as written would activate a worse bug.
2. A failed reload after vulnDB = nil strands the scanner, and there is no cold-start backoff
Severity: BLOCKER — CONFIRMED (the stranded state; the periodic-reload route
into it is currently blocked by finding 1)
scanner/internal/scan/grype.go:199-200, grype.go:212-214, grype.go:234-241
What happens. Every throttle in loadVulnDatabase is guarded by
vulnDB != nil:
- line 181 (fast-path backoff) —
vulnDB != nil && ... - line 212 (write-lock backoff) —
vulnDB != nil && ... - line 234 (serve-the-old-DB fallback) —
if vulnDB != nil
So with no provider in hand there is no throttle and no fallback: every single
scan runs a complete load attempt, which in production is a 30s listing check
(distribution.DefaultConfig().CheckTimeout) plus up to a 300s archive download
(UpdateTimeout) plus hydration of a multi-hundred-MB SQLite file — all of it
holding the exclusive lock. vulnDBAttempt is dutifully recorded at line 228
and then never consulted, because reading it requires a provider.
Two ways to get there:
- Cold start with an expired/absent on-disk DB and a failing upstream. The
startup goroutine at
worker.go:66-73logs "Vulnerability scanning will be disabled until database is available" — nothing is disabled; see finding 6 — and every job then fails and re-attempts. - The periodic reload, once finding 1 is fixed. Line 199-200 does
vulnDB.Close(); vulnDB = nilbefore the load, so a reload that fails for any reason (network blip, 5xx from the CDN, disk full during decompression) destroys a perfectly working in-memory provider and drops the scanner into the no-provider, no-backoff state — from a state where it was scanning fine.
The hold retries failed scans on its stale-scan loop, so failures feed the retry storm rather than draining it.
How to reproduce.
TestColdStartFailure_RetriesEveryCallWithNoBackoff— 5 sequential calls, 5 loader invocations, no throttle.TestColdStartFailure_SerializesEveryWorkerThroughTheDownload— 4 concurrent callers produce 4 sequential attempts; elapsed time is their sum.TestWorkerPool_EveryScanFailsWhileTheDBIsUnloadable— through the real worker pool and a mock hold: every job comes back as anerrormessage (which the hold retries) and the loader runs once per job.- The periodic-reload route is not directly reachable today (finding 1), so it is asserted by composition rather than end to end.
Suggested fix. Three parts:
- Drop the
vulnDB != nilguard from the backoff checks so a cold start throttles too — with a shorter backoff (say 1-5 min) while there is no provider at all, since that state is worth escaping quickly. - Single-flight the load so N workers share one attempt instead of queueing N.
- In the periodic reload, load the replacement first and only close the old
provider once the new one is in hand — the same
store, err := load(); if err == nil { old.Close(); vulnDB = store }shape already used at lines 254-258 on the normal path. Never null the working provider before you have its successor.
3. A refresh blocks every scan in the pool for the whole download
Severity: HIGH — CONFIRMED
scanner/internal/scan/grype.go:187-188 (write lock spans the load),
grype.go:105-106 (read lock spans FindMatches)
What happens. loadVulnDatabase takes vulnDBLock.Lock() at line 187 with
defer Unlock(), and the loadVulnDB call at line 229 sits inside it. That call
is the entire refresh: listing fetch, archive download, zstd decompression,
hydration (index build over the whole DB), and the replaceDB directory swap.
Grype bounds only the HTTP portion, at 300s.
Meanwhile scanVulnerabilities holds RLock across FindMatches
(grype.go:105-114). Go's RWMutex blocks new readers once a writer is queued,
so during a refresh:
- no worker can start matching,
- no worker can even take the read-lock fast path to discover the DB is cached,
- the whole pool is stalled for the refresh duration, once per refresh window.
With vulnDBRefreshAge at 7 days this is rare on the happy path. It stops being
rare in the finding-2 state, where the stall happens on every scan.
How to reproduce. TestRefresh_BlocksAllInFlightScans measures a reader's
wait on vulnDBLock while a refresh is in flight and reports it (~130ms for a
150ms stubbed load). Same lock, same acquisition scanVulnerabilities performs.
Suggested fix. Do the download outside the lock. The lock only needs to
cover the pointer swap: read vulnDB under RLock, run loadVulnDB with no
lock held, then take Lock() just to compare-and-swap the provider and close the
predecessor. Combine with the single-flight from finding 2 so only one goroutine
downloads. Also pass the context through — see finding 7.
4. The stale-DB fallback has no upper bound, and success is reported either way
Severity: HIGH — CONFIRMED
scanner/internal/scan/grype.go:212-214, grype.go:234-240
What happens. Once the upstream stops working, loadVulnDatabase serves the
last-loaded provider forever. Nothing caps how old it may get:
- Grype's 14-day
MaxAllowedBuiltAgeis only consulted by the curator when opening the file on disk. The already-open provider never re-checks — see finding 5. vulnDBRefreshAge(7 days) only decides whether to attempt a reload.vulnDBRetryBackoff(30 min) only throttles the attempts.
The provider comes back with a nil error, so the scan succeeds, results are
written to the hold, and nothing in the result marks them as coming from a stale
database. Only a slog.Warn at line 235 records it. A scanner whose egress is
blocked will keep publishing confident, wrong "0 critical" verdicts for months.
Note this is the intended trade (scanning against a slightly old DB beats refusing to scan), taken past the point where it is a trade.
How to reproduce. TestStaleDBIsServedForever_WithNoCeiling — a provider
built 120 days ago is served across ten backoff windows with a nil error every
time.
Suggested fix. Give the fallback a ceiling and make it visible:
- Refuse to serve past some hard age (Grype's 14 days is the natural line, since
that is the guarantee
grypeDBConfigalready claims to want), returning the error rather than a silent success. - Below that ceiling, propagate the DB build time into
scanner.ScanResultso the hold and appview can label a scan as run against an N-day-old database. The build time is already in hand asvulnDBBuilt.
5. The in-memory provider genuinely does not revalidate age — the comment is correct
Severity: LOW (informational) — CONFIRMED
grype.go:209-211 (the comment), grype/db/v6/vulnerability_provider.go:31-48
The comment claims "the in-memory provider doesn't re-validate build age on
queries, so a stale-but-loaded DB still scans fine". It is accurate.
v6.NewVulnerabilityProvider closes over a Reader and an architecture-alias
map and nothing else — no Config, no MaxAllowedBuiltAge, no build timestamp.
There is no field a query path could consult.
Worth recording because it cuts both ways: it is exactly why the fallback in
finding 4 works, and exactly why that fallback has no natural ceiling. Any fix
for finding 4 must impose the ceiling in loadVulnDatabase; Grype will not do
it.
Verified by TestGrype_InMemoryProviderDoesNotRevalidateAge: a provider
opened over a database stamped one year old answers queries normally.
6. A checksum / import-metadata failure is not self-healing
Severity: MEDIUM — CONFIRMED (mechanism); SUSPECTED as a production trigger
grype.go:157 (ValidateChecksum: true),
grype/db/v6/installation/curator.go:174-183 and :208-232
What happens. grypeDBConfig sets ValidateChecksum: true, and
curator.Status() joins the checksum failure into the same error the age check
produces. A database whose import.json is missing or corrupt fails to load even
when its build timestamp is minutes old. curator.Reader() needs the file too,
via isRehydrationNeeded — that path ignores ValidateChecksum entirely, so the
file is mandatory regardless of config.
The dangerous part is the asymmetry with finding 0:
| on-disk state | Update() behaviour |
self-heals? |
|---|---|---|
| expired (age) | current = nil, isSupersededBy(nil, …) is unconditionally true → always downloads |
yes, if upstream reachable |
| bad/missing import.json | current stays valid → downloads only if upstream is strictly newer |
no |
So a database that is current but whose import metadata was lost — interrupted
activate, truncated write, container killed mid-hydrate, volume snapshot
restore — fails every scan and no update fixes it until the upstream publishes
something newer. Nothing in the scanner ever deletes the DB directory. Combined
with finding 2 this becomes a permanent, full-speed retry storm.
Note also curator.replaceDB (curator.go:533-541) does Delete() (RemoveAll)
of the live DB directory before renaming the new one into place, so an
interruption in that window leaves no database at all.
How to reproduce. TestGrype_FreshDBWithoutImportMetadataIsAlsoAHardError —
a database built an hour ago fails to load with no import metadata file at: …/6/import.json.
Suggested fix. On a load failure whose cause is checksum/import-metadata
(as opposed to age or network), delete the DB directory and retry once, which is
what grype db delete && grype db update does for CLI users. Keep it to one
retry per attempt window so it cannot become its own storm.
7. loadVulnDatabase ignores its context
Severity: MEDIUM — CONFIRMED
scanner/internal/scan/grype.go:174 (parameter), never used in the body
ctx is accepted and never consulted. A shutdown, or a hold that has abandoned
the job, cannot abort an in-flight database refresh; the exclusive lock is held
until Grype's own timeouts expire (up to 300s for the download alone, unbounded
for hydration). Combined with finding 3 that is the whole pool.
How to reproduce. TestLoadVulnDatabase_IgnoresContextCancellation — an
already-cancelled context still runs a full load.
Suggested fix. Grype's LoadVulnerabilityDB takes no context, so the
honest options are (a) run the load in a goroutine and have
loadVulnDatabase select on ctx.Done(), abandoning the result, or (b) at
minimum stop accepting a parameter the function does not honour. (a) only
becomes worthwhile once the download is out of the lock (finding 3).
8. "Vulnerability scanning will be disabled until database is available" is not true
Severity: MEDIUM — CONFIRMED
scanner/internal/scan/worker.go:68-71
When the startup load fails, the pool logs that scanning "will be disabled until
database is available". Nothing is disabled. processJob still checks only
wp.cfg.Vuln.Enabled (worker.go:245), so every job runs the full pipeline —
blob download, OCI layout rebuild, a complete Syft catalog — and then fails at
the Grype step, discarding the SBOM it just spent minutes producing.
Directly observable in TestWorkerPool_EveryScanFailsWhileTheDBIsUnloadable's
log output: the warning is emitted at startup and both subsequent jobs still
download blobs and generate SBOMs before failing.
Two costs: wasted work per job, and a lost SBOM. The SBOM does not depend on the vulnerability database at all and is already computed by the time the failure happens.
Suggested fix. Either make the claim true (a flag that suppresses the Grype
step while the DB is unavailable), or — better — send the SBOM-only result
rather than an error, so the scan record carries the SBOM and can be re-scanned
for vulnerabilities later. That change interacts with result.Summary being nil,
so it must land together with the known worker.go:129 blocker.
9. The vulnerability report claims a Grype version the scanner does not run
Severity: LOW — CONFIRMED
scanner/internal/scan/grype.go:134 vs scanner/go.mod:6
The report descriptor is hardcoded "version": "v0.107.1"; the module pins
github.com/anchore/grype v0.118.0. Eleven minor versions of matcher and
schema changes are misattributed in every stored scan record.
Suggested fix. Read it from build info
(debug.ReadBuildInfo() → the github.com/anchore/grype dep version) so it
cannot drift again, or at minimum correct the constant and note next to it that
it must be bumped with the dependency.
Interaction summary: how the scanner gets wedged
Two absorbing states, both reached without a restart and neither escapable without one under the right conditions:
Wedged refusing. No provider (cold start, or post-finding-1 periodic reload)
- upstream failing or on-disk DB unrecoverable (finding 6). Every scan: full load attempt, exclusive lock, no backoff, error to the hold, hold re-queues. Escapes only when a load finally succeeds. Findings 2, 3, 6.
Wedged serving. Provider in hand + upstream failing. Every 30 minutes one probe fails; in between, an arbitrarily old database is served as success with no marker. Never escapes on its own and never complains. Findings 4, 5.
The failure the user reports ("out of date, please update") is the first state surfacing. The second state is the more alarming one, because it does not surface at all.
Seam needed (not added — production code untouched)
scanner/internal/e2e/vulndb_test.go was not written. loadVulnDB
(grype.go:167) is unexported, so the e2e harness cannot stub it, and the only
alternative is a real multi-hundred-MB download from grype.anchore.io. The
worker-pool-level scenario therefore lives in
scanner/internal/scan/vulndb_refresh_test.go instead, where it can reach the
package var. It wires the real WorkerPool, HoldClient, and JobQueue to
mockhold exactly as the harness does.
If e2e coverage is wanted, the minimal seam is to export the loader the way
JobCooldown is exported (worker.go:154) — e.g. var LoadVulnDB = grype.LoadVulnerabilityDB
with the same "tests only, production must not change it" comment — or to hang
it off WorkerPool so it can be injected at construction.
Note for the fix agent
The known worker.go:129 blocker (result.Summary.Total dereferenced when
Summary is nil under vuln.enabled=false) was worked around, not fixed:
TestWorkerPool_EveryScanFailsWhileTheDBIsUnloadable runs with
Vuln.Enabled = true and every job terminates as an error, so no successful
result is ever produced and the nil deref is not reached. That test will start
exercising the success path if finding 8's suggested fix (send SBOM-only
results) is applied, which is why the two must land together.
One test-only issue found while writing these: scan.JobCooldown is read by the
worker loop on every iteration (worker.go:142), so restoring it in a
t.Cleanup without first waiting for the pool is a real data race that -race
reports. The new test waits via pool.Wait() before restoring.
scanner/internal/e2e/harness.go:98-99 restores it without waiting and has
the same latent race; it has not been hit yet only because those scenarios
cancel and close in a different cleanup ordering. Worth fixing when someone is
in that file — it is not mine to touch.
8. Stuck scans (execution)
Scanner "stuck" investigation — findings
Scope: why a scan job stops making progress and never reaches a terminal state. Five candidate mechanisms were separated and tested. Four are real and demonstrated; one (the Grype lock) is a code reading only and is marked SUSPECTED.
Tests added (nothing else was touched, no production code was modified):
scanner/internal/e2e/stuck_test.go— 5 scenarios, all passing.pkg/hold/pds/scan_broadcaster_stuck_test.go— 8 scenarios, all passing.
How to run them:
cd /home/data/atcr.io && go test ./pkg/hold/pds/ -run TestScan -count=1
cd /home/data/atcr.io/scanner && go test ./internal/e2e -count=1 -race
##### Or, isolated from whatever else is landing in that package right now:
cd /home/data/atcr.io/scanner/internal/e2e && go test -count=1 -race harness.go stuck_test.go
Every scanner-side scenario is built so no job ever reaches a successful
scan, because a successful scan currently panics the test binary (finding 1) —
TestScanRealImage has since been t.Skipped for that reason by whoever owns
pipeline_test.go, but the constraint still applies to anything new.
1. A scanner with vuln.enabled=false panics itself and the hold on every successful scan
Severity: BLOCKER — CONFIRMED
scanner/internal/scan/worker.go:129 and pkg/hold/pds/scan_broadcaster.go:642
processJob only sets result.Summary when cfg.Vuln.Enabled (worker.go:243).
With vulnerability scanning off, every successful scan reaches
result.Summary.Total on line 129 and takes the scanner process down.
The scanner-side half was already known. The hold-side half is new and worse:
SendResult (client/hold.go:169) runs before the panicking log line, so the
hold does receive the summary-less result. handleResult guards its record
branch with if msg.Summary != nil (scan_broadcaster.go:597) and then
dereferences msg.Summary.Critical unguarded in its closing log line at
scan_broadcaster.go:642. That runs on the subscriber's reader goroutine, not in
an HTTP handler, so one misconfigured scanner crashes the whole hold
process — every connected scanner is dropped, and every assigned/processing
row is left to the reclaim loops.
Reproduce: TestScanHandleResult_PanicsOnResultWithoutSummary
(provokes the panic under recover, so the suite stays green). Scanner side:
cd scanner && go test ./internal/e2e -run TestScanRealImage panics at
worker.go:129 today.
Fix (described): give handleResult a local summary with a zero value when
msg.Summary == nil, and do the same in the scanner's log line. Grep for other
unguarded msg.Summary. / result.Summary. derefs — scan_broadcaster.go:603
and :616 are inside the nil check and are fine.
dfd604b: did not touch this.
2. Nothing in the scanner bounds a single job
Severity: HIGH — CONFIRMED
scanner/internal/scan/worker.go:107, extractor.go:49, syft.go:33,46,
grype.go:114
processJob takes a ctx, but the ctx it takes is the worker pool's, created
in main.go:74 and cancelled only at shutdown. There is no per-job deadline
anywhere. And the phases do not honour cancellation even in principle:
buildOCILayouttakes no context at all. Its only bound isclient.httpClient's 5-minuteTimeout(hold.go:23), which is per HTTP request, and the requests are serial: config, then every layer.img.Read()(syft.go:33) — stereoscope layer extraction — takes no context. A layer that decompresses effectively forever holds the worker forever.vulnerabilityMatcher.FindMatches(grype.go:114) takes no context either.ctxinscanVulnerabilitiesreaches only the database load.
Consequences, both demonstrated:
- Head-of-line blocking.
scanner.workersdefaults to 1, so a wedged job stops every job behind it.TestStuckJobBlocksEveryJobBehindItshows a second job acked immediately and then never started, with no terminal message for either. - The one timeout that exists multiplies.
TestBlobDownloadsAreSerialSoTheirTimeoutsAddshows 4 blobs fetched strictly serially (peak concurrency 1). A 19-layer image therefore has a worst case of 20 × 5 minutes = 100 minutes on the download phase alone, against the hold's 10-minute deadline (finding 3).
Fix (described): wrap each job in context.WithTimeout in worker(), thread
that context into buildOCILayout (and through downloadBlob →
http.NewRequestWithContext), and add a wall-clock guard around the
Syft/Grype phases — those two cannot be cancelled from inside, so the practical
options are a watchdog that fails the job and lets a supervisor restart the
process, or running extraction in a subprocess. A per-blob byte cap on
io.Copy in DownloadBlob would also bound the "layer is bigger than claimed"
case, since nothing checks the downloaded bytes against the claimed size or
digest.
dfd604b: did not touch this.
3. The hold's 10-minute processing deadline is measured from dispatch, not from work start
Severity: HIGH — CONFIRMED
pkg/hold/pds/scan_broadcaster.go:526 (handleAck), :833-837 (timeout)
The scanner acks on receipt, in connectOnce at client/hold.go:159, before
queue.Enqueue. handleAck moves the row assigned → processing and touches
nothing else, so assigned_at still holds the dispatch time.
reDispatchTimedOut then fails any processing row with
assigned_at < now-10m. The ten minutes therefore cover queueing, not
scanning.
Quantified, with the shipped defaults (scanner.workers: 1,
scanner.queue_size: 100, JobCooldown 10s):
- A worker's floor per job is the 10s cooldown alone. Job N cannot start before
10*(N-1)seconds after the burst is acked, so job 61 is past the deadline before a worker touches it, even if every scan were free. - At a realistic 30s scan (40s per job) the ceiling is 15 jobs.
- The queue is 100 deep and every one of those 100 is acked on arrival, so a
drain of a backlog marks up to 85 rows
processingthat are mathematically guaranteed to blow the deadline.
TestAcksLandLongBeforeTheWorkDoes shows a 6-job burst acked within
sub-millisecond of each other and then draining serially, and asserts the 60-job
ceiling so a change to either constant surfaces here.
TestScanAck_DoesNotRestartTheProcessingClock pins the hold half: ack does not
move assigned_at, and a job acked one second ago is failed.
Where the burst comes from: the proactive dispatchLoop gates on
waitForCapacity, so it only ever has one job outstanding. The deep backlogs
come from drainPendingJobs on reconnect and from push-triggered Enqueue.
Fix (described): stamp a separate started_at (or reset assigned_at) in
handleAck, and better, have the scanner ack only when a worker picks the job
up — the ack currently means "received", which is not what the hold reads it
as. Cheaper interim: make the scanner send a heartbeat/started message the
hold uses to extend the deadline.
dfd604b: did not touch this. dfd604b's own comment ("assigned and processing
jobs need no such bound — the ack and processing timeouts always resolve them",
scan_broadcaster.go:32) leans on this timeout being correct.
4. The processing timeout leaks the manifest digest out of the in-flight set forever
Severity: HIGH — CONFIRMED
pkg/hold/pds/scan_broadcaster.go:833-841
Every proactive enqueue path adds the manifest digest to sb.inflight
(:293, :1107, :1204) and relies on a terminal scanner message to remove it:
handleResult :635, handleError :683, handleSkipped :728. The processing
timeout is not one of those. It is a bare UPDATE ... SET status='failed' that
never looks at the digest.
So a job the scanner never answers for leaves its digest in the set
permanently. discoverUnscannedForUser (:1107) and runStalePass (:1204) both
continue when addInflight returns false, so that image is never offered
for scanning again for the life of the hold process. No scan record is
written either (unlike handleError, which writes a failed record), so nothing
in the UI or the stale loop knows it was ever attempted.
The leak is permanent only when the scanner never speaks for that seq again —
handleResult has no status guard, so a late result flips failed →
completed and releases the digest. The cases where it does not come back are
exactly findings 2 and 6.
Reproduce: TestScanProcessingTimeout_LeavesTheDigestInFlightForever and
TestScanResult_ArrivingAfterTheTimeoutHealsTheRow.
Fix (described): the timeout UPDATE should RETURNING manifest_digest (or
SELECT first) and call removeInflight + signalCompletion for each row it
fails, exactly as handleError does — and write a failed scan record so the
attempt is visible.
dfd604b: did not touch this. It is arguably more reachable after dfd604b —
see finding 5.
5. The timeout frees dispatch capacity while the scanner is still wedged, so doomed jobs drip out every 10 minutes
Severity: HIGH — CONFIRMED
pkg/hold/pds/scan_broadcaster.go:1564 (hasActiveJobs), :833
hasActiveJobs counts processing rows with no age bound, so a wedged scan
holds waitForCapacity still — until the 10-minute timeout marks it failed,
at which point capacity is free, dispatchLoop pops the next candidate, and
hands it to the same scanner, whose only worker is still stuck on the first
job. Every ten minutes: one more job dispatched, acked, queued behind the
wedge, failed by the timeout, and one more digest leaked (finding 4). After
about 17 hours the scanner's 100-deep queue is full and new jobs come back as
scanner queue full errors instead.
Reproduce: TestScanProcessingTimeout_ReleasesCapacityWhileTheScannerIsStillWedged.
This is the part of the nine-day outage dfd604b did not fix. dfd604b
correctly diagnosed that one undispatchable pending row froze everything, and
bounded pending in hasActiveJobs with pendingStaleAfter. It deliberately
left assigned/processing unbounded on the argument that their timeouts
always resolve them. They do resolve the row; they do not resolve the
scanner. So for a scanner-side wedge the fix converts a visible freeze into a
silent drip that also leaks in-flight digests. Both symptoms end in "no scans
are produced", but the second one is harder to see, because
logStalledCapacity (:1580) never fires — capacity is never blocked for the
ten minutes it warns after.
Fix (described): finding 4's fix plus something that notices a scanner which has had N consecutive jobs time out — drop that subscriber's socket so the worker pool restarts, or stop dispatching to it.
6. A terminal message computed while the socket is down is dropped silently
Severity: HIGH — CONFIRMED
scanner/internal/client/hold.go:204-215, :47, :66
sendJSON guards only on c.conn == nil, and nothing ever nils conn:
connectOnce assigns it on dial (:103) and the read loop just returns on error.
So a result, error, skip or ack produced between a disconnect and the next dial
is written to a closed connection; WriteJSON fails, sendJSON logs it and
returns. SendResult/SendError/SendSkipped return nothing, so the worker
cannot tell, does not retry, and moves on to the next job. The scan is done,
and its answer is gone.
The loss window is the reconnect backoff, which is a flat 5 seconds — Connect
declares var cursor int64 = -1 and never assigns it (so the cursor is dead)
and its comment claims exponential backoff to 30s while the code sleeps 5s
(:66).
Reproduce: TestResultComputedWhileTheSocketIsDownIsLost — job acked, socket
dropped mid-download, job completes, scanner redials, and no terminal message
for that seq ever reaches the hold. The hold only learns about it via the
10-minute processing timeout, which then does finding 4 to it.
Fix (described): make sendJSON return an error and have the worker retain the
terminal message for redelivery after the next connect (a small outbox keyed by
seq), or at minimum nil conn on disconnect and have sendJSON block briefly
for a live connection. Note the mirror case: if the worker finishes after the
redial, the message is delivered on the new socket, where the hold has already
re-dispatched the job — see finding 7.
dfd604b: did not touch this.
7. Re-dispatch after a disconnect runs the same scan twice, concurrently
Severity: MEDIUM — CONFIRMED (the prediction is correct)
pkg/hold/pds/scan_broadcaster.go:385 (Unsubscribe), :740 (drainPendingJobs),
scanner/internal/client/hold.go:162 (Enqueue with no dedupe)
Unsubscribe flips the dropped subscriber's assigned and processing rows
back to pending. Nothing tells the scanner: the job it was running is still
running and the copies still in its priority queue are still queued. On
reconnect drainPendingJobs hands the same seq straight back, and
queue.Enqueue has no dedupe by seq or by manifest digest.
Demonstrated by blob accounting, which is the only place it shows:
TestReDispatchAfterDisconnectScansTheSameImageTwice (workers=2) sees the same
config blob fetched twice with peak concurrency 2 — two workers downloading
and cataloguing the same image at once. The hold-side assumption is pinned
separately by TestScanUnsubscribe_ReoffersAJobTheScannerIsStillRunning.
With the default single worker the duplicate is serialized rather than concurrent, but it is still a full duplicate scan: double the S3 egress, double the CPU, and two results for one seq (the second overwrites the first's record).
Fix (described): dedupe in the scanner by seq — a small set of in-queue and in-progress seqs, dropping a re-offer that is already known, or acking it immediately as a duplicate. The hold cannot solve this alone, since it has no way to recall a job it already sent.
dfd604b: did not touch this. (It did add the RowsAffected guard in
dispatchJob at :426, which prevents two hold-side dispatchers racing for one
row; it does nothing about the scanner already holding a copy.)
8. drainPendingJobs marks rows assigned to a scanner it never sent them to
Severity: MEDIUM — CONFIRMED
pkg/hold/pds/scan_broadcaster.go:775-792
dispatchJob handles a full sub.send correctly: its default branch resets
the row to pending (:445). drainPendingJobs does not. It marks each row
assigned first, then blocks up to 5 seconds on the send (:788) and gives up
on timeout — leaving that row, and implicitly every row after it, marked
assigned to a subscriber that was never handed them. Those rows sit until the
5-minute ack timeout reclaims them, holding dispatch capacity the whole time.
Reproduce: TestScanDispatchQueue_BuriesJobsWhenTheScannerBufferFills —
4 pending jobs, a 2-deep buffer, and the result is 3 assigned / 1 pending where
only 2 were actually sent.
Fix (described): reset the row to pending in the timeout branch before
returning, the way dispatchJob does.
dfd604b: did not touch this.
9. A hung Grype match stalls every other worker via the vuln-DB lock
Severity: MEDIUM — SUSPECTED (code reading; not demonstrated)
scanner/internal/scan/grype.go:98-115, :175-190
scanVulnerabilities holds vulnDBLock.RLock() across FindMatches, which
takes no context and cannot be cancelled. loadVulnDatabase takes the write
lock (:187) whenever the DB is stale or on the periodic every-50-scans reload
(:194). Go's RWMutex blocks new readers once a writer is queued — the comment
at :102 relies on exactly that — so a single wedged FindMatches blocks the
queued reload, which blocks every other worker's loadVulnDatabase call, and
the whole pool stops. Only relevant with scanner.workers > 1; the shipped
default is 1.
Not demonstrated: provoking it needs a real Grype database and a matcher that hangs. Listed so the fix agent bounds the matcher (finding 2) rather than only the download.
dfd604b: did not touch this.
10. Reconnect backoff is a flat 5s and the resume cursor is dead
Severity: LOW — CONFIRMED (already known, restated for completeness)
scanner/internal/client/hold.go:47, :66
var cursor int64 = -1 is never reassigned, so ?cursor= is never sent and
drainPendingJobs always runs from 0. The comment above the sleep claims
exponential backoff to 30s; the code sleeps a flat 5s. Both are visible in
TestResultComputedWhileTheSocketIsDownIsLost, which waits out exactly 5s for
the redial.
11. The harness's JobCooldown restore raced with live workers (test infrastructure — since fixed)
Severity: LOW — CONFIRMED, and fixed by the harness owner during this investigation
scanner/internal/e2e/harness.go:99-122 vs scanner/internal/scan/worker.go:142
go test -race failed on any e2e scenario, including the pre-existing
TestSkipsHelm: the cleanup wrote scan.JobCooldown while a worker goroutine
that outlived the test was still reading it in its cooldown select. Verified
against harness.go pipeline_test.go alone, so it was not caused by the new
tests. The harness cleanup now joins the pool (cancel(); c.Close(); q.Close(); pool.Wait()) before restoring the variable, and
go test -race harness.go stuck_test.go is clean. Recorded only so nobody
reintroduces the ordering.
Note for anyone adding scenarios: pool.Wait() in cleanup means a test that
stalls a blob download must release the stall before the harness tears down,
or teardown blocks on the wedged worker. Register the release with t.Cleanup
after Start, so it runs first.
Summary against the five hypotheses in the brief
| # | Hypothesis | Verdict |
|---|---|---|
| 1 | Worker blocked forever, no deadline | CONFIRMED (finding 2) — no per-job ctx at all; extraction and matching are uncancellable |
| 2 | Hold bookkeeping wedged; what dfd604b did and did not fix |
CONFIRMED (findings 4, 5) — dfd604b fixed the pending freeze only; processing still leaks in-flight digests and now drips doomed jobs |
| 3 | Ack timing mismatch | CONFIRMED and quantified (finding 3) — ceiling is 60 jobs with free scans, ~15 realistically |
| 4 | Disconnect produces duplicate concurrent scans | CONFIRMED (finding 7) — peak concurrency 2 on the same digest |
| 5 | Results computed while the socket is down are dropped | CONFIRMED (finding 6) |
Ordering for a fix agent: finding 1 first (it is a live crash on both processes
and it blocks the e2e suite), then 4 + 5 together (they are one change in
reDispatchTimedOut), then 2, then 6, then 3, then 7 and 8.
9. Blob and OCI layout edge cases (execution)
Scanner blob download / OCI layout findings
Scope: scanner/internal/scan/extractor.go (buildOCILayout, downloadBlob, digestHex),
scanner/internal/client/hold.go (GetBlobPresignedURL, DownloadBlob, httpClient),
scanner/internal/scan/worker.go (size guard, skipReason).
All evidence comes from scanner/internal/e2e/blob_edge_test.go, run with
cd scanner && go test ./internal/e2e/ -run '<name>' -v. Every test in that file
passes (one is t.Skipped, see B-01), and the file is clean under -race.
Nothing here was fixed. Two known findings are assumed and not re-derived: the
worker.go:129 result.Summary nil-dereference BLOCKER, and the index-manifest
retry loop.
Retryable-vs-permanent summary (read this first)
The hold retries "error" on the stale-scan loop
(pkg/hold/pds/scan_broadcaster.go:1145 staleScanLoop → runStalePass, which
skips only ScanStatusSkipped records at line ~1215) and never retries
"skipped". Every failure below is reported as "error", and every one of them
is a deterministic, permanent property of the manifest record or of the
stored blob. Each is therefore an unbounded retry loop in production: the hold
re-offers the job on every stale pass, forever, and gets the same answer.
Confirmed permanently-failing shapes that come back retryable:
| shape | test | error the hold records |
|---|---|---|
| layer blob 404 (GC'd) | TestMissingBlobIsRetryableError |
failed to get presigned URL ...: hold returned status 404 |
| layer bytes ≠ digest | TestDigestIsNeverVerified |
failed to load OCI image: failed to read layer=... tar : unexpected EOF |
| layer shorter than declared | TestShortBlobIsAcceptedAndConfusesSyft |
failed to load OCI image: unexpected EOF |
| config blob not JSON | TestUnparseableConfigBlobIsRetryableError |
failed to load OCI image: invalid character 't' ... |
| digest with no algorithm prefix | TestMalformedDigestsAreAllRetryable |
cannot parse hash: "efef..." |
| non-sha256 algorithm | TestMalformedDigestsAreAllRetryable |
unsupported hash: "sha512" |
| index manifest (already known) | TestIndexManifestIsRetriedForever |
config blob has empty digest |
Only one blob-stage failure is genuinely transient and correctly retryable: a 5xx from getBlob, and an expired presigned URL (B-05, B-06).
B-01 — Digest path traversal writes downloaded bytes outside the blobs directory
Severity: HIGH (security) — CONFIRMED
scanner/internal/scan/extractor.go:184 (digestHex),
scanner/internal/scan/extractor.go:173-174 (downloadBlob),
scanner/internal/client/hold.go:277 (os.Create).
What happens
digestHex splits the digest on the first ":" and returns the remainder
verbatim, with no validation that it is a hex string:
func digestHex(digest string) string {
if _, hex, ok := strings.Cut(digest, ":"); ok {
return hex
}
return digest
}
downloadBlob then does filepath.Join(blobsDir, hex) and hands that to
client.DownloadBlob, which os.Creates it. filepath.Join cleans ..
segments after joining, so a digest of the form sha256:../../../name names a
path outside the scan directory, and the response body is written there.
Both job.Config.Digest and every job.Layers[i].Digest reach this path. Those
strings originate in an io.atcr.manifest record in the user's own PDS, which
the user can write directly (a push is not the only way a record gets there), so
this is attacker-controlled input on a shared scanner host. This is the classic
"unsanitised, externally supplied path segment used to build a filesystem path"
(CWE-22) shape, made worse by the fact that the traversal string never has to
look like a digest to anything upstream.
Blast radius (measured, not assumed)
- The write lands wherever the traversal points.
os.Createtruncates, so an existing file the scanner user can write is clobbered. - The parent directory must already exist: a probe with
sha256:../../../no-such-dir/deep/xreturnedfailed to create file: open .../no-such-dir/deep/x: no such file or directory. There is noMkdirAll, so the attacker picks among existing directories. - The download must return 200 before
os.Createis reached (status check atclient/hold.go:273precedes it), so a bogus digest that 404s in S3 writes nothing. Reachability of a 200 for a traversal digest in production is SUSPECTED, not confirmed. Candidate routes, in decreasing plausibility: a BYOS hold that is malicious or compromised (it chooses both the digest it dispatches and the URL getBlob returns, so the file content is attacker-chosen too); the hold's own XRPC proxy fallback inpkg/hold/pds/xrpc.go:1652, which returns a 200 JSON body from getBlob itself when the S3 client is unavailable; and any S3/HTTP path normalisation that collapses..in the presigned key (pkg/s3/types.go:459BlobPathinterpolates the digest straight into the key). - The written file is outside
scanDir, sobuildOCILayout's cleanup never removes it. It persists after the scan. - Even where content is not attacker-chosen, the primitive is "overwrite an arbitrary existing-directory path with blob bytes as the scanner user".
Reproduce
TestDigestPathTraversalEscapesBlobsDir. A layer descriptor with digest
sha256:../../../escaped-marker (three levels up from blobs/sha256 is exactly
the scanner's configured tmp dir) causes 50 bytes to be written to
<Vuln.TmpDir>/escaped-marker, which the test reads back and compares. The
scan itself then fails, so nothing else about the run signals that a file was
placed outside the layout.
Suggested fix (not applied)
Validate the digest before it is ever used as a path: require
^[a-z0-9]+:[a-f0-9]{32,}$ (or parse with go-digest), reject anything else as
a skip rather than an error (it can never succeed), and derive the on-disk
name from the validated hex only — ideally blobs/<algorithm>/<hex> per the OCI
layout spec rather than always blobs/sha256. A belt-and-braces check that
filepath.Clean(destPath) still has blobsDir + string(os.PathSeparator) as a
prefix belongs in downloadBlob regardless.
B-02 — Downloaded bytes are never checked against the digest or the declared size
Severity: HIGH — CONFIRMED
scanner/internal/scan/extractor.go:172-181, scanner/internal/client/hold.go:266-288.
What happens
Nothing in the scanner hashes a downloaded blob, and nothing compares the bytes
written against BlobDescriptor.Size. DownloadBlob copies the body to disk and
returns; buildOCILayout writes the declared digest and size into the layout
manifest it hands to Syft. Two distinct consequences:
- Integrity. A hold that serves the wrong object (mixed-up S3 key, poisoned cache, compromised BYOS hold) produces an SBOM and a vulnerability report describing bytes that are not the image. A compliant OCI client verifies digests on pull and would refuse the same bytes, so the scan record can attest to content no client would ever run. The scanner is the only consumer in the system that trusts the hold's bytes unconditionally.
- Diagnosis. When the bytes are simply corrupt, the failure surfaces from
deep inside stereoscope:
failed to read layer="sha256:9ae8..." tar : unexpected EOFfor wrong content, plainunexpected EOFfor a short layer. Neither mentions a digest or size mismatch, so an operator reading a failed scan record has no way to tell "the hold gave us the wrong bytes" from "this image is broken". And because it is an"error", it is retried forever.
Note the honest half: a body that is short relative to its own Content-Length
is caught by io.Copy (failed to write blob: unexpected EOF) —
TestTruncatedBodyIsDetected pins that. It is the internally-consistent short
body, and the wrong-content body, that sail through.
Reproduce
TestDigestIsNeverVerified— non-gzip bytes served under a valid layer digest.TestShortBlobIsAcceptedAndConfusesSyft— a quarter of the layer, served with a matching Content-Length, against a descriptor declaring the full size.TestTruncatedBodyIsDetected— the case that is handled.
Suggested fix (not applied)
Hash while copying (io.MultiWriter into a sha256.New()) and compare against
the digest, and compare the byte count against descriptor.Size when it is
non-zero. On mismatch, fail with a message naming the digest, the expected and
actual hash/size. Treat a size/digest mismatch on a blob the hold served as
retryable (it may be a transient storage fault) but consider a permanent skip
after N attempts, since today it retries unboundedly.
B-03 — MaxImageSize is measured against numbers the manifest supplies, not bytes transferred
Severity: MEDIUM — CONFIRMED
scanner/internal/scan/worker.go:212-221.
What happens
if wp.cfg.Vuln.MaxImageSize > 0 {
var totalSize int64
for _, layer := range job.Layers { totalSize += layer.Size }
totalSize += job.Config.Size
if totalSize > wp.cfg.Vuln.MaxImageSize { ... }
}
layer.Size comes from the same user-writable manifest record as the digests,
and no counter downstream measures what actually arrives. A record declaring one
byte per blob passes any ceiling and the scanner then writes the real bytes to
its tmp volume. Combined with B-02 (no size verification) there is no bound at
all on disk consumed by one scan job.
Measured: with MaxImageSize = 1024 and both descriptors declaring 1 byte, the
scanner transferred 65,696 bytes — 64× the limit — without complaint. Scaling
the served body is the attacker's choice; nothing in the pipeline notices.
The 0 = no limit behaviour is as documented and is confirmed separately
(TestMaxImageSizeZeroDisablesTheGuard): the guard is inside if > 0, so a
descriptor claiming 1 TiB is downloaded without a word.
Reproduce
TestMaxImageSizeCountsClaimedBytesNotTransferred,
TestMaxImageSizeZeroDisablesTheGuard.
Suggested fix (not applied)
Keep the cheap pre-check (it is still worth rejecting an honestly-large image
before downloading), but enforce the real ceiling during transfer: wrap the copy
in an io.LimitedReader sized to the remaining budget, decrement the budget per
blob, and abort the scan when it is exhausted. That also gives B-02's size check
for free.
B-04 — A stalled download has no deadline shorter than five minutes, and cannot be cancelled
Severity: MEDIUM — CONFIRMED (behaviour), timeout value confirmed by inspection
scanner/internal/client/hold.go:23, :267; scanner/internal/scan/extractor.go:176-180.
What happens
httpClient is &http.Client{Timeout: 5 * time.Minute}, and neither
GetBlobPresignedURL nor DownloadBlob builds a request with a context.
processJob receives the worker's ctx but never passes it down. So:
- The ceiling is per request, not per job. Downloads are strictly sequential
(confirmed:
TestManyLayersAreFetchedSequentiallyshows 20 ordered fetches for the corpus's widest manifest), so a hold that accepts connections and then stalls costs5 min × (1 + len(layers))for one job — 100 minutes for the 19-layer manifest in the corpus — during which that worker scans nothing else. With the shipped default of one worker, the whole scanner is stalled. - Cancelling the worker context (shutdown, SIGTERM, pool teardown) does not abort a download in flight. Shutdown waits out the HTTP timeout.
Measured with a 2-second stall: the worker waited the full stall and only then
reported failed to write blob: unexpected EOF. There is no shorter deadline.
Reproduce
TestStalledDownloadHasNoShortDeadline (deliberately stalls 2s, not 5min).
Suggested fix (not applied)
Thread ctx from processJob through buildOCILayout → downloadBlob →
client.GetBlobPresignedURL/DownloadBlob using http.NewRequestWithContext,
add a per-job deadline derived from the image size, and consider a
ResponseHeaderTimeout plus a stall detector (no bytes for N seconds) on the
transport rather than one flat whole-request timeout.
Seam needed for testing: client.httpClient is an unexported package
variable, so a test cannot shorten the 5-minute timeout. Exporting a
client.SetHTTPClient (or taking the client as a parameter) would let a test
prove the timeout fires rather than merely proving no shorter one exists.
B-05 — DownloadBlob discards the response body from a failed download
Severity: LOW — CONFIRMED
scanner/internal/client/hold.go:273-275.
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
An expired presigned URL produces exactly failed to download config blob: download returned status 403 in the hold's scan record: no URL, no host, no
S3 <Code>AccessDenied</Code><Message>Request has expired</Message>, and no
indication of which blob when several layers are involved (the wrapping in
buildOCILayout gives a layer index, but the config path gives nothing).
GetBlobPresignedURL at :250-253 does include the body; DownloadBlob should
match it.
Reproduce: TestExpiredPresignedURL.
Fix: include a bounded read of the body (say 1 KiB) and the digest in the error, as the sibling function already does.
B-06 — A blob 404 (garbage-collected layer) is retried forever
Severity: MEDIUM — CONFIRMED
scanner/internal/scan/extractor.go:90-93, dispatched at worker.go:112-121.
A layer collected out from under a queued job makes getBlob answer 404, and the
scanner reports "error". The hold marks the scan failed
(scan_broadcaster.go:647 handleError), and the stale loop re-offers failed
records on every pass. The blob is not coming back, so this is a permanent job
that burns a dispatch slot and a worker on every rescan interval, forever.
Reproduce: TestMissingBlobIsRetryableError.
Fix: distinguish 404/410 from other download failures and return a *SkipError
("blob no longer present") so the hold records a permanent skip; keep 5xx and
network errors retryable. The same treatment fits the malformed-digest cases in
B-07, which can also never succeed.
B-07 — Malformed digests reach the layout and fail late, as retryable errors
Severity: LOW — CONFIRMED
scanner/internal/scan/extractor.go:184-189, :61, :79-94.
digestHex accepts anything. Consequences confirmed by test:
- No algorithm prefix (
efef...with no colon): fetched anyway, written toblobs/sha256/efef..., and stereoscope fails withcannot parse hash: "efef...". - Non-sha256 algorithm (
sha512:cdcd...): the blobs directory is hardcoded toblobs/sha256(extractor.go:61), so a sha512 blob is written to the sha256 directory while the manifest descriptor says sha512. Stereoscope fails withunsupported hash: "sha512". If Syft ever gains sha512 support this becomes a silent "blob not found" instead, because the file is in the wrong directory. - Empty digest among valid layers: correctly skipped from both the download
loop and the layout manifest (
TestEmptyLayerDigestIsSkipped— exactly two blobs fetched). This one is handled. - Zero-size layer: fetched (the size field is not consulted at download time). See B-08 for what happens when the layout is otherwise valid.
All the failing cases are permanent and all come back as "error".
Reproduce: TestMalformedDigestsAreAllRetryable, TestEmptyLayerDigestIsSkipped,
TestZeroSizeLayerIsFetched.
Fix: validate digests in skipReason (before any download) and return a
*SkipError; use blobs/<algorithm>/<hex> for the layout path.
B-08 — A zero-byte layer scans clean rather than failing
Severity: LOW — CONFIRMED (by running the skipped test once with the skip removed)
scanner/internal/scan/extractor.go:79-94.
With a valid config and a layer descriptor whose blob is zero bytes, stereoscope accepts the empty layer, Syft catalogs nothing, and the pipeline returns a successful result with an SBOM containing no packages. There is no signal anywhere that the image was empty: a clean scan and a scan of nothing are the same record. Anything that reads "0 vulnerabilities" off such a record is reading an artefact of a broken image.
Reproduce: TestZeroSizeLayerProducesACleanScan, which is t.Skipped because
running it crashes the test binary on the known worker.go:129 BLOCKER (a
successful scan with vuln.enabled=false dereferences a nil Summary). Remove
the skip once that is fixed. The skip reason names the bug.
Fix: reject a layer whose transferred size is zero (or whose size disagrees with its descriptor — B-02 covers it), and consider failing a scan that produced zero packages and zero readable layers rather than reporting it as clean.
B-09 — A duplicated layer digest is downloaded twice
Severity: LOW — CONFIRMED
scanner/internal/scan/extractor.go:79-94, :107-119.
The download loop and the manifest builder both walk job.Layers with no
de-duplication. A manifest listing the same digest twice fetches the blob twice
over the network, writes it twice to the same path, and lists it twice in the
layout manifest. A separate probe (valid config, duplicated real layer) showed
Syft accepts the duplicate and the scan succeeds, so the only cost is wasted
bandwidth and time — proportional to layer size, and chosen by whoever writes
the manifest record.
Reproduce: TestDuplicateLayerIsDownloadedTwice (asserts 2 fetches of one
digest). The success-path half is not a test because a successful scan panics
(worker.go:129).
Fix: track digests already downloaded in buildOCILayout and skip the repeat
fetch; leave the layout manifest as-is (the OCI spec permits repeated layers).
B-10 — Blob downloads follow redirects anywhere, with the client's default 10-hop policy
Severity: LOW — CONFIRMED
scanner/internal/client/hold.go:267.
DownloadBlob uses httpClient.Get with no CheckRedirect policy and no
allow-list on the URL getBlob returned. Confirmed:
- A redirect to a completely different host is followed, and the bytes from
there are used (and, per B-02, never verified). No credentials leak — the
request carries no
Authorizationheader at all, which the test asserts. - A redirect loop costs 10 round trips per blob before erroring with
stopped after 10 redirects, and the error names the URL, not the blob.
This is not a vulnerability on its own (the hold is a trusted peer and the presigned URL is expected to be off-host), but it means the choice of server is entirely the hold's, and combined with B-02 the scanner will catalog whatever comes back.
Reproduce: TestDownloadFollowsRedirectToAnotherHost, TestRedirectChainStopsAtTen.
Fix: optional. If tightened, verify the digest (B-02) rather than restricting hosts, since presigned URLs legitimately point at arbitrary S3 endpoints.
Non-findings (checked, and clean)
Worth recording so nobody re-derives them:
- Temp directory cleanup is correct on every error path exercised here.
Every failure mode above leaves
Vuln.TmpDircompletely empty (assertNoLeakedScanDirsruns in all of them, including thegenerateSBOM-failure path inTestTmpDirIsCleanAfterAFailureInsideSyft).buildOCILayoutcallscleanup()on each of its own error returns, andprocessJob'sdefer cleanup()atworker.go:229covers the Syft and Grype stages. Stereoscope's own scratch space under the sameTMPDIRis also cleaned. The one exception is the file written outside the scan directory in B-01, which cleanup cannot see. - No per-layer file-descriptor leak. Three consecutive 19-layer jobs (20
downloads each) held the process at a steady 13 open descriptors.
TestManyLayersAreFetchedSequentiallyasserts this. - A body shorter than its declared Content-Length is caught by
io.Copy(B-02 notes this); the blob does not reach Syft. MaxImageSize: 0means unlimited, as documented.
10. Protocol, lifecycle and configuration (execution)
Scanner findings: message protocol, worker lifecycle, queue, configuration
Scope: scanner/internal/client/hold.go, scanner/internal/queue/priority_queue.go,
scanner/internal/scan/worker.go, scanner/internal/config/config.go, and the hold's
side in pkg/hold/pds/scan_broadcaster.go.
Tests: scanner/internal/e2e/protocol_test.go (12 tests, all green),
scanner/internal/config/config_edge_test.go (5 tests, all green).
Nothing is skipped: every finding below is pinned by a test that passes against the
current behaviour, and each such test says in its failure message that the finding has
been fixed if the behaviour changes.
Run them with:
cd /home/data/atcr.io/scanner
go test ./internal/config/ -count=1
go test ./internal/e2e/ -count=1 -run 'Unparseable|NullConfig|UnknownMessage|DuplicateSeq|QueueFull|ZeroQueueSize|ZeroWorkers|EmptyTmpDir|HighTier|QueueClose|Shutdown|HoldClientClose'
(The whole internal/e2e package cannot run as one binary today: TestScanRealImage
hits the known worker.go:129 BLOCKER as soon as the blob fixtures are present, and the
panic takes the test process down with it.)
Harness change, flagged as required: I added two additive helpers to
scanner/internal/mockhold/mockhold.go — SendRaw([]byte) error and NextSeq() int64.
SendJob always marshals a well-formed frame, so without a raw writer the entire
"frame the scanner cannot parse" surface (findings 1 and 2, the most serious ones here)
is untestable. No existing mockhold behaviour was changed and no production code was
touched.
1. A frame the scanner cannot parse is dropped in total silence, and one such job freezes proactive scanning for the whole hold
Severity: BLOCKER — CONFIRMED
scanner/internal/client/hold.go:121-124 (frame), :133-136 (config), :139-142 (layers)
pkg/hold/pds/scan_broadcaster.go:1564-1576 (hasActiveJobs), :829-903 (reDispatchTimedOut)
What happens. connectOnce decodes three things: the frame, then config, then
layers. Every one of the three failure branches is slog.Error(...); continue. Nothing
is sent back — not an ack, not an error, not a skip. Note the ordering: the ack is at
:159, after both sub-document unmarshals, so a job whose config or layers do not
decode is never even acknowledged.
The hold wrote status='assigned' before it wrote the frame. Its only escape is the
five-minute ackTimeout, after which reDispatchTimedOut (ticking every 30s) resets the
row to pending and immediately re-dispatches it — to the same scanner, which drops it
again for exactly the same reason. A decoding disagreement is permanent, so this is an
infinite five-minute retry loop with nothing in either log that names the job as stuck.
The real damage is not the one job. hasActiveJobs() counts rows in assigned or
processing, and dispatchLoop admits proactive scan candidates one at a time behind
waitForCapacity(). A row that is in assigned essentially 100% of the time (5 minutes
assigned, re-dispatched within 30s of timing out, assigned again) means
hasActiveJobs() never returns false and no proactive scan is ever dispatched again,
deployment-wide. The comment at scan_broadcaster.go:32-35 says this exact failure
mode already cost nine days of scanning once.
Reproduce. TestUnparseableFramesAreDroppedInSilence — six frame shapes (not JSON,
a JSON array, config as a string, config absent, layers as an object, layers
absent). Each asserts no message of any kind for that seq within a second, and the test
then proves the WebSocket is still healthy, which is what makes the loop infinite rather
than self-limiting.
Suggested fix (not applied). Every continue in the read loop that has a usable
raw.Seq should call c.SendSkipped(raw.Seq, ...) first — an undecodable job is a
permanent condition, so "skipped" is the correct verdict, not "error". A frame that does
not decode far enough to yield a seq has no addressable job and can only be logged, but
the hold should also grow a bound: a row re-dispatched N times without an ack belongs in
failed, not back in the rotation, and hasActiveJobs should not let one poisoned row
gate the entire dispatch loop.
2. The hold panics on a result with no summary, killing the hold process
Severity: BLOCKER — CONFIRMED (by inspection; not executable from the scanner module)
pkg/hold/pds/scan_broadcaster.go:642 (also the guard it escapes at :599)
What happens. handleResult correctly guards record creation with
if msg.Summary != nil at :599. The closing log statement at :637-643 is outside
that guard and reads msg.Summary.Critical, msg.Summary.High, msg.Summary.Total.
slog evaluates its arguments at the call site, so a result message with no summary
is a nil pointer dereference inside handleReader, which runs as a bare goroutine
started at :348 with no recover anywhere in pkg/hold — the hold process dies.
summary is omitempty on both sides (scanner/types.go:76,
scan_broadcaster.go:107), and SendResult copies result.Summary, which
processJob only sets when cfg.Vuln.Enabled (worker.go:253). So a scanner running
with vuln.enabled: false kills the hold on its first successful scan — and note the
send at worker.go:124 happens before the scanner's own known panic at :129, so the
poisoned frame is already on the wire. Both processes die, and this is reachable from a
supported configuration flag, or from anyone holding the scanner shared secret.
There is a second-order bug in the same block: when Summary is nil the job is still
marked completed at :624-627 with no scan record written, so discovery will rediscover
that manifest as unscanned forever.
Reproduce. Not reachable from the scanner module's tests — it needs a
ScanBroadcaster with a DB and a PDS, and I was scoped to two test files, neither of
which can live in pkg/hold/pds. The finding is static and unambiguous: the guarded and
unguarded dereferences of the same pointer are 44 lines apart in one function. A hold-side
test would be handleResult(sub, ScannerMessage{Type: "result", Seq: n}) against a
broadcaster with row n present.
Suggested fix (not applied). Move the closing log inside the msg.Summary != nil
branch, or read the counts from locals defaulted to zero. Separately: treat a result with
no summary as a protocol error rather than a completion, so the row does not go to
completed without a record.
3. null config (and anything else that decodes to a zero descriptor) is a permanent failure reported as retryable
Severity: HIGH — CONFIRMED
scanner/internal/client/hold.go:133-136, scanner/internal/scan/extractor.go:70,
scanner/internal/scan/worker.go:110-118
What happens. "config": null unmarshals into a zero BlobDescriptor without error,
so unlike finding 1 the job is acked and does enter the pipeline. skipReason does not
catch it ("" is not an unscannable media type, and the layer list is non-empty), so it
reaches buildOCILayout, which fails with "config blob has empty digest, cannot
download". The worker reports that as error, and handleError marks the record failed —
which runStalePass explicitly retries (scan_broadcaster.go:1216-1221 retries
everything except skipped). Nothing about a null config will ever change, so the job is
re-scanned on every rescan interval forever.
This is the same class as the pre-existing TestIndexManifestIsRetriedForever, and it is
worth noting the two are the only things standing between the scanner and this loop:
the hold's HasScannableContent guard, and nothing else.
Reproduce. TestNullConfigIsAckedThenFailsRetryably.
Suggested fix (not applied). Make skipReason reject an empty config digest with a
*SkipError, so a structurally unscannable job is answered "skipped" (permanent) rather
than "error" (retryable). That covers index manifests, null configs and absent configs in
one guard.
4. Queue overflow is reported as a per-job scan failure instead of backpressure
Severity: HIGH — CONFIRMED
scanner/internal/client/hold.go:159-167, scanner/internal/queue/priority_queue.go:99,
pkg/hold/pds/scan_broadcaster.go:650-690
What happens. Past the high-water mark the client acks the job (hold:
assigned → processing) and then immediately answers error: scanner queue full. The hold
writes a failed scan record into the user's scan history for a condition that has
nothing to do with their image, and failed records are retryable, so the same jobs come
back on the rescan interval and overflow again while the backlog persists.
Not a hot loop, quantified. handleError moves the row to failed, and
reDispatchTimedOut only re-offers assigned and pending rows, so nothing re-offers
it from scan_jobs. The retry comes from runStalePass, which sleeps
max(rescanInterval/2, 1h) between passes and only re-queues records older than
rescanInterval. So the cadence is hours, not milliseconds — but it is permanent, and
every cycle writes another failure record to the PDS.
Reproduce. TestQueueFullIsReportedAsRetryableError (queue_size 2, workers 0, four
jobs: the last two are acked and then failed with "queue full").
Suggested fix (not applied). Do not ack a job you are about to reject; and give the
protocol a distinct "busy"/"nack" verdict that returns the row to pending without
writing a scan record, so capacity pressure stops appearing in users' scan history as
failed scans. Failing that, at minimum send the rejection before the ack so the row
never enters processing.
5. Nothing dedupes a job: a repeated seq is acked twice, scanned twice, and answered twice
Severity: MEDIUM — CONFIRMED
scanner/internal/client/hold.go:159-167, scanner/internal/queue/priority_queue.go:88-107
What happens. Neither the client nor the queue tracks seqs or manifest digests, so a redelivered job is a full second download and Syft run.
On the hold side the second ack is harmless: handleAck guards its UPDATE with
status = 'assigned' AND assigned_to = ?, which the first ack already cleared, so the
second matches zero rows (and still logs "Scan job acknowledged" — the handler does not
check RowsAffected, so the log cannot distinguish the two cases). The second terminal
message is not harmless: handleResult/handleError/handleSkipped re-run
unconditionally and write a second scan record for the same manifest.
Redelivery is reachable: Unsubscribe returns assigned/processing rows to pending
on a disconnect, and if a result was already in flight when the socket dropped, the job
comes back.
Reproduce. TestDuplicateSeqIsProcessedTwice.
Suggested fix (not applied). Keep a small in-flight set keyed by seq (and optionally
manifest digest) in the client, ack the duplicate but do not enqueue it. On the hold side,
guard the terminal handlers with status NOT IN ('completed','failed') the way
handleAck guards itself.
6. Terminal messages are not checked against the subscriber the job was assigned to
Severity: MEDIUM — SUSPECTED (static; needs a hold-side test with two subscribers)
pkg/hold/pds/scan_broadcaster.go:524-528 vs :561-571, :653-658, :698-703
handleAck scopes its UPDATE to assigned_to = sub.id. None of handleResult,
handleError or handleSkipped do: they look the row up by seq alone and complete, fail
or skip it. So with two scanners connected, a result from scanner A closes out a job
assigned to scanner B (whose own result then arrives for an already-completed row — see
finding 5), and any process holding the shared secret can mark any seq completed or
failed and write a scan record into a user's PDS.
A result for a seq the hold never dispatched is handled correctly: QueryRow returns
sql.ErrNoRows, and handleResult logs and returns at :561-571. handleError and
handleSkipped are sloppier — they log the lookup failure but then run the UPDATE
anyway (matching zero rows) and call removeInflight("") — harmless today, but only by
accident.
Suggested fix (not applied). Add AND assigned_to = ? to all three terminal
handlers, and return early when the row lookup fails.
7. Result size: no read limit anywhere, so a big SBOM is a memory event rather than a truncation
Severity: MEDIUM — CONFIRMED (no limit exists) / SUSPECTED (OOM consequence)
grep -rn SetReadLimit over the repo returns nothing; gorilla's readLimit defaults to 0
and conn.go:933 only enforces readLimit > 0.
So the plausible silent-failure path in the brief does not exist: nothing truncates or
closes the connection on a large result. What exists instead is unbounded buffering. The
whole SBOM crosses as one text frame and is fully materialised several times over on each
side: sbomJSON []byte → string(result.SBOM) (client/hold.go:183-185) → the
WriteJSON encode buffer on the scanner; ReadMessage's buffer → json.Unmarshal →
[]byte(msg.SBOM) for the S3 upload on the hold. The scanner runs under a 512 MiB
GOMEMLIMIT (cmd/scanner/main.go:46), which the GC cannot honour against a
several-hundred-MB frame it is required to hold whole.
Related, same area: neither side ever calls SetWriteDeadline, and sendJSON holds
c.mu for the duration of the write (client/hold.go:205-215). If the hold stops reading,
the scanner blocks in WriteJSON indefinitely with the mutex held, which also blocks the
read loop's SendAck — the scanner wedges without disconnecting. I did not build a test
for this; filling TCP buffers needs megabytes of real result.
Suggested fix (not applied). Set an explicit SetReadLimit on both sides so an
oversize result fails loudly and with a message that names the job, cap SBOM size in the
scanner before sending (or upload it out-of-band and send a reference), and set a write
deadline so a stalled peer cannot wedge the client.
8. scanner.workers = 0 passes validation and strands every job it acks
Severity: HIGH — CONFIRMED
scanner/internal/config/config.go:114-124 (validation), scanner/internal/scan/worker.go:75-79
What happens. LoadConfig validates exactly two things: hold.url and hold.secret
non-empty. workers: 0 (or a negative value) loads clean, the for loop in
WorkerPool.Start runs zero times, and the pool logs workers=0 once at boot. The client
still acks everything, so the hold moves each job to processing and waits out the
ten-minute processing timeout before failing it — and while it waits,
hasActiveJobs() is true, so proactive dispatch is frozen exactly as in finding 1.
/healthz returns 200 throughout.
Reproduce. TestZeroWorkersAcksAndStrands (ack arrives, no terminal in 2s, job still
in the queue) and TestLoadConfigAcceptsSilentlyBrokenValues/workers:_0.
Suggested fix (not applied). Reject workers < 1 in LoadConfig, or clamp to 1 with
a warning.
9. scanner.queue_size = 0 passes validation and fails 100% of jobs
Severity: HIGH — CONFIRMED
scanner/internal/config/config.go:76, scanner/internal/queue/priority_queue.go:99
NewJobQueue(0)'s q.h.Len() >= q.maxSize is true for an empty queue, so every Enqueue
is refused and every job is acked then failed with "scanner queue full" (finding 4's
consequences, applied to everything). Negative values behave identically. The scanner
looks completely healthy.
Reproduce. TestZeroQueueSizeRejectsEveryJob,
TestLoadConfigAcceptsSilentlyBrokenValues/queue_size:_0 and /queue_size:_negative.
Suggested fix (not applied). Reject queue_size < 1.
10. vuln.tmp_dir: "" passes validation and fails every job, retryably
Severity: HIGH — CONFIRMED
scanner/internal/config/config.go:81 (default), scanner/internal/scan/worker.go:58-66,
:207, :265
The shipped default is /var/lib/atcr-scanner/tmp, and an empty env var does not
override it (Viper is built without AllowEmptyEnv, so SCANNER_VULN_TMP_DIR= reads as
unset). The empty value is reachable from YAML: vuln: {tmp_dir: ""} loads clean.
Two things then break. WorkerPool.Start skips the TMPDIR export entirely, which is
the hazard the code comments at :52-57 warn about (Grype's DB download and stereoscope's
extraction land on a small tmpfs). But it never gets that far: processJob calls
ensureDir("") at :207, and os.MkdirAll("") is an error, so every job fails with
failed to create tmp dir: mkdir : no such file or directory — retryably, so all of them
loop forever.
Reproduce. TestEmptyTmpDirFailsEveryJob and
TestLoadConfigAcceptsSilentlyBrokenValues/tmp_dir:_empty.
TestEmptyEnvVarDoesNotOverrideDefaults pins the accidental protection on the env path.
Suggested fix (not applied). Reject an empty vuln.tmp_dir, and fail the boot (not
just each job) if the directory cannot be created.
11. A malformed config file is ignored whole, silently, in every ATCR service
Severity: HIGH — CONFIRMED
pkg/config/viper.go:34 — _ = v.ReadInConfig()
What happens. The error from reading the YAML file is discarded. A file that does not parse (a duplicate key, a bad indent, a stray tab) is skipped in its entirety, with no log line and no failure. The process then boots on defaults plus environment.
In the shipped scanner deployment hold.url and hold.secret come from the environment,
so even LoadConfig's two checks pass, and the scanner comes up looking perfectly normal
with workers, queue_size, tmp_dir, db_path, max_image_size, log level and log
shipping all silently reverted to defaults. This is pkg/config, not scanner code, so
the appview and the hold have the same hole.
Reproduce. TestMalformedYAMLIsIgnoredEntirely (a duplicate-key file; tmp_dir and
workers both come back as defaults).
Suggested fix (not applied). Return the error from ReadInConfig unless it is
viper.ConfigFileNotFoundError / fs.ErrNotExist — an explicitly named config file that
cannot be parsed should be fatal.
12. Other configuration values accepted without complaint
Severity: MEDIUM — CONFIRMED
All pinned by TestLoadConfigAcceptsSilentlyBrokenValues:
hold.urlthat is not a URL (hold.example:8080):url.Parseaccepts it, the ws/wss switch does not match, andConnectredials a bad address every 5 seconds forever. (client/hold.go:79-86)vuln.max_image_size: -1: the guard isif wp.cfg.Vuln.MaxImageSize > 0(worker.go:212), so a negative ceiling silently means no ceiling — the opposite of what someone typing a negative number intends.vuln.db_path: ""withvuln.enabled: true:initializeVulnDatabasefails in a goroutine and logs "Vulnerability scanning will be disabled until database is available" (worker.go:69-76). Nothing actually disables it, so every scan then fails inscanVulnerabilities— a retryable error, forever. The log line is misleading, which is worse than silence.log_level: "verbose":InitLoggerWithShipper's default branch quietly falls back to info (pkg/logging/logger.go:67-68), so a typo in the level is invisible.server.addr: "nope:not-a-port":ListenAndServefails in a goroutine and only logs; the scanner keeps running with no health endpoint, so an orchestrator's liveness probe fails while the process is fine, or (with no probe configured) nobody notices.
Summary of the general question: the only two invalid configurations that fail loudly
are an empty hold.url and an empty hold.secret. Everything else in the file either
fails silently, or is ignored entirely (finding 11).
13. Priority is queue position, not preemption; the low tier can starve
Severity: LOW — CONFIRMED
scanner/internal/queue/priority_queue.go:45-51, scanner/internal/scan/worker.go:88-142
The heap works: with one worker busy, an owner-tier job that arrives after a
deckhand-tier job is dequeued first. TestHighTierJumpsQueuedBacklog proves that end to
end through the real client, queue and worker.
The limits are worth stating because "priority" oversells them. Priority is consulted
only at Dequeue, so a high-tier job that arrives mid-scan waits for the current scan to
finish plus the full JobCooldown (10s in production, worker.go:154). With
multi-minute scans and the shipped workers: 1, tier ordering buys position in a queue,
never preemption. And ordering is strict with no ageing: on a saturated scanner, every
owner-tier job admitted during a scan is dequeued ahead of a deckhand job regardless of
how long that job has waited, so the low tier starves for as long as the high-tier stream
lasts. Whether that is a bug or the intended product behaviour is a policy question, but
nothing in the code bounds it.
Suggested fix (not applied), if starvation is not intended. Age the priority: fold
enqueue time into Less so a job's effective priority improves as it waits.
14. Shutdown does not interrupt a scan, and queue.Close() drains rather than cancels
Severity: MEDIUM — CONFIRMED
scanner/internal/client/hold.go:23 (context-free httpClient), :266-288,
scanner/internal/scan/worker.go:88-142, scanner/internal/queue/priority_queue.go:117-127
What happens. processJob takes a ctx and passes it to Syft and Grype, but the blob
fetches go through client.GetBlobPresignedURL / DownloadBlob, which use a
package-level http.Client and http.NewRequest/Get with no request context at
all. Cancelling the pool's context mid-download changes nothing: the worker stays inside
the fetch until the client's own five-minute timeout, and WorkerPool.Wait() — which
cmd/scanner calls on SIGTERM at main.go:114 — blocks for exactly as long. Under a
typical 30-second termination grace period that is a SIGKILL, and the SIGKILL means
buildOCILayout's defer cleanup() never runs, so the scan-* directory is left behind
on the scanner volume. (For a job that does finish, cleanup is correct: cleanup() is
deferred immediately after MkdirTemp and every early return calls it.)
queue.Close() compounds it. Dequeue returns nil only when the queue is closed and
empty (:123), so closing a queue with a backlog hands every remaining job to a worker
rather than dropping it. The worker's only ctx check is in the cooldown select at the
bottom of the loop, so after cancellation each worker still runs one more full job to
completion. HoldClient.Close() has already severed the socket by then, so that job's
result is written into a dead connection and dropped with only a log line — the hold sees
nothing and sits on the row until the ten-minute processing timeout.
Reproduce. TestShutdownDoesNotInterruptInFlightDownload (runs the exact SIGTERM
sequence from cmd/scanner, then proves pool.Wait() is still blocked 500ms later, and
that it only returns once the download itself completes) and
TestQueueCloseDrainsRatherThanCancels.
Suggested fix (not applied). Thread ctx through GetBlobPresignedURL and
DownloadBlob (http.NewRequestWithContext), check ctx at the top of the worker loop
as well as the bottom, and give JobQueue a Drain/Cancel distinction so shutdown can
discard a backlog it can no longer answer for.
15. HoldClient.Close() is not idempotent — a second call panics the process
Severity: LOW — CONFIRMED
scanner/internal/client/hold.go:219-226 — close(c.done) with no guard.
cmd/scanner calls it exactly once, which is the only reason this is not already an
incident; I hit it immediately when a test's cleanup repeated the shutdown sequence.
Any future reconnect supervisor or restart path that calls Close twice takes the
process down with "close of closed channel".
Reproduce. TestHoldClientCloseIsNotIdempotent.
Suggested fix (not applied). sync.Once, or a closed flag under the existing mutex.
Non-findings (checked, behaving correctly)
- Unknown message type from the hold is logged and skipped, the connection survives,
and nothing is sent back (
client/hold.go:126-129). Correct today, since the hold only ever sends"job"; the note is that an older scanner will silently swallow any message type added later rather than refusing it.TestUnknownMessageTypeIsIgnored. - Result for a seq the hold never dispatched is handled:
handleResultreturns onsql.ErrNoRows(scan_broadcaster.go:561-571).handleError/handleSkippedrun their UPDATE anyway, but it matches zero rows. See finding 6 for the part that is not safe. - A skipped verdict is never retried (
scan_broadcaster.go:1216-1221), which is what makes "skipped" the right answer for every permanent condition in findings 1 and 3.
11. Performance and resources (execution)
ATCR scanner: performance and resource findings
All benchmarks and load scenarios live in one file:
/home/data/atcr.io/scanner/internal/e2e/bench_test.go. Nothing else was
modified. No bug found here was fixed.
1. What performance testing is worth doing on this scanner, and how to run it
The scanner is a single-image-at-a-time pipeline with four stages (blob download, OCI layout assembly, stereoscope load/extract, Syft catalog + SPDX encode), a 100-deep priority queue in front of it, and a process-wide soft memory limit around it. The things worth measuring are the ones where a production incident would come from a resource, not from a wrong answer:
| Question | Scenario | Cost |
|---|---|---|
| Which stage dominates? | TestPerfStageBreakdown, BenchmarkPipeline |
~60 s |
| Does memory ratchet across jobs? Does the 10 s cooldown earn its cost? | TestPerfSustainedLoad (run twice, prod and none) |
~3 min each |
What does the 512 MiB SetMemoryLimit cost? |
TestPerfMemoryLimit |
~2.5 min |
| How do time and memory scale with layers, bytes, file count? | TestPerfSyntheticScaling |
~20 s |
| How much disk does a scan really use? | TestPerfDiskAmplification, TestPerfTempCleanup |
~10 s |
Does raising scanner.workers help, and what does it cost in RSS? |
TestPerfConcurrency |
~100 s |
| What does the vulnDB lock cost when a reload lands? | TestPerfVulnDBLockModel |
~4 s |
| What is the last job in a burst waiting for? | TestPerfQueueBurst, TestPerfWorkerCadence |
~45 s |
| Do goroutines or fds leak across jobs and reconnects? | TestPerfJobChurn, TestPerfConnectionChurn |
~25 s |
| What does the real worker cost end to end? | TestPerfRealWorkerScan (subprocess) |
~16 s |
Everything is off by default: each TestPerf* skips unless
ATCR_SCANNER_PERF=1, and the benchmarks need -bench. Every image-dependent
scenario skips cleanly when its fixture is absent, so a clean checkout still
runs the ordinary suite in under a second.
Commands
cd /home/data/atcr.io/scanner
##### fixtures (gitignored, a few hundred MB; skip and the scenarios skip too)
cd internal/mockhold/testdata/blobs
skopeo copy docker://docker.io/library/alpine:3.20 oci:perf-alpine:img
skopeo copy docker://docker.io/library/python:3.12-slim oci:perf-python:img
skopeo copy docker://docker.io/library/node:22 oci:perf-node:img
cd -
./internal/mockhold/testdata/fetch-blobs.sh # hsm-secrets-operator, needs buoy.cr creds
##### everything except the two that must run one per process
ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run 'TestPerf' -v -timeout 40m
##### the cooldown comparison: one process per mode, or the second mode
##### inherits the first one's heap
ATCR_SCANNER_PERF=1 ATCR_SCANNER_PERF_COOLDOWN=prod \
go test ./internal/e2e/ -run TestPerfSustainedLoad -v -timeout 15m
ATCR_SCANNER_PERF=1 ATCR_SCANNER_PERF_COOLDOWN=none \
go test ./internal/e2e/ -run TestPerfSustainedLoad -v -timeout 15m
##### stable per-op numbers
ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run XXX -bench BenchmarkPipeline -benchtime 3x
What could not be measured, and why
- The real
WorkerPoolon a successful scan.worker.go:129dereferencesresult.Summary.Total, which is nil whenevervuln.enabledis false, so the first successful scan segfaults the process (known BLOCKER, not fixed here). Every throughput, sustained-memory and concurrency number below therefore comes frompipelineOncein the bench file: a replica that runs the same four stages against the same libraries and the same mock hold, becausebuildOCILayoutandgenerateSBOMare unexported and this file may not add a seam to them.TestPerfRealWorkerScanruns the real pool once in a child process and prints production's ownScan pipeline completed duration=line next to the replica's, so the two can be compared; they agree. - Anything involving a real Grype database. Never downloaded. The vulnDB
lock is therefore characterised by a structural model
(
TestPerfVulnDBLockModel) plus code reading, not by running Grype. Every vulnerability-scanning number in production will be additional to what is reported here: none of these measurements includeFindMatches. - Blob download at production latency. The mock hold serves over loopback, so download time here (~200 ms for 390 MiB, ≈2 GB/s) is a floor, not a forecast. Against S3 it is bandwidth-bound; see finding P-7.
Hardware and noise
AMD Ryzen 9 7900X3D (12 cores / 24 threads), 61 GiB RAM, NVMe SSD, Linux 7.1.10, Go 1.26.7, load average ~1-3 during runs (shared developer machine, other agents active in the same repo). Timings below are from three passes per fixture unless stated; the pass-to-pass spread on the real images was under 3%, which is the honest error bar. Single numbers from a single pass are labelled indicative.
2. Measurements
2.1 Per-stage cost (three passes per image, TestPerfStageBreakdown)
| Fixture | Layers | Compressed | Total | Download | Load/extract | Catalog | Encode | Packages | SBOM | Peak tmp | Peak RSS |
|---|---|---|---|---|---|---|---|---|---|---|---|
| hsm-secrets-operator | 1 | 2.1 MiB | 0.68-0.73 s | 2 ms | 79-83 ms | 0.60-0.64 s | <1 ms | 1 | 3 KiB | 6.6 MiB | ~200 MiB |
| alpine:3.20 | 1 | 3.5 MiB | 0.59-0.62 s | 3 ms | 112-119 ms | 0.48-0.50 s | 1 ms | 14 | 80 KiB | 11.2 MiB | ~200 MiB |
| python:3.12-slim | 4 | 44.0 MiB | 2.25-2.36 s | 24 ms | 1.63-1.70 s | 0.56-0.60 s | 34 ms | 95 | 1.9 MiB | 169.5 MiB | 232-270 MiB |
| node:22 | 8 | 389.5 MiB | 15.87-16.33 s | ~200 ms | 12.84-13.31 s | 2.50-2.70 s | ~250 ms | 613 | 14.0 MiB | 1492.6 MiB | 717-813 MiB |
Share of a node:22 scan: load/extract 81%, catalog 16%, encode 1.5%, download 1.2% (loopback). For the two tiny images the fixed cost of Syft's catalog set (~0.5 s) is the whole scan.
2.2 Scaling (TestPerfSyntheticScaling, one pass each, indicative)
Layer count at a fixed 64 MiB of incompressible content:
| Layers | Total | Load | Catalog | ΔHeap |
|---|---|---|---|---|
| 1 | 513 ms | 98 ms | 382 ms | 122 MiB |
| 4 | 559 ms | 104 ms | 420 ms | 191 MiB |
| 16 | 588 ms | 97 ms | 459 ms | 318 MiB |
| 32 | 598 ms | 106 ms | 459 ms | 345 MiB |
Time is essentially flat in layer count; heap is not (+~7 MiB per layer at constant bytes). The corpus's 19-layer images are therefore not a time risk.
Total bytes at a fixed 4 layers:
| Bytes | Total | Download | Load | Catalog | Peak tmp |
|---|---|---|---|---|---|
| 8 MiB | 430 ms | 6 ms | 13 ms | 411 ms | 16 MiB |
| 32 MiB | 497 ms | 17 ms | 50 ms | 430 ms | 64 MiB |
| 128 MiB | 689 ms | 63 ms | 199 ms | 427 ms | 256 MiB |
| 512 MiB | 1.42 s | 225 ms | 755 ms | 435 ms | 1024 MiB |
Load scales linearly with bytes (~1.5 ms/MiB); catalog is flat when there is nothing to catalog. Peak temp disk is consistently 2x the compressed bytes for incompressible content, because the compressed layout and the extracted tree coexist.
File count at a fixed 16 MiB across 4 layers:
| Files/layer | Total files | Load | Total |
|---|---|---|---|
| 16 | 64 | 101 ms | 531 ms |
| 256 | 1 024 | 142 ms | 652 ms |
| 4 096 | 16 384 | 1 556 ms | 2.01 s |
Entry count, not byte count, is what the load stage walks: 16k small files cost 15x the load time of 64 large ones at identical bytes.
2.3 Concurrency (TestPerfConcurrency, node:22, 2 scans per worker)
| Workers | Wall | Per scan | Throughput | Peak RSS | ΔRSS |
|---|---|---|---|---|---|
| 1 | 31.8 s | 15.9 s | 3.77 scans/min | 836 MiB | 640 MiB |
| 2 | 32.7 s | 8.2 s | 7.34 scans/min | 1414 MiB | 1168 MiB |
| 4 | 35.0 s | 4.4 s | 13.71 scans/min | 3040 MiB | 2464 MiB |
Throughput scales almost linearly (0.97x and 0.91x of ideal) on a 24-thread box, and so does memory: each additional concurrent scan of a 390 MiB image costs roughly another 600-800 MiB of RSS. On a production VM with fewer cores the throughput half of that trade will not hold; the memory half will.
2.4 Disk (TestPerfDiskAmplification, TestPerfTempCleanup, and the tmp column above)
| Image | Compressed (what MaxImageSize checks) |
Peak bytes under the scan tmp dir | Ratio |
|---|---|---|---|
| hsm-secrets-operator | 2.1 MiB | 6.6 MiB | 3.1x |
| alpine:3.20 | 3.5 MiB | 11.2 MiB | 3.2x |
| python:3.12-slim | 44.0 MiB | 169.5 MiB | 3.9x |
| node:22 | 389.5 MiB | 1492.6 MiB | 3.8x |
| synthetic, 64 MiB of zeros | 0.08 MiB | 64.1 MiB | 816x |
| synthetic, 512 MiB of zeros | 0.62 MiB | 512.6 MiB | 823x |
Cleanup is reliable: after each of five consecutive scans the tmp dir was empty (0 entries, 0 bytes), and the zero-filled runs left 0 bytes behind.
2.5 Sustained load and the inter-job cooldown (TestPerfSustainedLoad, node:22, 6 jobs, one process per mode)
| Mode | Wall | Time spent scanning | Throughput | Peak RSS | Peak heap | RSS between jobs | VmHWM |
|---|---|---|---|---|---|---|---|
prod (runtime.GC() + 10 s sleep) |
2 m 35.1 s | 1 m 35.1 s | 2.32 jobs/min | 800 MiB | 602 MiB | 210-284 MiB | 820 MiB |
none (nothing between jobs) |
1 m 35.8 s | 1 m 35.8 s | 3.76 jobs/min | 837 MiB | 606 MiB | 455-608 MiB | 833 MiB |
Per-job scan time was identical in both modes (15.57-16.14 s, every job, both
runs), and neither mode ratcheted: in none, RSS after six consecutive scans
was 596 MiB against 608 MiB after the first, and heap after the last job
(277 MiB) was below the first (316 MiB). Goroutines stayed at 7 and file
descriptors at 11-12 throughout both runs.
So the pause costs 39% of wall-clock throughput and moves peak memory by 4.6%, which is inside the run-to-run spread. What it does buy is idle footprint: RSS between jobs is 210 MiB with it and 596 MiB without, because Go's scavenger needs idle time to hand freed pages back to the OS. That matters if something else shares the box; it does nothing for the peak, which is what an OOM kill is decided on.
2.6 Corpus shape (computed from internal/mockhold/testdata/corpus.json, not timed)
57 image manifests, 8.18 GiB of layer bytes in total, 3.80 GiB of them unique:
a 2.15x duplication factor across the corpus, and 5.9x within
agent-gateway alone (14 manifests, 4.16 GiB declared, 0.71 GiB unique). Every
manifest is scanned independently, so shared layers are downloaded and
extracted once per manifest.
2.7 Queue, worker cadence, and allocation (TestPerfQueueBurst, TestPerfWorkerCadence, BenchmarkPipeline)
- 110 jobs dispatched over the WebSocket in 1 ms; the queue reached exactly its configured 100 and the remaining 10 came back as "scanner queue full".
- Draining those 100 no-op jobs took 1.09 s at a 10 ms cooldown: 11 ms per job, so the worker's own per-job overhead outside the cooldown is 1 ms.
- At the production cooldown the real worker's gaps were exactly 10.002 s per job across four jobs, whatever the job did.
BenchmarkPipeline/perf-alpine: 594 ms/op, 385 MB allocated and 6.5 M allocations per op to catalog a 3.5 MiB single-layer image. Allocation volume, not resident size, is what the GC has to keep up with; this is the number to watch if P-9's memory limit is ever tightened.
3. Findings
P-1. BLOCKER (known, not re-derived) — a successful scan panics the process
scanner/internal/scan/worker.go:129. Recorded here only for its effect on
measurement: it is why nothing below drives more than one successful scan
through the real WorkerPool, and why the throughput, sustained-memory and
concurrency numbers come from the pipelineOnce replica instead.
Reproduced twice. Early in this session go test ./internal/e2e/ -run TestScanRealImage ended in SIGSEGV ... worker.go:129 immediately after
production logged Scan pipeline completed duration=677.569153ms; that test
has since been changed by another agent to skip with "blocked on the nil
result.Summary dereference at scan/worker.go:129", so it no longer demonstrates
the crash. TestPerfRealWorkerScan in the bench file still does, in a child
process that is allowed to die:
child fixture=perf-node wall=15.742s exit=exit status 2
production log line: INFO Scan pipeline completed repository=perf-node duration=15.593324819s
child died as expected on the known BLOCKER: panic: runtime error: invalid memory address or nil pointer dereference
That child run is also the only end-to-end timing of the real WorkerPool on a
real image in this report, and it agrees with the replica to within 3%.
P-2. MEDIUM / CONFIRMED — the 10 s inter-job cooldown costs 39% of throughput and does not lower peak memory
scanner/internal/scan/worker.go:135-143 (result = nil; runtime.GC(), then
<-time.After(JobCooldown), default 10 s at worker.go:154).
What happens. The comment says the pause exists "to reduce sustained memory pressure", because "Syft/Grype allocate heavily and Go's GC needs idle time to catch up under sustained load". Measured over six consecutive node:22 scans in separate processes, with and without the pause:
- peak RSS 800 MiB (with) vs 837 MiB (without) — a 4.6% difference, inside the run-to-run spread;
- peak heap 602 MiB vs 606 MiB;
- no ratchet in either mode: without the pause, RSS after six scans (596 MiB) was below RSS after the first (608 MiB), and heap after the last job (277 MiB) below the first (316 MiB);
- per-scan time identical (15.57-16.14 s in both);
- throughput 2.32 vs 3.76 jobs/min.
What the pause does change is idle RSS between jobs: 210-284 MiB with it, 455-608 MiB without, because Go's scavenger only returns freed pages to the OS after the heap has been idle. That is a real effect, and it is the only one measured. It does not protect the peak, and the peak is what an OOM kill is decided on.
TestPerfWorkerCadence measures the cost on the real worker directly: four
no-op jobs (they fail before opening a socket) took 30.006 s, with inter-job
gaps of exactly 10.002 s. A single worker therefore cannot exceed 6 jobs/min
however cheap the jobs are, and the cooldown is 39% of the wall time of a
node:22 workload.
Reproduce.
ATCR_SCANNER_PERF=1 ATCR_SCANNER_PERF_COOLDOWN=prod go test ./internal/e2e/ -run TestPerfSustainedLoad -v -timeout 15m
ATCR_SCANNER_PERF=1 ATCR_SCANNER_PERF_COOLDOWN=none go test ./internal/e2e/ -run TestPerfSustainedLoad -v -timeout 15m
ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run TestPerfWorkerCadence -v
Suggested fix (not applied). Make the cooldown a config field defaulting to
something far smaller (1 s or 0), and keep runtime.GC() plus a
debug.FreeOSMemory() call, which returns the pages immediately instead of
waiting for the scavenger. If the pause is retained for the idle-RSS effect,
say so in the comment, because the current comment claims a peak-memory benefit
that is not there. Caveat: this was measured with vuln.enabled=false, so
Grype's own allocation is not in the picture; the same comparison should be
re-run once P-1 is fixed and a database is available in a soak environment.
P-3. HIGH / CONFIRMED — a full queue outlives the hold's processing timeout, so the hold fails jobs the scanner is still working on
scanner/internal/client/hold.go:159 (ack sent on receipt, before the job is
queued), scanner/internal/scan/worker.go:154 (10 s cooldown),
scanner/internal/config/config.go (queue_size default 100, workers
default 1), against pkg/hold/pds/scan_broadcaster.go:523-528 and
pkg/hold/pds/scan_broadcaster.go:833-838.
What happens. The scanner acks a job the moment it arrives on the socket,
which moves the hold's row from assigned to processing without touching
assigned_at. reDispatchTimedOut then marks any processing row whose
assigned_at is older than ten minutes as failed. Nothing in the scanner
bounds how long a job sits in the queue before it starts.
Measured with the worker parked and the queue filled exactly to its configured
depth (TestPerfQueueBurst): 110 jobs dispatched in 1 ms, the queue reached
100 of 100, the last 10 were rejected with "scanner queue full", and draining
the 100 accepted no-op jobs took 1.09 s at a 10 ms cooldown — 11 ms per job, of
which 1 ms is the worker's own overhead and the rest is the cooldown.
Substituting the production 10 s cooldown into that measured per-job overhead: a full 100-deep queue of jobs that do no work at all drains in 16 m 40 s, and queue position 59 is already past the ten minute deadline. With node:22-sized scans (measured 15.6-16.1 s each) the drain is 43 m 20 s and the deadline is crossed at queue position 23.
So on any hold with a burst of more than ~20 scannable manifests — the corpus has 57 image manifests on one hold — the tail of the queue is marked failed while the scanner is still holding it, and the scanner later sends results for seqs the hold has already written off.
Reproduce. ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run TestPerfQueueBurst -v
Suggested fix (not applied). Three independent options, in increasing
order of intrusiveness: (a) have the hold re-stamp assigned_at on ack, or add
a separate started_at that the scanner sets when the job leaves the queue, so
the ten minute budget covers scanning rather than queueing; (b) have the
scanner refuse jobs it cannot start within the deadline — a queue admission
check against queue.Len() * expected per-job time — so the hold can hold them
pending instead; (c) shrink queue_size to something the worker count can
actually drain inside ten minutes (with a 10 s cooldown and one worker, that is
under 60 no-op jobs and under 23 real ones). (a) is the honest fix; (c) alone
just moves the loss earlier.
P-4. MEDIUM / CONFIRMED — MaxImageSize bounds compressed bytes, and disk usage is 3.8x that for real images and 800x+ for compressible ones
scanner/internal/scan/worker.go:212-220 (the check),
scanner/internal/config/config.go:59 (2 GiB default).
What happens. The guard sums the declared compressed sizes of the config
and layers. What lands on disk is the compressed layout plus the extracted
tree, and stereoscope extracts under TMPDIR, which WorkerPool.Start points
at the same vuln.tmp_dir.
Measured peak bytes under the scan tmp dir:
| Image | Compressed | Peak on disk | Ratio |
|---|---|---|---|
| alpine:3.20 | 3.5 MiB | 11.2 MiB | 3.2x |
| python:3.12-slim | 44.0 MiB | 169.5 MiB | 3.9x |
| node:22 | 389.5 MiB | 1492.6 MiB | 3.8x |
| 512 MiB of zeros in one layer | 0.62 MiB | 512.6 MiB | 823x |
At the 2 GiB default, an ordinary image that passes the check can therefore
need roughly 7.6 GiB of temp space, and a deliberately compressible one is
bounded only by the compression ratio: 2 GiB of compressed zeros would extract
to well over a terabyte. The guard also uses the sizes the manifest declares,
not the bytes actually received, and downloadBlob
(scanner/internal/scan/extractor.go:172) streams to disk with no running
total, so a manifest that understates its layer sizes is not caught at all.
Reproduce. ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run 'TestPerfDiskAmplification|TestPerfStageBreakdown' -v
Suggested fix (not applied). Enforce a byte budget during the download
itself (count bytes written and abort past the limit), and add a separate
extracted-size ceiling, either by checking free space on vuln.tmp_dir before
extraction or by capping total extracted bytes. Document that max_image_size
is a compressed bound and that the volume needs roughly 4x it for ordinary
images.
P-5. HIGH / CONFIRMED (by inspection; a test is proposed, not written) — the "every 50 scans" vulnDB reload is unreachable
scanner/internal/scan/grype.go:174-205.
What happens. loadVulnDatabase opens with a read-locked fast path:
if vulnDB != nil && (time.Since(vulnDBBuilt) < vulnDBRefreshAge ||
time.Since(vulnDBAttempt) < vulnDBRetryBackoff) {
return vulnDB, nil // grype.go:181
}
The scan counter that drives the periodic reload lives past that return, in a block guarded by the same freshness test under the write lock:
if vulnDB != nil && time.Since(vulnDBBuilt) < vulnDBRefreshAge { // grype.go:193
n := vulnDBScans.Add(1) // grype.go:196
if n%50 == 0 { ... reload ... }
Reaching line 196 requires the freshness test to be false at line 181 and
true at line 193 — that is, another goroutine must have completed a reload in
the window between the RUnlock and the Lock. With the default
scanner.workers: 1 there is no other goroutine at all, and with the
production template's workers: 2 it takes 50 such races to fire the reload
once. In ordinary operation vulnDBScans never increments, so
vulnDB.Close()/reopen never happens.
The comment says that reload exists to "flush SQLite's page cache and mmap region", i.e. it is a memory workaround. If the growth it was written for is real, the scanner still has it and always has: the workaround has never run.
Reproduce. Not reachable from internal/e2e — loadVulnDB is unexported
in package scan. The test belongs beside the existing stubs in
scanner/internal/scan/grype_test.go, and would read: reset state, stub the
loader with a DB built one hour ago, call loadVulnDatabase 200 times, then
assert vulnDBScans.Load() > 0. It fails today, returning 0.
Suggested fix (not applied). Increment the counter on the fast path (an
atomic.Int64, so it costs nothing under the read lock) and take the write
lock only when n%50 == 0. While doing so, decide whether the flush is still
wanted at all: it has never executed, so any belief about its benefit is
untested. If it is kept, the reload should also be measured, since it happens
under the write lock (see P-6).
P-6. MEDIUM / CONFIRMED structurally, SUSPECTED in magnitude — a vulnDB reload stalls every worker for the whole download
scanner/internal/scan/grype.go:105-106 (read lock held across FindMatches)
and grype.go:187-259 (write lock held across loadVulnDB, which downloads,
decompresses and opens the database).
What happens. sync.RWMutex blocks new readers as soon as a writer is
queued, so once one worker starts a database refresh, every other worker's next
scan blocks until the download finishes. The model in
TestPerfVulnDBLockModel reproduces the structure with 4 workers, 50 ms
"scans" and a 2 s "reload": the writer acquired the lock in 2 ms and the worst
reader stall was 2.001 s — 100% of the reload duration. In production the
reload is a Grype v6 database fetch, which WorkerPool.Start describes as
"1 GB+" after decompression, so the stall is however long that takes, not 2 s.
This is rare rather than frequent: with P-5 unfixed, the write lock is only
reached at cold start, at the weekly vulnDBRefreshAge boundary, or after the
30 minute retry backoff on a stale database. It is listed because the blast
radius is every worker, and because fixing P-5 would make it routine.
Reproduce. ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run TestPerfVulnDBLockModel -v
(a model of the lock discipline, not the real loader — the real path cannot be
driven without downloading the database).
Suggested fix (not applied). Download and open the new provider outside the lock, and take the write lock only to swap the pointer and close the old one. That turns a minutes-long exclusive section into a pointer assignment. The existing "serve the old provider while the new one loads" logic already assumes the old provider stays usable, so the swap is the only part that needs exclusion.
P-7. MEDIUM / CONFIRMED — 81% of a large scan is layer load/extract, and every manifest pays it again for layers it shares with its neighbours
scanner/internal/scan/syft.go:22-35 (stereoscope provide + img.Read()),
scanner/internal/scan/extractor.go:49 (buildOCILayout downloads per job).
What happens. For node:22 the pipeline splits 12.84-13.31 s load, 2.50-2.70 s catalog, ~250 ms encode, ~200 ms download (loopback) out of 15.87-16.33 s total: 81% of the scan is stereoscope reading and extracting layers. The synthetic sweeps isolate what drives it: load time is linear in bytes (~1.5 ms/MiB) and, at constant bytes, 15x worse for 16 384 small files than for 64 large ones (1.56 s vs 101 ms). Layer count alone barely matters (513 ms at 1 layer vs 598 ms at 32, same bytes).
Nothing is cached between jobs. Layers are content-addressed, and the corpus
shows how much that costs: 57 image manifests declare 8.18 GiB of layers of
which 3.80 GiB are distinct (2.15x), and within agent-gateway alone 14
manifests declare 4.16 GiB of which 0.71 GiB are distinct (5.9x). Rescanning
that repository's history downloads and extracts the same base layers fourteen
times.
Reproduce. ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run 'TestPerfStageBreakdown|TestPerfSyntheticScaling' -v
Suggested fix (not applied). A content-addressed blob cache under
vuln.tmp_dir keyed by layer digest, with an LRU bound, would remove the
download and (if the extracted tree is cached too) most of the load stage for
repeat scans of a repository's history. That is a design change, not a patch,
and it interacts with P-4: a cache needs its own disk budget.
P-8. MEDIUM / CONFIRMED — the download path takes no context and no deadline, so a scan cannot be cancelled and has no upper bound
scanner/internal/scan/extractor.go:49 (buildOCILayout(job, tmpDir, secret)
— no context.Context), scanner/internal/scan/worker.go:230 (the ctx
processJob receives is passed to Syft and Grype but never to the download),
scanner/internal/client/hold.go:23 (http.Client{Timeout: 5 * time.Minute}).
What happens. The only bound on the download stage is a five minute
per-request timeout, applied independently to every presign call and every blob
body. A 19-layer image therefore has a worst case of 19 × 2 × 5 minutes before
the pipeline gives up, and shutting the scanner down (ctx cancellation) does
not interrupt a download in flight. There is also no retry: one failed blob
fails the whole job, and the hold re-dispatches the entire image.
This was not measured against a slow server — the mock serves over loopback — so the number above is read off the code, not observed. What was observed is that a single node:22 blob transfer is ~200 ms locally, so the five minute budget is only in play against a slow or stalled S3.
Suggested fix (not applied). Thread the worker's ctx through
buildOCILayout and downloadBlob and give each job an overall deadline
derived from the hold's ten minute budget (see P-3), so a job that cannot
finish in time fails fast rather than finishing work nobody will accept. A
bounded retry on a blob download would also stop one transient 500 from
costing a full re-download of the image.
P-9. MEDIUM / CONFIRMED — the 512 MiB soft memory limit is free at one worker and costs 24-92% once workers scan concurrently
scanner/cmd/scanner/main.go:46 (debug.SetMemoryLimit(512 * 1024 * 1024)),
against deploy/upcloud/configs/scanner.yaml.tmpl which sets
scanner.workers: 2.
What happens. GOMEMLIMIT is process-wide, so it is divided among
concurrent scans rather than multiplied by them. Measured on node:22 with the
limit set to the production value and again with it off (TestPerfMemoryLimit):
| Concurrent scans | Limit | Wall | Per scan | Peak RSS | GC cycles | GC CPU |
|---|---|---|---|---|---|---|
| 1 | off | 15.53-16.23 s | — | 748-806 MiB | 37-41 | 0.1-0.4% |
| 1 | 512 MiB | 15.68-15.79 s | — | 561-562 MiB | 42-43 | 0.2-0.4% |
| 2 | off | 16.33 s | 8.17 s | 1357 MiB | 44 | 0.5% |
| 2 | 512 MiB | 20.31 s | 10.15 s | 687 MiB | 230 | 1.8% |
| 4 | off | 17.56 s | 4.39 s | 2951 MiB | 53 | 1.8% |
| 4 | 512 MiB | 33.77 s | 8.44 s | 1224 MiB | 497 | 5.0% |
At one worker the limit is doing its job for free: it holds peak RSS to
561 MiB instead of 748 MiB and costs 1.7% of wall time, because most of the
peak is collectible garbage rather than live data. My prior expectation that a
512 MiB limit under a ~545 MiB peak heap would provoke a GC death spiral was
wrong at one worker, and right at two: with the production workers: 2
setting, two concurrent node:22 scans run 24% slower and do 5.2x the GC cycles;
at four they run 92% slower and do 9.4x the cycles. The limit is buying real
RSS containment in exchange for that, so this is a tuning finding rather than a
defect: what it should not be is invisible.
Reproduce. ATCR_SCANNER_PERF=1 go test ./internal/e2e/ -run TestPerfMemoryLimit -v -timeout 20m
Suggested fix (not applied). Make the limit configurable and scale it with
scanner.workers (e.g. 512 MiB × workers, still overridable by GOMEMLIMIT),
or document that raising workers without raising GOMEMLIMIT trades
throughput for RSS rather than gaining throughput. The 512 MiB constant is also
undersized for a single node:22-class image: it is below the 561 MiB RSS the
scan reaches even with the limit in force.
P-10. LOW / CONFIRMED — the result message carries the whole SBOM inline
scanner/internal/client/hold.go:177-190 (SendResult puts the SBOM in the
WebSocket message), pkg/hold/pds/scan_broadcaster.go:572-581 (the hold then
uploads it to S3).
node:22 produced a 14.0 MiB SPDX JSON document (613 packages); python:3.12-slim 1.9 MiB; alpine 80 KiB. That whole document crosses the WebSocket as one text frame and is buffered in memory on both ends. Neither side sets a read limit, so nothing breaks today, but SBOM size grows with package count and the hold holds it entirely in RAM before the S3 upload. Worth a cap, or a separate upload path, before an image with several thousand packages arrives.
P-11. Negative results worth recording
- No goroutine or descriptor leak. Across 200 no-op jobs: goroutines 6→6, fds 11→11, heap 6.2→6.2 MiB. Across 4 reconnect cycles (alternating abrupt TCP close and clean close frame): goroutines 6→6, fds 11→11. Across six node:22 scans: goroutines steady at 7, fds 11-12.
- Temp directories are cleaned up reliably. After each of five consecutive scans the tmp dir held 0 entries and 0 bytes, including the runs that extracted 512 MiB.
- The replica pipeline matches production. The real
WorkerPoolloggedScan pipeline completed duration=15.593324819sfor node:22 in the child process, against 15.53-16.33 s for the replica — the worker adds no measurable overhead beyond the cooldown. - Concurrency scales, memory scales with it. 1/2/4 concurrent scans give 3.77 / 7.34 / 13.71 scans/min (0.97x and 0.91x of linear on a 24-thread box) at 836 / 1414 / 3040 MiB peak RSS.
4. Priority, and what a soak test should cover next
In the order I would act on them:
- P-1 (existing BLOCKER) — nothing else about the worker can be measured or operated until it is fixed.
- P-3 — the queue silently exceeds the hold's deadline at a burst size the corpus already exhibits, and the failure is invisible from the scanner side.
- P-5 — a memory workaround that has never executed is worse than no workaround, because it makes the problem look handled.
- P-2 and P-9 together — they are the two throughput knobs, and each is currently set by a constant with no measurement behind it. Changing either without the other is a guess: dropping the cooldown raises sustained heap pressure, which the 512 MiB limit then converts into GC time.
- P-4, P-7, P-8, P-6, P-10 as capacity work.
What is still unmeasured
- Grype. Every number here has
vuln.enabled=false.FindMatchescost, the vulnerability database's resident footprint, and whether the mmap growth P-5's dead workaround was written for is real — none of that can be measured without a database, and it should be measured in a soak environment once P-1 is fixed. My expectation, stated as an expectation and not a measurement, is that Grype adds materially to both peak heap and scan time, which would make P-9's limit tighter than these numbers suggest. - Real S3 latency. Download here is loopback (~200 ms for 390 MiB). The same transfer at 100 MB/s takes ~4 s and would move the download stage from 1% of a scan to roughly a fifth of it, which changes where optimisation pays.
- A long soak. The longest run here is six consecutive scans. A ratchet with a period longer than that would not show up. The evidence against a ratchet (heap and RSS both lower at job 6 than job 1, in both cooldown modes) is good but not a substitute for a multi-hour run.
- Multi-worker behaviour through the real
WorkerPool. All concurrency numbers come from the replica; the pool's own contention (the queue mutex, the shared WebSocket write mutex insendJSON) was not exercised under load because P-1 prevents it.
12. Scanner module code review (reading)
ATCR Scanner — deep reading review
Scope: every non-test file under scanner/, the scanner's tests, Dockerfile.scanner,
deploy/upcloud/configs/scanner.yaml.tmpl, deploy/upcloud/systemd/scanner.service.tmpl, and the
hold-side counterpart (pkg/hold/pds/scan_broadcaster.go, pkg/hold/oci/xrpc.go) where the
protocol contract lives.
Method: reading only. No production code was modified and no tests were written. Two things were
executed and are marked CONFIRMED: go vet ./... + go test -race ./... inside scanner/, and a
throwaway scratch program (outside the repo) to check os.MkdirAll("") and filepath.Join
semantics.
go vet ./... is clean. go test -race ./... currently FAILS: internal/e2e panics at
worker.go:129, exactly finding 1. internal/scan and internal/queue pass under -race; no
race was reported, but the e2e binary died before it could exercise much.
Section 1 — assessment of the existing sweep
1. worker.go:129 nil deref of result.Summary.Total when vuln.enabled=false
AGREE — and understated. CONFIRMED by running go test -race ./... in scanner/; the
existing TestScanRealImage panics at exactly this line and takes the test binary down.
processJob sets result.Summary only inside if wp.cfg.Vuln.Enabled (worker.go:245-254).
The success branch at worker.go:124-129 dereferences it unconditionally.
Three things make the blast radius larger than "every successful scan panics":
- It is a whole-process kill, not a worker kill. There is no
recover()anywhere in the scanner. Withscanner.workers: 2(the production value indeploy/upcloud/configs/scanner.yaml.tmpl:14) the panicking worker also destroys the other worker's in-flight scan and the WebSocket read loop. - It is a permanent crash loop, not a one-off.
systemd/scanner.service.tmplsetsRestart=on-failure/RestartSec=10. Ten seconds between restarts never trips systemd's default start-limit burst (5 in 10s), so the unit restarts forever. Meanwhile the job it crashed on sits inprocessing;reDispatchTimedOutflips it tofailedafter 10 minutes (scan_broadcaster.go:833-837), a failed record is retryable by the stale loop (scan_broadcaster.go:1204-1210), and the next pass re-serves it. Steady-state: crash, restart, crash. SendResultis called before the panic (worker.go:124, panic at 129). So the hold has already received and stored a result. Which leads to the trap below.
The obvious fix is itself a bug. Guarding the log line and sending a summary-less result makes
the hold panic: handleResult guards if msg.Summary != nil for the record write
(scan_broadcaster.go:599) but the closing log at scan_broadcaster.go:642-644 dereferences
msg.Summary.Critical/.High/.Total outside that guard. A summary-less result therefore kills the
hold process from inside a WebSocket reader goroutine. Whoever fixes worker.go:129 must fix
scan_broadcaster.go:642 in the same change. See new finding N1.
Same-shape nil derefs elsewhere: I found no other unconditional deref of an omitempty pointer in
the scanner. s.Artifacts.LinuxDistribution is nil-checked at syft.go:58 and grype.go:63.
m.Vulnerability.Metadata is nil-checked at grype.go:277. result.Summary is the only one.
2. Dead cursor; comment claims exponential backoff, code sleeps a flat 5s
AGREE on both. The cursor half is understated.
client/hold.go:47 declares var cursor int64 = -1, passes it to connectOnce(cursor) at line 56,
and never assigns to it. connectOnce:91-93 therefore never sets the cursor query param.
client/hold.go:62 says "Exponential backoff with max 30s" directly above a flat
time.After(5 * time.Second) at line 66 — a comment that lies, and the kind that marks a
half-finished change.
Why the cursor matters more than "dead variable": the hold reads it
(pkg/hold/pds/xrpc.go:1093-1101) and passes it to drainPendingJobs, whose query is
WHERE status = 'pending' AND seq > ? (scan_broadcaster.go:757-761). With no cursor the hold
defaults to -1, so every reconnect drains every pending row from the beginning of time. That
is the safe direction, but it means the resume protocol is entirely notional: the scanner has no
way to say "I already handled up to N", the wire format has a field for it, the hold parses it,
and nothing ever populates it. Anyone reading this assumes resumption works.
Also worth noting the flat backoff is not merely cosmetic: if hold.url fails to parse
(connectOnce:73-75) the loop retries a permanently-invalid URL every 5s forever, logging on each
pass, with no escalation and no distinct signal from "hold is briefly down".
3. sendJSON only guards c.conn == nil, which is never restored
AGREE, with severity moderated — but the return-type half is the real finding.
c.conn is set at client/hold.go:105 and never set back to nil; connectOnce's
defer conn.Close() (line 102) leaves c.conn pointing at a closed socket. sendJSON:208-215
therefore passes the nil guard, fails the write, logs, and returns. SendResult/SendError/
SendSkipped/SendAck all return void (lines 172-202), so the worker at worker.go:115-124
cannot know its result was dropped.
Moderating factor the finding omits: the loss window is bounded. On disconnect the hold's
Unsubscribe re-marks that scanner's assigned/processing rows as pending
(scan_broadcaster.go:383-386), and on reconnect drainPendingJobs re-offers them. So a dropped
result costs a re-scan, not a permanently lost job. Rate it MEDIUM, not HIGH.
Aggravating factor the finding omits: once the reconnect has completed, c.conn points at a
live connection and the late result is delivered — while the hold has already re-dispatched the
same job. Two workers then scan the same manifest and two result messages arrive for the same
seq. handleResult has no idempotency guard; the second one re-uploads the SBOM and vuln blobs to
S3 and rewrites the record. Wasted work and wasted storage, not corruption (rkey is the digest).
The durable fix is the signature, not the nil check: these four methods should return error, and
nil-ing c.conn under the mutex when connectOnce unwinds should be a secondary tidy-up.
4. buildOCILayout never verifies downloaded bytes against the claimed digest
AGREE. extractor.go:172-181 → client.DownloadBlob (client/hold.go:266-288) does a plain
io.Copy(out, resp.Body). Nothing hashes the stream, nothing compares to digest, and nothing
checks the byte count against layer.Size — which is then written verbatim into the synthesised
manifest descriptor (extractor.go:117).
Two consequences beyond "we trust the storage layer":
- A truncated download (proxy hiccup, S3 partial) produces a silently wrong SBOM rather than an error, because the layer tar just ends early and Syft catalogues whatever it managed to read. For a security tool "fewer packages than reality" is the worst failure direction.
- The size mismatch may instead surface as an opaque stereoscope/go-containerregistry error ("size mismatch"), which is reported to the hold as a retryable error and retried forever.
A single io.TeeReader into sha256.New() in DownloadBlob, compared before the file is
considered good, closes both. It also happens to close finding 5.
5. digestHex() splits on ":" and the remainder is joined onto the blobs dir
AGREE — and materially understated. This is the security finding in the module.
extractor.go:184-189 returns everything after the first : with no validation.
extractor.go:173-174 does filepath.Join(blobsDir, hex) and hands it to os.Create via
DownloadBlob (client/hold.go:277). CONFIRMED by scratch program that filepath.Join resolves
.. segments into a genuinely escaping absolute path — it does not sanitise.
The reason this is worse than a hypothetical: the attacker's own PDS is an unvalidated input
path into this function. The hold's proactive discovery loop reads io.atcr.manifest records
straight out of a user's repo (scan_broadcaster.go:1075-1090), and a user can write arbitrary
records to their own repo. The only gates applied are holdDID == ours (attacker-chosen),
len(Layers) > 0, Subject == nil, Config != nil
(scan_broadcaster.go:1086-1089). Digest format is never checked — not in the hold, not in
client/hold.go, not in extractor.go. The push path is better protected (the distribution
library validates digests on manifest PUT), but the discovery path bypasses it entirely.
Reachability of the write still requires the blob fetch to return HTTP 200, because os.Create
runs after the status check (client/hold.go:274-277). The hold turns the digest into the S3 key
docker/registry/v2/blobs/sha256/<xx>/<hash>/data (pkg/s3/types.go:459, xrpc.go:1586-1605)
with no normalisation, so a raw S3 backend most likely 404s. That is a deployment-dependent
mitigation, not a control: any CDN or gateway in front that normalises .. in the path (per
MEMORY.md, production fronts storage with Bunny) turns the 404 into a 200 for a real object.
Impact if reachable, under the production sandbox: systemd/scanner.service.tmpl grants
ReadWritePaths={{.DataDir}} = /var/lib/seamark, which contains both scanner/tmp and
scanner/vulndb (configs/scanner.yaml.tmpl:18-19). So an escaping write stays inside the
sandbox but can land on the Grype vulnerability database — corrupting or replacing the DB every
subsequent scan matches against. A security scanner that reports "0 vulnerabilities" is a worse
outcome than one that crashes. Under Dockerfile.scanner (FROM scratch, no USER, runs as
root) there is no sandbox at all.
Class of problem, not an exploit: unvalidated, externally-sourced identifiers used as filesystem
path components. Fix: reject any digest not matching ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$
(the OCI grammar) at the ScanJob boundary in client/hold.go before enqueue, and independently
refuse any digestHex result containing a path separator or . before it is joined. Verifying
the content hash (finding 4) makes the write self-defeating even if the path check is bypassed.
6. MaxImageSize sums claimed sizes; nothing bounds decompressed bytes
AGREE, and there is a third unbounded dimension the finding misses.
worker.go:212-221 sums layer.Size + job.Config.Size — numbers from the same attacker-writable
manifest record as the digests. A manifest can claim size: 1 per layer and pass.
The finding names the decompressed dimension (stereoscope extracting into TMPDIR, which
worker.go:52-63 deliberately points at the scanner data volume — so a zip-bomb layer fills the
same partition the Grype DB and the hold's data live on). It misses that the compressed transfer
is equally unbounded: DownloadBlob (client/hold.go:283) does a bare io.Copy with no
io.LimitReader. The claimed size is never used as a transfer ceiling. The only backstop is the
5-minute httpClient.Timeout (client/hold.go:23) — which is a rate limiter, not a size limiter,
and see N9 for how badly that interacts with honest large layers.
Fix shape: pass the claimed size into DownloadBlob, copy through
io.LimitReader(resp.Body, claimed+1), error if it overruns; and enforce a separate
decompressed-bytes budget by checking free space on TmpDir before and during extraction.
7. Ack-on-receipt versus the hold's 10-minute processing deadline
AGREE, exactly as described.
client/hold.go:159 sends the ack from the WebSocket read loop, before queue.Enqueue on line
162. The hold's handleAck moves the row assigned → processing
(scan_broadcaster.go:525-528) and does not touch assigned_at. The timeout is
WHERE status = 'processing' AND assigned_at < now-10m (scan_broadcaster.go:833-837), so the
clock runs from dispatchJob's assignment (scan_broadcaster.go:417-420), not from the start of
work.
Quantifying it: production is workers: 2, queue_size: 100, and JobCooldown = 10s
(worker.go:154) between jobs. A single deep image can occupy a worker for minutes. A queue only
~10 jobs deep can therefore push a job's start past the 10-minute mark, at which point the hold
declares it failed and re-dispatches it — while the scanner is still holding it and will
eventually return a result for a seq the hold has already marked failed. handleResult will
happily process that late result and set the row back to completed, so the states silently
disagree.
Cheapest correct fix: ack when the worker dequeues, not when the client receives; or have the
hold refresh assigned_at on ack so the processing deadline measures processing.
8. "version": "v0.107.1" hardcoded against grype v0.118.0
AGREE, straightforward. grype.go:134 versus scanner/go.mod:6
(github.com/anchore/grype v0.118.0). The descriptor is published into a public
io.atcr.hold.scan record, so anyone reconciling a report against a Grype changelog or a matcher
behaviour change is reading a lie. Related drift worth folding into the same fix: the hold stamps
"atcr-scanner-v1.0.0" at scan_broadcaster.go:605/638/670, Dockerfile.scanner labels the
image 0.1.0, and config.version defaults to "0.1" — four independent, all-wrong version
strings for one component. Ideally read the real version from runtime/debug.ReadBuildInfo() (the
binary already imports runtime/debug in main.go) rather than hardcoding a fifth.
9. Multi-arch index manifests fail as a retryable error
AGREE with the mechanism; the "only the hold's guard prevents this" claim is slightly overstated.
The mechanism is exactly right, and TestIndexManifestIsRetriedForever
(internal/e2e/pipeline_test.go:322-345) already pins it: skipReason returns "" because an
index's config media type isn't in unscannableConfigTypes and the layer check is gated on
len(job.Layers) > 0 (worker.go:174); buildOCILayout then fails with "config blob has empty
digest" (extractor.go:68-71); an error is retryable.
The overstatement: there are two hold-side guards, not one, and they are independent implementations of the same rule — which is itself the risk.
- Push path:
HasScannableContent()=!IsMultiArch() && !IsReferrer()(pkg/hold/oci/xrpc.go:238-240), applied atxrpc.go:436. - Discovery path: an open-coded
len(manifest.Layers) == 0 || manifest.Subject != nil || manifest.Config == nil(scan_broadcaster.go:1087-1089).
Two copies of one invariant in two packages, with the scanner having no defence of its own, is the
real finding. And the second copy is not equivalent to the first: it also rejects
Config == nil, which the first does not. The right fix is still on the scanner side — make
"empty config digest" a *SkipError, not an error — so that a regression in either hold guard
degrades to a skip instead of an infinite retry.
Section 2 — new findings
Ordered by severity.
N1 — [BLOCKER] [SUSPECTED] Fixing worker.go:129 naively panics the hold instead
pkg/hold/pds/scan_broadcaster.go:642-644 (trigger at scanner/internal/scan/worker.go:124)
The hold's handleResult nil-guards msg.Summary for the record write at line 599 but
dereferences msg.Summary.Critical, .High, .Total in the trailing slog.Info at 642-644,
outside the guard. ResultMessage.Summary is omitempty (scanner/types.go:76), and the scanner
constructs the message straight from result.Summary (client/hold.go:181) — which is nil
whenever vuln.enabled=false.
Today the scanner panics first (finding 1) so this never fires. The instant someone guards
worker.go:129 and lets a summary-less result onto the wire, the panic moves from the scanner to
the hold, into a WebSocket reader goroutine, taking the registry's storage backend down with it.
Why I believe it: the deref is plainly outside the if msg.Summary != nil block; grep of
msg.Summary in that file shows 599 opening the guard and 642-644 well past its close at 621.
Fix: guard the trailing log (or build the log attrs from a zero-value summary), in the same
commit as the worker.go:129 fix. Decide deliberately whether a summary-less result is legal at
all — if it is not, the scanner should refuse to send one and the hold should reject it.
N2 — [HIGH] [SUSPECTED] No recover() anywhere: one malformed layer kills the process
scanner/internal/scan/worker.go:88-145, cmd/scanner/main.go:159-164
worker() calls processJob with no defer recover(), and main has none either. Everything
processJob touches downstream — stereoscope's OCI layout reader, Syft's cataloguers, and the
archive stack Syft pulls in (go.mod lists sevenzip, rardecode, squashfs, lzip, ppmd, xz, brotli,
diskfs, rpmutils, sif, macholibre) — is parser code operating on fully attacker-controlled
bytes. That is a large surface for an index-out-of-range or a nil map write.
Any panic anywhere in that stack takes down the whole scanner, kills the sibling worker's in-flight scan and the WebSocket loop, and (per finding 1's analysis) enters a permanent systemd restart loop because the poisoned job is re-served after the hold's 10-minute processing timeout. A single crafted image is therefore a fleet-wide, self-sustaining DoS on scanning.
Fix: defer func() { if r := recover(); r != nil { … } }() wrapping the per-job body, converting
the panic into a SendError (or better a SendSkipped, since a panic on a given image is
deterministic — see N4) and letting the worker continue.
N3 — [HIGH] [SUSPECTED] The periodic 50-scan vulnDB reload is unreachable; its comment lies
scanner/internal/scan/grype.go:193-205
The write-locked double-check is if vulnDB != nil && time.Since(vulnDBBuilt) < vulnDBRefreshAge
(line 193), and the 50-scan counter/reload lives inside it (lines 196-201). But the read-locked
fast path at line 181 already returns whenever
vulnDB != nil && (fresh || withinBackoff). Reaching the write lock with vulnDB != nil
therefore implies !fresh, so line 193's condition is false by construction. vulnDBScans never
increments and the reload never happens, except in a vanishing race window where another goroutine
completes a reload between the fast path's RUnlock (line 185) and this goroutine's Lock
(line 187).
The comment at lines 197-198 states the reload happens "every 50 scans to flush SQLite's page
cache and mmap region". It does not. That matters here specifically: main.go:46 sets a 512 MiB
soft memory limit, production runs 2 workers, and worker.go:132-136 goes to the trouble of a
forced runtime.GC() plus a 10-second cooldown per job — the whole file is shaped around memory
pressure, and the one mechanism aimed at the largest retained allocation is inert.
Corroboration: grype_test.go resets vulnDBScans in resetVulnDBState but no test asserts the
50-scan behaviour, consistent with it never having worked.
Fix: increment the counter on the read-lock fast path (atomically, it already is an
atomic.Int64) and take the write lock to reload when it trips, rather than burying the counter
in a branch the fast path pre-empts.
N4 — [HIGH] [SUSPECTED] Enumerated deterministic failures reported as retryable errors
scanner/internal/scan/worker.go:107-122, extractor.go:68-71, worker.go:207-221
The hold retries error on the stale-scan loop (scan_broadcaster.go:1204-1210 skips only
ScanStatusSkipped) and never retries skipped. Every deterministic failure below is therefore an
infinite retry loop that also writes a fresh public failure record each pass:
- Empty config digest (
extractor.go:68-71) — multi-arch index; finding 9. - Image too large (
worker.go:218-219) — the claimed size does not change on retry. This is the clearest one:TestOversizeImageIsRejectedBeforeDownloadassertserrortoday. - Malformed digest — a digest whose S3 key does not exist 404s on every attempt
(
extractor.go:178). ensureDirfailure (worker.go:207-209) — a misconfigured or unwritabletmp_dirfails identically forever; see N5.- Cold-start vulnerability DB failure (
grype.go:241, surfaced viaworker.go:249) — retries are appropriate here, but at the rescan interval with a permanent record written each time. sha512/non-sha256 digests — see N7; deterministic.- Layer/config size mismatch rejected by stereoscope — deterministic for a given manifest.
Only 5 is genuinely transient. Fix: classify. Anything determined by the content of the job
rather than the state of the world should be a *SkipError. The SkipError type already exists
(worker.go:27-31) and the transport already exists (SendSkipped); it is just under-used.
N5 — [HIGH] [SUSPECTED] vuln.tmp_dir: "" deterministically breaks every scan, silently
scanner/internal/scan/worker.go:58-63 and worker.go:207-209, 265-267
CONFIRMED by scratch program: os.MkdirAll("", 0755) returns
mkdir : no such file or directory. It does not mean "use the default".
Start skips the TMPDIR export entirely when TmpDir == "" (worker.go:58), and then
processJob calls ensureDir("") on every job (worker.go:207), which fails. Every job returns
failed to create tmp dir, which is a retryable error (N4), forever. Nothing at startup validates
it — config.LoadConfig only checks hold.url and hold.secret
(internal/config/config.go:116-121).
"" is a natural thing for an operator to write meaning "use the system temp dir", and the
comment tag ("Directory for temporary layer extraction", config.go:56) does nothing to warn
them off. It is also the value you get by hand-writing a minimal YAML instead of running
config init.
Aggravating: even if ensureDir were fixed to fall through to the system default, Dockerfile.scanner
builds FROM scratch — there is no /tmp in the image, so os.MkdirTemp("", …)
(extractor.go:50) would fail too, and Grype's go-getter download would land nowhere.
Fix: validate in LoadConfig that vuln.tmp_dir is non-empty and writable, and fail startup
loudly rather than failing every job forever.
N6 — [HIGH] [SUSPECTED] Silent drops: a malformed job message strands the row on the hold
scanner/internal/client/hold.go:120-142
Four continue statements send nothing back at all:
- line 123: message fails to unmarshal as
ScanJobRaw; - line 128:
raw.Type != "job"; - line 135:
raw.Configfails to unmarshal; - line 141:
raw.Layersfails to unmarshal.
Cases 3 and 4 have already consumed a valid raw.Seq — the scanner knows precisely which job it
is dropping and says nothing. Worse, the ack is only sent at line 159, after both unmarshals, so
the hold's row stays assigned. reDispatchTimedOut re-offers it after the 5-minute ackTimeout
(scan_broadcaster.go:158, 830), and the same message is redelivered and dropped again — a
5-minute-period infinite loop with no scan record ever written and nothing in the hold's logs
explaining why. This is the "worse than an error" case the brief names.
Today the hold always marshals Config/Layers with json.Marshal
(scan_broadcaster.go:1379-1380, xrpc.go:449) so these are unreachable from the current hold.
That is a protocol-version assumption, not a guarantee.
Fix: for any message carrying a parseable seq, respond — SendSkipped for a structurally
undecodable job, since redelivery cannot change the outcome.
N7 — [MEDIUM] [SUSPECTED] The OCI layout hardcodes blobs/sha256/ regardless of algorithm
scanner/internal/scan/extractor.go:61, 173-174, 184-189
blobsDir is filepath.Join(scanDir, "blobs", "sha256") unconditionally, while digestHex
discards the algorithm. A sha512: descriptor (legal OCI, image-spec registers sha512) is
written to blobs/sha256/<hex> while the manifest descriptor still says sha512:<hex>
(extractor.go:103, 116), so the layout reader looks in blobs/sha512/ and finds nothing.
Deterministic failure, reported as a retryable error (N4).
Fix: derive the directory from the digest's algorithm component — which requires parsing the digest properly, which is the same change finding 5 needs.
N8 — [MEDIUM] [SUSPECTED] Dropping non-tar layers desynchronises the manifest from the config
scanner/internal/scan/extractor.go:107-119 vs worker.go:174-193
buildOCILayout filters non-tar layers out of the synthesised manifest, but the config blob is
copied through untouched (extractor.go:100-104) with its original rootfs.diff_ids array
intact. For a mixed-content manifest — which skipReason explicitly allows through, and which
worker_skip_test.go:55-58 pins as "mixed layers keep the scannable one" — the resulting layout
has a config claiming N diff_ids and a manifest listing M < N layers.
go-containerregistry/stereoscope validate this relationship when constructing an image; the
typical symptom is mismatched image rootfs and manifest layers. If it errors, that is another
deterministic-failure-as-retryable-error. If it doesn't error, layers are attributed to the wrong
history entries and the SBOM's layer provenance is wrong.
What would settle it: feed the pipeline a real mixed manifest (an image whose manifest carries both
a tar layer and an in-toto layer) and see whether stereoscope errors or silently mis-attributes.
The e2e harness plus the corpus can express this; the "mixed" case currently exists only as a
skipReason unit test, which never reaches buildOCILayout.
N9 — [MEDIUM] [SUSPECTED] The 5-minute HTTP timeout is a hard ceiling on total layer transfer
scanner/internal/client/hold.go:23, 266-288
http.Client.Timeout covers the entire exchange including body read. A 1.5 GiB layer — well inside
the 2 GiB max_image_size default (config.go:82) — needs a sustained ~5 MB/s just to finish. Any
slower origin, cold CDN, or throttled S3 endpoint fails the download at exactly the 5-minute mark,
every time, for that image. Reported as a retryable error, retried forever (N4), never succeeding.
The comment at lines 21-22 frames the timeout as leak prevention, which is right, but a whole-request timeout is the wrong instrument: it conflates "stalled" with "large". A stall is better caught by a response-header timeout plus a per-read idle timeout on the transport, leaving total duration bounded by the size limit instead.
N10 — [MEDIUM] [SUSPECTED] The shared secret travels in the URL query string
scanner/internal/client/hold.go:90 (and pkg/hold/pds/xrpc.go:1084-1089)
q.Set("secret", c.secret) puts the long-lived shared secret in the request line of the WebSocket
upgrade. Request lines land in reverse-proxy access logs, CDN logs, and any hold-side HTTP
middleware that logs URLs — none of which are treated as secret stores. The hold already accepts
the same credential in an X-Scanner-Secret header (xrpc.go:1086), so the safe path exists and
is simply not taken. The scanner's own log is careful (client/hold.go:96 logs u.Host only),
which suggests the risk was noticed on one side and not the other.
Secondary, same credential: ValidateScannerSecret compares with ==
(scan_broadcaster.go:930-932) rather than subtle.ConstantTimeCompare.
Fix: send the header, and drop the query-param branch from the hold once no old scanners remain.
N11 — [MEDIUM] [SUSPECTED] Nothing detects a silently dead WebSocket; /healthz lies about it
scanner/internal/client/hold.go:111-118, cmd/scanner/main.go:92-95
Grep confirms neither side sets SetReadDeadline, SetPongHandler, or sends pings (the only
WriteControl in the tree is in mockhold). A connection killed by a NAT/idle-LB timeout or a
network partition leaves conn.ReadMessage() blocked indefinitely: the scanner believes it is
connected, c.conn is non-nil so sends "succeed" into the void, no reconnect is attempted, and the
hold — which also has no keepalive — parks jobs against a subscriber that will never answer.
Meanwhile /healthz returns 200 unconditionally: it reflects only that the HTTP goroutine is
scheduled. It knows nothing about connection state, queue depth, worker liveness, or whether the
vuln DB ever loaded. An orchestrator restarting on health failure will never restart the one
failure mode that actually needs it. This is the exact "an idle scanner is indistinguishable from a
wedged one" problem main.go:54-56 claims to have solved for logging.
Fix: SetReadDeadline + periodic ping with a pong handler that extends it; and make /healthz
report connected-ness and last-job-completed age.
N12 — [MEDIUM] [SUSPECTED] Failure strings become public ATProto records
scanner/internal/scan/worker.go:121, extractor.go:63/92/178, client/hold.go:252
SendError(job.Seq, err.Error()) ships the fully wrapped error, and the hold writes it verbatim
into an io.atcr.hold.scan record via NewFailedScanRecord (scan_broadcaster.go:600-608) —
a public record in the hold's repo, readable by anyone.
What can end up in there: the scanner's temp directory path (extractor.go:63,
"failed to create temp directory: %w" wraps the OS error including the path); internal hold
error bodies verbatim (client/hold.go:252 embeds the hold's response body, which is then wrapped
twice more at extractor.go:178 and worker.go:227); and the digest of every blob involved.
Nothing is redacted or truncated.
Fix: send a bounded, classified failure string to the hold and keep the full wrapped error in the scanner's own logs.
N13 — [MEDIUM] [SUSPECTED] No dedup or bound on duplicate seqs; queue full is a permanent-looking failure
scanner/internal/queue/priority_queue.go:91-111, client/hold.go:162-167
Enqueue does not check whether job.Seq is already queued or in flight. The hold re-dispatches
after ackTimeout (5m) and on every reconnect, so the same seq can legitimately arrive twice; both
copies are scanned and both results sent. With workers: 2 and expensive images this doubles the
cost of exactly the situation (a backlog) that caused it.
Separately, Enqueue returns false for two very different conditions — queue full (line 99) and
queue closed (line 95) — and the caller reports both as "scanner queue full" (client/hold.go:166).
During shutdown, every in-flight job is thus reported to the hold as a capacity failure and written
as a permanent public failure record, when the truthful answer is "this scanner is going away, give
it to someone else".
Fix: key the queue by seq for dedup; distinguish closed from full and, on closed, send nothing (let
Unsubscribe's re-pending do its job) rather than a misleading error.
N14 — [MEDIUM] [SUSPECTED] initializeVulnDatabase's failure message describes behaviour that doesn't exist
scanner/internal/scan/worker.go:66-73
On failure the log says "Vulnerability scanning will be disabled until database is available".
Nothing is disabled. wp.cfg.Vuln.Enabled is untouched, so processJob still enters the Grype
branch (worker.go:245), scanVulnerabilities still calls loadVulnDatabase, and each job fails
with failed to load vulnerability database (grype.go:59) — a retryable error, retried forever
(N4), with a public failure record per attempt.
Also, this goroutine is untracked by wp.wg (contrast lines 75-78), so pool.Wait() returns while
a multi-hundred-MB DB download is still running, and the process exits mid-download.
Third, workers start immediately (line 75) and race the init: the first jobs each call
loadVulnDatabase and serialise on the write lock behind the init's download, holding jobs for the
full download duration — which counts against the hold's 10-minute processing deadline (finding 7).
Fix: either genuinely degrade (scan SBOM-only and say so in the result) or fail startup. The
current middle position is the one that helps nobody. And wg.Add(1) the goroutine.
N15 — [LOW] [SUSPECTED] ctx is threaded through the Grype path and used for nothing
scanner/internal/scan/grype.go:55, 58, 174, 267
scanVulnerabilities(ctx, …) passes ctx to loadVulnDatabase(ctx, …), which never reads it;
loadVulnDB (grype.LoadVulnerabilityDB) takes no context; FindMatches takes a grypePkg.Context,
not a context.Context. The parameter implies cancellation and delivers none.
Consequences: a shutdown cannot interrupt a vulnerability DB download or a long match run, so
pool.Wait() in main.go:114 can block for the download's full duration with no ceiling — and
main sets no shutdown timeout at all, so SIGTERM handling depends entirely on systemd's
TimeoutStopSec SIGKILL.
Fix: drop the parameter (honest), or wrap the load in a goroutine + select on ctx.Done() so the
signature becomes true.
N16 — [LOW] [SUSPECTED] Dequeue ignores context; Start(ctx)'s cancellation contract is partial
scanner/internal/queue/priority_queue.go:115-129, worker.go:93-98
Dequeue blocks on a sync.Cond and can only be released by Close(). Cancelling the context
passed to WorkerPool.Start releases a worker only at the cooldown select
(worker.go:139-143); a worker parked in Dequeue stays parked forever. main gets this right by
calling q.Close() (line 113), but the API's shape says otherwise, and the e2e harness's cleanup
(harness.go:110-112) depends on the same undocumented ordering.
Fix: document that Close() is mandatory, or give Dequeue a context-aware variant.
N17 — [LOW] [SUSPECTED] strings.Contains(mediaType, "tar") as the scannability test
scanner/internal/scan/extractor.go:85, 111; worker.go:188
A substring test on an untrusted media type. It is correct for every real layer type I can think of
(…tar, …tar+gzip, …tar+zstd, …rootfs.diff.tar.gzip, …nondistributable.v1.tar+gzip), and
the two call sites agree with each other so the comment at worker.go:181-182 is honest. But the
predicate accepts anything containing the letters "tar" anywhere, and rejects a legitimately
scannable layer whose type omits it.
The three copies (two in extractor.go, one in worker.go) must not drift; they should be one
function.
N18 — [LOW] [SUSPECTED] Dead fields, dead stores, vestigial cleanup
ScanResult.SBOMDigest/.VulnDigest(types.go:49,51) are computed (syft.go:75,grype.go:145) and never transmitted —ResultMessagehas no digest fields (types.go:71-77). The hold recomputes nothing and re-uploads the bytes. Either send them (so the hold can verify) or stop computing them.ScanResult.ManifestDigest(types.go:47) is set atworker.go:239and never read; the hold correlates by seq alone.result = nilatworker.go:135andsbomResult = nilatworker.go:255are dead stores to locals about to leave scope. They cannot help the GC (the compiler already knows), and the comment atworker.go:132-134claims they do.ScanJob.UserDIDand.Tagare carried across the wire and used only in log lines.
N19 — [LOW] [SUSPECTED] Documentation and deployment-template drift
CLAUDE.mdstates twice that the scanner is "env vars only, no YAML" and "Scanner config (env-only, no Viper)".internal/config/config.gois a Viper implementation with a YAML file, a--configflag (main.go:151), and aconfig initsubcommand. Every other component's docs match its code; this one doesn't.- There is no
config-scanner.example.yamlat the repo root, althoughconfig-appview,config-holdandconfig-labelerall exist andatcr-scanner config initcan generate one. deploy/upcloud/configs/scanner.yaml.tmplomitsvuln.max_image_sizeentirely, so the deployed config is out of sync with the config struct — the exact step CLAUDE.md's implementation checklist item 2 exists to prevent. (Behaviour is currently correct by accident: the Viper default supplies 2 GiB.)Dockerfile.scannerdeclaresorg.opencontainers.image.version="0.1.0"; see finding 8 for the other three disagreeing version strings.
N20 — [LOW] [SUSPECTED] index.json omits the image-index mediaType
scanner/internal/scan/extractor.go:30-33, 136-145
ociIndex has no MediaType field, so the generated index.json has none. OCI image-spec 1.1
says the index's mediaType property "SHOULD be used and MUST be
application/vnd.oci.image.index.v1+json". Stereoscope tolerates the omission today (the e2e run
above got a real SBOM out of a real image), so this is spec hygiene rather than a live defect — but
it is the kind of omission that breaks on a dependency bump, and it would fail oci-image-tool validate.
Also missing and worth considering: no org.opencontainers.image.ref.name annotation on the
index's manifest descriptor, which is what gives the layout a usable reference name; syft.go:41
passes the directory path as the reference instead, which is why source names in SBOMs are temp
paths (see I5).
N21 — [LOW] [SUSPECTED] Untrusted strings reach log lines unsanitised
scanner/internal/scan/worker.go:100-105, 126-129, extractor.go:72, 86, 89
job.Repository, job.Tag, job.UserHandle, job.ManifestDigest and layer.MediaType all
originate in a user-writable PDS record and are logged directly. slog's handlers quote and escape
values, so this is not classic log injection — but these fields also flow to the remote log shipper
configured in production (main.go:57-65), and nothing bounds their length. A manifest record with
a megabyte-long repository is logged on every job.
N22 — [LOW] [SUSPECTED] No validation of numeric config; broken values fail silently
scanner/internal/config/config.go:107-123
LoadConfig validates two strings and nothing else:
scanner.workers: 0(or negative) starts no workers (worker.go:75). The client still connects, still acks every job, still enqueues — and the hold sees healthy acks while nothing is ever scanned. Worst possible shape: it looks alive.scanner.queue_size: 0makesEnqueuealways return false (priority_queue.go:99), so every job is rejected as "queue full" forever.vuln.max_image_size: -1is treated as "no limit" by the> 0guard (worker.go:212), which is not what "-1" suggests to anyone.vuln.db_path: ""→os.MkdirAll("")fails insideloadVulnDatabase(grype.go:218), same shape as N5.
N23 — [LOW] [SUSPECTED] The scanner sends its shared secret to whatever endpoint the job names
scanner/internal/scan/extractor.go:176 → client/hold.go:230-242
GetBlobPresignedURL takes job.HoldEndpoint — a field from the job message — and sends
Authorization: Bearer <shared secret> to it, with no scheme check and no comparison against the
configured hold.url. The value is set hold-side from its own config (scan_broadcaster.go:290),
so it is same-trust-domain today; but a misconfigured server.public_url on the hold (e.g. left as
http://) silently downgrades every credential-bearing request to cleartext, and the scanner has
no say.
Fix: require the endpoint's origin to match the configured hold URL, or at minimum refuse to attach the bearer token to a non-TLS URL.
N24 — [LOW] [SUSPECTED] Duplicate digests are downloaded once per occurrence
scanner/internal/scan/extractor.go:79-94
The layer loop does no digest dedup. Images that repeat a layer digest (common with shared base
layers or repeated empty layers) fetch and rewrite the same file multiple times — wasted presigned
URL round-trips and wasted bytes, and each repeat counts against the 5-minute HTTP window (N9).
Trivial fix: a map[string]bool of already-fetched digests.
N25 — [LOW] [SUSPECTED] Close() panics if called twice
scanner/internal/client/hold.go:219-226
close(c.done) with no sync.Once guard. One call site today (main.go:112) plus one in the test
harness, so it is latent — but the method reads as idempotent and isn't.
Section 3 — improvements that are not bugs
I1 — There is no metric surface at all
Not one counter, gauge, or histogram. Nothing reports jobs received, queue depth by tier,
scan duration, download bytes, DB age, or failure counts by class. Every question the findings above
raise ("how often does N9's timeout fire?", "is N13's duplicate dispatch actually happening?") is
currently unanswerable in production without reading journald by hand. /healthz (main.go:92-95)
is the natural place to start: make it a JSON status document rather than a literal ok.
I2 — Split client.HoldClient into transport and protocol
The type currently owns URL construction, dialing, the reconnect loop, message decoding, ack
policy, and the write mutex, and it exports package-level HTTP helpers
(GetBlobPresignedURL, DownloadBlob) that have nothing to do with the WebSocket. That coupling is
why findings 2, 3, N6, N10 and N13 all live in one 288-line file and why none of them are unit
testable — there is no seam. A Conn (dial/read/write/reconnect) under a Session (decode, ack
policy, enqueue) would make the ack-timing question in finding 7 a two-line change.
I3 — The digest is a value type waiting to be introduced
digestHex (extractor.go:184) is the third place a digest is destructured by string surgery
(the others: s3.BlobPath, pkg/hold/pds rkey handling). A parsed Digest{Algo, Hex} type with a
validating constructor at the wire boundary would make finding 5, N7 and half of N4 structurally
impossible rather than individually patched. github.com/opencontainers/go-digest is already an
indirect dependency.
I4 — The wire protocol has no version and no schema
ScanJobRaw / ResultMessage / ErrorMessage / SkippedMessage (types.go) are duplicated by
hand on the hold side (ScanJobEvent / ScannerMessage, scan_broadcaster.go:86-110). Two
independently maintained copies of one wire format, with no version field and no lexicon under
lexicons/ (unlike every other hold endpoint). N1 is precisely the class of bug this invites: one
side's omitempty is the other side's unconditional deref. Either generate both from one source or
add the lexicon and a version field.
I5 — SBOM source naming leaks temp paths
syft.go:41 sets Reference: ociLayoutDir — a path like
/var/lib/seamark/scanner/tmp/scan-2171136778. That string is embedded in the SPDX document and
published, so every published SBOM names a directory that stopped existing seconds later. The
reference should be <repository>:<tag>@<manifestDigest> from the job.
I6 — worker.go is doing three jobs
processJob mixes policy (skip rules, size limits), orchestration (download → SBOM → match), and
transport (which message type to send). The dispatch if/else at lines 107-130 is where finding 1
lives, and the skip-vs-error classification of N4 has to be made in two places as a result. Pulling
"classify this outcome into a message" into one function would give N4 a single edit site.
I7 — The cooldown is a blunt instrument
JobCooldown = 10s unconditionally (worker.go:154), including after a job that was skipped
before downloading anything (worker.go:202-204) — a path that allocates essentially nothing. A
run of attestation-heavy repositories therefore idles 10 seconds per refusal, for no memory reason,
while the hold's 10-minute processing deadline runs (finding 7). Gate the cooldown on whether a
scan actually ran.
I8 — Tests cannot reach the two riskiest paths
internal/scan has no test for buildOCILayout and internal/client has no tests at all
(go test reports "no test files" for both client and config). Every finding in extractor.go
and client/hold.go above is unverified for exactly that reason. buildOCILayout is already
close to testable — it takes a job, a tmpDir and a secret, and mockhold can serve the
blobs; the only obstacle is that the download helpers are package-level functions in client
rather than an injectable interface.
I9 — Comment audit
Comments that do not match the code, gathered for one pass:
client/hold.go:62— "Exponential backoff with max 30s" over a flat 5s sleep (finding 2).grype.go:197-198— "Periodic reload ... every 50 scans" for a branch that never executes (N3).worker.go:70— "Vulnerability scanning will be disabled until database is available"; nothing is disabled (N14).worker.go:132-134— theresult = nil/runtime.GC()comment credits the nil-store with freeing memory it cannot free (N18).client/hold.go:21-22— describes the 5-minute timeout purely as leak prevention, without noting it also caps total transfer size (N9).extractor.go:35-48— accurate and genuinely useful; called out as the counter-example.
13. Hold-side code review (reading)
Hold-side scan pipeline: code review
Scope: pkg/hold/pds/scan_broadcaster.go, pkg/hold/pds/scan.go, the push-triggered
enqueue path in pkg/hold/oci/xrpc.go, the scan record shape in
pkg/atproto/lexicon.go, the WebSocket boundary against scanner/types.go and
scanner/internal/client/hold.go, and how results surface in pkg/appview.
Reading pass. No production code was modified and no tests were written. Two things were verified by execution and are marked CONFIRMED; everything else is SUSPECTED.
Baseline: go test -race -run TestScan ./pkg/hold/pds/ passes (6.3s).
Section 1: the job state machine
1.1 Storage
One SQLite table, scan_jobs (scan_broadcaster.go:257). The status column is free
text with five observed values: pending, assigned, processing, completed,
failed. Two other pieces of state live outside the table and matter as much:
sb.inflight(:71) — an in-memorymap[digest]struct{}that gates proactive discovery. Not persisted; empty on every boot.sub.send(depth 20,:329) and the two queuesunscannedQueue(500) /staleQueue(200) — in-memory, lost on restart.
1.2 States and transitions
Enqueue (:296) [push notify :459 | proactive :1395]
│ + addInflight(digest) (:293, return value ignored)
▼
┌───────────┐
┌──────────────│ pending │◄─────────────────────────────────┐
│ └─────┬─────┘ │
│ │ │
│ dispatchJob (:418, guarded by status='pending') │
│ drainPendingJobs (:775, guard present but RESULT IGNORED) │
│ ▼ │
│ ┌───────────┐ │
│ │ assigned │ assigned_at = dispatch time │
│ └─────┬─────┘ │
│ │ │
│ scanner "ack" │ handleAck (:525) │
│ (sent on RECEIPT, not on work start; │
│ assigned_at is NOT refreshed) │
│ ▼ │
│ ┌────────────┐ │
│ │ processing │ │
│ └─────┬──────┘ │
│ │ │
│ ┌────────────────┼─────────────────┐ │
│ │ "result" │ "skipped" │ "error" │
│ ▼ ▼ ▼ │
│ completed completed failed │
│ (:624) (:718) (:673) │
│ +scan record +skipped record +failed record │
│ (only if │
│ Summary!=nil) │
│ │
│ reDispatchTimedOut, assigned older than ackTimeout=5m │
└───(:888) status→pending, assigned_to/at→NULL ────────────────┘
│
│ reDispatchTimedOut, pending older than 1m → re-offered (:902 dispatchJob)
│
│ Unsubscribe (:383): ALL rows assigned_to=<dead sub> in
└───(pending|assigned|processing) → pending, assigned_to/at→NULL
reDispatchTimedOut (:834): processing AND assigned_at < now-10m
→ failed. NO scan record. NO removeInflight.
(terminal; the only edge out of processing
that produces no record at all)
dispatchJob buffer-full fallback (:445): assigned → pending, unguarded
Terminal states: completed, failed. Nothing ever deletes a row (Finding 11).
1.3 Who performs each transition
| Transition | Actor | Site |
|---|---|---|
| ∅ → pending | push notify handler / proactive dispatch loop | oci/xrpc.go:459, scan_broadcaster.go:1395 → :296 |
| pending → assigned | dispatchJob (round-robin, holds sb.mu) |
:418 |
| pending → assigned | drainPendingJobs (per new subscriber, no sb.mu) |
:775 |
| assigned → pending | dispatchJob when the send buffer is full |
:445 |
| assigned → processing | handleAck, on the reader goroutine |
:525 |
| assigned → pending | reDispatchTimedOut, after ackTimeout (5m) |
:888 |
| {pending,assigned,processing} → pending | Unsubscribe, per dead subscriber |
:383 |
| processing → completed | handleResult |
:624 |
| processing → completed | handleSkipped |
:718 |
| processing → failed | handleError |
:673 |
| processing → failed | reDispatchTimedOut, after 10m from dispatch |
:834 |
Note the two dispatchers for pending and the two producers of failed. Both pairs
are asymmetric, and both asymmetries are bugs (Findings 2 and 3).
1.4 What dfd604b fixed, and what it did not
The nine-day outage was: one pending row that no code path would ever dispatch
again, counted forever by hasActiveJobs(), which waitForCapacity() spins on.
dfd604b added three things:
pendingStaleAfter(15m) sohasActiveJobsstops counting an old pending row (:1564). This is the actual unwedge.- A pending reclaim in
reDispatchTimedOut(:848) so a stale pending row gets re-offered every 30s instead of waiting for the next scanner connect. - A
RowsAffectedguard ondispatchJob's assign UPDATE (:429), because (2) created a second dispatcher and two dispatchers can race for one row.
What remains:
- (3) was applied to only one of the two dispatchers.
drainPendingJobsruns the same guarded UPDATE at:775and then sends regardless of whether it won (Finding 2). The racedfd604bclosed is still open through the other door. - The capacity gate is still global, not proactive-scoped.
hasActiveJobs(:1566) counts every row including push-triggered ones, so on a hold with steady paid-tier pushes the proactive loop never gets capacity. That is a throttle, not a wedge, but it means "one proactive job at a time" is really "zero while anyone is pushing". hasActiveJobsstill returnstrueon any DB error (:1573). The exact failure shape of the outage — proactive scanning silently off forever — is still reachable, now via a persistently failing query rather than a stuck row.- The pending reclaim is only a re-offer, not a repair. If the row is
undispatchable for a reason inside the row (Finding 27: NULL
user_handlemakesrows.Scanfail in both the drain and the reclaim), it is skipped silently bycontinueat:872and:764forever. It no longer blocks anything, but it also never runs and nothing reports it. - Nothing reconciles at boot. Rows left
assigned/processingby a restart hold capacity until their timeouts fire (5m/10m), and theprocessingones then go tofailedwith no record (Finding 3).
1.5 States a row can enter and never leave
Strictly at the row level, every non-terminal state has an exit edge, so there is no
permanently stuck row after dfd604b. The defects are one level up:
processing→failedis a one-way door with no record and no inflight release (Finding 3). The manifest is what gets stuck: no scan record exists, so the appview shows "Not scanned" forever, and the digest stays insb.inflight, so discovery will refuse to re-queue it for the life of the process.completed/failedare absorbing for the row and the table is never pruned (Finding 11).- A row whose
rows.Scanfails (NULLuser_handle, Finding 27) is skipped by both the drain and the reclaim, silently, forever. - The
pendingrow that outlives its subscriber:Unsubscribe(:383) runs its requeue UPDATE, and then the still-runningdrainPendingJobsgoroutine assigns more rows to that same dead subscriber id (:775, no membership check). Those rows sitassignedto a subscriber that no longer exists until the 5-minute ack timeout.
Section 2: findings
F1 — BLOCKER — Nil Summary in a result message panics the hold process
CONFIRMED by inspection (unconditional nil dereference; not executed).
pkg/hold/pds/scan_broadcaster.go:638-644
slog.Info("Scan job completed",
...
"critical", msg.Summary.Critical, // :642
"high", msg.Summary.High, // :643
"total", msg.Summary.Total) // :644
msg.Summary is *VulnerabilitySummary (:107) and is explicitly nil-checked
seventeen lines earlier at :599. The log statement is outside that guard. A result
message with no summary key unmarshals to a nil pointer and dereferences it on
handleReader's goroutine, which is not recovered anywhere — the hold process dies.
This is reachable from the shipped scanner, not just a hostile one. With
vuln.enabled=false, scanner/internal/scan/worker.go:245 never sets
result.Summary, and SendResult (scanner/internal/client/hold.go:177) copies the
nil straight through. The scanner then panics on its own next line
(worker.go:129) — but only after the message is on the wire, so the hold gets it.
Both processes crash-loop: the scanner restarts, the job is re-dispatched, the hold
dies again.
Anything else that produces a summary-less result (a Grype path that returns early, a third-party scanner, a truncated message) has the same effect. The shared secret is the only thing between the network and a remote hold kill.
Fix: move the three fields into the msg.Summary != nil branch, or log a
zero-value summary. Then handle F2 — the guard at :599 is itself wrong.
F2 — HIGH — drainPendingJobs sends jobs it did not win, re-opening the double-dispatch dfd604b closed
SUSPECTED. pkg/hold/pds/scan_broadcaster.go:775-791
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'assigned', assigned_to = ?, assigned_at = ?
WHERE seq = ? AND status = 'pending'
`, sub.id, time.Now(), job.Seq)
if err != nil {
continue
}
select {
case sub.send <- job:
The AND status = 'pending' guard is present but RowsAffected is never read, so a
row already claimed by dispatchJob or by the re-dispatch loop is still pushed onto
this scanner's queue. dfd604b added exactly this check to dispatchJob (:429) with
the comment "Sending it anyway would scan it twice", and the regression test
TestScanDispatchJob_SkipsClaimedJob covers only that path.
The window is wide because drainPendingJobs runs unsynchronised on its own goroutine
(:351) while reDispatchTimedOut fires every 30 seconds and Enqueue fires on every
qualifying push. Concretely: scanner B connects and drains; the re-dispatch tick has
already claimed row N for scanner A; B's UPDATE affects zero rows but B receives the
job. Both scan it. B's ack is then silently dropped, because handleAck (:527) does
guard on assigned_to = ? AND status = 'assigned' — so the row is now assigned to A,
B is scanning it anyway, and whichever result lands first wins.
The mirror-image case is worse: dispatchJob assigns row N to A, finds A's buffer
full, and resets the row to pending at :445 with no status guard — while B,
having lost the UPDATE race, is already holding the job. The row goes back to pending,
gets handed to a third scanner, and now three copies exist.
Fix: read RowsAffected in drainPendingJobs exactly as dispatchJob does, and
add AND assigned_to = ? to the buffer-full reset at :445.
F3 — HIGH — The processing timeout marks a job failed with no scan record and never releases the inflight mark
SUSPECTED. pkg/hold/pds/scan_broadcaster.go:833-842
processingTimeout := time.Now().Add(-10 * time.Minute)
res, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'failed', completed_at = ?
WHERE status = 'processing' AND assigned_at < ?
`, time.Now(), processingTimeout)
Every other producer of a terminal state writes an io.atcr.hold.scan record —
handleResult (:600), handleError (:662), handleSkipped (:707). This one
writes nothing, and it also never calls removeInflight.
Two user-visible consequences.
No record. The appview distinguishes "hold unreachable" / "not scanned" /
"skipped" / "scan failed" by fetching the scan record and reading Status
(pkg/appview/handlers/scan_result.go:53-72); a 404 renders NotScanned
(:125), which the badge shows as a grey "Not scanned" pill
(pkg/appview/templates/partials/vuln-badge.html:8) and the panel as "Scans run
automatically shortly after a push. Check back in a few minutes"
(partials/vulns-section.html:25). A job the hold has definitively given up on is
therefore indistinguishable from one that was enqueued thirty seconds ago. There is no
"Scan failed" badge, no reason string, no timestamp.
Never retried, for the life of the process. The digest was added to sb.inflight
by Enqueue (:293) and is only removed by the three message handlers. After this
UPDATE it is still in the map, so discoverUnscannedForUser's addInflight check at
:1107 returns false and continues — permanently. The stale loop cannot pick it up
either (it iterates scan records, and there is none). So the manifest is excluded
from both proactive paths until the hold restarts.
The record only ever appears if the scanner it gave up on eventually replies anyway —
which does work, because handleResult selects by seq with no status filter
(:561) and overwrites failed with completed. That accidental recovery is the only
thing keeping this from being a permanent hole, and it is exactly the case F4 shows is
common.
Fix: write a NewFailedScanRecord with a reason like "scanner did not report
within 10 minutes" before flipping the status, and call removeInflight for each
affected digest (the UPDATE must therefore select the digests first, as the sibling
loop below it already does).
F4 — HIGH — assigned_at is stamped at dispatch and never refreshed, so a healthy backlogged scanner has its work cancelled and the dispatch throttle inverts
SUSPECTED. scan_broadcaster.go:419 (stamp), :525 (ack does not refresh),
:833 (10-minute deadline measured from the stamp).
The scanner acks on receipt, before any work: connectOnce reads the frame, calls
SendAck(job.Seq), and only then does queue.Enqueue
(scanner/internal/client/hold.go:159-162). Default config is workers: 1,
queue_size: 100 (scanner/internal/config/config.go:74-75). So the elapsed time
between assigned_at and the start of actual work is the depth of the scanner's local
queue multiplied by the per-image scan time — Syft plus Grype plus layer downloads,
routinely minutes for a real image.
With one worker, the second job in a backlog is already at risk, and the fifth is certain to breach ten minutes without anything being wrong. The hold marks it failed (F3: no record, capacity released) while the scanner is still holding it.
The second-order effect is the damaging one. hasActiveJobs (:1566) counts only
assigned/processing, so the moment the row flips to failed the dispatch loop
believes it has capacity and enqueues another proactive job (:1285, :1322). That
job lands behind the same backlog, breaches ten minutes, is failed, frees capacity
again. The "throttled to one proactive job at a time" guarantee in the comment at
:1262 inverts into an unbounded feed under exactly the condition it exists to
prevent. A hold with a slow scanner will pump the scanner's 100-slot queue full and
then start getting error: scanner queue full back (F8).
How often: any hold whose scanner is not idle. The ten-minute budget is per-connection-backlog, not per-scan, and nothing anywhere caps the backlog.
Fix: the ack should mean "I have it", and a separate signal should mean "I started
it" — either add a started message type and refresh assigned_at on it, or have the
scanner send periodic heartbeats for the job it is working on and reset the deadline on
each. Failing that, scale the deadline by the number of jobs outstanding to that
subscriber. What would settle the sizing: log the observed distribution of
ack→result latency, which is not currently recorded anywhere.
F5 — HIGH — A five-second network blip permanently un-adopts a predecessor hold's manifests
SUSPECTED. scan_broadcaster.go:1468-1475 and :1480-1545
isPredecessor := sb.checkPredecessor(ctx, holdDID)
sb.predecessorCache[holdDID] = isPredecessor // :1474
checkPredecessor returns bare false for every failure mode: DID resolution failed
(:1487), HTTP error (:1505), non-200 (:1511), unreadable body (:1516), JSON that
does not parse (:1523, :1528). All of them are cached, and the cache is never
invalidated or expired for the life of the process. Its timeout is 5 seconds
(:1481).
So one slow response from a predecessor hold during one discovery pass means every manifest that names that hold is treated as not ours, forever. The hold is likely retired — that is what makes it a predecessor — so "unreachable for five seconds" is its normal condition, and the cache is populated on the first pass after boot.
This exact defect was found and fixed in the sibling implementation. pkg/hold/gc/gc.go
carries a second return value: "The second return value reports whether the answer is
definitive. It is false whenever the hold could not be reached or its reply could not
be understood" (gc.go:2027-2035), the cache comment at gc.go:257 says "Only
definitive answers belong here. The cache is never reset, so a false recorded from an
unreachable hold would outlive the outage", and gc.go:2006-2019 routes
non-definitive answers to a per-run predecessorUnresolved map instead. The scan
broadcaster was never brought along.
Blast radius here is milder than GC's (missed scans, not deleted blobs), but it is
silent and permanent, and it is logged at Debug (:1486, :1506).
Related: predecessorCache is read and written with no mutex (:1468, :1474).
Today only the discovery goroutine reaches it, so there is no race yet, but nothing
documents or enforces that.
Fix: port GC's definitive/unresolved split verbatim.
F6 — HIGH — handleResult does S3 uploads and a PDS commit inline on the reader goroutine, with no timeout
SUSPECTED. scan_broadcaster.go:543-645, called from handleReader's loop at
:510.
ctx := context.Background() (:544) — no deadline anywhere. On that context it does
two S3 PutBytes (:575, :588, via pkg/hold/pds/profile.go:95) and a
CreateScanRecord (:607) which takes the repomgr per-user lock
(pkg/hold/pds/repomgr.go:345) and writes a CAR delta. The hold is a single uid, so
that lock is contended with every layer record, stats increment and Bluesky post the
hold is writing.
While this runs, handleReader is not calling ReadMessage. Acks, errors and results
for every other job on that connection are stuck in the socket buffer; the scanner's
writes eventually block on it. An S3 endpoint that accepts the connection and then
stalls holds the whole scanner connection hostage indefinitely — and because the
scanner is still nominally connected, Unsubscribe never runs, so nothing requeues the
work. Jobs then age out through F4's path.
Payload size compounds it: the SBOM and the Grype report arrive as JSON strings
inside the frame, so a 40 MB SPDX document is ~40 MB of frame, ~40 MB of unmarshalled
string, and another ~40 MB of []byte at :575, all live simultaneously, per
in-flight result.
Fix: hand the result to a small worker pool (the startJob pattern in
pkg/hold/admin/jobs.go and gc.startBackground are the house precedents) and give
the context a deadline. Keep the DB status update ordered after the record write so a
crash mid-upload leaves the row reclaimable.
F7 — HIGH — No WebSocket keepalive or read deadline on the hold side
SUSPECTED. scan_broadcaster.go:325-357 (Subscribe), :452-481 (writer),
:484-521 (reader).
The hold never calls SetReadDeadline, SetReadLimit, SetPongHandler, or sends a
ping. The scanner never pings either (scanner/internal/client/hold.go:111). Neither
side has a heartbeat.
A half-open connection — NAT/conntrack expiry, a load balancer idle timeout, a scanner
host that vanished — therefore leaves the subscriber registered in sb.subscribers
indefinitely. hasConnectedScanners (:1548) returns true, dispatchJob picks it in
the round-robin (:414), the write into the 20-deep buffer succeeds, and the writer's
WriteMessage may sit in the kernel send buffer for a long time before the OS gives up
(default TCP keepalive is on the order of two hours, and only if SO_KEEPALIVE is set).
Every job routed to that subscriber goes to assigned and then times out five minutes
later. With two scanners, round-robin sends half the fleet's work into the hole.
The dfd604b commit message notes the hold had been up since Aug 14 and the scanner
since Aug 21 "on the same websocket" — a long-lived idle connection is the normal
state here, which is precisely when this bites.
Missing read limit is the same line of code: sub.conn.ReadMessage() (:488) with no
SetReadLimit will allocate whatever the peer sends.
Fix: conn.SetReadDeadline refreshed by a SetPongHandler, a ping ticker in
handleWriter, and conn.SetReadLimit sized to the largest acceptable SBOM.
F8 — HIGH — Transient scanner-side failures are recorded as permanent for a full rescan interval
SUSPECTED. scan_broadcaster.go:650-690 (all errors treated identically),
:1218 (stale loop's only exemption is skipped), default rescan_interval 168h
(docs/SBOM_SCANNING.md).
Everything the scanner can fail on becomes one NewFailedScanRecord with a free-text
reason and no retry classification. Once written, the manifest has a scan record, so
discoverUnscannedForUser (:1112) skips it and only the stale loop will ever look at
it again — after rescanInterval, a week by default.
Transient failures wrongly made week-long:
"scanner queue full"(scanner/internal/client/hold.go:166) — a pure backpressure signal, emitted precisely when the hold is over-dispatching (F4). The hold's own overload gets written into the user's scan history as a failure and suppresses retries for a week.- Blob download failures — a 5xx from S3 or an expired presigned URL during layer fetch.
- Grype DB download/update failures on scanner start.
- OOM or disk-full in
vuln.tmp_dir.
Deterministic failures that will be retried forever, roughly weekly, each costing a full image download plus Syft plus Grype:
- Image larger than
vuln.max_image_size(2 GiB default) — this is aSendError, not aSendSkipped, so it is retried indefinitely and will always fail. - A manifest whose blobs have been garbage-collected — the record persists after the blobs are gone.
- Any malformed or unsupported artifact the scanner errors on rather than skipping. The
dfd604bcommit message describes exactly this class: an in-toto attestation whose config mediaType looks like an ordinary image config.
Note also the asymmetry with pkg/hold/pds/scan.go:126-133, where the backfill writes
NewFailedScanRecord("backfilled: legacy record (no SBOM and zero counts)") — so every
legacy record on a backfilled hold is now in the perpetual weekly-retry set too.
Fix: give the boundary a retryable/permanent distinction. Cheapest version: a
retryable bool on ErrorMessage, with the scanner setting it false for size limits
and unsupported artifacts; hold side, a permanent failure is treated like skipped by
the stale loop, and a retryable one gets a short backoff rather than the full rescan
interval. ScanRecord.Reason already exists to carry the explanation to the UI.
F9 — MEDIUM — A result with no summary is silently discarded and the job marked complete
SUSPECTED. scan_broadcaster.go:599 and :624
Independent of the panic in F1: the if msg.Summary != nil guard means a summary-less
result writes no scan record at all, yet :624 still marks the row completed and
:635 releases the inflight mark. The SBOM and vuln report blobs were already uploaded
at :575/:588, outside the guard, so they are now orphaned in S3 with nothing
referencing them.
The manifest ends up with no record, so discovery re-queues it on the next 4-hour pass, scans it again, discards it again — forever, at full scan cost, with the only trace being a growing set of orphaned blobs.
Fix: treat a missing summary as an error (write a failed record) or as a zero
summary, but never as a success. An SBOM without Grype counts is a legitimate outcome
when vuln.enabled=false and arguably deserves its own record shape.
F10 — MEDIUM — Disconnect/reconnect produces duplicate concurrent scans of the same job
SUSPECTED. scan_broadcaster.go:383-391 (Unsubscribe requeue), :351 (drain on
reconnect), scanner/internal/client/hold.go:46-68 (the scanner's queue outlives the
connection).
On any read or write error the hold flips that subscriber's assigned and
processing rows back to pending. The scanner, meanwhile, loses only the connection:
connectOnce returns, Connect sleeps 5s and redials, but the queue.JobQueue and
the worker pool are owned by main and keep running. Jobs already in the local queue are
still scanned, and their results are written to the dead c.conn — sendJSON
(hold.go:213) logs the write error and drops the message on the floor.
So after a reconnect: the hold re-drains the same seq to the same scanner, the scanner
enqueues a second copy of a job it may still be running, and the image is scanned twice
concurrently on one host. With workers: 1 the duplicate is serialised behind the
original, doubling the backlog and feeding F4.
Is handleResult idempotent for a repeated seq? Nearly. The blob uploads are
content-addressed (profile.go:80-93), so re-uploading writes the same key.
CreateScanRecord upserts on the digest-derived rkey (pkg/hold/pds/scan.go:178-187),
so no duplicate record. But each repeat costs two full S3 PUTs and a fresh CAR commit
with a new CID and a firehose event, all under the repomgr lock, on the reader goroutine
(F6). It is safe, not free.
Also note the ordering hole this opens: Unsubscribe takes sb.mu and runs its
requeue UPDATE, but the already running drainPendingJobs goroutine for that same
subscriber does not hold sb.mu and does not check membership, so it can assign
further rows to the dead subscriber id after the requeue has swept. Those rows sit
assigned to a ghost until the 5-minute ack timeout.
Fix: on reconnect the scanner should drop or re-key its outstanding queue, or the
hold should include an epoch/lease token in the job and reject results whose lease has
been revoked. Minimally, drainPendingJobs should re-check subscriber membership
before each assign.
F11 — MEDIUM — scan_jobs is never pruned, and the capacity query scans it every 5 seconds
SUSPECTED. scan_broadcaster.go:1566-1570, schema at :257-277.
No code anywhere deletes from scan_jobs (grep for the table name returns only
scan_broadcaster.go and its tests). Every push and every proactive scan for the life
of the hold is a row. The stall test's own comment acknowledges the scale — "the table
holds tens of thousands of them" (scan_broadcaster_stall_test.go:71).
Against that table, waitForCapacity runs hasActiveJobs() in a loop with a 5-second
fallback tick (:1348), and the query is:
SELECT COUNT(*) FROM scan_jobs
WHERE status IN ('assigned','processing')
OR (status = 'pending' AND datetime(created_at) > datetime('now', ?))
The datetime(created_at) call is not sargable, and the OR limits what
idx_scan_jobs_status can do. The two indexes present (:275, :276) cover
status and (assigned_to, status); there is no index supporting
status='processing' AND assigned_at < ? (:836) or the ORDER BY seq reclaim scan
(:849) beyond the status prefix.
Fix: a retention sweep for terminal rows older than some window (they are pure
history; the scan records are the durable artifact), plus an index on
(status, assigned_at) and (status, created_at).
F12 — MEDIUM — Every hold walks every ATCR user's PDS every four hours, and on every scanner reconnect
SUSPECTED. scan_broadcaster.go:1010 → :1041 → :1067, and :354.
fetchManifestDIDs asks the relay for all DIDs with an io.atcr.manifest record
(:1047) — network-wide, not hold-scoped. discoverUnscannedForUser then resolves
each identity and paginates that user's entire manifest collection 100 at a time
(:1079), and only after unmarshalling each record does it ask whether the manifest
belongs to this hold (:1097). Every hold in the network therefore performs a full
read sweep of every ATCR user's PDS, every four hours, to find its own manifests.
Subscribe calls triggerDiscovery() unconditionally (:354), and the scanner
reconnects every 5 seconds while it cannot connect
(scanner/internal/client/hold.go:66). discoverNow has depth 1 so triggers coalesce,
but a pass that ends while a trigger is pending starts another immediately — a
crash-looping scanner turns discovery into a continuous loop with no rate limit.
For each manifest that passes the filter, GetScanRecord (:1112) is a carstore read;
for each unknown hold DID seen, a 5-second HTTP probe (F5).
The project already knows third-party PDSes have spam daemons
(project_pds_spam_suspension). These are reads, not writes, but they are unbounded,
unthrottled, and multiplied by the number of holds.
Fix: rate-limit runDiscoveryPass (a minimum interval between passes regardless of
trigger); skip users whose manifests have never named this hold, cached across passes;
and prefer the hold's own io.atcr.hold.layer records — which already join manifest
AT-URIs to this hold — as the candidate source, falling back to the relay only for
predecessor adoption.
F13 — MEDIUM — resolveManifestForCandidate walks a user's entire PDS for one digest, under a shared 30-second budget
SUSPECTED. scan_broadcaster.go:1412-1453, budget set at :1356.
Every stale candidate arrives with only a digest (:1235-1239), so the manifest must be
re-resolved. The resolution is a linear scan: page through io.atcr.manifest 100 at a
time and compare manifest.Digest (:1437). For a user with 2,000 manifests that is
20 sequential PDS round-trips to find one record whose rkey is already known — the
rkey is the digest hex (BuildManifestURI, lexicon.go:539), so
com.atproto.repo.getRecord would fetch it in one call.
The 30-second context at :1356 covers the staleness re-check, identity resolution and
the whole walk. On a large repo it expires, ListRecordsForRepo errors, the function
returns false at :1428, the candidate is dropped with a Debug log, and the inflight
mark is released — so it comes back on the next stale pass and fails the same way,
indefinitely. Large accounts silently stop being rescanned.
The same walk is the cost of every deleted manifest that still has a scan record:
:1449 is reached only after paginating the whole collection.
Fix: getRecord by rkey directly; fall back to the walk only on 404. Widen or
remove the 30s cap on the resolution step.
F14 — MEDIUM — Strict priority starves the stale queue, and a blocked stale pass holds inflight marks
SUSPECTED. scan_broadcaster.go:1300-1320 (strict priority), :1241-1247
(blocking send).
dispatchLoop drains unscannedQueue entirely before ever looking at staleQueue.
Discovery re-queues every never-scanned manifest on every 4-hour pass, so on a hold with
a persistent unscanned backlog — which is exactly a hold whose scanner is slow, i.e.
F4's hold — rescans never run at all. The rescan_interval becomes advisory.
Meanwhile runStalePass blocks in case sb.staleQueue <- candidate: with only stopCh
as an alternative, so it parks indefinitely once the 200-slot queue fills. It parks
holding an inflight mark for the candidate in hand plus the 200 queued, and the pass
never completes, so its found > 0 summary log (:1256) never prints. From the outside
the stale loop simply looks absent.
discoverUnscannedForUser has the same unbounded blocking send at :1127, which also
means the 30-minute discovery context at :978 is fiction — the send does not select
on it.
Fix: age-weighted priority instead of strict priority (or a simple 4:1 ratio), and make both queue sends select on the pass context so a pass ends when its budget does.
F15 — MEDIUM — The scan record rkey is the digest alone, so two users pushing the same image share one record
SUSPECTED. pkg/atproto/lexicon.go:829 (ScanRecordKey = digest hex),
:774-776 (Manifest AT-URI is built from the job's userDID).
CreateScanRecord upserts at rkey = <digest hex> per hold. Container digests are
content-addressed and shared: two users pushing the same base image, or one user
pushing to two repositories, collide. Whichever scan lands last overwrites
repository, userDid and the manifest AT-URI.
Consequences:
- The record served for user B's digest names user A's DID and repository name. The
appview only renders counts, so nothing leaks in the UI, but the record itself is
public on the hold's PDS via
com.atproto.repo.getRecord(docs/SBOM_SCANNING.md), which discloses that A pushed that image and under what repository name. - Stale rescans of a shared digest are dispatched against
scanRecord.UserDID(:1238). If that user has since deleted the manifest,resolveManifestForCandidatewalks their PDS, does not find it, and drops the candidate (F13) — even though another user still has it. The digest is then unrescannable while the record still claims it was scanned. sb.inflightis keyed by digest too (:1611), so a push of a shared digest by user B is deduped against a proactive scan for user A — correct for scan cost, but it means B's push-triggered scan silently does not happen.
Fix: this is a schema decision, so flag rather than change casually. Either accept
it and document that scan records are hold-scoped facts about a digest (in which case
repository/userDid/manifest should be dropped or made a list), or key the rkey on
hash(userDID + digest) and accept duplicate scan work.
F16 — MEDIUM — handleError and handleSkipped leak an inflight mark when their lookup fails
SUSPECTED. scan_broadcaster.go:653-683 and :698-728.
var manifestDigest, repository, userDID string
err := sb.db.QueryRow(...).Scan(&manifestDigest, ...)
if err != nil {
slog.Error("Failed to get job details for failure record", ...)
} else { ...write record... }
...
sb.removeInflight(manifestDigest) // :683 — manifestDigest is "" on the error path
On the error branch manifestDigest is still the zero value, so removeInflight("")
deletes a key that was never inserted and the real digest stays in the map forever —
the F3 exclusion, by a different door. handleResult avoids it only because it
returns early at :569 — which leaves the row in processing instead, to be failed
by the 10-minute sweep, which is F3 proper.
The lookup fails if the row was deleted (nothing deletes rows today) or on any DB
error, e.g. a SQLITE_BUSY past the 5-second timeout during a heavy CAR commit.
Fix: carry the digest on the job in memory (it is already in ScanJobEvent) rather
than re-reading it, or track inflight by seq and translate once.
F17 — MEDIUM — hasActiveJobs fails closed forever on a DB error
SUSPECTED. scan_broadcaster.go:1571-1574
if err != nil {
slog.Error("Failed to check active scan jobs", "error", err)
return true // Assume busy on error
}
waitForCapacity spins on this. A persistently failing query — a corrupted index, a
locked DB, a closed connection after Close() on a shared handle — stops proactive
scanning for the life of the process, with one error line per five seconds and no other
signal. logStalledCapacity (:1580) will not fire either, because it runs the same
DB and takes its own error branch at :1592.
This is the same silent-indefinite-halt shape as the nine-day outage dfd604b fixed.
Fix: fail open after N consecutive errors, and surface the condition on the health endpoint rather than only in logs.
F18 — MEDIUM — Reader, writer and drain goroutines are not tracked, so Close() can pull the DB out from under them
SUSPECTED. scan_broadcaster.go:913-922 (Close), :345-351 (untracked goroutines).
sb.wg covers only the four background loops (:177, :182). handleWriter,
handleReader and drainPendingJobs are bare go statements. Close() closes
stopCh, waits for the loops, and then closes the DB when ownsDB. Any in-flight
handleResult is then doing S3 uploads and db.Exec against a closed handle — errors,
a half-written result, and in the NewScanBroadcasterWithDB case (which is the
production path, pkg/hold/server.go:233) a shared handle whose lifecycle is owned
elsewhere.
Nothing closes the subscriber connections on shutdown either, so the goroutines are only released when the peer notices — which, per F7, may be a long time.
Related, minor: Close() closes stopCh unconditionally, so a second call panics
(:915).
Fix: add the three goroutines to the WaitGroup, close subscriber conns in Close,
and guard stopCh with sync.Once.
F19 — MEDIUM — Relay failover is poisoned by the shared context
SUSPECTED. scan_broadcaster.go:1010-1037, ctx from :978.
fetchManifestDIDs loops over relays passing the same ctx to each. That ctx is the
30-minute discovery budget. If the first relay is not down but slow — the more common
failure — it can consume the entire budget, at which point every subsequent relay
attempt fails instantly with the already-expired context, :1035 logs "all relays
failed", and the pass returns nothing. Failover buys nothing in the case it matters
most.
fetchManifestDIDsFrom also accumulates every DID in memory with no bound (:1044,
:1053) and no cursor-progress check, so a relay that returns the same cursor
repeatedly spins until the context expires.
Fix: a per-relay sub-context (say 2 minutes), and a guard that the cursor advanced.
F20 — MEDIUM — Enqueue ignores addInflight, creating duplicate rows for one digest
SUSPECTED. scan_broadcaster.go:293
sb.addInflight(job.ManifestDigest) // bool return discarded
addInflight exists precisely to answer "is this digest already queued or being
scanned" (:1610), and both proactive producers honour it (:1107, :1204).
Enqueue does not. A push arriving for a digest the proactive loop is already scanning
inserts a second row and dispatches a second job. Both scan; both call
CreateScanRecord (upsert, so one record); the first to complete calls
removeInflight, after which discovery is free to queue a third copy while the second
is still running.
Whether Enqueue should refuse or proceed is a judgement call — a push-triggered scan is arguably more urgent than the proactive one already running. But silently discarding the signal is not a judgement, and the round-trip is expensive.
Fix: if already inflight, either skip the enqueue (log it) or cancel/supersede the proactive job. At minimum, record the collision.
F21 — MEDIUM — No back-channel for cancellation; the protocol has no way to say "stop"
SUSPECTED. scan_broadcaster.go:86-110 vs scanner/types.go:23-95.
Message inventory. Hold → scanner: job only. Scanner → hold: ack, result,
error, skipped — all four are handled at :507-514, and the scanner sends nothing
else. The wire shapes match field-for-field (ScanJobEvent vs ScanJobRaw;
BlobReference's JSON tags at lexicon.go:132-141 match BlobDescriptor at
scanner/types.go:39-43). So no message is unhandled in either direction.
The gap is the missing message. Every point at which the hold gives up on a job —
Unsubscribe's requeue (:383), the ack timeout (:888), the processing timeout
(:834) — is invisible to the scanner, which keeps burning CPU and disk on work whose
result will be redundant (F10) or arrive after the row was already failed (F3/F4).
Fix: a cancel {seq} message from hold to scanner, and a lease/epoch on the job so
the hold can reject results it no longer wants.
F22 — LOW — Subscribe reads len(sb.subscribers) outside the lock
SUSPECTED. scan_broadcaster.go:342
The mutation at :335 is inside sb.mu; the log statement at :338-342 is after the
Unlock and reads the slice header again. Concurrent Subscribe/Unsubscribe makes it
a genuine data race, reportable by -race. Today the count is only wrong in a log line,
but a racing slice header read is undefined behaviour, and Unsubscribe reslices the
same backing array at :367.
The existing tests do not catch it because they never call Subscribe concurrently.
Fix: capture the count inside the critical section.
F23 — LOW — assigned_at is stored as a local-offset RFC3339 string and compared lexicographically
CONFIRMED by execution. A scratch program using the project's pinned
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 shows:
row 1 type=text assigned_at="2026-09-05T11:27:03.069534699-05:00" created_at="2026-09-05T16:27:03Z"
rows older than cutoff (want '2'): 2 # comparison works in a stable offset
created_at within 15m (want 2): 2 # datetime() parses both formats
So assigned_at (bound as a Go time.Time at :421, :778) is stored as TEXT with
the host's local UTC offset, while created_at's CURRENT_TIMESTAMP default is
normalised to ...Z. The comparisons at :836 and :847 are string comparisons
between two values formatted the same way, so they are correct as long as the offset
never changes.
They stop being correct across a DST transition on a non-UTC host: after a fall-back,
rows stamped 01:59:00-05:00 compare greater than a cutoff computed as
01:10:00-06:00, so timed-out jobs are not reclaimed for an hour; at spring-forward the
error runs the other way and healthy jobs are reclaimed an hour early. Production
containers are typically UTC, which is why this has not bitten.
Fix: store assigned_at as time.Now().UTC() (or as a Unix integer) and compare
the same way, matching created_at.
F24 — LOW — Rows with a NULL user_handle are permanently undispatchable and silently skipped
SUSPECTED. :757-765 (drain) and :866-873 (reclaim) scan user_handle into a
plain string; handleResult at :562 uses COALESCE(user_handle,'') and the column
is nullable (:263).
Enqueue always binds a value, so today's rows hold '' rather than NULL and the
question is only about rows from an older schema or an out-of-band insert. But if such
a row exists, both dispatchers fail rows.Scan and continue — the drain logs
Failed to scan pending job row at :763, the reclaim logs nothing at all (:872).
The row is pending forever, never dispatched, never reported. The asymmetric COALESCE
in handleResult suggests someone has seen a NULL here.
Fix: COALESCE(user_handle,'') in all three queries, or NOT NULL DEFAULT '' on
the column.
F25 — LOW — The cursor parameter is dead, and harmful if ever used
SUSPECTED. pkg/hold/pds/xrpc.go:1093-1102, scan_broadcaster.go:744,
scanner/internal/client/hold.go:47.
The scanner initialises cursor := int64(-1) and never assigns it, so the query
parameter is never sent and the hold's default of -1 makes the drain's
WHERE ... AND seq > ? a no-op. Fine today.
But the semantics are wrong for what it looks like. scan_jobs.seq is not an event
sequence; it is a job identity, and pending jobs are not monotonic in it (a row
reclaimed by Unsubscribe keeps its old low seq). If the scanner ever started sending
its last-seen seq — the obvious reading of the name, and what the sibling firehose
Subscribe(conn, cursor, userAgent) at xrpc.go:1072 actually means — every reclaimed
older job would become permanently undrainable.
Fix: remove the parameter from this endpoint, or rename it and document it as a lower bound on job identity, not a replay cursor.
F26 — LOW — handleAck neither verifies its effect nor refreshes the lease
SUSPECTED. scan_broadcaster.go:525-539
The UPDATE is correctly guarded on assigned_to = ? AND status = 'assigned', but
RowsAffected is discarded and the code logs "Scan job acknowledged" unconditionally.
An ack for a job that was reclaimed, reassigned, or belongs to another subscriber (F2's
scenario) is indistinguishable in the logs from a successful one. Given that the ack is
the only positive confirmation the hold ever gets that a scanner has the work, that is
the single most useful thing to count.
See also F4 for the lease-refresh half of this.
F27 — LOW — dispatchJob's buffer-full reset is unguarded
SUSPECTED. scan_broadcaster.go:445
UPDATE scan_jobs SET status='pending', assigned_to=NULL, assigned_at=NULL WHERE seq = ?
No AND assigned_to = ?. Under F2's race, where another dispatcher already handed the
row out, this resets a row that is legitimately assigned elsewhere. Also, the send
attempt uses default: (:441) — a strictly non-blocking try — so a scanner whose
20-deep buffer is momentarily full loses the job to a full requeue cycle rather than
waiting a few milliseconds.
F28 — LOW — Orphaned SBOM and vuln blobs are created and never referenced
SUSPECTED. :575, :588, versus the record write at :607.
Both blobs are uploaded before the record is written, and the record write can fail
(:608) or be skipped entirely (F9) while the job is still marked completed. The
resulting S3 objects are unreferenced by any record. pkg/hold/gc sweeps orphaned
records but I did not find a sweep for ATProto blobs whose referencing record was never
written; worth confirming before sizing this.
Fix: write the record first with the blob refs computed locally (the CID is a pure
function of the bytes, profile.go:80-90), or upload after a successful record write.
F29 — LOW — Proactive jobs lose tier and tag
SUSPECTED. scan_broadcaster.go:1396-1402
Every proactive job is hard-coded Tier: "deckhand" and carries no Tag. The scanner's
priority queue maps that to the lowest priority (scanner/internal/queue/priority_queue.go:29),
so a paying customer's weekly rescan queues behind every free-tier discovery job. The
missing tag degrades the scanner's logs, which are the only place a job is
human-identifiable.
The owner DID and crew tier are both knowable at dispatch time (the hold has the crew records locally).
F30 — LOW — Discovery's scannability filter disagrees with the push path's
SUSPECTED. :1102 versus pkg/hold/oci/xrpc.go:238.
Discovery skips a manifest when len(Layers)==0 || Subject != nil || Config == nil.
HasScannableContent() skips when IsMultiArch() || IsReferrer() — it does not require
a config. So a manifest with layers but no config is enqueued on push and never
proactively; and conversely, HasScannableContent has no layer-count check, so a
single-arch manifest with zero layers is enqueued on push and will fail or skip at the
scanner.
One predicate, used by both, would remove a class of "why did this scan / why didn't it" questions.
Section 3: improvements that are not bugs
Observability. The pipeline has no counters at all — everything is inferred from
slog lines. The cheapest high-value additions, all derivable from data already in
hand:
- Ack→result latency distribution. This is the single number that would settle F4's sizing, and nothing records it today.
- Counts by terminal transition, split by producer:
completedvia result, via skip;failedvia scanner error, via the 10-minute sweep. The last of those is currently invisible except as oneWarnwith a count (:841). - Size of
sb.inflightand depth of both queues, exported on the health endpoint. An inflight set that only grows is the signature of F3/F16 and is currently unobservable. - The scan-jobs table by status, on the admin UI. The hold's admin panel has a scan
backfill page (
pkg/hold/admin/handlers_scan.go) but nothing that shows the live queue.
Structure.
scan_broadcaster.gois 1,642 lines carrying five separable concerns: the WebSocket subscriber lifecycle, the SQLite job store, the result-handling side effects, the three discovery loops, and the predecessor probe. The job store in particular deserves its own type with the status transitions as methods — most of Section 1's asymmetries (guarded UPDATE here, unguarded there; record written here, not there) are the kind that disappear when there is exactly one function per transition.- The predecessor probe is now duplicated between
scan_broadcaster.go:1480andpkg/hold/gc/gc.go:2037, and they have diverged (F5). One implementation, in a shared package, with GC's definitive/unresolved contract. handleResult/handleError/handleSkippedshare the same three-step shape (look up the job, write a record, close the row) with three different treatments of the lookup failure. Unifying them would fix F16 and F9 as a side effect.
Tests worth adding later (none written here):
handleResultwithmsg.Summary == nil— currently panics (F1). This is the one to write first; it is three lines.drainPendingJobsagainst a row claimed between the SELECT and the UPDATE — the mirror of the existingTestScanDispatchJob_SkipsClaimedJob(F2).- The 10-minute processing sweep: assert a scan record exists afterwards and that the
digest left
sb.inflight(F3). Both assertions fail today. Unsubscriberacing an in-flightdrainPendingJobsassign (not just the send the existing test covers) — assert no row ends up assigned to a departed subscriber (F10).checkPredecessoragainst a hold that times out, asserting the answer is not cached (F5).pkg/hold/gc/predecessor_test.gois a ready-made template.- A
-racetest that callsSubscribeandUnsubscribeconcurrently (F22). - Round-trip of the
assigned_atcomparison across a simulated offset change (F23).
Documentation. docs/SBOM_SCANNING.md:148-150 describes the timeouts accurately but
omits that the 10-minute sweep writes no scan record, so a reader would reasonably
believe a failed job shows as "Scan failed" in the UI. It also does not mention that a
disconnect re-dispatches work the scanner may still be running. Both are worth a
sentence once F3 and F10 are decided.