Files
at-container-registry/SCANNER_BUGS.md
T
Evan JarrettandClaude Opus 5 22058cc5f4 scanner: bound a scan job, and lose the race to the hold on purpose
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
2026-09-05 16:16:16 -05:00

237 KiB
Raw Blame History

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:22 scan, 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 GOMEMLIMIT is 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 with scanner.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/e2e is not run by CI. make test is go test ./... from the root module, which excludes the atcr.io/scanner module 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:

  1. curator.Update() reads the on-disk description, validateAge fails, so it sets current = nil (curator.go:219-224) and always attempts a download — an expired DB is self-healing if the upstream is reachable.
  2. If the upstream check or download fails, Update returns an error. distribution.DefaultConfig() has RequireUpdateCheck:false, so LoadVulnerabilityDB only logs it (load_vulnerability_db.go:24-30) and continues.
  3. c.Status() then runs validateAge again on the unchanged on-disk DB and LoadVulnerabilityDB returns 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-73 logs "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 = nil before 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 an error message (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:

  1. Drop the vulnDB != nil guard 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.
  2. Single-flight the load so N workers share one attempt instead of queueing N.
  3. 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 MaxAllowedBuiltAge is 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 grypeDBConfig already claims to want), returning the error rather than a silent success.
  • Below that ceiling, propagate the DB build time into scanner.ScanResult so the hold and appview can label a scan as run against an N-day-old database. The build time is already in hand as vulnDBBuilt.

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:

  • buildOCILayout takes no context at all. Its only bound is client.httpClient's 5-minute Timeout (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. ctx in scanVulnerabilities reaches only the database load.

Consequences, both demonstrated:

  • Head-of-line blocking. scanner.workers defaults to 1, so a wedged job stops every job behind it. TestStuckJobBlocksEveryJobBehindIt shows a second job acked immediately and then never started, with no terminal message for either.
  • The one timeout that exists multiplies. TestBlobDownloadsAreSerialSo­TheirTimeoutsAdd shows 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 downloadBlobhttp.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 assignedprocessing 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 processing that 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 failedcompleted 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 staleScanLooprunStalePass, 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.Create truncates, 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/x returned failed to create file: open .../no-such-dir/deep/x: no such file or directory. There is no MkdirAll, so the attacker picks among existing directories.
  • The download must return 200 before os.Create is reached (status check at client/hold.go:273 precedes 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 in pkg/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:459 BlobPath interpolates the digest straight into the key).
  • The written file is outside scanDir, so buildOCILayout'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: HIGHCONFIRMED

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:

  1. 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.
  2. Diagnosis. When the bytes are simply corrupt, the failure surfaces from deep inside stereoscope: failed to read layer="sha256:9ae8..." tar : unexpected EOF for wrong content, plain unexpected EOF for 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: MEDIUMCONFIRMED

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: MEDIUMCONFIRMED (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: TestManyLayersAreFetchedSequentially shows 20 ordered fetches for the corpus's widest manifest), so a hold that accepts connections and then stalls costs 5 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 buildOCILayoutdownloadBlobclient.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: LOWCONFIRMED

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: MEDIUMCONFIRMED

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: LOWCONFIRMED

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 to blobs/sha256/efef..., and stereoscope fails with cannot parse hash: "efef...".
  • Non-sha256 algorithm (sha512:cdcd...): the blobs directory is hardcoded to blobs/sha256 (extractor.go:61), so a sha512 blob is written to the sha256 directory while the manifest descriptor says sha512. Stereoscope fails with unsupported 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: LOWCONFIRMED (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: LOWCONFIRMED

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: LOWCONFIRMED

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 Authorization header 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.TmpDir completely empty (assertNoLeakedScanDirs runs in all of them, including the generateSBOM-failure path in TestTmpDirIsCleanAfterAFailureInsideSyft). buildOCILayout calls cleanup() on each of its own error returns, and processJob's defer cleanup() at worker.go:229 covers the Syft and Grype stages. Stereoscope's own scratch space under the same TMPDIR is 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. TestManyLayersAreFetchedSequentially asserts 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: 0 means 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.goSendRaw([]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 []bytestring(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.url that is not a URL (hold.example:8080): url.Parse accepts it, the ws/wss switch does not match, and Connect redials a bad address every 5 seconds forever. (client/hold.go:79-86)
  • vuln.max_image_size: -1: the guard is if 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: "" with vuln.enabled: true: initializeVulnDatabase fails 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 in scanVulnerabilities — 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": ListenAndServe fails 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-226close(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: handleResult returns on sql.ErrNoRows (scan_broadcaster.go:561-571). handleError/handleSkipped run 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 WorkerPool on a successful scan. worker.go:129 dereferences result.Summary.Total, which is nil whenever vuln.enabled is false, so the first successful scan segfaults the process (known BLOCKER, not fixed here). Every throughput, sustained-memory and concurrency number below therefore comes from pipelineOnce in the bench file: a replica that runs the same four stages against the same libraries and the same mock hold, because buildOCILayout and generateSBOM are unexported and this file may not add a seam to them. TestPerfRealWorkerScan runs the real pool once in a child process and prints production's own Scan 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 include FindMatches.
  • 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/e2eloadVulnDB 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 WorkerPool logged Scan pipeline completed duration=15.593324819s for 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:

  1. P-1 (existing BLOCKER) — nothing else about the worker can be measured or operated until it is fixed.
  2. 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.
  3. P-5 — a memory workaround that has never executed is worse than no workaround, because it makes the problem look handled.
  4. 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.
  5. 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. FindMatches cost, 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 in sendJSON) 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. With scanner.workers: 2 (the production value in deploy/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.tmpl sets Restart=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 in processing; reDispatchTimedOut flips it to failed after 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.
  • SendResult is 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-181client.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 assignedprocessing (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 at xrpc.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:

  1. Empty config digest (extractor.go:68-71) — multi-arch index; finding 9.
  2. Image too large (worker.go:218-219) — the claimed size does not change on retry. This is the clearest one: TestOversizeImageIsRejectedBeforeDownload asserts error today.
  3. Malformed digest — a digest whose S3 key does not exist 404s on every attempt (extractor.go:178).
  4. ensureDir failure (worker.go:207-209) — a misconfigured or unwritable tmp_dir fails identically forever; see N5.
  5. Cold-start vulnerability DB failure (grype.go:241, surfaced via worker.go:249) — retries are appropriate here, but at the rescan interval with a permanent record written each time.
  6. sha512/non-sha256 digests — see N7; deterministic.
  7. 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.Config fails to unmarshal;
  • line 141: raw.Layers fails 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 — ResultMessage has 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 at worker.go:239 and never read; the hold correlates by seq alone.
  • result = nil at worker.go:135 and sbomResult = nil at worker.go:255 are dead stores to locals about to leave scope. They cannot help the GC (the compiler already knows), and the comment at worker.go:132-134 claims they do.
  • ScanJob.UserDID and .Tag are carried across the wire and used only in log lines.
N19 — [LOW] [SUSPECTED] Documentation and deployment-template drift
  • CLAUDE.md states twice that the scanner is "env vars only, no YAML" and "Scanner config (env-only, no Viper)". internal/config/config.go is a Viper implementation with a YAML file, a --config flag (main.go:151), and a config init subcommand. Every other component's docs match its code; this one doesn't.
  • There is no config-scanner.example.yaml at the repo root, although config-appview, config-hold and config-labeler all exist and atcr-scanner config init can generate one.
  • deploy/upcloud/configs/scanner.yaml.tmpl omits vuln.max_image_size entirely, 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.scanner declares org.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: 0 makes Enqueue always return false (priority_queue.go:99), so every job is rejected as "queue full" forever.
  • vuln.max_image_size: -1 is treated as "no limit" by the > 0 guard (worker.go:212), which is not what "-1" suggests to anyone.
  • vuln.db_path: ""os.MkdirAll("") fails inside loadVulnDatabase (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:176client/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 — the result = 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-memory map[digest]struct{} that gates proactive discovery. Not persisted; empty on every boot.
  • sub.send (depth 20, :329) and the two queues unscannedQueue (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:

  1. pendingStaleAfter (15m) so hasActiveJobs stops counting an old pending row (:1564). This is the actual unwedge.
  2. A pending reclaim in reDispatchTimedOut (:848) so a stale pending row gets re-offered every 30s instead of waiting for the next scanner connect.
  3. A RowsAffected guard on dispatchJob'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. drainPendingJobs runs the same guarded UPDATE at :775 and then sends regardless of whether it won (Finding 2). The race dfd604b closed 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".
  • hasActiveJobs still returns true on 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_handle makes rows.Scan fail in both the drain and the reclaim), it is skipped silently by continue at :872 and :764 forever. It no longer blocks anything, but it also never runs and nothing reports it.
  • Nothing reconciles at boot. Rows left assigned/processing by a restart hold capacity until their timeouts fire (5m/10m), and the processing ones then go to failed with 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:

  • processingfailed is 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 in sb.inflight, so discovery will refuse to re-queue it for the life of the process.
  • completed/failed are absorbing for the row and the table is never pruned (Finding 11).
  • A row whose rows.Scan fails (NULL user_handle, Finding 27) is skipped by both the drain and the reclaim, silently, forever.
  • The pending row that outlives its subscriber: Unsubscribe (:383) runs its requeue UPDATE, and then the still-running drainPendingJobs goroutine assigns more rows to that same dead subscriber id (:775, no membership check). Those rows sit assigned to 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 a SendError, not a SendSkipped, 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 dfd604b commit 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.connsendJSON (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:

  1. 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.
  2. Stale rescans of a shared digest are dispatched against scanRecord.UserDID (:1238). If that user has since deleted the manifest, resolveManifestForCandidate walks 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.
  3. sb.inflight is 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: completed via result, via skip; failed via scanner error, via the 10-minute sweep. The last of those is currently invisible except as one Warn with a count (:841).
  • Size of sb.inflight and 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.go is 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:1480 and pkg/hold/gc/gc.go:2037, and they have diverged (F5). One implementation, in a shared package, with GC's definitive/unresolved contract.
  • handleResult/handleError/handleSkipped share 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):

  • handleResult with msg.Summary == nil — currently panics (F1). This is the one to write first; it is three lines.
  • drainPendingJobs against a row claimed between the SELECT and the UPDATE — the mirror of the existing TestScanDispatchJob_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.
  • Unsubscribe racing an in-flight drainPendingJobs assign (not just the send the existing test covers) — assert no row ends up assigned to a departed subscriber (F10).
  • checkPredecessor against a hold that times out, asserting the answer is not cached (F5). pkg/hold/gc/predecessor_test.go is a ready-made template.
  • A -race test that calls Subscribe and Unsubscribe concurrently (F22).
  • Round-trip of the assigned_at comparison 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.