-
Notifications
You must be signed in to change notification settings - Fork 25
feat: SBOM scanner sidecar for memory-isolated SBOM generation #335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| package v1 | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/kubescape/go-logger" | ||
| "github.com/kubescape/go-logger/helpers" | ||
| helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" | ||
| "github.com/kubescape/kubevuln/core/domain" | ||
| "github.com/kubescape/kubevuln/core/ports" | ||
| "github.com/kubescape/kubevuln/internal/tools" | ||
| sbomscanner "github.com/kubescape/kubevuln/pkg/sbomscanner/v1" | ||
| ) | ||
|
|
||
| const maxCrashRetries = 3 | ||
|
|
||
| // SidecarSBOMAdapter implements ports.SBOMCreator by delegating SBOM generation | ||
| // to the sbom-scanner sidecar container via gRPC over a Unix domain socket. | ||
| type SidecarSBOMAdapter struct { | ||
| client sbomscanner.SBOMScannerClient | ||
| maxImageSize int64 | ||
| maxSBOMSize int | ||
| scanTimeout time.Duration | ||
| scanEmbeddedSBOMs bool | ||
| memoryLimit string | ||
|
|
||
| mu sync.Mutex | ||
| retryCount map[string]int | ||
|
|
||
| versionOnce sync.Once | ||
| versionStr string | ||
| } | ||
|
|
||
| var _ ports.SBOMCreator = (*SidecarSBOMAdapter)(nil) | ||
|
|
||
| // NewSidecarSBOMAdapter creates a new adapter that delegates to the sidecar scanner. | ||
| func NewSidecarSBOMAdapter( | ||
| client sbomscanner.SBOMScannerClient, | ||
| scanTimeout time.Duration, | ||
| maxImageSize int64, | ||
| maxSBOMSize int, | ||
| scanEmbeddedSBOMs bool, | ||
| memoryLimit string, | ||
| ) *SidecarSBOMAdapter { | ||
| return &SidecarSBOMAdapter{ | ||
| client: client, | ||
| maxImageSize: maxImageSize, | ||
| maxSBOMSize: maxSBOMSize, | ||
| scanTimeout: scanTimeout, | ||
| scanEmbeddedSBOMs: scanEmbeddedSBOMs, | ||
| memoryLimit: memoryLimit, | ||
| retryCount: make(map[string]int), | ||
| } | ||
| } | ||
|
|
||
| func (s *SidecarSBOMAdapter) CreateSBOM(ctx context.Context, name, imageID, imageTag string, options domain.RegistryOptions) (domain.SBOM, error) { | ||
| // Normalize image ID for consistent naming | ||
| if imageTag != "" { | ||
| imageID = NormalizeImageID(imageID, imageTag) | ||
| } | ||
|
|
||
| domainSBOM := domain.SBOM{ | ||
| Name: name, | ||
| SBOMCreatorVersion: s.Version(), | ||
| SBOMCreatorName: "syft", | ||
| Annotations: map[string]string{ | ||
| helpersv1.ImageIDMetadataKey: imageID, | ||
| helpersv1.ToolVersionMetadataKey: s.Version(), | ||
| }, | ||
| Labels: tools.LabelsFromImageID(imageID), | ||
| } | ||
| domainSBOM.Annotations[helpersv1.ImageTagMetadataKey] = imageTag | ||
|
|
||
| req := sbomscanner.ScanRequest{ | ||
| ImageID: imageID, | ||
| ImageTag: imageTag, | ||
| Options: options, | ||
| MaxImageSize: s.maxImageSize, | ||
| MaxSBOMSize: int32(s.maxSBOMSize), | ||
| EnableEmbeddedSBOMs: s.scanEmbeddedSBOMs, | ||
| Timeout: s.scanTimeout, | ||
| } | ||
|
|
||
| result, err := s.client.CreateSBOM(ctx, req) | ||
| if err != nil { | ||
| if errors.Is(err, sbomscanner.ErrScannerCrashed) { | ||
| return s.handleCrash(ctx, name, imageID, imageTag, options, domainSBOM, err) | ||
| } | ||
| return domainSBOM, err | ||
| } | ||
|
|
||
| // Map response status to domain SBOM | ||
| domainSBOM.Status = result.Status | ||
| domainSBOM.Annotations[helpersv1.ResourceSizeMetadataKey] = fmt.Sprintf("%d", result.SBOMSize) | ||
|
|
||
| if result.SyftDocument != nil { | ||
| domainSBOM.Content = result.SyftDocument | ||
| } | ||
|
|
||
| if result.ErrorMessage != "" && result.Status != helpersv1.Learning { | ||
| return domainSBOM, fmt.Errorf("%s", result.ErrorMessage) | ||
| } | ||
|
|
||
| // Clear retry count on success | ||
| s.mu.Lock() | ||
| delete(s.retryCount, imageID) | ||
| s.mu.Unlock() | ||
|
|
||
| return domainSBOM, nil | ||
| } | ||
|
|
||
| func (s *SidecarSBOMAdapter) handleCrash(ctx context.Context, name, imageID, imageTag string, options domain.RegistryOptions, domainSBOM domain.SBOM, crashErr error) (domain.SBOM, error) { | ||
| s.mu.Lock() | ||
| s.retryCount[imageID]++ | ||
| retries := s.retryCount[imageID] | ||
| s.mu.Unlock() | ||
|
|
||
| logger.L().Warning("SBOM scanner sidecar crashed during scan", | ||
| helpers.String("imageID", imageID), | ||
| helpers.Int("retry", retries), | ||
| helpers.Int("maxRetries", maxCrashRetries)) | ||
|
|
||
| if retries >= maxCrashRetries { | ||
| // Exhausted retries — mark as TooLarge with memory-limit annotation | ||
| s.mu.Lock() | ||
| delete(s.retryCount, imageID) | ||
| s.mu.Unlock() | ||
|
|
||
| domainSBOM.Status = helpersv1.TooLarge | ||
| if s.memoryLimit != "" { | ||
| domainSBOM.Annotations[helpersv1.StatusMetadataKey] = fmt.Sprintf( | ||
| "scanner OOM after %d retries (memory limit: %s)", maxCrashRetries, s.memoryLimit) | ||
| } | ||
| logger.L().Warning("SBOM scanner exhausted retries, marking as TooLarge", | ||
| helpers.String("imageID", imageID)) | ||
| return domainSBOM, nil | ||
| } | ||
|
|
||
| // Return the crash error so the caller can retry later | ||
| return domainSBOM, crashErr | ||
| } | ||
|
|
||
| func (s *SidecarSBOMAdapter) Version() string { | ||
| s.versionOnce.Do(func() { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
| version, _, err := s.client.Health(ctx) | ||
| if err != nil { | ||
| logger.L().Warning("failed to get scanner version", helpers.Error(err)) | ||
| s.versionStr = "unknown" | ||
| return | ||
| } | ||
| s.versionStr = version | ||
| }) | ||
| return s.versionStr | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| package v1 | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/kubescape/kubevuln/core/domain" | ||
| sbomscanner "github.com/kubescape/kubevuln/pkg/sbomscanner/v1" | ||
| "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" | ||
| ) | ||
|
|
||
| // mockScannerClient implements sbomscanner.SBOMScannerClient for testing | ||
| type mockScannerClient struct { | ||
| createSBOMFunc func(ctx context.Context, req sbomscanner.ScanRequest) (*sbomscanner.ScanResult, error) | ||
| healthVersion string | ||
| healthReady bool | ||
| healthErr error | ||
| } | ||
|
|
||
| func (m *mockScannerClient) CreateSBOM(ctx context.Context, req sbomscanner.ScanRequest) (*sbomscanner.ScanResult, error) { | ||
| if m.createSBOMFunc != nil { | ||
| return m.createSBOMFunc(ctx, req) | ||
| } | ||
| return nil, errors.New("not implemented") | ||
| } | ||
|
|
||
| func (m *mockScannerClient) Health(ctx context.Context) (string, bool, error) { | ||
| return m.healthVersion, m.healthReady, m.healthErr | ||
| } | ||
|
|
||
| func (m *mockScannerClient) Ready() bool { | ||
| return m.healthReady | ||
| } | ||
|
|
||
| func (m *mockScannerClient) Close() error { | ||
| return nil | ||
| } | ||
|
|
||
| func TestSidecarSBOMAdapter_CreateSBOM_Success(t *testing.T) { | ||
| mock := &mockScannerClient{ | ||
| healthVersion: "v0.100.0", | ||
| healthReady: true, | ||
| createSBOMFunc: func(ctx context.Context, req sbomscanner.ScanRequest) (*sbomscanner.ScanResult, error) { | ||
| return &sbomscanner.ScanResult{ | ||
| SyftDocument: &v1beta1.SyftDocument{ | ||
| Artifacts: []v1beta1.SyftPackage{ | ||
| {PackageBasicData: v1beta1.PackageBasicData{Name: "test-pkg", Version: "1.0.0"}}, | ||
| }, | ||
| }, | ||
| SBOMSize: 1024, | ||
| Status: helpersv1.Learning, | ||
| }, nil | ||
| }, | ||
| } | ||
|
|
||
| adapter := NewSidecarSBOMAdapter(mock, 5*time.Minute, 512*1024*1024, 20*1024*1024, false, "5Gi") | ||
|
|
||
| sbom, err := adapter.CreateSBOM(context.Background(), "test-sbom", "", "nginx:latest", domain.RegistryOptions{}) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, helpersv1.Learning, sbom.Status) | ||
| assert.NotNil(t, sbom.Content) | ||
| assert.Len(t, sbom.Content.Artifacts, 1) | ||
| assert.Equal(t, "test-pkg", sbom.Content.Artifacts[0].Name) | ||
| } | ||
|
|
||
| func TestSidecarSBOMAdapter_CreateSBOM_TooLarge(t *testing.T) { | ||
| mock := &mockScannerClient{ | ||
| healthVersion: "v0.100.0", | ||
| healthReady: true, | ||
| createSBOMFunc: func(ctx context.Context, req sbomscanner.ScanRequest) (*sbomscanner.ScanResult, error) { | ||
| return &sbomscanner.ScanResult{ | ||
| SBOMSize: 999999999, | ||
| Status: helpersv1.TooLarge, | ||
| }, nil | ||
| }, | ||
| } | ||
|
|
||
| adapter := NewSidecarSBOMAdapter(mock, 5*time.Minute, 512*1024*1024, 20*1024*1024, false, "5Gi") | ||
|
|
||
| sbom, err := adapter.CreateSBOM(context.Background(), "test-sbom", "", "large-image:latest", domain.RegistryOptions{}) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, helpersv1.TooLarge, sbom.Status) | ||
| assert.Nil(t, sbom.Content) | ||
| } | ||
|
|
||
| func TestSidecarSBOMAdapter_CreateSBOM_CrashRetry(t *testing.T) { | ||
| callCount := 0 | ||
| mock := &mockScannerClient{ | ||
| healthVersion: "v0.100.0", | ||
| healthReady: true, | ||
| createSBOMFunc: func(ctx context.Context, req sbomscanner.ScanRequest) (*sbomscanner.ScanResult, error) { | ||
| callCount++ | ||
| return nil, sbomscanner.ErrScannerCrashed | ||
| }, | ||
| } | ||
|
|
||
| adapter := NewSidecarSBOMAdapter(mock, 5*time.Minute, 512*1024*1024, 20*1024*1024, false, "5Gi") | ||
|
|
||
| // First two attempts should return crash error (for retry) | ||
| for i := 0; i < 2; i++ { | ||
| _, err := adapter.CreateSBOM(context.Background(), "test-sbom", "", "crash-image:latest", domain.RegistryOptions{}) | ||
| require.Error(t, err) | ||
| assert.ErrorIs(t, err, sbomscanner.ErrScannerCrashed) | ||
| } | ||
|
|
||
| // Third attempt should mark as TooLarge (exhausted retries) | ||
| sbom, err := adapter.CreateSBOM(context.Background(), "test-sbom", "", "crash-image:latest", domain.RegistryOptions{}) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, helpersv1.TooLarge, sbom.Status) | ||
| assert.Equal(t, 3, callCount) | ||
| } | ||
|
|
||
| func TestSidecarSBOMAdapter_Version(t *testing.T) { | ||
| mock := &mockScannerClient{ | ||
| healthVersion: "v0.100.0", | ||
| healthReady: true, | ||
| } | ||
|
|
||
| adapter := NewSidecarSBOMAdapter(mock, 5*time.Minute, 512*1024*1024, 20*1024*1024, false, "5Gi") | ||
| assert.Equal(t, "v0.100.0", adapter.Version()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ import ( | |
| "github.com/kubescape/kubevuln/controllers" | ||
| "github.com/kubescape/kubevuln/core/ports" | ||
| "github.com/kubescape/kubevuln/core/services" | ||
| sbomscanner "github.com/kubescape/kubevuln/pkg/sbomscanner/v1" | ||
| "github.com/kubescape/kubevuln/repositories" | ||
| "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" | ||
| ) | ||
|
|
@@ -68,7 +69,21 @@ func main() { | |
| logger.L().Ctx(ctx).Fatal("storage initialization error", helpers.Error(err)) | ||
| } | ||
| } | ||
| sbomAdapter := v1.NewSyftAdapter(c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms) | ||
| var sbomAdapter ports.SBOMCreator | ||
| if socketPath := os.Getenv("SBOM_SCANNER_SOCKET"); socketPath != "" { | ||
| logger.L().Info("connecting to SBOM scanner sidecar", helpers.String("socket", socketPath)) | ||
| scannerClient, err := sbomscanner.NewSBOMScannerClient(socketPath) | ||
| if err != nil { | ||
| logger.L().Warning("failed to connect to SBOM scanner sidecar, falling back to in-process Syft", | ||
| helpers.Error(err)) | ||
| sbomAdapter = v1.NewSyftAdapter(c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms) | ||
| } else { | ||
| memoryLimit := os.Getenv("SCANNER_MEMORY_LIMIT") | ||
| sbomAdapter = v1.NewSidecarSBOMAdapter(scannerClient, c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms, memoryLimit) | ||
| } | ||
| } else { | ||
| sbomAdapter = v1.NewSyftAdapter(c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms) | ||
| } | ||
|
Comment on lines
+72
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing cleanup of sidecar client on shutdown. The 🛡️ Proposed fix to add client cleanup+ var scannerClient sbomscanner.SBOMScannerClient
var sbomAdapter ports.SBOMCreator
if socketPath := os.Getenv("SBOM_SCANNER_SOCKET"); socketPath != "" {
logger.L().Info("connecting to SBOM scanner sidecar", helpers.String("socket", socketPath))
- scannerClient, err := sbomscanner.NewSBOMScannerClient(socketPath)
+ scannerClient, err = sbomscanner.NewSBOMScannerClient(socketPath)
if err != nil {Then add cleanup before exit: // After controller.Shutdown()
if scannerClient != nil {
scannerClient.Close()
}🤖 Prompt for AI Agents |
||
| cveAdapter := v1.NewGrypeAdapter(c.ListingURL, c.UseDefaultMatchers) | ||
| var platform ports.Platform | ||
| if c.KeepLocal { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/kubevuln/pkg/sbomscanner/v1" | ||
| pb "github.com/kubescape/kubevuln/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)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential integer overflow when casting
maxSBOMSizetoint32.s.maxSBOMSizeis typed asint, which on 64-bit systems can exceedint32max value (2,147,483,647). If a very large value is configured, this cast could silently overflow to a negative number.🛡️ Proposed fix: validate or use int64
Option 1: Add validation in constructor:
func NewSidecarSBOMAdapter( client sbomscanner.SBOMScannerClient, scanTimeout time.Duration, maxImageSize int64, maxSBOMSize int, scanEmbeddedSBOMs bool, memoryLimit string, ) *SidecarSBOMAdapter { + if maxSBOMSize > math.MaxInt32 { + maxSBOMSize = math.MaxInt32 + } return &SidecarSBOMAdapter{Option 2: Change proto field to
int64to match Go'sinton 64-bit.🤖 Prompt for AI Agents