mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 11:14:14 +00:00
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
290 lines
9.8 KiB
Go
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
|
|
}
|