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) + } + } +}