From c29b93ce8d63ccb75c9abacd1ff790df42fa3d85 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Wed, 3 Jun 2026 08:01:49 +0900 Subject: [PATCH] worker: preflight check that sandbox images are accessible The worker only found out that a sandbox container image was missing or misconfigured when it tried to run the sandbox while handling a message. By that point a message had already been received, and a failure there could lead to messages being acked errantly. Add a startup preflight that resolves each configured sandbox image (static and dynamic, with the configured tag) using go-containerregistry's remote.Get before the worker starts consuming messages. If any image is not accessible the worker logs an error naming it and exits. The check is skipped when noPull is set, since that mode relies on images already present locally. The registry lookup is injected behind a checker func so the aggregation logic is unit tested without network access. Fixes #346 Signed-off-by: Arpit Jain --- cmd/worker/main.go | 12 ++++ go.mod | 10 +++- go.sum | 22 +++++-- internal/worker/preflight.go | 65 +++++++++++++++++++++ internal/worker/preflight_test.go | 96 +++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 internal/worker/preflight.go create mode 100644 internal/worker/preflight_test.go diff --git a/cmd/worker/main.go b/cmd/worker/main.go index bb3edca0..f160371a 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -248,6 +248,18 @@ func main() { sandbox.InitNetwork(ctx) + // Confirm the configured sandbox images are accessible before we start + // consuming messages. This catches a misconfigured or unavailable image at + // startup instead of letting messages be acked errantly later on. When + // noPull is set the worker uses images that are already present locally, so + // the remote registry check is skipped. + if !cfg.imageSpec.noPull { + if err := worker.CheckSandboxImagesAccessible(ctx, cfg.imageSpec.tag); err != nil { + slog.ErrorContext(ctx, "Sandbox image preflight check failed", "error", err) + os.Exit(1) + } + } + // If configured, start a webserver so that Go's pprof can be accessed for // debugging and profiling. if os.Getenv("OSSF_MALWARE_ANALYSIS_ENABLE_PROFILER") != "" { diff --git a/go.mod b/go.mod index 23acc82d..f1f4d6d9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( cloud.google.com/go/pubsub v1.50.2 github.com/blendle/zapdriver v1.3.1 + github.com/google/go-containerregistry v0.21.6 github.com/gopacket/gopacket v1.6.0 github.com/ossf/package-feeds v0.0.0-20240903033607-939890176fa6 github.com/package-url/packageurl-go v0.1.6 @@ -57,6 +58,8 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v29.4.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect @@ -78,11 +81,13 @@ require ( github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect @@ -104,4 +109,5 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 052e4fad..bd6de9be 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= +github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= @@ -115,6 +119,8 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= @@ -150,8 +156,12 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ossf/package-feeds v0.0.0-20240903033607-939890176fa6 h1:Yb9zEPd7FX8INY7PGCb7VYS6bpawSLYDgmfadScUi68= github.com/ossf/package-feeds v0.0.0-20240903033607-939890176fa6/go.mod h1:IufKOa9FZbng8zOYAmzzosmV1mxp+Wzth7wGQugSvt8= github.com/package-url/packageurl-go v0.1.6 h1:YO3p6u1XmCUliivUg/qWphaY8vI6hxSnnPv7Bfg3m5M= @@ -166,8 +176,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -175,7 +185,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -257,7 +266,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= @@ -300,3 +308,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/worker/preflight.go b/internal/worker/preflight.go new file mode 100644 index 00000000..ce47abfa --- /dev/null +++ b/internal/worker/preflight.go @@ -0,0 +1,65 @@ +package worker + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// SandboxImageURLs returns the fully qualified references (image:tag) of every +// sandbox container image the worker needs in order to run analysis. If tag is +// empty, "latest" is used to match the default applied by the sandbox package. +func SandboxImageURLs(tag string) []string { + if tag == "" { + tag = "latest" + } + return []string{ + fmt.Sprintf("%s:%s", defaultStaticAnalysisImage, tag), + fmt.Sprintf("%s:%s", defaultDynamicAnalysisImage, tag), + } +} + +// ImageChecker reports whether a single image reference is accessible. It +// returns a non-nil error if the image cannot be resolved. It exists so that +// CheckSandboxImagesAvailable can be unit tested without making real network +// calls. +type ImageChecker func(ctx context.Context, image string) error + +// remoteImageChecker confirms that image is accessible in its registry by +// performing a HEAD/GET against the manifest using go-containerregistry's +// remote.Get. It does not pull the image. +func remoteImageChecker(ctx context.Context, image string) error { + ref, err := name.ParseReference(image) + if err != nil { + return fmt.Errorf("parsing image reference: %w", err) + } + if _, err := remote.Get(ref, remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain)); err != nil { + return err + } + return nil +} + +// CheckSandboxImagesAvailable verifies that every image in images is accessible +// using check. It returns an error naming each image that could not be +// accessed, or nil if they are all available. +func CheckSandboxImagesAvailable(ctx context.Context, images []string, check ImageChecker) error { + var errs []error + for _, image := range images { + if err := check(ctx, image); err != nil { + errs = append(errs, fmt.Errorf("sandbox image %q is not accessible: %w", image, err)) + } + } + return errors.Join(errs...) +} + +// CheckSandboxImagesAccessible confirms that all sandbox images configured for +// the given tag can be reached in their registry before the worker starts +// consuming messages. It fails loudly so a misconfigured or unavailable image +// is caught at startup rather than silently acking messages later on. +func CheckSandboxImagesAccessible(ctx context.Context, tag string) error { + return CheckSandboxImagesAvailable(ctx, SandboxImageURLs(tag), remoteImageChecker) +} diff --git a/internal/worker/preflight_test.go b/internal/worker/preflight_test.go new file mode 100644 index 00000000..113ec878 --- /dev/null +++ b/internal/worker/preflight_test.go @@ -0,0 +1,96 @@ +package worker + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSandboxImageURLs(t *testing.T) { + tests := []struct { + name string + tag string + want []string + }{ + { + name: "explicit tag", + tag: "v1.2.3", + want: []string{ + "gcr.io/ossf-malware-analysis/static-analysis:v1.2.3", + "gcr.io/ossf-malware-analysis/dynamic-analysis:v1.2.3", + }, + }, + { + name: "empty tag defaults to latest", + tag: "", + want: []string{ + "gcr.io/ossf-malware-analysis/static-analysis:latest", + "gcr.io/ossf-malware-analysis/dynamic-analysis:latest", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SandboxImageURLs(tt.tag) + if len(got) != len(tt.want) { + t.Fatalf("SandboxImageURLs(%q) = %v; want %v", tt.tag, got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Errorf("SandboxImageURLs(%q)[%d] = %q; want %q", tt.tag, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCheckSandboxImagesAvailable_AllPresent(t *testing.T) { + images := []string{"registry/a:latest", "registry/b:latest"} + check := func(ctx context.Context, image string) error { return nil } + + if err := CheckSandboxImagesAvailable(context.Background(), images, check); err != nil { + t.Errorf("CheckSandboxImagesAvailable() = %v; want nil", err) + } +} + +func TestCheckSandboxImagesAvailable_OneMissing(t *testing.T) { + const missing = "registry/missing:latest" + images := []string{"registry/present:latest", missing} + + check := func(ctx context.Context, image string) error { + if image == missing { + return errors.New("manifest unknown") + } + return nil + } + + err := CheckSandboxImagesAvailable(context.Background(), images, check) + if err == nil { + t.Fatal("CheckSandboxImagesAvailable() = nil; want error naming the missing image") + } + if !strings.Contains(err.Error(), missing) { + t.Errorf("error %q does not name the missing image %q", err.Error(), missing) + } + if strings.Contains(err.Error(), "registry/present:latest") { + t.Errorf("error %q should not name the available image", err.Error()) + } +} + +func TestCheckSandboxImagesAvailable_MultipleMissing(t *testing.T) { + images := []string{"registry/a:latest", "registry/b:latest"} + check := func(ctx context.Context, image string) error { + return errors.New("manifest unknown") + } + + err := CheckSandboxImagesAvailable(context.Background(), images, check) + if err == nil { + t.Fatal("CheckSandboxImagesAvailable() = nil; want error") + } + for _, image := range images { + if !strings.Contains(err.Error(), image) { + t.Errorf("error %q does not name image %q", err.Error(), image) + } + } +}