diff --git a/adapters/v1/sidecar.go b/adapters/v1/sidecar.go new file mode 100644 index 00000000..8100d761 --- /dev/null +++ b/adapters/v1/sidecar.go @@ -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 +} diff --git a/adapters/v1/sidecar_test.go b/adapters/v1/sidecar_test.go new file mode 100644 index 00000000..26b8a6dc --- /dev/null +++ b/adapters/v1/sidecar_test.go @@ -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()) +} diff --git a/build/Dockerfile b/build/Dockerfile index 2307ab82..cbc9c9e9 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -7,7 +7,8 @@ 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 @@ -15,6 +16,7 @@ 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 diff --git a/cmd/http/main.go b/cmd/http/main.go index ed6694ed..08b19aef 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -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) + } cveAdapter := v1.NewGrypeAdapter(c.ListingURL, c.UseDefaultMatchers) var platform ports.Platform if c.KeepLocal { diff --git a/cmd/sbom-scanner/main.go b/cmd/sbom-scanner/main.go new file mode 100644 index 00000000..f9e8619e --- /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/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)) + } +} diff --git a/pkg/sbomscanner/v1/client.go b/pkg/sbomscanner/v1/client.go new file mode 100644 index 00000000..469d9af1 --- /dev/null +++ b/pkg/sbomscanner/v1/client.go @@ -0,0 +1,138 @@ +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/kubevuln/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 +} + +// NewSBOMScannerClient creates a gRPC client connected to the scanner sidecar via Unix socket. +// It performs a health check with exponential backoff before returning. +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), + } + + // Wait for the sidecar to become ready + _, 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) { + // Map domain credentials to proto + creds := make([]*pb.RegistryCredentials, len(req.Options.Credentials)) + for i, v := range req.Options.Credentials { + creds[i] = &pb.RegistryCredentials{ + Authority: v.Authority, + Username: v.Username, + Password: v.Password, + Token: v.Token, + } + } + + pbReq := &pb.CreateSBOMRequest{ + ImageId: req.ImageID, + ImageTag: req.ImageTag, + Platform: req.Options.Platform, + Credentials: creds, + InsecureSkipTlsVerify: req.Options.InsecureSkipTLSVerify, + InsecureUseHttp: req.Options.InsecureUseHTTP, + 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 + } + + result := &ScanResult{ + SBOMSize: resp.SbomSize, + Status: resp.Status, + ErrorMessage: resp.ErrorMessage, + } + + // Deserialize SBOM document if present + if len(resp.SbomDocument) > 0 { + var doc v1beta1.SyftDocument + if err := json.Unmarshal(resp.SbomDocument, &doc); err != nil { + return nil, fmt.Errorf("failed to deserialize SBOM document: %w", err) + } + result.SyftDocument = &doc + } + + return result, nil +} + +func (c *sbomScannerClient) Health(ctx context.Context) (string, bool, error) { + resp, err := c.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return "", false, err + } + return resp.Version, resp.Ready, 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 00000000..f9993c74 --- /dev/null +++ b/pkg/sbomscanner/v1/integration_test.go @@ -0,0 +1,88 @@ +package v1 + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + "github.com/kubescape/kubevuln/core/domain" + pb "github.com/kubescape/kubevuln/pkg/sbomscanner/v1/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +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_HealthCheck(t *testing.T) { + client, srv, sock := startIntegrationServer(t) + defer srv.Stop() + defer os.Remove(sock) + defer client.Close() + + assert.True(t, client.Ready()) + + version, ready, err := client.Health(context.Background()) + require.NoError(t, err) + assert.True(t, ready) + assert.NotEmpty(t, version) +} + +func TestIntegration_SimulatedCrash(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", + Options: domain.RegistryOptions{}, + MaxImageSize: 1024 * 1024 * 1024, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrScannerCrashed) +} + +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 00000000..091691fc --- /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 00000000..d40045b2 --- /dev/null +++ b/pkg/sbomscanner/v1/proto/scanner.pb.go @@ -0,0 +1,459 @@ +// 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 RegistryCredentials struct { + state protoimpl.MessageState `protogen:"open.v1"` + Authority string `protobuf:"bytes,1,opt,name=authority,proto3" json:"authority,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` + Token string `protobuf:"bytes,4,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegistryCredentials) Reset() { + *x = RegistryCredentials{} + mi := &file_scanner_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegistryCredentials) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegistryCredentials) ProtoMessage() {} + +func (x *RegistryCredentials) 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 RegistryCredentials.ProtoReflect.Descriptor instead. +func (*RegistryCredentials) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{0} +} + +func (x *RegistryCredentials) GetAuthority() string { + if x != nil { + return x.Authority + } + return "" +} + +func (x *RegistryCredentials) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *RegistryCredentials) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *RegistryCredentials) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +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"` + Platform string `protobuf:"bytes,3,opt,name=platform,proto3" json:"platform,omitempty"` + Credentials []*RegistryCredentials `protobuf:"bytes,4,rep,name=credentials,proto3" json:"credentials,omitempty"` + InsecureSkipTlsVerify bool `protobuf:"varint,5,opt,name=insecure_skip_tls_verify,json=insecureSkipTlsVerify,proto3" json:"insecure_skip_tls_verify,omitempty"` + InsecureUseHttp bool `protobuf:"varint,6,opt,name=insecure_use_http,json=insecureUseHttp,proto3" json:"insecure_use_http,omitempty"` + MaxImageSize int64 `protobuf:"varint,7,opt,name=max_image_size,json=maxImageSize,proto3" json:"max_image_size,omitempty"` + MaxSbomSize int32 `protobuf:"varint,8,opt,name=max_sbom_size,json=maxSbomSize,proto3" json:"max_sbom_size,omitempty"` + EnableEmbeddedSboms bool `protobuf:"varint,9,opt,name=enable_embedded_sboms,json=enableEmbeddedSboms,proto3" json:"enable_embedded_sboms,omitempty"` + TimeoutSeconds int64 `protobuf:"varint,10,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[1] + 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[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 CreateSBOMRequest.ProtoReflect.Descriptor instead. +func (*CreateSBOMRequest) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{1} +} + +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) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *CreateSBOMRequest) GetCredentials() []*RegistryCredentials { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *CreateSBOMRequest) GetInsecureSkipTlsVerify() bool { + if x != nil { + return x.InsecureSkipTlsVerify + } + return false +} + +func (x *CreateSBOMRequest) GetInsecureUseHttp() bool { + if x != nil { + return x.InsecureUseHttp + } + return false +} + +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"` + // Terminal status: "Learning", "Incomplete", "TooLarge", "Unauthorize" + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + // JSON-serialized v1beta1.SyftDocument (empty for non-Learning statuses) + SbomDocument []byte `protobuf:"bytes,2,opt,name=sbom_document,json=sbomDocument,proto3" json:"sbom_document,omitempty"` + // Size of the in-memory SBOM before serialization + SbomSize int64 `protobuf:"varint,3,opt,name=sbom_size,json=sbomSize,proto3" json:"sbom_size,omitempty"` + // Error message (for non-terminal errors that should be propagated) + ErrorMessage string `protobuf:"bytes,4,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSBOMResponse) Reset() { + *x = CreateSBOMResponse{} + mi := &file_scanner_proto_msgTypes[2] + 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[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 CreateSBOMResponse.ProtoReflect.Descriptor instead. +func (*CreateSBOMResponse) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{2} +} + +func (x *CreateSBOMResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +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 +} + +func (x *CreateSBOMResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +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[3] + 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[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 HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_scanner_proto_rawDescGZIP(), []int{3} +} + +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[4] + 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[4] + 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{4} +} + +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\"\x81\x01\n" + + "\x13RegistryCredentials\x12\x1c\n" + + "\tauthority\x18\x01 \x01(\tR\tauthority\x12\x1a\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x03 \x01(\tR\bpassword\x12\x14\n" + + "\x05token\x18\x04 \x01(\tR\x05token\"\xba\x03\n" + + "\x11CreateSBOMRequest\x12\x19\n" + + "\bimage_id\x18\x01 \x01(\tR\aimageId\x12\x1b\n" + + "\timage_tag\x18\x02 \x01(\tR\bimageTag\x12\x1a\n" + + "\bplatform\x18\x03 \x01(\tR\bplatform\x12E\n" + + "\vcredentials\x18\x04 \x03(\v2#.sbomscanner.v1.RegistryCredentialsR\vcredentials\x127\n" + + "\x18insecure_skip_tls_verify\x18\x05 \x01(\bR\x15insecureSkipTlsVerify\x12*\n" + + "\x11insecure_use_http\x18\x06 \x01(\bR\x0finsecureUseHttp\x12$\n" + + "\x0emax_image_size\x18\a \x01(\x03R\fmaxImageSize\x12\"\n" + + "\rmax_sbom_size\x18\b \x01(\x05R\vmaxSbomSize\x122\n" + + "\x15enable_embedded_sboms\x18\t \x01(\bR\x13enableEmbeddedSboms\x12'\n" + + "\x0ftimeout_seconds\x18\n" + + " \x01(\x03R\x0etimeoutSeconds\"\x93\x01\n" + + "\x12CreateSBOMResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status\x12#\n" + + "\rsbom_document\x18\x02 \x01(\fR\fsbomDocument\x12\x1b\n" + + "\tsbom_size\x18\x03 \x01(\x03R\bsbomSize\x12#\n" + + "\rerror_message\x18\x04 \x01(\tR\ferrorMessage\"\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.HealthResponseB8Z6github.com/kubescape/kubevuln/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, 5) +var file_scanner_proto_goTypes = []any{ + (*RegistryCredentials)(nil), // 0: sbomscanner.v1.RegistryCredentials + (*CreateSBOMRequest)(nil), // 1: sbomscanner.v1.CreateSBOMRequest + (*CreateSBOMResponse)(nil), // 2: sbomscanner.v1.CreateSBOMResponse + (*HealthRequest)(nil), // 3: sbomscanner.v1.HealthRequest + (*HealthResponse)(nil), // 4: sbomscanner.v1.HealthResponse +} +var file_scanner_proto_depIdxs = []int32{ + 0, // 0: sbomscanner.v1.CreateSBOMRequest.credentials:type_name -> sbomscanner.v1.RegistryCredentials + 1, // 1: sbomscanner.v1.SBOMScanner.CreateSBOM:input_type -> sbomscanner.v1.CreateSBOMRequest + 3, // 2: sbomscanner.v1.SBOMScanner.Health:input_type -> sbomscanner.v1.HealthRequest + 2, // 3: sbomscanner.v1.SBOMScanner.CreateSBOM:output_type -> sbomscanner.v1.CreateSBOMResponse + 4, // 4: sbomscanner.v1.SBOMScanner.Health:output_type -> sbomscanner.v1.HealthResponse + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] 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: 5, + 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 00000000..074b0b9c --- /dev/null +++ b/pkg/sbomscanner/v1/proto/scanner.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; +package sbomscanner.v1; + +option go_package = "github.com/kubescape/kubevuln/pkg/sbomscanner/v1/proto"; + +service SBOMScanner { + rpc CreateSBOM(CreateSBOMRequest) returns (CreateSBOMResponse); + rpc Health(HealthRequest) returns (HealthResponse); +} + +message RegistryCredentials { + string authority = 1; + string username = 2; + string password = 3; + string token = 4; +} + +message CreateSBOMRequest { + string image_id = 1; + string image_tag = 2; + string platform = 3; + repeated RegistryCredentials credentials = 4; + bool insecure_skip_tls_verify = 5; + bool insecure_use_http = 6; + int64 max_image_size = 7; + int32 max_sbom_size = 8; + bool enable_embedded_sboms = 9; + int64 timeout_seconds = 10; +} + +message CreateSBOMResponse { + // Terminal status: "Learning", "Incomplete", "TooLarge", "Unauthorize" + string status = 1; + // JSON-serialized v1beta1.SyftDocument (empty for non-Learning statuses) + bytes sbom_document = 2; + // Size of the in-memory SBOM before serialization + int64 sbom_size = 3; + // Error message (for non-terminal errors that should be propagated) + string error_message = 4; +} + +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 00000000..4cd45bc5 --- /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 00000000..e514cf23 --- /dev/null +++ b/pkg/sbomscanner/v1/server.go @@ -0,0 +1,303 @@ +package v1 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "runtime" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/DmitriyVTitov/size" + "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/stereoscope/pkg/image" + "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" + sbomcataloger "github.com/anchore/syft/syft/pkg/cataloger/sbom" + "github.com/anchore/syft/syft/format/syftjson" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" + "github.com/eapache/go-resiliency/deadline" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + pb "github.com/kubescape/kubevuln/pkg/sbomscanner/v1/proto" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/google/go-containerregistry/pkg/name" + "github.com/opencontainers/go-digest" +) + +type scannerServer struct { + pb.UnimplementedSBOMScannerServer + mu sync.Mutex + version string +} + +// NewScannerServer creates a new gRPC scanner server. +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() + + imageID := req.ImageId + imageTag := req.ImageTag + + // Normalize image ID (same logic as SyftAdapter) + if imageTag != "" { + imageID = normalizeImageID(imageID, imageTag) + } + + // Parse platform for multi-arch image resolution. + // The platform specifier uses OCI format: "os/arch[/variant]" (e.g. "linux/amd64"). + // If only an architecture is provided (e.g. "amd64"), we prepend "linux/". + platformStr := req.Platform + if platformStr == "" { + platformStr = runtime.GOARCH + } + if !strings.Contains(platformStr, "/") { + platformStr = "linux/" + platformStr + } + imgPlatform, err := image.NewPlatform(platformStr) + if err != nil { + return nil, fmt.Errorf("invalid platform %q: %w", platformStr, err) + } + + // Build registry credentials + credentials := make([]image.RegistryCredentials, len(req.Credentials)) + for i, c := range req.Credentials { + credentials[i] = image.RegistryCredentials{ + Authority: c.Authority, + Username: c.Username, + Password: c.Password, + Token: c.Token, + } + } + registryOptions := image.RegistryOptions{ + InsecureSkipTLSVerify: req.InsecureSkipTlsVerify, + InsecureUseHTTP: req.InsecureUseHttp, + Credentials: credentials, + } + + // Prepare temp dir for stereoscope + t := file.NewTempDirGenerator("stereoscope") + defer func() { + if err := t.Cleanup(); err != nil { + logger.L().Warning("failed to cleanup temp dir", helpers.Error(err), + helpers.String("imageID", imageID)) + } + }() + + // Download image from registry + logger.L().Debug("downloading image", helpers.String("imageID", imageID)) + ctxWithSize := context.WithValue(context.Background(), image.MaxImageSize, req.MaxImageSize) + src, err := syft.GetSource(ctxWithSize, imageID, + syft.DefaultGetSourceConfig().WithRegistryOptions(®istryOptions).WithPlatform(imgPlatform).WithSources("registry")) + + if err != nil && strings.Contains(err.Error(), "MANIFEST_UNKNOWN") { + logger.L().Debug("got MANIFEST_UNKNOWN, retrying with imageTag", + helpers.String("imageTag", imageTag), + helpers.String("imageID", imageID)) + src, err = syft.GetSource(ctxWithSize, imageTag, + syft.DefaultGetSourceConfig().WithRegistryOptions(®istryOptions).WithPlatform(imgPlatform).WithSources("registry")) + } + + if err != nil && strings.Contains(err.Error(), "401 Unauthorized") { + logger.L().Debug("got 401, retrying without credentials", + helpers.String("imageID", imageID)) + registryOptions.Credentials = nil + src, err = syft.GetSource(ctxWithSize, imageID, + syft.DefaultGetSourceConfig().WithRegistryOptions(®istryOptions).WithPlatform(imgPlatform).WithSources("registry")) + } + + switch { + case err != nil && strings.Contains(err.Error(), image.ErrImageTooLarge.Error()): + logger.L().Warning("Image exceeds size limit", + helpers.Int("maxImageSize", int(req.MaxImageSize)), + helpers.String("imageID", imageID)) + return &pb.CreateSBOMResponse{ + Status: helpersv1.Incomplete, + }, nil + case err != nil && strings.Contains(err.Error(), "401 Unauthorized"): + return &pb.CreateSBOMResponse{ + Status: helpersv1.Unauthorize, + ErrorMessage: err.Error(), + }, nil + case err != nil: + return &pb.CreateSBOMResponse{ + ErrorMessage: err.Error(), + }, nil + } + + // Generate SBOM with timeout + var syftSBOM *sbom.SBOM + timeout := time.Duration(req.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 5 * time.Minute + } + dl := deadline.New(timeout) + err = dl.Run(func(stopper <-chan struct{}) error { + defer func(src source.Source) { + if err := src.Close(); err != nil { + logger.L().Warning("failed to close source", helpers.Error(err), + helpers.String("imageID", imageID)) + } + }(src) + + logger.L().Debug("generating SBOM", helpers.String("imageID", imageID)) + cfg := syft.DefaultCreateSBOMConfig() + cfg.ToolName = "syft" + cfg.ToolVersion = s.version + if req.EnableEmbeddedSboms { + cfg.WithCatalogers(pkgcataloging.NewCatalogerReference( + sbomcataloger.NewCataloger(), []string{pkgcataloging.ImageTag})) + } + // NOTE: Syft's cataloguers do not support context cancellation (see + // https://github.com/anchore/syft/issues/3705). The deadline.Run wrapper + // will return ErrTimedOut, but the Syft goroutine may continue until it + // finishes naturally. This is an accepted tradeoff — the sidecar's memory + // limit will OOM-kill the container if resource usage grows unbounded. + syftSBOM, err = syft.CreateSBOM(context.Background(), src, cfg) + if err != nil { + return fmt.Errorf("failed to generate SBOM: %w", err) + } + return nil + }) + + switch { + case errors.Is(err, deadline.ErrTimedOut): + logger.L().Warning("Syft timed out", helpers.String("imageID", imageID)) + return &pb.CreateSBOMResponse{ + Status: helpersv1.Incomplete, + }, nil + case err == nil: + // continue + default: + return &pb.CreateSBOMResponse{ + Status: helpersv1.Incomplete, + ErrorMessage: err.Error(), + }, nil + } + + // Strip the SBOM to reduce size + v1beta1.StripSBOM(syftSBOM) + + // Check in-memory size + sz := size.Of(syftSBOM) + if sz > int(req.MaxSbomSize) { + logger.L().Warning("SBOM exceeds size limit", + helpers.Int("maxSBOMSize", int(req.MaxSbomSize)), + helpers.Int("size", sz), + helpers.String("imageID", imageID)) + return &pb.CreateSBOMResponse{ + Status: helpersv1.TooLarge, + SbomSize: int64(sz), + }, nil + } + + // Convert to SyftDocument and serialize + doc := syftToDomain(*syftSBOM) + docBytes, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("failed to serialize SBOM: %w", err) + } + + logger.L().Info("SBOM scan completed", + helpers.String("imageID", imageID), + helpers.Int("sbomSize", len(docBytes)), + helpers.Int("packages", len(doc.Artifacts))) + + return &pb.CreateSBOMResponse{ + Status: helpersv1.Learning, + SbomDocument: docBytes, + SbomSize: int64(sz), + }, nil +} + +func (s *scannerServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + return &pb.HealthResponse{ + Version: s.version, + Ready: true, + }, nil +} + +// syftToDomain converts a Syft SBOM to a v1beta1.SyftDocument. +// This is the same logic as SyftAdapter.syftToDomain. +func syftToDomain(sbomSBOM sbom.SBOM) *v1beta1.SyftDocument { + doc := syftjson.ToFormatModel(sbomSBOM, syftjson.EncoderConfig{ + Pretty: false, + Legacy: false, + }) + + b, err := json.Marshal(doc) + if err != nil { + return nil + } + + var syftDoc *v1beta1.SyftDocument + if err := json.Unmarshal(b, &syftDoc); err != nil { + return nil + } + for i := range syftDoc.Artifacts { + for j := range doc.Artifacts { + if syftDoc.Artifacts[i].ID == doc.Artifacts[j].ID { + syftDoc.Artifacts[i].MetadataType = doc.Artifacts[j].MetadataType + if b, err := json.Marshal(doc.Artifacts[j].Metadata); err == nil { + syftDoc.Artifacts[i].Metadata = b + } + break + } + } + } + + return syftDoc +} + +const digestDelim = "@" + +// normalizeImageID is the same logic as the top-level NormalizeImageID in adapters/v1/syft.go. +func normalizeImageID(imageID, imageTag string) string { + if imageID == "" { + return imageTag + } + + // try to parse imageID as a full digest + if newDigest, err := name.NewDigest(imageID); err == nil { + return newDigest.String() + } + // if it's not a full digest, use imageTag as a reference + tag, err := name.ParseReference(imageTag) + if err != nil { + return "" + } + + // and append imageID as a digest + parts := strings.Split(imageID, digestDelim) + if len(parts) > 1 { + imageID = parts[len(parts)-1] + } + prefix := digest.Canonical.String() + ":" + if !strings.HasPrefix(imageID, prefix) { + imageID = prefix + imageID + } + return tag.Context().String() + "@" + imageID +} + +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 00000000..7d1e7440 --- /dev/null +++ b/pkg/sbomscanner/v1/server_test.go @@ -0,0 +1,95 @@ +package v1 + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + pb "github.com/kubescape/kubevuln/pkg/sbomscanner/v1/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +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_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) +} + +func TestNormalizeImageID(t *testing.T) { + tests := []struct { + name string + imageID string + imageTag string + expected string + }{ + { + name: "empty imageID uses imageTag", + imageID: "", + imageTag: "nginx:latest", + expected: "nginx:latest", + }, + { + name: "full digest reference", + imageID: "docker.io/library/nginx@sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", + imageTag: "nginx:latest", + expected: "docker.io/library/nginx@sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeImageID(tt.imageID, tt.imageTag) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/sbomscanner/v1/types.go b/pkg/sbomscanner/v1/types.go new file mode 100644 index 00000000..fe26b6e8 --- /dev/null +++ b/pkg/sbomscanner/v1/types.go @@ -0,0 +1,42 @@ +package v1 + +import ( + "context" + "errors" + "time" + + "github.com/kubescape/kubevuln/core/domain" + "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") +) + +// ScanRequest contains all parameters needed for a registry-based SBOM scan. +type ScanRequest struct { + ImageID string + ImageTag string + Options domain.RegistryOptions + MaxImageSize int64 + MaxSBOMSize int32 + EnableEmbeddedSBOMs bool + Timeout time.Duration +} + +// ScanResult contains the SBOM document and metadata from a successful scan. +type ScanResult struct { + SyftDocument *v1beta1.SyftDocument + SBOMSize int64 + Status string + ErrorMessage string +} + +// SBOMScannerClient is the interface for communicating with the sidecar scanner. +type SBOMScannerClient interface { + CreateSBOM(ctx context.Context, req ScanRequest) (*ScanResult, error) + Health(ctx context.Context) (version string, ready bool, err error) + Ready() bool + Close() error +}