Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions adapters/v1/sidecar.go
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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential integer overflow when casting maxSBOMSize to int32.

s.maxSBOMSize is typed as int, which on 64-bit systems can exceed int32 max 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 int64 to match Go's int on 64-bit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@adapters/v1/sidecar.go` at line 83, The MaxSBOMSize assignment casts
s.maxSBOMSize (an int) to int32 which can overflow on 64-bit systems; update the
code that sets MaxSBOMSize (where MaxSBOMSize: int32(s.maxSBOMSize) is used) to
validate s.maxSBOMSize before casting: either ensure it's within math.MaxInt32
(return an error or clamp the value) or change the proto/field to use int64 so
no downcast is necessary; reference the MaxSBOMSize field and the s.maxSBOMSize
variable when applying the validation or type change.

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
}
127 changes: 127 additions & 0 deletions adapters/v1/sidecar_test.go
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())
}
4 changes: 3 additions & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ ARG TARGETOS TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/kubevuln cmd/http/main.go
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/kubevuln cmd/http/main.go && \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/sbom-scanner cmd/sbom-scanner/main.go

FROM gcr.io/distroless/static-debian13:nonroot

USER nonroot
WORKDIR /home/nonroot/

COPY --from=builder /out/kubevuln /usr/bin/kubevuln
COPY --from=builder /out/sbom-scanner /usr/bin/sbom-scanner

ARG image_version
ENV RELEASE=$image_version
Expand Down
17 changes: 16 additions & 1 deletion cmd/http/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing cleanup of sidecar client on shutdown.

The scannerClient connection is never closed during graceful shutdown. While the process exits cleanly, it's good practice to close gRPC connections explicitly to ensure proper resource cleanup and observability.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@cmd/http/main.go` around lines 72 - 86, The SBOM scanner gRPC client
(scannerClient created by NewSBOMScannerClient) is never closed on shutdown;
declare scannerClient in the outer scope so it is accessible after setup, and
ensure you close it during shutdown (e.g., after controller.Shutdown() or in the
registered shutdown/cleanup path) by checking scannerClient != nil and calling
its Close() method; update the sbom adapter setup around sbomAdapter,
NewSidecarSBOMAdapter and NewSBOMScannerClient to preserve scannerClient for
later cleanup.

cveAdapter := v1.NewGrypeAdapter(c.ListingURL, c.UseDefaultMatchers)
var platform ports.Platform
if c.KeepLocal {
Expand Down
47 changes: 47 additions & 0 deletions cmd/sbom-scanner/main.go
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))
}
}
Loading
Loading