From 6b3bf169d4c498f7a24a47fac37f83e378ea7d5a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 Mar 2026 12:25:28 +0200 Subject: [PATCH 1/4] feat: add SBOM scanner sidecar for memory-isolated SBOM generation Move Syft-based SBOM scanning into a separate gRPC sidecar container to prevent OOM kills from crashing the main node-agent process. The sidecar communicates via Unix domain socket and follows the existing ClamAV sidecar pattern. - Define protobuf service (CreateSBOM, Health RPCs) - Extract shared Syft helpers into syftutil subpackage - Implement gRPC server with sequential scan processing - Create sbom-scanner binary entrypoint - Implement gRPC client with crash detection and backoff - Add sidecar/not-ready/in-process fallback branching in SbomManager - Add retry tracking with TooLarge-with-memory-limit annotations - Add Prometheus metrics (scan_total, duration, restarts, ready) - Update Dockerfile to build both node-agent and sbom-scanner binaries - Add unit and integration tests Made-with: Cursor Signed-off-by: Ben --- build/Dockerfile | 6 + cmd/main.go | 12 +- cmd/sbom-scanner/main.go | 47 +++ pkg/sbommanager/v1/metrics.go | 29 ++ pkg/sbommanager/v1/sbom_manager.go | 377 ++++++------------ pkg/sbommanager/v1/syftutil/document.go | 208 ++++++++++ pkg/sbommanager/v1/{ => syftutil}/helpers.go | 2 +- pkg/sbommanager/v1/{ => syftutil}/resolver.go | 2 +- pkg/sbommanager/v1/{ => syftutil}/source.go | 6 +- .../v1/{ => syftutil}/source_test.go | 2 +- pkg/sbomscanner/v1/client.go | 109 +++++ pkg/sbomscanner/v1/integration_test.go | 142 +++++++ pkg/sbomscanner/v1/proto/buf.gen.yaml | 12 + pkg/sbomscanner/v1/proto/scanner.pb.go | 344 ++++++++++++++++ pkg/sbomscanner/v1/proto/scanner.proto | 32 ++ pkg/sbomscanner/v1/proto/scanner_grpc.pb.go | 159 ++++++++ pkg/sbomscanner/v1/server.go | 109 +++++ pkg/sbomscanner/v1/server_test.go | 128 ++++++ pkg/sbomscanner/v1/types.go | 36 ++ 19 files changed, 1509 insertions(+), 253 deletions(-) create mode 100644 cmd/sbom-scanner/main.go create mode 100644 pkg/sbommanager/v1/metrics.go create mode 100644 pkg/sbommanager/v1/syftutil/document.go rename pkg/sbommanager/v1/{ => syftutil}/helpers.go (96%) rename pkg/sbommanager/v1/{ => syftutil}/resolver.go (99%) rename pkg/sbommanager/v1/{ => syftutil}/source.go (97%) rename pkg/sbommanager/v1/{ => syftutil}/source_test.go (99%) create mode 100644 pkg/sbomscanner/v1/client.go create mode 100644 pkg/sbomscanner/v1/integration_test.go create mode 100644 pkg/sbomscanner/v1/proto/buf.gen.yaml create mode 100644 pkg/sbomscanner/v1/proto/scanner.pb.go create mode 100644 pkg/sbomscanner/v1/proto/scanner.proto create mode 100644 pkg/sbomscanner/v1/proto/scanner_grpc.pb.go create mode 100644 pkg/sbomscanner/v1/server.go create mode 100644 pkg/sbomscanner/v1/server_test.go create mode 100644 pkg/sbomscanner/v1/types.go diff --git a/build/Dockerfile b/build/Dockerfile index c043723b4..a99f30291 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -9,9 +9,15 @@ RUN --mount=target=. \ --mount=type=cache,target=/go/pkg \ GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/node-agent -ldflags="-s -w" ./cmd/main.go +RUN --mount=target=. \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + GOOS=$TARGETOS GOARCH=$TARGETARCH GOEXPERIMENT=greenteagc go build -o /out/sbom-scanner -ldflags="-s -w" ./cmd/sbom-scanner/main.go + FROM gcr.io/distroless/static-debian13:latest COPY --from=builder /out/node-agent /usr/bin/node-agent +COPY --from=builder /out/sbom-scanner /usr/bin/sbom-scanner COPY tracers.tar /root/tracers.tar COPY configuration/ig-config.yaml /root/.ig/config.yaml diff --git a/cmd/main.go b/cmd/main.go index 7960ed3dd..f8467a530 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -63,6 +63,7 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/ruleswatcher" "github.com/kubescape/node-agent/pkg/sbommanager" sbommanagerv1 "github.com/kubescape/node-agent/pkg/sbommanager/v1" + sbomscanner "github.com/kubescape/node-agent/pkg/sbomscanner/v1" "github.com/kubescape/node-agent/pkg/seccompmanager" seccompmanagerv1 "github.com/kubescape/node-agent/pkg/seccompmanager/v1" "github.com/kubescape/node-agent/pkg/storage/v1" @@ -381,10 +382,19 @@ func main() { logger.L().Info("IG Kubernetes client created", helpers.Interface("client", igK8sClient)) logger.L().Info("detected container runtime", helpers.String("containerRuntime", igK8sClient.RuntimeConfig.Name.String())) + // Create the SBOM scanner sidecar client (if configured) + var scannerClient sbomscanner.SBOMScannerClient + if socket, ok := os.LookupEnv("SBOM_SCANNER_SOCKET"); ok { + scannerClient, err = sbomscanner.NewSBOMScannerClient(socket) + if err != nil { + logger.L().Ctx(ctx).Warning("SBOM scanner sidecar not available, falling back to in-process scanning", helpers.Error(err)) + } + } + // Create the SBOM manager var sbomManager sbommanager.SbomManagerClient if cfg.EnableSbomGeneration { - sbomManager, err = sbommanagerv1.CreateSbomManager(ctx, cfg, igK8sClient.RuntimeConfig.SocketPath, storageClient, k8sObjectCache) + sbomManager, err = sbommanagerv1.CreateSbomManager(ctx, cfg, igK8sClient.RuntimeConfig.SocketPath, storageClient, k8sObjectCache, scannerClient) if err != nil { logger.L().Ctx(ctx).Fatal("error creating SbomManager", helpers.Error(err)) } diff --git a/cmd/sbom-scanner/main.go b/cmd/sbom-scanner/main.go new file mode 100644 index 000000000..80152a6bc --- /dev/null +++ b/cmd/sbom-scanner/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "net" + "os" + "os/signal" + "syscall" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + sbomscanner "github.com/kubescape/node-agent/pkg/sbomscanner/v1" + pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" + "google.golang.org/grpc" +) + +func main() { + socketPath := os.Getenv("SOCKET_PATH") + if socketPath == "" { + socketPath = "/sbom-comm/scanner.sock" + } + + // Remove stale socket file from a previous run + os.Remove(socketPath) + + lis, err := net.Listen("unix", socketPath) + if err != nil { + logger.L().Fatal("failed to listen on socket", helpers.Error(err), helpers.String("path", socketPath)) + } + + srv := grpc.NewServer() + pb.RegisterSBOMScannerServer(srv, sbomscanner.NewScannerServer()) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + go func() { + sig := <-sigCh + logger.L().Info("received signal, shutting down", helpers.String("signal", sig.String())) + srv.GracefulStop() + os.Remove(socketPath) + }() + + logger.L().Info("SBOM scanner sidecar started", helpers.String("socket", socketPath)) + if err := srv.Serve(lis); err != nil { + logger.L().Fatal("gRPC server failed", helpers.Error(err)) + } +} diff --git a/pkg/sbommanager/v1/metrics.go b/pkg/sbommanager/v1/metrics.go new file mode 100644 index 000000000..6a553b65a --- /dev/null +++ b/pkg/sbommanager/v1/metrics.go @@ -0,0 +1,29 @@ +package v1 + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + sbomScanTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "sbom_scan_total", + Help: "Total SBOM scan attempts", + }, []string{"status"}) + + sbomScanDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "sbom_scan_duration_seconds", + Help: "SBOM scan duration in seconds", + Buckets: prometheus.ExponentialBuckets(1, 2, 12), + }, []string{"status"}) + + sbomScannerRestartsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sbom_scanner_restarts_total", + Help: "Total number of SBOM scanner sidecar restarts detected via connection loss", + }) + + sbomScannerReady = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "sbom_scanner_ready", + Help: "Whether the SBOM scanner sidecar is connected and healthy (1=ready, 0=not ready)", + }) +) diff --git a/pkg/sbommanager/v1/sbom_manager.go b/pkg/sbommanager/v1/sbom_manager.go index 26f266cc5..2ade02c96 100644 --- a/pkg/sbommanager/v1/sbom_manager.go +++ b/pkg/sbommanager/v1/sbom_manager.go @@ -17,12 +17,7 @@ import ( "github.com/DmitriyVTitov/size" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/cataloging/pkgcataloging" - "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/format" - "github.com/anchore/syft/syft/format/syftjson" - "github.com/anchore/syft/syft/format/syftjson/model" sbomcataloger "github.com/anchore/syft/syft/pkg/cataloger/sbom" - "github.com/anchore/syft/syft/sbom" "github.com/aquilax/truncate" "github.com/cenkalti/backoff/v5" securejoin "github.com/cyphar/filepath-securejoin" @@ -37,6 +32,8 @@ import ( "github.com/kubescape/node-agent/pkg/config" "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/sbommanager" + "github.com/kubescape/node-agent/pkg/sbommanager/v1/syftutil" + sbomscanner "github.com/kubescape/node-agent/pkg/sbomscanner/v1" "github.com/kubescape/node-agent/pkg/storage" "github.com/kubescape/node-agent/pkg/utils" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" @@ -54,8 +51,10 @@ import ( ) const ( - digestDelim = "@" - NodeNameMetadataKey = "kubescape.io/node-name" + digestDelim = "@" + NodeNameMetadataKey = "kubescape.io/node-name" + ScannerMemoryLimitAnnotation = "kubescape.io/scanner-memory-limit" + maxScanRetries = 3 ) type SbomManager struct { @@ -70,11 +69,14 @@ type SbomManager struct { processing mapset.Set[string] storageClient storage.SbomClient version string + scannerClient sbomscanner.SBOMScannerClient + scannerMemLimit int64 + scanRetries map[string]int } var _ sbommanager.SbomManagerClient = (*SbomManager)(nil) -func CreateSbomManager(ctx context.Context, cfg config.Config, socketPath string, storageClient storage.SbomClient, k8sObjectCache objectcache.K8sObjectCache) (*SbomManager, error) { +func CreateSbomManager(ctx context.Context, cfg config.Config, socketPath string, storageClient storage.SbomClient, k8sObjectCache objectcache.K8sObjectCache, scannerClient sbomscanner.SBOMScannerClient) (*SbomManager, error) { // read HOST_ROOT from env hostRoot, exists := os.LookupEnv("HOST_ROOT") if !exists { @@ -94,6 +96,11 @@ func CreateSbomManager(ctx context.Context, cfg config.Config, socketPath string return d.DialContext(ctx, "unix", socketPath) }), ) + var scannerMemLimit int64 + if memStr, ok := os.LookupEnv("SCANNER_MEMORY_LIMIT"); ok { + scannerMemLimit, _ = strconv.ParseInt(memStr, 10, 64) + } + return &SbomManager{ appFs: afero.NewOsFs(), cfg: cfg, @@ -106,6 +113,9 @@ func CreateSbomManager(ctx context.Context, cfg config.Config, socketPath string processing: mapset.NewSet[string](), storageClient: storageClient, version: packageVersion("github.com/anchore/syft"), + scannerClient: scannerClient, + scannerMemLimit: scannerMemLimit, + scanRetries: make(map[string]int), }, nil } @@ -247,6 +257,15 @@ func (s *SbomManager) processContainer(notif containercollection.PubSubEvent, mo } switch { case wipSbom.Annotations[helpersv1.StatusMetadataKey] == helpersv1.TooLarge: + if recordedLimit := wipSbom.Annotations[ScannerMemoryLimitAnnotation]; recordedLimit != "" { + if recordedLimit != fmt.Sprintf("%d", s.scannerMemLimit) && s.scannerMemLimit > 0 { + logger.L().Debug("SbomManager - scanner memory limit changed, retrying previously failed SBOM", + helpers.String("sbomName", sbomName), + helpers.String("recordedLimit", recordedLimit), + helpers.Int("currentLimit", int(s.scannerMemLimit))) + break + } + } logger.L().Debug("SbomManager - image is too large for SBOM processing, skipping", helpers.String("namespace", notif.Container.K8s.Namespace), helpers.String("pod", notif.Container.K8s.PodName), @@ -316,49 +335,101 @@ func (s *SbomManager) processContainer(notif containercollection.PubSubEvent, mo // track SBOM as processing in internal state to prevent concurrent processing s.processing.Add(sbomName) defer s.processing.Remove(sbomName) - // prepare image source - src, err := NewSource(sharedData.ImageTag, sharedData.ImageID, imageID, imageStatus, mounts, s.cfg.MaxImageSize) - if err != nil { - logger.L().Ctx(s.ctx).Error("SbomManager - failed to create image source", - helpers.Error(err), - helpers.String("namespace", notif.Container.K8s.Namespace), - helpers.String("pod", notif.Container.K8s.PodName), - helpers.String("container", notif.Container.K8s.ContainerName), - helpers.String("sbomName", sbomName)) - if errors.Is(err, ErrImageTooLarge) { - delete(wipSbom.Annotations, NodeNameMetadataKey) - wipSbom.Annotations[helpersv1.StatusMetadataKey] = helpersv1.TooLarge - _, _ = s.storageClient.ReplaceSBOM(wipSbom) + var syftDoc v1beta1.SyftDocument + scanStart := time.Now() + + if s.scannerClient != nil && s.scannerClient.Ready() { + sbomScannerReady.Set(1) + // sidecar path: delegate SBOM creation to the scanner sidecar + imageStatusBytes, marshalErr := json.Marshal(imageStatus) + if marshalErr != nil { + logger.L().Ctx(s.ctx).Error("SbomManager - failed to marshal image status", + helpers.Error(marshalErr), + helpers.String("sbomName", sbomName)) + return } - return - } - // create the SBOM - cfg := syft.DefaultCreateSBOMConfig() - cfg.ToolName = "syft" - cfg.ToolVersion = s.version - if s.cfg.EnableEmbeddedSboms { - // ask Syft to also scan the image for embedded SBOMs - cfg.WithCatalogers(pkgcataloging.NewCatalogerReference(sbomcataloger.NewCataloger(), []string{pkgcataloging.ImageTag})) - } - syftSBOM, err := syft.CreateSBOM(context.Background(), src, cfg) - if err != nil { - logger.L().Ctx(s.ctx).Error("SbomManager - failed to generate SBOM", - helpers.Error(err), - helpers.String("namespace", notif.Container.K8s.Namespace), - helpers.String("pod", notif.Container.K8s.PodName), - helpers.String("container", notif.Container.K8s.ContainerName), + result, scanErr := s.scannerClient.CreateSBOM(s.ctx, sbomscanner.ScanRequest{ + ImageID: imageID, + ImageTag: sharedData.ImageTag, + LayerPaths: mounts, + ImageStatus: imageStatusBytes, + MaxImageSize: s.cfg.MaxImageSize, + MaxSBOMSize: int32(s.cfg.MaxSBOMSize), + EnableEmbeddedSBOMs: s.cfg.EnableEmbeddedSboms, + Timeout: 15 * time.Minute, + }) + if scanErr != nil { + scanDuration := time.Since(scanStart).Seconds() + if errors.Is(scanErr, sbomscanner.ErrScannerCrashed) { + sbomScanTotal.WithLabelValues("oom_killed").Inc() + sbomScanDuration.WithLabelValues("oom_killed").Observe(scanDuration) + sbomScannerRestartsTotal.Inc() + sbomScannerReady.Set(0) + s.handleScannerCrash(sbomName, wipSbom, notif, scanErr) + return + } + sbomScanTotal.WithLabelValues("error").Inc() + sbomScanDuration.WithLabelValues("error").Observe(scanDuration) + logger.L().Ctx(s.ctx).Error("SbomManager - sidecar scan failed", + helpers.Error(scanErr), + helpers.String("namespace", notif.Container.K8s.Namespace), + helpers.String("pod", notif.Container.K8s.PodName), + helpers.String("container", notif.Container.K8s.ContainerName), + helpers.String("sbomName", sbomName)) + return + } + sbomScanTotal.WithLabelValues("success").Inc() + sbomScanDuration.WithLabelValues("success").Observe(time.Since(scanStart).Seconds()) + syftDoc = result.SyftDocument + } else if s.scannerClient != nil { + sbomScannerReady.Set(0) + // sidecar configured but not ready — skip, will retry on next container event + logger.L().Debug("SbomManager - scanner sidecar not ready, will retry later", helpers.String("sbomName", sbomName)) - // TODO we could save the error in a status field return + } else { + // in-process fallback (current behavior) + src, srcErr := syftutil.NewSource(sharedData.ImageTag, sharedData.ImageID, imageID, imageStatus, mounts, s.cfg.MaxImageSize) + if srcErr != nil { + logger.L().Ctx(s.ctx).Error("SbomManager - failed to create image source", + helpers.Error(srcErr), + helpers.String("namespace", notif.Container.K8s.Namespace), + helpers.String("pod", notif.Container.K8s.PodName), + helpers.String("container", notif.Container.K8s.ContainerName), + helpers.String("sbomName", sbomName)) + if errors.Is(srcErr, syftutil.ErrImageTooLarge) { + delete(wipSbom.Annotations, NodeNameMetadataKey) + wipSbom.Annotations[helpersv1.StatusMetadataKey] = helpersv1.TooLarge + _, _ = s.storageClient.ReplaceSBOM(wipSbom) + } + return + } + sbomCfg := syft.DefaultCreateSBOMConfig() + sbomCfg.ToolName = "syft" + sbomCfg.ToolVersion = s.version + if s.cfg.EnableEmbeddedSboms { + sbomCfg.WithCatalogers(pkgcataloging.NewCatalogerReference(sbomcataloger.NewCataloger(), []string{pkgcataloging.ImageTag})) + } + syftSBOM, syftErr := syft.CreateSBOM(context.Background(), src, sbomCfg) + if syftErr != nil { + logger.L().Ctx(s.ctx).Error("SbomManager - failed to generate SBOM", + helpers.Error(syftErr), + helpers.String("namespace", notif.Container.K8s.Namespace), + helpers.String("pod", notif.Container.K8s.PodName), + helpers.String("container", notif.Container.K8s.ContainerName), + helpers.String("sbomName", sbomName)) + return + } + v1beta1.StripSBOM(syftSBOM) + syftDoc = syftutil.ToSyftDocument(syftSBOM) } - // strip the SBOM to reduce size - v1beta1.StripSBOM(syftSBOM) + // prepare the SBOM delete(wipSbom.Annotations, NodeNameMetadataKey) wipSbom.Spec.Metadata.Report.CreatedAt = wipSbom.CreationTimestamp wipSbom.Spec.Metadata.Tool.Name = "syft" wipSbom.Spec.Metadata.Tool.Version = s.version - wipSbom.Spec.Syft = toSyftDocument(syftSBOM) + wipSbom.Spec.Syft = syftDoc // check the size of the SBOM sz := size.Of(wipSbom) wipSbom.Annotations[helpersv1.ResourceSizeMetadataKey] = fmt.Sprintf("%d", sz) @@ -403,12 +474,27 @@ func (s *SbomManager) waitForSharedContainerData(containerID string) (*objectcac }, backoff.WithBackOff(backoff.NewExponentialBackOff())) } -func formatSBOM(s sbom.SBOM) ([]byte, error) { - bytes, err := format.Encode(s, syftjson.NewFormatEncoder()) - if err != nil { - return nil, fmt.Errorf("failed to encode SBOM: %w", err) +func (s *SbomManager) handleScannerCrash(sbomName string, wipSbom *v1beta1.SBOMSyft, notif containercollection.PubSubEvent, scanErr error) { + s.scanRetries[sbomName]++ + retryCount := s.scanRetries[sbomName] + + logger.L().Error("SbomManager - SBOM scanner sidecar crashed during scan", + helpers.Error(scanErr), + helpers.String("namespace", notif.Container.K8s.Namespace), + helpers.String("pod", notif.Container.K8s.PodName), + helpers.String("container", notif.Container.K8s.ContainerName), + helpers.String("sbomName", sbomName), + helpers.Int("retryCount", retryCount), + helpers.Int("maxRetries", maxScanRetries)) + + if retryCount >= maxScanRetries { + delete(wipSbom.Annotations, NodeNameMetadataKey) + wipSbom.Annotations[helpersv1.StatusMetadataKey] = helpersv1.TooLarge + wipSbom.Annotations[ScannerMemoryLimitAnnotation] = fmt.Sprintf("%d", s.scannerMemLimit) + wipSbom.Spec = v1beta1.SBOMSyftSpec{} + _, _ = s.storageClient.ReplaceSBOM(wipSbom) + delete(s.scanRetries, sbomName) } - return bytes, nil } func labelsFromImageID(imageID string) map[string]string { @@ -488,200 +574,3 @@ func sanitize(s string) string { return s2 } -func toCPEs(c []model.CPE) v1beta1.CPEs { - cpes := make(v1beta1.CPEs, len(c)) - for i := range c { - cpes[i] = v1beta1.CPE(c[i]) - } - return cpes -} - -func toDigests(d []file.Digest) []v1beta1.Digest { - digests := make([]v1beta1.Digest, len(d)) - for i := range d { - digests[i].Algorithm = d[i].Algorithm - digests[i].Value = d[i].Value - } - return digests -} - -func toELFSecurityFeatures(f *file.ELFSecurityFeatures) *v1beta1.ELFSecurityFeatures { - if f == nil { - return nil - } - return &v1beta1.ELFSecurityFeatures{ - SymbolTableStripped: f.SymbolTableStripped, - StackCanary: f.StackCanary, - NoExecutable: f.NoExecutable, - RelocationReadOnly: v1beta1.RelocationReadOnly(f.RelocationReadOnly), - PositionIndependentExecutable: f.PositionIndependentExecutable, - DynamicSharedObject: f.DynamicSharedObject, - LlvmSafeStack: f.LlvmSafeStack, - LlvmControlFlowIntegrity: f.LlvmControlFlowIntegrity, - ClangFortifySource: f.ClangFortifySource, - } -} - -func toExecutable(e *file.Executable) *v1beta1.Executable { - if e == nil { - return nil - } - return &v1beta1.Executable{ - Format: v1beta1.ExecutableFormat(e.Format), - HasExports: e.HasExports, - HasEntrypoint: e.HasEntrypoint, - ImportedLibraries: e.ImportedLibraries, - ELFSecurityFeatures: toELFSecurityFeatures(e.ELFSecurityFeatures), - } -} - -func toFileLicenseEvidence(e *model.FileLicenseEvidence) *v1beta1.FileLicenseEvidence { - if e == nil { - return nil - } - return &v1beta1.FileLicenseEvidence{ - Confidence: int64(e.Confidence), - Offset: int64(e.Offset), - Extent: int64(e.Extent), - } -} - -func toFileLicenses(l []model.FileLicense) []v1beta1.FileLicense { - licenses := make([]v1beta1.FileLicense, len(l)) - for i := range l { - licenses[i].Value = l[i].Value - licenses[i].SPDXExpression = l[i].SPDXExpression - licenses[i].Type = v1beta1.LicenseType(l[i].Type) - licenses[i].Evidence = toFileLicenseEvidence(l[i].Evidence) - } - return licenses -} - -func toFileMetadataEntry(m *model.FileMetadataEntry) *v1beta1.FileMetadataEntry { - if m == nil { - return nil - } - return &v1beta1.FileMetadataEntry{ - Mode: int64(m.Mode), - Type: m.Type, - LinkDestination: m.LinkDestination, - UserID: int64(m.UserID), - GroupID: int64(m.GroupID), - MIMEType: m.MIMEType, - Size_: m.Size, - } -} - -func toLicenses(l []model.License) v1beta1.Licenses { - licenses := make(v1beta1.Licenses, len(l)) - for i := range l { - licenses[i].Value = l[i].Value - licenses[i].SPDXExpression = l[i].SPDXExpression - licenses[i].Type = v1beta1.LicenseType(l[i].Type) - licenses[i].URLs = l[i].URLs - licenses[i].Locations = toLocations(l[i].Locations) - } - return licenses -} - -func toLocations(l []file.Location) []v1beta1.Location { - locations := make([]v1beta1.Location, len(l)) - for i := range l { - locations[i].Coordinates = v1beta1.Coordinates(l[i].Coordinates) - locations[i].VirtualPath = l[i].AccessPath - locations[i].RealPath = l[i].RealPath - locations[i].Annotations = l[i].Annotations - } - return locations -} - -func toSyftDocument(sbomSBOM *sbom.SBOM) v1beta1.SyftDocument { - doc := syftjson.ToFormatModel(*sbomSBOM, syftjson.EncoderConfig{ - Pretty: false, - Legacy: false, - }) - configuration, _ := json.Marshal(doc.Descriptor.Configuration) - metadata, _ := json.Marshal(doc.Source.Metadata) - syftDocument := v1beta1.SyftDocument{ - Artifacts: toSyftPackages(doc.Artifacts), - ArtifactRelationships: toSyftRelationships(doc.ArtifactRelationships), - Files: make([]v1beta1.SyftFile, len(doc.Files)), - SyftSource: v1beta1.SyftSource{ - ID: doc.Source.ID, - Name: doc.Source.Name, - Version: doc.Source.Version, - Type: doc.Source.Type, - Metadata: metadata, - }, - Distro: v1beta1.LinuxRelease{ - PrettyName: doc.Distro.PrettyName, - Name: doc.Distro.Name, - ID: doc.Distro.ID, - IDLike: v1beta1.IDLikes(doc.Distro.IDLike), - Version: doc.Distro.Version, - VersionID: doc.Distro.VersionID, - VersionCodename: doc.Distro.VersionCodename, - BuildID: doc.Distro.BuildID, - ImageID: doc.Distro.ImageID, - ImageVersion: doc.Distro.ImageVersion, - Variant: doc.Distro.Variant, - VariantID: doc.Distro.VariantID, - HomeURL: doc.Distro.HomeURL, - SupportURL: doc.Distro.SupportURL, - BugReportURL: doc.Distro.BugReportURL, - PrivacyPolicyURL: doc.Distro.PrivacyPolicyURL, - CPEName: doc.Distro.CPEName, - SupportEnd: doc.Distro.SupportEnd, - }, - SyftDescriptor: v1beta1.SyftDescriptor{ - Name: doc.Descriptor.Name, - Version: doc.Descriptor.Version, - Configuration: configuration, - }, - Schema: v1beta1.Schema{ - Version: doc.Schema.Version, - URL: doc.Schema.URL, - }, - } - // convert files - for i := range doc.Files { - syftDocument.Files[i].ID = doc.Files[i].ID - syftDocument.Files[i].Location.RealPath = doc.Files[i].Location.RealPath - syftDocument.Files[i].Location.FileSystemID = doc.Files[i].Location.FileSystemID - syftDocument.Files[i].Metadata = toFileMetadataEntry(doc.Files[i].Metadata) - syftDocument.Files[i].Contents = doc.Files[i].Contents - syftDocument.Files[i].Digests = toDigests(doc.Files[i].Digests) - syftDocument.Files[i].Licenses = toFileLicenses(doc.Files[i].Licenses) - syftDocument.Files[i].Executable = toExecutable(doc.Files[i].Executable) - } - return syftDocument -} - -func toSyftPackages(p []model.Package) []v1beta1.SyftPackage { - packages := make([]v1beta1.SyftPackage, len(p)) - for i := range p { - packages[i].ID = p[i].ID - packages[i].Name = p[i].Name - packages[i].Version = p[i].Version - packages[i].Type = string(p[i].Type) - packages[i].FoundBy = p[i].FoundBy - packages[i].Locations = toLocations(p[i].Locations) - packages[i].Licenses = toLicenses(p[i].Licenses) - packages[i].Language = string(p[i].Language) - packages[i].CPEs = toCPEs(p[i].CPEs) - packages[i].PURL = p[i].PURL - packages[i].Metadata, _ = json.Marshal(p[i].Metadata) - packages[i].MetadataType = p[i].MetadataType - } - return packages -} - -func toSyftRelationships(r []model.Relationship) []v1beta1.SyftRelationship { - relationships := make([]v1beta1.SyftRelationship, len(r)) - for i := range r { - relationships[i].Parent = r[i].Parent - relationships[i].Child = r[i].Child - relationships[i].Type = r[i].Type - } - return relationships -} diff --git a/pkg/sbommanager/v1/syftutil/document.go b/pkg/sbommanager/v1/syftutil/document.go new file mode 100644 index 000000000..5ce184b67 --- /dev/null +++ b/pkg/sbommanager/v1/syftutil/document.go @@ -0,0 +1,208 @@ +package syftutil + +import ( + "encoding/json" + + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/format/syftjson" + "github.com/anchore/syft/syft/format/syftjson/model" + "github.com/anchore/syft/syft/sbom" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +func ToSyftDocument(sbomSBOM *sbom.SBOM) v1beta1.SyftDocument { + doc := syftjson.ToFormatModel(*sbomSBOM, syftjson.EncoderConfig{ + Pretty: false, + Legacy: false, + }) + configuration, _ := json.Marshal(doc.Descriptor.Configuration) + metadata, _ := json.Marshal(doc.Source.Metadata) + syftDocument := v1beta1.SyftDocument{ + Artifacts: toSyftPackages(doc.Artifacts), + ArtifactRelationships: toSyftRelationships(doc.ArtifactRelationships), + Files: make([]v1beta1.SyftFile, len(doc.Files)), + SyftSource: v1beta1.SyftSource{ + ID: doc.Source.ID, + Name: doc.Source.Name, + Version: doc.Source.Version, + Type: doc.Source.Type, + Metadata: metadata, + }, + Distro: v1beta1.LinuxRelease{ + PrettyName: doc.Distro.PrettyName, + Name: doc.Distro.Name, + ID: doc.Distro.ID, + IDLike: v1beta1.IDLikes(doc.Distro.IDLike), + Version: doc.Distro.Version, + VersionID: doc.Distro.VersionID, + VersionCodename: doc.Distro.VersionCodename, + BuildID: doc.Distro.BuildID, + ImageID: doc.Distro.ImageID, + ImageVersion: doc.Distro.ImageVersion, + Variant: doc.Distro.Variant, + VariantID: doc.Distro.VariantID, + HomeURL: doc.Distro.HomeURL, + SupportURL: doc.Distro.SupportURL, + BugReportURL: doc.Distro.BugReportURL, + PrivacyPolicyURL: doc.Distro.PrivacyPolicyURL, + CPEName: doc.Distro.CPEName, + SupportEnd: doc.Distro.SupportEnd, + }, + SyftDescriptor: v1beta1.SyftDescriptor{ + Name: doc.Descriptor.Name, + Version: doc.Descriptor.Version, + Configuration: configuration, + }, + Schema: v1beta1.Schema{ + Version: doc.Schema.Version, + URL: doc.Schema.URL, + }, + } + for i := range doc.Files { + syftDocument.Files[i].ID = doc.Files[i].ID + syftDocument.Files[i].Location.RealPath = doc.Files[i].Location.RealPath + syftDocument.Files[i].Location.FileSystemID = doc.Files[i].Location.FileSystemID + syftDocument.Files[i].Metadata = toFileMetadataEntry(doc.Files[i].Metadata) + syftDocument.Files[i].Contents = doc.Files[i].Contents + syftDocument.Files[i].Digests = toDigests(doc.Files[i].Digests) + syftDocument.Files[i].Licenses = toFileLicenses(doc.Files[i].Licenses) + syftDocument.Files[i].Executable = toExecutable(doc.Files[i].Executable) + } + return syftDocument +} + +func toSyftPackages(p []model.Package) []v1beta1.SyftPackage { + packages := make([]v1beta1.SyftPackage, len(p)) + for i := range p { + packages[i].ID = p[i].ID + packages[i].Name = p[i].Name + packages[i].Version = p[i].Version + packages[i].Type = string(p[i].Type) + packages[i].FoundBy = p[i].FoundBy + packages[i].Locations = toLocations(p[i].Locations) + packages[i].Licenses = toLicenses(p[i].Licenses) + packages[i].Language = string(p[i].Language) + packages[i].CPEs = toCPEs(p[i].CPEs) + packages[i].PURL = p[i].PURL + packages[i].Metadata, _ = json.Marshal(p[i].Metadata) + packages[i].MetadataType = p[i].MetadataType + } + return packages +} + +func toSyftRelationships(r []model.Relationship) []v1beta1.SyftRelationship { + relationships := make([]v1beta1.SyftRelationship, len(r)) + for i := range r { + relationships[i].Parent = r[i].Parent + relationships[i].Child = r[i].Child + relationships[i].Type = r[i].Type + } + return relationships +} + +func toCPEs(c []model.CPE) v1beta1.CPEs { + cpes := make(v1beta1.CPEs, len(c)) + for i := range c { + cpes[i] = v1beta1.CPE(c[i]) + } + return cpes +} + +func toDigests(d []file.Digest) []v1beta1.Digest { + digests := make([]v1beta1.Digest, len(d)) + for i := range d { + digests[i].Algorithm = d[i].Algorithm + digests[i].Value = d[i].Value + } + return digests +} + +func toELFSecurityFeatures(f *file.ELFSecurityFeatures) *v1beta1.ELFSecurityFeatures { + if f == nil { + return nil + } + return &v1beta1.ELFSecurityFeatures{ + SymbolTableStripped: f.SymbolTableStripped, + StackCanary: f.StackCanary, + NoExecutable: f.NoExecutable, + RelocationReadOnly: v1beta1.RelocationReadOnly(f.RelocationReadOnly), + PositionIndependentExecutable: f.PositionIndependentExecutable, + DynamicSharedObject: f.DynamicSharedObject, + LlvmSafeStack: f.LlvmSafeStack, + LlvmControlFlowIntegrity: f.LlvmControlFlowIntegrity, + ClangFortifySource: f.ClangFortifySource, + } +} + +func toExecutable(e *file.Executable) *v1beta1.Executable { + if e == nil { + return nil + } + return &v1beta1.Executable{ + Format: v1beta1.ExecutableFormat(e.Format), + HasExports: e.HasExports, + HasEntrypoint: e.HasEntrypoint, + ImportedLibraries: e.ImportedLibraries, + ELFSecurityFeatures: toELFSecurityFeatures(e.ELFSecurityFeatures), + } +} + +func toFileLicenseEvidence(e *model.FileLicenseEvidence) *v1beta1.FileLicenseEvidence { + if e == nil { + return nil + } + return &v1beta1.FileLicenseEvidence{ + Confidence: int64(e.Confidence), + Offset: int64(e.Offset), + Extent: int64(e.Extent), + } +} + +func toFileLicenses(l []model.FileLicense) []v1beta1.FileLicense { + licenses := make([]v1beta1.FileLicense, len(l)) + for i := range l { + licenses[i].Value = l[i].Value + licenses[i].SPDXExpression = l[i].SPDXExpression + licenses[i].Type = v1beta1.LicenseType(l[i].Type) + licenses[i].Evidence = toFileLicenseEvidence(l[i].Evidence) + } + return licenses +} + +func toFileMetadataEntry(m *model.FileMetadataEntry) *v1beta1.FileMetadataEntry { + if m == nil { + return nil + } + return &v1beta1.FileMetadataEntry{ + Mode: int64(m.Mode), + Type: m.Type, + LinkDestination: m.LinkDestination, + UserID: int64(m.UserID), + GroupID: int64(m.GroupID), + MIMEType: m.MIMEType, + Size_: m.Size, + } +} + +func toLicenses(l []model.License) v1beta1.Licenses { + licenses := make(v1beta1.Licenses, len(l)) + for i := range l { + licenses[i].Value = l[i].Value + licenses[i].SPDXExpression = l[i].SPDXExpression + licenses[i].Type = v1beta1.LicenseType(l[i].Type) + licenses[i].URLs = l[i].URLs + licenses[i].Locations = toLocations(l[i].Locations) + } + return licenses +} + +func toLocations(l []file.Location) []v1beta1.Location { + locations := make([]v1beta1.Location, len(l)) + for i := range l { + locations[i].Coordinates = v1beta1.Coordinates(l[i].Coordinates) + locations[i].VirtualPath = l[i].AccessPath + locations[i].RealPath = l[i].RealPath + locations[i].Annotations = l[i].Annotations + } + return locations +} diff --git a/pkg/sbommanager/v1/helpers.go b/pkg/sbommanager/v1/syftutil/helpers.go similarity index 96% rename from pkg/sbommanager/v1/helpers.go rename to pkg/sbommanager/v1/syftutil/helpers.go index f0a1752f0..3a504dbed 100644 --- a/pkg/sbommanager/v1/helpers.go +++ b/pkg/sbommanager/v1/syftutil/helpers.go @@ -1,4 +1,4 @@ -package v1 +package syftutil import ( "fmt" diff --git a/pkg/sbommanager/v1/resolver.go b/pkg/sbommanager/v1/syftutil/resolver.go similarity index 99% rename from pkg/sbommanager/v1/resolver.go rename to pkg/sbommanager/v1/syftutil/resolver.go index 9d3769801..5b2eda441 100644 --- a/pkg/sbommanager/v1/resolver.go +++ b/pkg/sbommanager/v1/syftutil/resolver.go @@ -1,4 +1,4 @@ -package v1 +package syftutil import ( "context" diff --git a/pkg/sbommanager/v1/source.go b/pkg/sbommanager/v1/syftutil/source.go similarity index 97% rename from pkg/sbommanager/v1/source.go rename to pkg/sbommanager/v1/syftutil/source.go index eb0beec8c..ff4f7f058 100644 --- a/pkg/sbommanager/v1/source.go +++ b/pkg/sbommanager/v1/syftutil/source.go @@ -1,4 +1,4 @@ -package v1 +package syftutil import ( "encoding/json" @@ -37,16 +37,13 @@ type ImageInfo struct { } func NewSource(imageName, imageDigest, imageID string, imageStatus *runtime.ImageStatusResponse, mounts []string, maxImageSize int64) (*NodeSource, error) { - // unmarshal image info var imageInfo ImageInfo err := json.Unmarshal([]byte(imageStatus.Info["info"]), &imageInfo) if err != nil { return nil, fmt.Errorf("unable to unmarshal image info: %w", err) } reverseLayers := imageInfo.ImageSpec.RootFS.DiffIDs - // reverse layers to match the order of the mounts slices.Reverse(reverseLayers) - // prepare image config configFile := v1.ConfigFile{ Architecture: imageInfo.ImageSpec.Architecture, Author: imageInfo.ImageSpec.Author, @@ -66,7 +63,6 @@ func NewSource(imageName, imageDigest, imageID string, imageStatus *runtime.Imag return nil, fmt.Errorf("unable to marshal image config: %w", err) } layers, totalSize := toLayers(imageInfo.ImageSpec.RootFS.DiffIDs, mounts) - // check total size if totalSize > maxImageSize { return nil, ErrImageTooLarge } diff --git a/pkg/sbommanager/v1/source_test.go b/pkg/sbommanager/v1/syftutil/source_test.go similarity index 99% rename from pkg/sbommanager/v1/source_test.go rename to pkg/sbommanager/v1/syftutil/source_test.go index 6e85f22b9..ce0cf267b 100644 --- a/pkg/sbommanager/v1/source_test.go +++ b/pkg/sbommanager/v1/syftutil/source_test.go @@ -1,4 +1,4 @@ -package v1 +package syftutil import ( "testing" diff --git a/pkg/sbomscanner/v1/client.go b/pkg/sbomscanner/v1/client.go new file mode 100644 index 000000000..c772cf5de --- /dev/null +++ b/pkg/sbomscanner/v1/client.go @@ -0,0 +1,109 @@ +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const ( + healthCheckTimeout = 5 * time.Second +) + +type sbomScannerClient struct { + conn *grpc.ClientConn + client pb.SBOMScannerClient +} + +func NewSBOMScannerClient(socketPath string) (SBOMScannerClient, error) { + target := fmt.Sprintf("unix://%s", socketPath) + conn, err := grpc.NewClient(target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create gRPC client: %w", err) + } + + c := &sbomScannerClient{ + conn: conn, + client: pb.NewSBOMScannerClient(conn), + } + + _, err = backoff.Retry(context.Background(), func() (struct{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout) + defer cancel() + resp, err := c.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return struct{}{}, fmt.Errorf("health check failed: %w", err) + } + if !resp.Ready { + return struct{}{}, fmt.Errorf("scanner not ready") + } + return struct{}{}, nil + }, backoff.WithBackOff(backoff.NewExponentialBackOff())) + if err != nil { + logger.L().Error("SBOM scanner sidecar health check failed after retries", helpers.Error(err)) + conn.Close() + return nil, err + } + + logger.L().Info("SBOM scanner sidecar connected") + return c, nil +} + +func (c *sbomScannerClient) CreateSBOM(ctx context.Context, req ScanRequest) (*ScanResult, error) { + pbReq := &pb.CreateSBOMRequest{ + ImageId: req.ImageID, + ImageTag: req.ImageTag, + LayerPaths: req.LayerPaths, + ImageStatus: req.ImageStatus, + MaxImageSize: req.MaxImageSize, + MaxSbomSize: req.MaxSBOMSize, + EnableEmbeddedSboms: req.EnableEmbeddedSBOMs, + TimeoutSeconds: int64(req.Timeout.Seconds()), + } + + resp, err := c.client.CreateSBOM(ctx, pbReq) + if err != nil { + st, ok := status.FromError(err) + if ok && (st.Code() == codes.Unavailable || st.Code() == codes.Aborted) { + return nil, fmt.Errorf("%w: %v", ErrScannerCrashed, err) + } + return nil, err + } + + var doc v1beta1.SyftDocument + if err := json.Unmarshal(resp.SbomDocument, &doc); err != nil { + return nil, fmt.Errorf("failed to deserialize SBOM document: %w", err) + } + + return &ScanResult{ + SyftDocument: doc, + SBOMSize: resp.SbomSize, + }, nil +} + +func (c *sbomScannerClient) Ready() bool { + ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout) + defer cancel() + resp, err := c.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return false + } + return resp.Ready +} + +func (c *sbomScannerClient) Close() error { + return c.conn.Close() +} diff --git a/pkg/sbomscanner/v1/integration_test.go b/pkg/sbomscanner/v1/integration_test.go new file mode 100644 index 000000000..359c6c6eb --- /dev/null +++ b/pkg/sbomscanner/v1/integration_test.go @@ -0,0 +1,142 @@ +package v1 + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + + pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + runtime "k8s.io/cri-api/pkg/apis/runtime/v1" + _ "modernc.org/sqlite" +) + +func makeTestImageStatus(t *testing.T) []byte { + t.Helper() + infoJSON := `{"imageSpec":{"rootfs":{"type":"layers","diff_ids":["sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"architecture":"amd64","os":"linux","config":{}}}` + isr := &runtime.ImageStatusResponse{ + Image: &runtime.Image{ + Id: "sha256:abc", + RepoTags: []string{"test:latest"}, + RepoDigests: []string{"test@sha256:abc"}, + Size: 100, + }, + Info: map[string]string{"info": infoJSON}, + } + data, err := json.Marshal(isr) + require.NoError(t, err) + return data +} + +func startIntegrationServer(t *testing.T) (SBOMScannerClient, *grpc.Server, string) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "scanner.sock") + + lis, err := net.Listen("unix", sock) + require.NoError(t, err) + + srv := grpc.NewServer() + pb.RegisterSBOMScannerServer(srv, NewScannerServer()) + go srv.Serve(lis) + + conn, err := grpc.NewClient("unix://"+sock, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + client := &sbomScannerClient{ + conn: conn, + client: pb.NewSBOMScannerClient(conn), + } + + return client, srv, sock +} + +func TestIntegration_FullScanLifecycle(t *testing.T) { + client, srv, sock := startIntegrationServer(t) + defer srv.Stop() + defer os.Remove(sock) + defer client.Close() + + assert.True(t, client.Ready()) + + layerDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "test.txt"), []byte("hello"), 0644)) + + result, err := client.CreateSBOM(context.Background(), ScanRequest{ + ImageID: "sha256:abc", + ImageTag: "test:latest", + LayerPaths: []string{layerDir}, + ImageStatus: makeTestImageStatus(t), + MaxImageSize: 1024 * 1024 * 1024, + MaxSBOMSize: 10 * 1024 * 1024, + }) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Greater(t, result.SBOMSize, int64(0)) +} + +func TestIntegration_SimulatedOOM(t *testing.T) { + client, srv, sock := startIntegrationServer(t) + defer os.Remove(sock) + defer client.Close() + + assert.True(t, client.Ready()) + + // Kill the server to simulate OOM + srv.Stop() + + _, err := client.CreateSBOM(context.Background(), ScanRequest{ + ImageID: "sha256:abc", + ImageTag: "test:latest", + LayerPaths: []string{t.TempDir()}, + ImageStatus: makeTestImageStatus(t), + MaxImageSize: 1024 * 1024 * 1024, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrScannerCrashed) +} + +func TestIntegration_ImageTooLarge(t *testing.T) { + client, srv, sock := startIntegrationServer(t) + defer srv.Stop() + defer os.Remove(sock) + defer client.Close() + + layerDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "big.bin"), make([]byte, 2048), 0644)) + + _, err := client.CreateSBOM(context.Background(), ScanRequest{ + ImageID: "sha256:abc", + ImageTag: "test:latest", + LayerPaths: []string{layerDir}, + ImageStatus: makeTestImageStatus(t), + MaxImageSize: 1, + }) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.FailedPrecondition, st.Code()) +} + +func TestIntegration_ReadyCheck(t *testing.T) { + client, srv, sock := startIntegrationServer(t) + defer os.Remove(sock) + + assert.True(t, client.Ready()) + + srv.Stop() + + assert.False(t, client.Ready()) + + client.Close() +} diff --git a/pkg/sbomscanner/v1/proto/buf.gen.yaml b/pkg/sbomscanner/v1/proto/buf.gen.yaml new file mode 100644 index 000000000..091691fcb --- /dev/null +++ b/pkg/sbomscanner/v1/proto/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: . + opt: + - paths=source_relative + - local: protoc-gen-go-grpc + out: . + opt: + - paths=source_relative +inputs: + - directory: . diff --git a/pkg/sbomscanner/v1/proto/scanner.pb.go b/pkg/sbomscanner/v1/proto/scanner.pb.go new file mode 100644 index 000000000..375c42b51 --- /dev/null +++ b/pkg/sbomscanner/v1/proto/scanner.pb.go @@ -0,0 +1,344 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: scanner.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CreateSBOMRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ImageId string `protobuf:"bytes,1,opt,name=image_id,json=imageId,proto3" json:"image_id,omitempty"` + ImageTag string `protobuf:"bytes,2,opt,name=image_tag,json=imageTag,proto3" json:"image_tag,omitempty"` + LayerPaths []string `protobuf:"bytes,3,rep,name=layer_paths,json=layerPaths,proto3" json:"layer_paths,omitempty"` + ImageStatus []byte `protobuf:"bytes,4,opt,name=image_status,json=imageStatus,proto3" json:"image_status,omitempty"` + MaxImageSize int64 `protobuf:"varint,5,opt,name=max_image_size,json=maxImageSize,proto3" json:"max_image_size,omitempty"` + MaxSbomSize int32 `protobuf:"varint,6,opt,name=max_sbom_size,json=maxSbomSize,proto3" json:"max_sbom_size,omitempty"` + EnableEmbeddedSboms bool `protobuf:"varint,7,opt,name=enable_embedded_sboms,json=enableEmbeddedSboms,proto3" json:"enable_embedded_sboms,omitempty"` + TimeoutSeconds int64 `protobuf:"varint,8,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSBOMRequest) Reset() { + *x = CreateSBOMRequest{} + mi := &file_scanner_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSBOMRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSBOMRequest) ProtoMessage() {} + +func (x *CreateSBOMRequest) ProtoReflect() protoreflect.Message { + mi := &file_scanner_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSBOMRequest.ProtoReflect.Descriptor instead. +func (*CreateSBOMRequest) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateSBOMRequest) GetImageId() string { + if x != nil { + return x.ImageId + } + return "" +} + +func (x *CreateSBOMRequest) GetImageTag() string { + if x != nil { + return x.ImageTag + } + return "" +} + +func (x *CreateSBOMRequest) GetLayerPaths() []string { + if x != nil { + return x.LayerPaths + } + return nil +} + +func (x *CreateSBOMRequest) GetImageStatus() []byte { + if x != nil { + return x.ImageStatus + } + return nil +} + +func (x *CreateSBOMRequest) GetMaxImageSize() int64 { + if x != nil { + return x.MaxImageSize + } + return 0 +} + +func (x *CreateSBOMRequest) GetMaxSbomSize() int32 { + if x != nil { + return x.MaxSbomSize + } + return 0 +} + +func (x *CreateSBOMRequest) GetEnableEmbeddedSboms() bool { + if x != nil { + return x.EnableEmbeddedSboms + } + return false +} + +func (x *CreateSBOMRequest) GetTimeoutSeconds() int64 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +type CreateSBOMResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SbomDocument []byte `protobuf:"bytes,1,opt,name=sbom_document,json=sbomDocument,proto3" json:"sbom_document,omitempty"` + SbomSize int64 `protobuf:"varint,2,opt,name=sbom_size,json=sbomSize,proto3" json:"sbom_size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSBOMResponse) Reset() { + *x = CreateSBOMResponse{} + mi := &file_scanner_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSBOMResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSBOMResponse) ProtoMessage() {} + +func (x *CreateSBOMResponse) ProtoReflect() protoreflect.Message { + mi := &file_scanner_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSBOMResponse.ProtoReflect.Descriptor instead. +func (*CreateSBOMResponse) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateSBOMResponse) GetSbomDocument() []byte { + if x != nil { + return x.SbomDocument + } + return nil +} + +func (x *CreateSBOMResponse) GetSbomSize() int64 { + if x != nil { + return x.SbomSize + } + return 0 +} + +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_scanner_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_scanner_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{2} +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Ready bool `protobuf:"varint,2,opt,name=ready,proto3" json:"ready,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_scanner_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_scanner_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *HealthResponse) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +var File_scanner_proto protoreflect.FileDescriptor + +const file_scanner_proto_rawDesc = "" + + "\n" + + "\rscanner.proto\x12\x0esbomscanner.v1\"\xb6\x02\n" + + "\x11CreateSBOMRequest\x12\x19\n" + + "\bimage_id\x18\x01 \x01(\tR\aimageId\x12\x1b\n" + + "\timage_tag\x18\x02 \x01(\tR\bimageTag\x12\x1f\n" + + "\vlayer_paths\x18\x03 \x03(\tR\n" + + "layerPaths\x12!\n" + + "\fimage_status\x18\x04 \x01(\fR\vimageStatus\x12$\n" + + "\x0emax_image_size\x18\x05 \x01(\x03R\fmaxImageSize\x12\"\n" + + "\rmax_sbom_size\x18\x06 \x01(\x05R\vmaxSbomSize\x122\n" + + "\x15enable_embedded_sboms\x18\a \x01(\bR\x13enableEmbeddedSboms\x12'\n" + + "\x0ftimeout_seconds\x18\b \x01(\x03R\x0etimeoutSeconds\"V\n" + + "\x12CreateSBOMResponse\x12#\n" + + "\rsbom_document\x18\x01 \x01(\fR\fsbomDocument\x12\x1b\n" + + "\tsbom_size\x18\x02 \x01(\x03R\bsbomSize\"\x0f\n" + + "\rHealthRequest\"@\n" + + "\x0eHealthResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x14\n" + + "\x05ready\x18\x02 \x01(\bR\x05ready2\xab\x01\n" + + "\vSBOMScanner\x12S\n" + + "\n" + + "CreateSBOM\x12!.sbomscanner.v1.CreateSBOMRequest\x1a\".sbomscanner.v1.CreateSBOMResponse\x12G\n" + + "\x06Health\x12\x1d.sbomscanner.v1.HealthRequest\x1a\x1e.sbomscanner.v1.HealthResponseB:Z8github.com/kubescape/node-agent/pkg/sbomscanner/v1/protob\x06proto3" + +var ( + file_scanner_proto_rawDescOnce sync.Once + file_scanner_proto_rawDescData []byte +) + +func file_scanner_proto_rawDescGZIP() []byte { + file_scanner_proto_rawDescOnce.Do(func() { + file_scanner_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_scanner_proto_rawDesc), len(file_scanner_proto_rawDesc))) + }) + return file_scanner_proto_rawDescData +} + +var file_scanner_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_scanner_proto_goTypes = []any{ + (*CreateSBOMRequest)(nil), // 0: sbomscanner.v1.CreateSBOMRequest + (*CreateSBOMResponse)(nil), // 1: sbomscanner.v1.CreateSBOMResponse + (*HealthRequest)(nil), // 2: sbomscanner.v1.HealthRequest + (*HealthResponse)(nil), // 3: sbomscanner.v1.HealthResponse +} +var file_scanner_proto_depIdxs = []int32{ + 0, // 0: sbomscanner.v1.SBOMScanner.CreateSBOM:input_type -> sbomscanner.v1.CreateSBOMRequest + 2, // 1: sbomscanner.v1.SBOMScanner.Health:input_type -> sbomscanner.v1.HealthRequest + 1, // 2: sbomscanner.v1.SBOMScanner.CreateSBOM:output_type -> sbomscanner.v1.CreateSBOMResponse + 3, // 3: sbomscanner.v1.SBOMScanner.Health:output_type -> sbomscanner.v1.HealthResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_scanner_proto_init() } +func file_scanner_proto_init() { + if File_scanner_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_scanner_proto_rawDesc), len(file_scanner_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_scanner_proto_goTypes, + DependencyIndexes: file_scanner_proto_depIdxs, + MessageInfos: file_scanner_proto_msgTypes, + }.Build() + File_scanner_proto = out.File + file_scanner_proto_goTypes = nil + file_scanner_proto_depIdxs = nil +} diff --git a/pkg/sbomscanner/v1/proto/scanner.proto b/pkg/sbomscanner/v1/proto/scanner.proto new file mode 100644 index 000000000..1d24fe793 --- /dev/null +++ b/pkg/sbomscanner/v1/proto/scanner.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package sbomscanner.v1; + +option go_package = "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto"; + +service SBOMScanner { + rpc CreateSBOM(CreateSBOMRequest) returns (CreateSBOMResponse); + rpc Health(HealthRequest) returns (HealthResponse); +} + +message CreateSBOMRequest { + string image_id = 1; + string image_tag = 2; + repeated string layer_paths = 3; + bytes image_status = 4; + int64 max_image_size = 5; + int32 max_sbom_size = 6; + bool enable_embedded_sboms = 7; + int64 timeout_seconds = 8; +} + +message CreateSBOMResponse { + bytes sbom_document = 1; + int64 sbom_size = 2; +} + +message HealthRequest {} + +message HealthResponse { + string version = 1; + bool ready = 2; +} diff --git a/pkg/sbomscanner/v1/proto/scanner_grpc.pb.go b/pkg/sbomscanner/v1/proto/scanner_grpc.pb.go new file mode 100644 index 000000000..4cd45bc5c --- /dev/null +++ b/pkg/sbomscanner/v1/proto/scanner_grpc.pb.go @@ -0,0 +1,159 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: scanner.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SBOMScanner_CreateSBOM_FullMethodName = "/sbomscanner.v1.SBOMScanner/CreateSBOM" + SBOMScanner_Health_FullMethodName = "/sbomscanner.v1.SBOMScanner/Health" +) + +// SBOMScannerClient is the client API for SBOMScanner service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type SBOMScannerClient interface { + CreateSBOM(ctx context.Context, in *CreateSBOMRequest, opts ...grpc.CallOption) (*CreateSBOMResponse, error) + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) +} + +type sBOMScannerClient struct { + cc grpc.ClientConnInterface +} + +func NewSBOMScannerClient(cc grpc.ClientConnInterface) SBOMScannerClient { + return &sBOMScannerClient{cc} +} + +func (c *sBOMScannerClient) CreateSBOM(ctx context.Context, in *CreateSBOMRequest, opts ...grpc.CallOption) (*CreateSBOMResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateSBOMResponse) + err := c.cc.Invoke(ctx, SBOMScanner_CreateSBOM_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sBOMScannerClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, SBOMScanner_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SBOMScannerServer is the server API for SBOMScanner service. +// All implementations must embed UnimplementedSBOMScannerServer +// for forward compatibility. +type SBOMScannerServer interface { + CreateSBOM(context.Context, *CreateSBOMRequest) (*CreateSBOMResponse, error) + Health(context.Context, *HealthRequest) (*HealthResponse, error) + mustEmbedUnimplementedSBOMScannerServer() +} + +// UnimplementedSBOMScannerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSBOMScannerServer struct{} + +func (UnimplementedSBOMScannerServer) CreateSBOM(context.Context, *CreateSBOMRequest) (*CreateSBOMResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSBOM not implemented") +} +func (UnimplementedSBOMScannerServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedSBOMScannerServer) mustEmbedUnimplementedSBOMScannerServer() {} +func (UnimplementedSBOMScannerServer) testEmbeddedByValue() {} + +// UnsafeSBOMScannerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SBOMScannerServer will +// result in compilation errors. +type UnsafeSBOMScannerServer interface { + mustEmbedUnimplementedSBOMScannerServer() +} + +func RegisterSBOMScannerServer(s grpc.ServiceRegistrar, srv SBOMScannerServer) { + // If the following call panics, it indicates UnimplementedSBOMScannerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SBOMScanner_ServiceDesc, srv) +} + +func _SBOMScanner_CreateSBOM_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSBOMRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SBOMScannerServer).CreateSBOM(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SBOMScanner_CreateSBOM_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SBOMScannerServer).CreateSBOM(ctx, req.(*CreateSBOMRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SBOMScanner_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SBOMScannerServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SBOMScanner_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SBOMScannerServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SBOMScanner_ServiceDesc is the grpc.ServiceDesc for SBOMScanner service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SBOMScanner_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sbomscanner.v1.SBOMScanner", + HandlerType: (*SBOMScannerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CreateSBOM", + Handler: _SBOMScanner_CreateSBOM_Handler, + }, + { + MethodName: "Health", + Handler: _SBOMScanner_Health_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "scanner.proto", +} diff --git a/pkg/sbomscanner/v1/server.go b/pkg/sbomscanner/v1/server.go new file mode 100644 index 000000000..250ff544c --- /dev/null +++ b/pkg/sbomscanner/v1/server.go @@ -0,0 +1,109 @@ +package v1 + +import ( + "context" + "encoding/json" + "errors" + "runtime/debug" + "sync" + "time" + + "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" + sbomcataloger "github.com/anchore/syft/syft/pkg/cataloger/sbom" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/sbommanager/v1/syftutil" + pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + runtime "k8s.io/cri-api/pkg/apis/runtime/v1" +) + +type scannerServer struct { + pb.UnimplementedSBOMScannerServer + mu sync.Mutex + version string +} + +func NewScannerServer() pb.SBOMScannerServer { + return &scannerServer{ + version: packageVersion("github.com/anchore/syft"), + } +} + +func (s *scannerServer) CreateSBOM(ctx context.Context, req *pb.CreateSBOMRequest) (*pb.CreateSBOMResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if req.TimeoutSeconds > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutSeconds)*time.Second) + defer cancel() + } + + var imageStatus runtime.ImageStatusResponse + if err := json.Unmarshal(req.ImageStatus, &imageStatus); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid image_status: %v", err) + } + + src, err := syftutil.NewSource(req.ImageTag, req.ImageId, req.ImageId, &imageStatus, req.LayerPaths, req.MaxImageSize) + if err != nil { + if errors.Is(err, syftutil.ErrImageTooLarge) { + return nil, status.Error(codes.FailedPrecondition, "image size exceeds maximum allowed size") + } + return nil, status.Errorf(codes.Internal, "failed to create image source: %v", err) + } + + cfg := syft.DefaultCreateSBOMConfig() + cfg.ToolName = "syft" + cfg.ToolVersion = s.version + if req.EnableEmbeddedSboms { + cfg.WithCatalogers(pkgcataloging.NewCatalogerReference(sbomcataloger.NewCataloger(), []string{pkgcataloging.ImageTag})) + } + + syftSBOM, err := syft.CreateSBOM(ctx, src, cfg) + if err != nil { + if ctx.Err() != nil { + return nil, status.Error(codes.DeadlineExceeded, "scan timed out") + } + return nil, status.Errorf(codes.Internal, "failed to generate SBOM: %v", err) + } + + v1beta1.StripSBOM(syftSBOM) + doc := syftutil.ToSyftDocument(syftSBOM) + + docBytes, err := json.Marshal(doc) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to serialize SBOM: %v", err) + } + + logger.L().Info("SBOM scan completed", + helpers.String("imageTag", req.ImageTag), + helpers.Int("sbomSize", len(docBytes))) + + return &pb.CreateSBOMResponse{ + SbomDocument: docBytes, + SbomSize: int64(len(docBytes)), + }, nil +} + +func (s *scannerServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + return &pb.HealthResponse{ + Version: s.version, + Ready: true, + }, nil +} + +func packageVersion(name string) string { + bi, ok := debug.ReadBuildInfo() + if ok { + for _, dep := range bi.Deps { + if dep.Path == name { + return dep.Version + } + } + } + return "unknown" +} diff --git a/pkg/sbomscanner/v1/server_test.go b/pkg/sbomscanner/v1/server_test.go new file mode 100644 index 000000000..2dbf02a31 --- /dev/null +++ b/pkg/sbomscanner/v1/server_test.go @@ -0,0 +1,128 @@ +package v1 + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + + pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + runtime "k8s.io/cri-api/pkg/apis/runtime/v1" +) + +func startTestServer(t *testing.T) (pb.SBOMScannerClient, func()) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "scanner.sock") + + lis, err := net.Listen("unix", sock) + require.NoError(t, err) + + srv := grpc.NewServer() + pb.RegisterSBOMScannerServer(srv, NewScannerServer()) + go srv.Serve(lis) + + conn, err := grpc.NewClient("unix://"+sock, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + client := pb.NewSBOMScannerClient(conn) + cleanup := func() { + conn.Close() + srv.Stop() + os.Remove(sock) + } + return client, cleanup +} + +func TestHealth(t *testing.T) { + client, cleanup := startTestServer(t) + defer cleanup() + + resp, err := client.Health(context.Background(), &pb.HealthRequest{}) + require.NoError(t, err) + assert.True(t, resp.Ready) + assert.NotEmpty(t, resp.Version) +} + +func TestCreateSBOM_InvalidImageStatus(t *testing.T) { + client, cleanup := startTestServer(t) + defer cleanup() + + resp, err := client.CreateSBOM(context.Background(), &pb.CreateSBOMRequest{ + ImageId: "test-image", + ImageTag: "test:latest", + ImageStatus: []byte("invalid json"), + MaxImageSize: 1024 * 1024 * 1024, + }) + assert.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.InvalidArgument, st.Code()) +} + +func makeImageStatusJSON(t *testing.T) []byte { + t.Helper() + infoJSON := `{"imageSpec":{"rootfs":{"type":"layers","diff_ids":["sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"architecture":"amd64","os":"linux","config":{}}}` + isr := &runtime.ImageStatusResponse{ + Image: &runtime.Image{ + Id: "sha256:abc", + RepoTags: []string{"test:latest"}, + RepoDigests: []string{"test@sha256:abc"}, + Size: 100, + }, + Info: map[string]string{"info": infoJSON}, + } + data, err := json.Marshal(isr) + require.NoError(t, err) + return data +} + +func TestCreateSBOM_ImageTooLarge(t *testing.T) { + client, cleanup := startTestServer(t) + defer cleanup() + + layerDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(layerDir, "bigfile"), make([]byte, 1024), 0644)) + + resp, err := client.CreateSBOM(context.Background(), &pb.CreateSBOMRequest{ + ImageId: "sha256:abc", + ImageTag: "test:latest", + LayerPaths: []string{layerDir}, + ImageStatus: makeImageStatusJSON(t), + MaxImageSize: 1, + }) + assert.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.FailedPrecondition, st.Code()) +} + +func TestCreateSBOM_ContextCancelled(t *testing.T) { + client, cleanup := startTestServer(t) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + resp, err := client.CreateSBOM(ctx, &pb.CreateSBOMRequest{ + ImageId: "test-image", + ImageTag: "test:latest", + }) + assert.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Canceled, st.Code()) +} diff --git a/pkg/sbomscanner/v1/types.go b/pkg/sbomscanner/v1/types.go new file mode 100644 index 000000000..5e4433e3d --- /dev/null +++ b/pkg/sbomscanner/v1/types.go @@ -0,0 +1,36 @@ +package v1 + +import ( + "context" + "errors" + "time" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +var ( + ErrScannerCrashed = errors.New("SBOM scanner sidecar crashed during scan") + ErrScannerNotReady = errors.New("SBOM scanner sidecar not ready") +) + +type ScanRequest struct { + ImageID string + ImageTag string + LayerPaths []string + ImageStatus []byte // serialized CRI ImageStatusResponse JSON + MaxImageSize int64 + MaxSBOMSize int32 + EnableEmbeddedSBOMs bool + Timeout time.Duration +} + +type ScanResult struct { + SyftDocument v1beta1.SyftDocument + SBOMSize int64 +} + +type SBOMScannerClient interface { + CreateSBOM(ctx context.Context, req ScanRequest) (*ScanResult, error) + Ready() bool + Close() error +} From 2dda665140bc1f683fc362028157efbccd3cdd44 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 Mar 2026 16:06:05 +0200 Subject: [PATCH 2/4] Fix in the sbom scanner executable Signed-off-by: Ben --- cmd/sbom-scanner/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/sbom-scanner/main.go b/cmd/sbom-scanner/main.go index 80152a6bc..06ff53f06 100644 --- a/cmd/sbom-scanner/main.go +++ b/cmd/sbom-scanner/main.go @@ -11,6 +11,7 @@ import ( sbomscanner "github.com/kubescape/node-agent/pkg/sbomscanner/v1" pb "github.com/kubescape/node-agent/pkg/sbomscanner/v1/proto" "google.golang.org/grpc" + _ "modernc.org/sqlite" ) func main() { From c3f3a4189238107690930527f30e877794c3abb8 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 Mar 2026 16:12:44 +0200 Subject: [PATCH 3/4] Fix: Clear scan retries on successful SBOM processing and improve error handling for canceled and timed-out scans Signed-off-by: Ben --- pkg/sbommanager/v1/sbom_manager.go | 1 + pkg/sbomscanner/v1/server.go | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/sbommanager/v1/sbom_manager.go b/pkg/sbommanager/v1/sbom_manager.go index 2ade02c96..fc94d85bb 100644 --- a/pkg/sbommanager/v1/sbom_manager.go +++ b/pkg/sbommanager/v1/sbom_manager.go @@ -380,6 +380,7 @@ func (s *SbomManager) processContainer(notif containercollection.PubSubEvent, mo } sbomScanTotal.WithLabelValues("success").Inc() sbomScanDuration.WithLabelValues("success").Observe(time.Since(scanStart).Seconds()) + delete(s.scanRetries, sbomName) syftDoc = result.SyftDocument } else if s.scannerClient != nil { sbomScannerReady.Set(0) diff --git a/pkg/sbomscanner/v1/server.go b/pkg/sbomscanner/v1/server.go index 250ff544c..12c0296ea 100644 --- a/pkg/sbomscanner/v1/server.go +++ b/pkg/sbomscanner/v1/server.go @@ -65,7 +65,10 @@ func (s *scannerServer) CreateSBOM(ctx context.Context, req *pb.CreateSBOMReques syftSBOM, err := syft.CreateSBOM(ctx, src, cfg) if err != nil { - if ctx.Err() != nil { + if ctx.Err() == context.Canceled { + return nil, status.Error(codes.Canceled, "scan canceled") + } + if ctx.Err() == context.DeadlineExceeded { return nil, status.Error(codes.DeadlineExceeded, "scan timed out") } return nil, status.Errorf(codes.Internal, "failed to generate SBOM: %v", err) From 4fbfe6461650ffebc27b713a4ef89fd25e01fd08 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 Mar 2026 16:15:24 +0200 Subject: [PATCH 4/4] Add comment to clarify thread safety of scanRetries in SbomManager Signed-off-by: Ben --- pkg/sbommanager/v1/sbom_manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sbommanager/v1/sbom_manager.go b/pkg/sbommanager/v1/sbom_manager.go index fc94d85bb..15b4d6be8 100644 --- a/pkg/sbommanager/v1/sbom_manager.go +++ b/pkg/sbommanager/v1/sbom_manager.go @@ -71,7 +71,7 @@ type SbomManager struct { version string scannerClient sbomscanner.SBOMScannerClient scannerMemLimit int64 - scanRetries map[string]int + scanRetries map[string]int // safe without mutex: only accessed from pool workers (pool size 1) } var _ sbommanager.SbomManagerClient = (*SbomManager)(nil)