Files
at-container-registry/scanner/internal/scan/extractor.go
T
Evan JarrettandClaude Opus 5 265e533ad3 scanner: reclaim scan directories a killed process left behind
buildOCILayout already removes its scan dir on every error path, and syft.go
defers the stereoscope generator's Cleanup. What neither can do is clean up
after a process that dies mid-scan: the deferred call never runs, and nothing
afterwards ever looks at what was left. Every restart therefore leaks the
in-flight layout and extraction permanently, and a restart is routine — a
deploy is one.

On seamark-hold that reached 8.8 GB of orphaned scan-*, syft-scan-* and
syft-cataloger-* directories under a 20 GB disk, at which point the disk was
97% full and scans began failing on it:

  failed to load OCI image: unable to populate layer cache
    dir="/var/lib/seamark/scanner/tmp/syft-scan-1546187834/..."
    : no space left on device

  failed to download layer 5: failed to write blob:
    write /var/lib/seamark/scanner/tmp/scan-4160414849/blobs/sha256/...
    : no space left on device

The leaked directories cluster at the scanner's restart timestamps, which is
what identifies the killed process rather than the error paths as the source.
Manual removal reclaimed 8.8 GB and took the disk from 97% to 50%.

Startup is where this belongs: it is the one moment the previous process is
known to be gone, and it is immediately after the event that caused the leak.
The sweep runs in WorkerPool.Start after TMPDIR is set and before any worker
can dequeue, so nothing it removes can be work in progress here.

Three constraints shape what it will touch:

  - Only the three per-job prefixes, only as direct children, only
    directories. The Grype database lives beside the tmp dir at
    <parent>/vulndb and go-getter unpacks into grype-dl underneath it; both
    are state the scanner needs and neither matches a prefix. The prefixes now
    have one definition each, used by both the creator and the sweeper, so
    renaming a directory cannot silently take it out of the sweep's scope.

  - An age threshold, vuln.sweep_max_age, default 1h. A second scanner sharing
    the directory has an in-flight scan-* dir that is minutes old, and
    scanner.job_timeout is 8m, so an hour clears both with room to spare. 0
    disables the sweep rather than removing a peer's live work.

  - Nothing is fatal. A stat or removal failure is a WARN and the sweep moves
    on, so a permission problem in the tmp dir cannot keep the scanner from
    starting.

The sweep only runs at startup, so a scanner that is killed twice between
deploys carries the first leak until its next restart. That is the tradeoff
for never racing a live peer; a periodic sweep would be the follow-up if
processes ever live long enough for it to matter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
2026-09-08 21:59:58 -05:00

290 lines
9.8 KiB
Go

package scan
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
scanner "atcr.io/scanner"
"atcr.io/scanner/internal/client"
)
// OCI image layout types for constructing the layout on disk.
type ociDescriptor struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
type ociManifest struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType,omitempty"`
Config ociDescriptor `json:"config"`
Layers []ociDescriptor `json:"layers"`
}
type ociIndex struct {
SchemaVersion int `json:"schemaVersion"`
Manifests []ociDescriptor `json:"manifests"`
}
// buildOCILayout downloads image blobs and constructs an OCI image layout directory.
// Instead of extracting layers to a rootfs (which requires decompression and causes
// permission/security issues), this writes compressed blobs directly and lets Syft's
// stereoscope handle layer processing internally.
//
// Layout structure:
//
// scan-*/
// ├── oci-layout
// ├── index.json
// └── blobs/sha256/
// ├── <manifest-hex>
// ├── <config-hex>
// └── <layer-hex>...
//
// Every blob is verified against its descriptor as it streams, and every
// digest is validated before it becomes a path, so the layout describes bytes
// that are on disk and hash to the names they are filed under.
//
// maxBytes is the ceiling on the total transferred for this job; zero or less
// means unlimited. It is spent down blob by blob, so it bounds the real bytes
// on disk rather than the numbers the manifest record claims.
//
// ctx bounds the whole download stage. Every request below carries it, so a
// cancelled or expired context aborts the transfer in flight rather than
// waiting out the HTTP client's own per-request timeout, and every exit from
// here removes the scan directory — the deadline path included.
func buildOCILayout(ctx context.Context, job *scanner.ScanJob, tmpDir, secret string, maxBytes int64) (string, func(), error) {
scanDir, err := os.MkdirTemp(tmpDir, scanDirPrefix+"*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
}
cleanup := func() {
if err := os.RemoveAll(scanDir); err != nil {
slog.Warn("Failed to clean up temp directory", "dir", scanDir, "error", err)
}
}
blobsDir := filepath.Join(scanDir, "blobs", "sha256")
if err := os.MkdirAll(blobsDir, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create blobs directory: %w", err)
}
if job.Config.Digest == "" {
cleanup()
return "", nil, fmt.Errorf("config blob has empty digest, cannot download")
}
// Download every referenced blob and describe it in the same pass, so the
// layout can only ever list a blob that arrived and was verified, at the
// size it actually arrived in. Verification has already established that a
// declared size, where the record gave one, matches; recording the
// measured figure keeps the layout honest for the descriptors that
// declared none.
remaining := int64(-1) // -1 is unbounded
if maxBytes > 0 {
remaining = maxBytes
}
manifest := ociManifest{
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.manifest.v1+json",
Layers: make([]ociDescriptor, 0, len(job.Layers)),
}
for _, ref := range referencedBlobs(job) {
// Checked per blob as well as inside each request: a job that has
// already spent its budget must not open the next connection, and the
// error here names the deadline rather than whatever the transport
// happens to report when it is torn down mid-handshake.
if err := ctx.Err(); err != nil {
cleanup()
return "", nil, fmt.Errorf("downloading %s: %w", ref.what(), err)
}
digest, err := scanner.ParseDigest(ref.Descriptor.Digest)
if err != nil {
cleanup()
return "", nil, &SkipError{Reason: fmt.Sprintf("%s: %v", ref.what(), err)}
}
slog.Info("Downloading blob", "blob", ref.what(), "digest", digest,
"declaredSize", ref.Descriptor.Size, "mediaType", ref.Descriptor.MediaType)
n, err := downloadBlob(ctx, job, digest, ref.Descriptor.Size, remaining, blobsDir, secret)
if err != nil {
cleanup()
return "", nil, blobFailure(ref.what(), err)
}
if remaining >= 0 {
remaining -= n
}
d := ociDescriptor{
MediaType: defaultMediaType(ref.Descriptor.MediaType, ref.defaultMediaType()),
Digest: digest.String(),
Size: n,
}
if ref.isConfig() {
manifest.Config = d
continue
}
manifest.Layers = append(manifest.Layers, d)
}
// Write manifest blob
manifestJSON, err := json.Marshal(manifest)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to marshal manifest: %w", err)
}
manifestHash := sha256.Sum256(manifestJSON)
manifestDigest := fmt.Sprintf("sha256:%x", manifestHash)
manifestPath := filepath.Join(blobsDir, fmt.Sprintf("%x", manifestHash))
if err := os.WriteFile(manifestPath, manifestJSON, 0644); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to write manifest blob: %w", err)
}
// Write index.json
index := ociIndex{
SchemaVersion: 2,
Manifests: []ociDescriptor{
{
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: manifestDigest,
Size: int64(len(manifestJSON)),
},
},
}
indexJSON, err := json.Marshal(index)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to marshal index: %w", err)
}
if err := os.WriteFile(filepath.Join(scanDir, "index.json"), indexJSON, 0644); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to write index.json: %w", err)
}
// Write oci-layout file
ociLayout := []byte(`{"imageLayoutVersion":"1.0.0"}`)
if err := os.WriteFile(filepath.Join(scanDir, "oci-layout"), ociLayout, 0644); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to write oci-layout: %w", err)
}
slog.Info("OCI layout built",
"dir", scanDir,
"layers", len(manifest.Layers),
"manifestDigest", manifestDigest)
return scanDir, cleanup, nil
}
// blobRef is one blob the layout will contain: the descriptor that named it,
// plus where it sits in the record so an error can say which blob it means.
type blobRef struct {
Index int // position in job.Layers; configIndex for the config blob
Descriptor scanner.BlobDescriptor
}
const configIndex = -1
func (r blobRef) isConfig() bool { return r.Index == configIndex }
func (r blobRef) what() string {
if r.isConfig() {
return "config blob"
}
return fmt.Sprintf("layer %d", r.Index)
}
func (r blobRef) defaultMediaType() string {
if r.isConfig() {
return "application/vnd.oci.image.config.v1+json"
}
return "application/vnd.oci.image.layer.v1.tar+gzip"
}
// referencedBlobs returns every blob a scan of this job touches, in the order
// it touches them: the config first, then the layers that are not dropped.
//
// This is the single definition of "which blobs does this job reference".
// Digest validation, the download loop, the byte budget and the layout
// manifest all walk this list, so a layer dropped here is dropped everywhere
// and a layer kept here is one that has been checked.
func referencedBlobs(job *scanner.ScanJob) []blobRef {
refs := make([]blobRef, 0, len(job.Layers)+1)
if job.Config.Digest != "" {
refs = append(refs, blobRef{Index: configIndex, Descriptor: job.Config})
}
for i, layer := range job.Layers {
if layer.Digest == "" {
continue
}
// Non-tar layers (cosign signatures, in-toto attestations) are not
// something Syft can read, so they are never fetched or listed.
if layer.MediaType != "" && !strings.Contains(layer.MediaType, "tar") {
continue
}
refs = append(refs, blobRef{Index: i, Descriptor: layer})
}
return refs
}
// blobFailure labels a download error, converting the ones that can never
// succeed on a retry into a SkipError.
//
// The hold's stale-scan loop re-offers "error" records on every pass and never
// re-offers "skipped" ones, so a failure decided by the record itself or by
// bytes already stored belongs on the skip side. A transport fault (a 5xx, a
// dropped connection, an expired presigned URL) stays an error, because those
// really can succeed next time.
// A cancelled or expired context is emphatically on the error side: the
// deadline is a property of this host on this afternoon, not of the image.
func blobFailure(what string, err error) error {
if errors.Is(err, client.ErrBlobCorrupt) || errors.Is(err, client.ErrBlobTooLarge) {
return &SkipError{Reason: fmt.Sprintf("%s: %v", what, err)}
}
return fmt.Errorf("failed to download %s: %w", what, err)
}
// downloadBlob fetches one validated blob into the blobs directory and returns
// how many bytes arrived. maxBytes is the remaining job budget, negative for
// unbounded.
func downloadBlob(ctx context.Context, job *scanner.ScanJob, digest scanner.Digest, declaredSize, maxBytes int64, blobsDir, secret string) (int64, error) {
destPath := filepath.Join(blobsDir, digest.Hex)
// The invariant, asserted rather than assumed. ParseDigest has already
// constrained Hex to [0-9a-f], so this cannot fire; it is here so that any
// future path that reaches os.Create with something less constrained fails
// loudly instead of writing outside the scan directory.
if filepath.Dir(filepath.Clean(destPath)) != filepath.Clean(blobsDir) {
return 0, fmt.Errorf("refusing to write blob %s outside %s", digest, blobsDir)
}
presignedURL, err := client.GetBlobPresignedURL(ctx, job.HoldEndpoint, job.HoldDID, digest, secret)
if err != nil {
return 0, fmt.Errorf("failed to get presigned URL for %s: %w", digest, err)
}
return client.DownloadBlob(ctx, presignedURL, destPath, client.BlobExpectation{
Digest: digest,
DeclaredSize: declaredSize,
MaxBytes: maxBytes,
})
}
func defaultMediaType(mediaType, fallback string) string {
if mediaType == "" {
return fallback
}
return mediaType
}