diff --git a/go.mod b/go.mod index b2f835110..297399d5e 100644 --- a/go.mod +++ b/go.mod @@ -82,7 +82,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/internal/app/sarif/importer.go b/internal/app/sarif/importer.go new file mode 100644 index 000000000..363ad3b13 --- /dev/null +++ b/internal/app/sarif/importer.go @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "context" + "fmt" + "time" + + "github.com/cloudoperators/heureka/internal/app/scanner" + "github.com/cloudoperators/heureka/internal/entity" + appErrors "github.com/cloudoperators/heureka/internal/errors" + + "github.com/google/uuid" +) + +// Mock Database interface for POC +type mockDatabase struct{} + +func (m *mockDatabase) CreateScannerRun(run *entity.ScannerRun) (*entity.ScannerRun, error) { + run.RunID = 1 + return run, nil +} +func (m *mockDatabase) CompleteScannerRun(runUUID string) (*entity.ScannerRun, error) { + return &entity.ScannerRun{UUID: runUUID}, nil +} +func (m *mockDatabase) FailScannerRun(runUUID, message string) (*entity.ScannerRun, error) { + return &entity.ScannerRun{UUID: runUUID}, nil +} +func (m *mockDatabase) GetComponentInstance(id int64) (*entity.ComponentInstance, error) { + return &entity.ComponentInstance{Id: id}, nil +} +func (m *mockDatabase) CreateScannerAssetMapping(mapping *entity.ScannerAssetMapping) (*entity.ScannerAssetMapping, error) { + mapping.Id = 1 + return mapping, nil +} +func (m *mockDatabase) GetScannerAssetMappingByUri(scannerName, artifactUri string) (*entity.ScannerAssetMapping, error) { + // purely mock + if artifactUri == "/path/to/known/asset" { + return &entity.ScannerAssetMapping{ + ComponentInstanceId: 42, + ArtifactUri: artifactUri, + }, nil + } + return nil, nil +} + +type mockIssueHandler struct{} + +func (m *mockIssueHandler) CreateIssue(ctx context.Context, issue *entity.Issue) (*entity.Issue, error) { + issue.Id = 1 + return issue, nil +} +func (m *mockIssueHandler) GetIssue(ctx context.Context, id int64) (*entity.Issue, error) { + return nil, nil +} +func (m *mockIssueHandler) ListIssues(ctx context.Context, options entity.IssueListOptions) ([]*entity.Issue, error) { + return nil, nil +} +func (m *mockIssueHandler) UpdateIssue(ctx context.Context, issue *entity.Issue) (*entity.Issue, error) { + return nil, nil +} +func (m *mockIssueHandler) DeleteIssue(ctx context.Context, id int64) error { return nil } + +type mockIssueMatchHandler struct{} + +func (m *mockIssueMatchHandler) CreateIssueMatch(ctx context.Context, match *entity.IssueMatch) (*entity.IssueMatch, error) { + match.Id = 1 + return match, nil +} +func (m *mockIssueMatchHandler) GetIssueMatch(ctx context.Context, id int64) (*entity.IssueMatch, error) { + return nil, nil +} +func (m *mockIssueMatchHandler) ListIssueMatches(filter *entity.IssueMatchFilter, options *entity.ListOptions) (*entity.List[entity.IssueMatchResult], error) { + return nil, nil +} +func (m *mockIssueMatchHandler) UpdateIssueMatch(ctx context.Context, match *entity.IssueMatch) (*entity.IssueMatch, error) { + return nil, nil +} +func (m *mockIssueMatchHandler) DeleteIssueMatch(ctx context.Context, id int64) error { return nil } +func (m *mockIssueMatchHandler) ListIssueMatchesByIssue(ctx context.Context, issueId int64) ([]*entity.IssueMatch, error) { + return nil, nil +} + +type sarifImporter struct { + parser *Parser + assetMapper scanner.AssetMapper + issueHandler mockIssueHandler + matchHandler mockIssueMatchHandler + db *mockDatabase +} + +func NewSARIFImporter() Importer { + db := &mockDatabase{} + return &sarifImporter{ + parser: &Parser{}, + assetMapper: scanner.NewAssetMapper(db), + issueHandler: mockIssueHandler{}, + matchHandler: mockIssueMatchHandler{}, + db: db, + } +} + +func (m *mockDatabase) ListComponentInstances(serviceId int64) ([]ComponentMatch, error) { + // purely mock + if serviceId == 1 { + return []ComponentMatch{ + {ComponentInstanceId: 101, PackageName: "example/lib", Version: "1.0.0", Purl: "pkg:npm/example/lib@1.0.0"}, + {ComponentInstanceId: 102, PackageName: "openssl", Version: "3.0.1", Purl: "pkg:generic/openssl@3.0.1"}, + }, nil + } + return []ComponentMatch{}, nil +} + +func (si *sarifImporter) ImportSARIF(ctx context.Context, input *ImportInput) (*ImportResult, error) { + op := appErrors.Op("SARIFImporter.ImportSARIF") + + if input.SARIFDocument == "" { + return nil, appErrors.E(op, "SARIF document is required") + } + + if input.ServiceId == 0 { + return nil, appErrors.E(op, "Service ID is required") + } + + if input.Tag == "" { + return nil, appErrors.E(op, "Scanner run tag is required") + } + + // AUTOMATION: If no components provided, fetch them from the database automatically + serviceComponents := input.ServiceComponents + if len(serviceComponents) == 0 { + autoComponents, err := si.db.ListComponentInstances(input.ServiceId) + if err != nil { + return nil, appErrors.E(op, "Failed to auto-discover service components", err) + } + serviceComponents = autoComponents + } + + if len(serviceComponents) == 0 { + return nil, appErrors.E(op, "No component instances found for service. Resolution is impossible.") + } + + result := &ImportResult{ + Errors: []ImportError{}, + } + + parsed, err := si.parser.ParseSARIFDocument(input.SARIFDocument) + if err != nil { + return result, appErrors.E(op, "Failed to parse SARIF", err) + } + + if input.ScannerName != "" && input.ScannerName != parsed.ScannerName { + return result, appErrors.E(op, fmt.Sprintf("Scanner name mismatch: input specified '%s' but SARIF document specifies '%s'", input.ScannerName, parsed.ScannerName)) + } + + for _, parseErr := range parsed.Errors { + result.Errors = append(result.Errors, ImportError{ + Line: parseErr.Line, + Message: parseErr.Message, + Severity: parseErr.Severity, + }) + } + + scannerRun := &entity.ScannerRun{ + UUID: uuid.New().String(), + Tag: input.Tag, + StartRun: time.Now(), + Completed: false, + } + + createdRun, err := si.db.CreateScannerRun(scannerRun) + if err != nil { + return result, appErrors.E(op, "Failed to create scanner run", err) + } + + result.ScannerRunId = createdRun.RunID + + // Create package resolver from service components (either provided or auto-discovered) + resolver := NewPackageResolver(serviceComponents) + + uniqueArtifacts := make(map[string]bool) + + for _, parsedResult := range parsed.Results { + artifactUri := parsedResult.ArtifactUri + var componentInstanceId int64 + + // Strategy 1: Look up by ScannerAssetMapping (Direct Mapping) + mapping, err := si.assetMapper.GetAssetMapping(ctx, parsed.ScannerName, artifactUri) + if err == nil && mapping != nil { + componentInstanceId = mapping.ComponentInstanceId + } else { + // Strategy 2: Extract package info from SARIF and Resolve (Standardized Meta-matching) + info, found := parsedResult.GetPackageInfo() + if !found { + result.Errors = append(result.Errors, ImportError{ + Line: 0, + Message: fmt.Sprintf("Failed to extract package info from artifact %s and no pre-mapping found", artifactUri), + Severity: "warning", + }) + continue + } + + // Resolve via PackageResolver (PURL first, then Name/Version) + id, resolved := resolver.Resolve(info) + if !resolved { + result.Errors = append(result.Errors, ImportError{ + Line: 0, + Message: fmt.Sprintf("Could not resolve package %s to a component instance", info.String()), + Severity: "warning", + }) + continue + } + componentInstanceId = id + } + + asset := &entity.ComponentInstance{Id: componentInstanceId} + // ... + + issueEntity := &entity.Issue{ + Type: entity.IssueTypeVulnerability, + PrimaryName: parsedResult.Rule.Id, + Description: parsedResult.Rule.ShortDescription.Text, + } + + createdIssue, err := si.issueHandler.CreateIssue(ctx, issueEntity) + if err != nil { + result.Errors = append(result.Errors, ImportError{ + Line: 0, + Message: fmt.Sprintf("Failed to create issue %s: %v", issueEntity.PrimaryName, err), + Severity: "error", + }) + continue + } + + matchEntity := &entity.IssueMatch{ + IssueId: createdIssue.Id, + ComponentInstanceId: asset.Id, + Status: entity.IssueMatchStatusValuesNew, + UserId: 1, + } + + severity := entity.NewSeverityFromRating(parsedResult.Severity) + matchEntity.Severity = severity + + _, err = si.matchHandler.CreateIssueMatch(ctx, matchEntity) + if err != nil { + result.Errors = append(result.Errors, ImportError{ + Line: 0, + Message: fmt.Sprintf("Failed to create issue match for %s on %s: %v", issueEntity.PrimaryName, artifactUri, err), + Severity: "warning", + }) + continue + } + + result.IssueMatchesCreated++ + + if !uniqueArtifacts[artifactUri] { + uniqueArtifacts[artifactUri] = true + result.IssuesCreated++ + } + } + + if len(result.Errors) == 0 { + _, err = si.db.CompleteScannerRun(scannerRun.UUID) + if err != nil { + return result, appErrors.E(op, "Failed to complete scanner run", err) + } + } else { + _, err = si.db.FailScannerRun(scannerRun.UUID, fmt.Sprintf("Import completed with %d errors", len(result.Errors))) + if err != nil { + return result, appErrors.E(op, "Failed to fail scanner run", err) + } + } + + return result, nil +} diff --git a/internal/app/sarif/importer_interface.go b/internal/app/sarif/importer_interface.go new file mode 100644 index 000000000..0446698e8 --- /dev/null +++ b/internal/app/sarif/importer_interface.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "context" +) + +type Importer interface { + ImportSARIF(ctx context.Context, input *ImportInput) (*ImportResult, error) +} + +type ImportInput struct { + SARIFDocument string + ScannerName string + ServiceId int64 + Tag string + ServiceComponents []ComponentMatch +} + +type ImportResult struct { + ScannerRunId int64 + IssuesCreated int + IssueMatchesCreated int + AssetsCreated int + Errors []ImportError +} + +type ImportError struct { + Line int + Message string + Severity string +} diff --git a/internal/app/sarif/parser.go b/internal/app/sarif/parser.go new file mode 100644 index 000000000..24eb2581d --- /dev/null +++ b/internal/app/sarif/parser.go @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "encoding/json" + "fmt" + + appErrors "github.com/cloudoperators/heureka/internal/errors" // Corrected import path +) + +type Parser struct{} + +func (p *Parser) ParseSARIFDocument(sarifJSON string) (*ParsedSARIFData, error) { + op := appErrors.Op("Parser.ParseSARIFDocument") + + var doc SARIFDocument + if err := json.Unmarshal([]byte(sarifJSON), &doc); err != nil { + return nil, appErrors.E(op, "Invalid SARIF JSON", err) + } + + // Validate SARIF structure + if err := p.validateSARIF(&doc); err != nil { + return nil, appErrors.E(op, "SARIF validation failed", err) + } + + parsed := &ParsedSARIFData{ + Rules: make(map[string]*SARIFRule), + Results: []ParsedSARIFResult{}, + Errors: []ParseError{}, + } + + // Process all runs + for _, run := range doc.Runs { + parsed.ScannerName = run.Tool.Driver.Name + + // Index all rules by ID + for i := range run.Tool.Driver.Rules { + rule := &run.Tool.Driver.Rules[i] + parsed.Rules[rule.Id] = rule + } + + // Process all results + for resultIdx, result := range run.Results { + parsedResult, err := p.parseResult(result, parsed.Rules) + if err != nil { + parsed.Errors = append(parsed.Errors, ParseError{ + Line: resultIdx, + Message: err.Error(), + Severity: "error", + }) + continue + } + parsed.Results = append(parsed.Results, parsedResult) + } + } + + return parsed, nil +} + +// validateSARIF checks SARIF document structure and required fields +func (p *Parser) validateSARIF(doc *SARIFDocument) error { + op := appErrors.Op("Parser.validateSARIF") + + if doc.Version != "2.1.0" { + return appErrors.E(op, fmt.Sprintf("Unsupported SARIF version: %s (expected 2.1.0)", doc.Version)) + } + + if len(doc.Runs) == 0 { + return appErrors.E(op, "SARIF document must contain at least one run") + } + + for runIdx, run := range doc.Runs { + if err := p.validateRun(&run, runIdx); err != nil { + return err + } + } + + return nil +} + +func (p *Parser) validateRun(run *SARIFRun, runIdx int) error { + op := appErrors.Op("Parser.validateRun") + + if run.Tool.Driver.Name == "" { + return appErrors.E(op, fmt.Sprintf("Tool driver name is required in run %d", runIdx)) + } + + if err := p.validateRules(run.Tool.Driver.Rules, runIdx); err != nil { + return err + } + + if err := p.validateResults(run.Results, runIdx); err != nil { + return err + } + + return nil +} + +func (p *Parser) validateRules(rules []SARIFRule, runIdx int) error { + op := appErrors.Op("Parser.validateRules") + + for ruleIdx, rule := range rules { + if rule.Id == "" { + return appErrors.E(op, fmt.Sprintf("Rule ID is required in run %d, rule index %d", runIdx, ruleIdx)) + } + + if rule.DefaultConfiguration.Level != "" { + if !isValidSARIFLevel(rule.DefaultConfiguration.Level) { + return appErrors.E(op, fmt.Sprintf("Invalid rule level '%s' in run %d, rule '%s'. Must be one of: none, note, warning, error", rule.DefaultConfiguration.Level, runIdx, rule.Id)) + } + } + } + + return nil +} + +func (p *Parser) validateResults(results []SARIFResult, runIdx int) error { + op := appErrors.Op("Parser.validateResults") + + for resultIdx, result := range results { + if result.RuleId == "" { + return appErrors.E(op, fmt.Sprintf("Result rule ID is required in run %d, result index %d", runIdx, resultIdx)) + } + + if result.Level != "" { + if !isValidSARIFLevel(result.Level) { + return appErrors.E(op, fmt.Sprintf("Invalid result level '%s' in run %d, result %d. Must be one of: none, note, warning, error", result.Level, runIdx, resultIdx)) + } + } + + if result.Message.Text == "" && result.Message.Markdown == "" { + return appErrors.E(op, fmt.Sprintf("Result must have either text or markdown message in run %d, result %d", runIdx, resultIdx)) + } + + if len(result.Locations) == 0 { + return appErrors.E(op, fmt.Sprintf("Result must have at least one location in run %d, result %d", runIdx, resultIdx)) + } + + for locIdx, location := range result.Locations { + if err := p.validateLocation(&location, runIdx, resultIdx, locIdx); err != nil { + return err + } + } + } + + return nil +} + +func (p *Parser) validateLocation(location *SARIFLocation, runIdx, resultIdx, locIdx int) error { + op := appErrors.Op("Parser.validateLocation") + + if location.PhysicalLocation.ArtifactLocation.Uri == "" { + return appErrors.E(op, fmt.Sprintf("Artifact location URI is required in run %d, result %d, location %d", runIdx, resultIdx, locIdx)) + } + + return nil +} + +func isValidSARIFLevel(level string) bool { + validLevels := map[string]bool{ + "none": true, + "note": true, + "warning": true, + "error": true, + } + return validLevels[level] +} + +func (p *Parser) parseResult(result SARIFResult, rules map[string]*SARIFRule) (ParsedSARIFResult, error) { + op := appErrors.Op("Parser.parseResult") + + if result.RuleId == "" { + return ParsedSARIFResult{}, appErrors.E(op, "Result rule ID is required") + } + + rule, exists := rules[result.RuleId] + if !exists { + return ParsedSARIFResult{}, appErrors.E(op, fmt.Sprintf("Rule not found: %s", result.RuleId)) + } + + // Extract artifact URI + artifactUri := "" + if len(result.Locations) > 0 && result.Locations[0].PhysicalLocation.ArtifactLocation.Uri != "" { + artifactUri = result.Locations[0].PhysicalLocation.ArtifactLocation.Uri + } + + if artifactUri == "" { + return ParsedSARIFResult{}, appErrors.E(op, "No artifact location found in result") + } + + // Map severity + severity := MapSeverity(result.Level, rule, result.Properties) + + return ParsedSARIFResult{ + Rule: rule, + Result: &result, + ArtifactUri: artifactUri, + Severity: severity, + Message: result.Message.Text, + }, nil +} + +// GetRuleById retrieves a rule by ID from parsed data +func (p *ParsedSARIFData) GetRuleById(ruleId string) *SARIFRule { + return p.Rules[ruleId] +} + +// GetResultsByArtifact returns all results for a specific artifact +func (p *ParsedSARIFData) GetResultsByArtifact(artifactUri string) []ParsedSARIFResult { + var results []ParsedSARIFResult + for _, result := range p.Results { + if result.ArtifactUri == artifactUri { + results = append(results, result) + } + } + return results +} + +// HasErrors returns true if there were parsing errors +func (p *ParsedSARIFData) HasErrors() bool { + return len(p.Errors) > 0 +} + +// ErrorCount returns the number of errors +func (p *ParsedSARIFData) ErrorCount() int { + return len(p.Errors) +} diff --git a/internal/app/sarif/resolver.go b/internal/app/sarif/resolver.go new file mode 100644 index 000000000..e06a528a1 --- /dev/null +++ b/internal/app/sarif/resolver.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +// ComponentMatch represents a component instance that can be matched against package names/versions +type ComponentMatch struct { + ComponentInstanceId int64 + PackageName string + Version string + Purl string +} + +type PackageResolver struct { + components []ComponentMatch +} + +func NewPackageResolver(components []ComponentMatch) *PackageResolver { + return &PackageResolver{ + components: components, + } +} + +// Resolve attempts to find a ComponentInstanceId for a package with the following strategies: +// 1. Exact PURL match +// 2. Exact (Name + Version) match +// 3. Name-only match +func (pr *PackageResolver) Resolve(info *PackageInfo) (int64, bool) { + if pr == nil || len(pr.components) == 0 || info == nil { + return 0, false + } + + if info.Purl != "" { + for _, comp := range pr.components { + if comp.Purl == info.Purl { + return comp.ComponentInstanceId, true + } + } + } + + if info.Name != "" && info.Version != "" { + for _, comp := range pr.components { + if comp.PackageName == info.Name && comp.Version == info.Version { + return comp.ComponentInstanceId, true + } + } + } + + if info.Name != "" { + for _, comp := range pr.components { + if comp.PackageName == info.Name { + return comp.ComponentInstanceId, true + } + } + } + + return 0, false +} diff --git a/internal/app/sarif/resolver_test.go b/internal/app/sarif/resolver_test.go new file mode 100644 index 000000000..6a332cdb0 --- /dev/null +++ b/internal/app/sarif/resolver_test.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "testing" +) + +func TestPackageResolver(t *testing.T) { + components := []ComponentMatch{ + { + ComponentInstanceId: 10, + PackageName: "example/lib", + Version: "1.0.0", + Purl: "pkg:deb/debian/example/lib@1.0.0", + }, + { + ComponentInstanceId: 11, + PackageName: "another/dep", + Version: "2.1.0", + Purl: "pkg:npm/another/dep@2.1.0", + }, + } + + resolver := NewPackageResolver(components) + + tests := []struct { + name string + pkgName string + version string + expectedId int64 + shouldFind bool + }{ + { + name: "Exact match with name and version", + pkgName: "example/lib", + version: "1.0.0", + expectedId: 10, + shouldFind: true, + }, + { + name: "Exact match second component", + pkgName: "another/dep", + version: "2.1.0", + expectedId: 11, + shouldFind: true, + }, + { + name: "Fallback match - only name matches (different version)", + pkgName: "example/lib", + version: "2.0.0", + expectedId: 10, + shouldFind: true, + }, + { + name: "Not found - package doesn't exist", + pkgName: "unknown/package", + version: "1.0.0", + expectedId: 0, + shouldFind: false, + }, + { + name: "Not found - empty package name", + pkgName: "", + version: "1.0.0", + expectedId: 0, + shouldFind: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &PackageInfo{Name: tt.pkgName, Version: tt.version} + id, found := resolver.Resolve(info) + + if found != tt.shouldFind { + t.Errorf("expected found=%v, got %v", tt.shouldFind, found) + } + + if found && id != tt.expectedId { + t.Errorf("expected id=%d, got %d", tt.expectedId, id) + } + }) + } +} + +func TestPackageResolverNilSafety(t *testing.T) { + var resolver *PackageResolver + id, found := resolver.Resolve(&PackageInfo{Name: "example/lib", Version: "1.0.0"}) + if found { + t.Errorf("expected found=false for nil resolver, got true") + } + + if id != 0 { + t.Errorf("expected id=0 for nil resolver, got %d", id) + } + + emptyResolver := NewPackageResolver([]ComponentMatch{}) + id, found = emptyResolver.Resolve(&PackageInfo{Name: "example/lib", Version: "1.0.0"}) + if found { + t.Errorf("expected found=false for empty resolver, got true") + } + + if id != 0 { + t.Errorf("expected id=0 for empty resolver, got %d", id) + } +} diff --git a/internal/app/sarif/sarif_import_scenarios_test.go b/internal/app/sarif/sarif_import_scenarios_test.go new file mode 100644 index 000000000..6c5d78fb8 --- /dev/null +++ b/internal/app/sarif/sarif_import_scenarios_test.go @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "context" + "testing" +) + +func TestSARIFImportWithUnresolvedPackages(t *testing.T) { + exampleTrivySARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "Trivy", + "version": "0.49.1", + "rules": [ + { + "id": "CVE-2024-58251", + "name": "CVE-2024-58251", + "shortDescription": { "text": "Example vulnerability" }, + "fullDescription": { "text": "Example" }, + "defaultConfiguration": { "level": "warning" }, + "properties": { "security-severity": "6.1" } + }, + { + "id": "CVE-2025-99999", + "name": "CVE-2025-99999", + "shortDescription": { "text": "Unknown package vulnerability" }, + "fullDescription": { "text": "This package is not in our system" }, + "defaultConfiguration": { "level": "error" }, + "properties": { "security-severity": "9.0" } + } + ] + } + }, + "results": [ + { + "ruleId": "CVE-2024-58251", + "level": "warning", + "message": { "text": "Found in known package" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "pkg:deb/debian/example/lib@1.0.0" } + } + } + ], + "properties": { "PkgName": "example/lib", "InstalledVersion": "1.0.0", "VulnerabilityID": "CVE-2024-58251" } + }, + { + "ruleId": "CVE-2025-99999", + "level": "error", + "message": { "text": "Found in unknown package" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "pkg:npm/unknown/package@5.0.0" } + } + } + ], + "properties": { "PkgName": "unknown/package", "InstalledVersion": "5.0.0", "VulnerabilityID": "CVE-2025-99999" } + } + ] + } + ] +}` + + importer := NewSARIFImporter() + ctx := context.Background() + + serviceComponents := []ComponentMatch{ + { + ComponentInstanceId: 10, + PackageName: "example/lib", + Version: "1.0.0", + Purl: "pkg:deb/debian/example/lib@1.0.0", + }, + } + + input := &ImportInput{ + SARIFDocument: exampleTrivySARIF, + ScannerName: "Trivy", + ServiceId: 1, + Tag: "test-scan-with-unresolved", + ServiceComponents: serviceComponents, + } + + result, err := importer.ImportSARIF(ctx, input) + if err != nil { + t.Fatalf("ImportSARIF failed: %v", err) + } + + // We should have 1 successful issue match and 1 warning about unresolved package + if result.IssueMatchesCreated != 1 { + t.Errorf("Expected 1 issue match to be created, got %d", result.IssueMatchesCreated) + } + + if result.IssuesCreated != 1 { + t.Errorf("Expected 1 unique issue to be processed, got %d", result.IssuesCreated) + } + + // Should have at least 1 warning about unresolved package + if len(result.Errors) == 0 { + t.Errorf("Expected errors from unresolved package, got %d errors", len(result.Errors)) + } + + // Check that the error mentions the unresolved package + foundUnresolvedError := false + for _, errMsg := range result.Errors { + if errMsg.Message == "Could not resolve package unknown/package (version 5.0.0) to a component instance" { + foundUnresolvedError = true + break + } + } + + if !foundUnresolvedError { + t.Errorf("Expected error about unresolved package 'unknown/package', got errors: %v", result.Errors) + } + + t.Logf("SARIF Import with unresolved packages test successful. IssuesCreated: %d, IssueMatchesCreated: %d, Errors: %+v", + result.IssuesCreated, result.IssueMatchesCreated, result.Errors) +} + +func TestSARIFImportMissingServiceComponents(t *testing.T) { + exampleTrivySARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "Trivy", + "version": "0.49.1", + "rules": [] + } + }, + "results": [] + } + ] +}` + + importer := NewSARIFImporter() + ctx := context.Background() + + // Empty service components - this should fail + input := &ImportInput{ + SARIFDocument: exampleTrivySARIF, + ScannerName: "Trivy", + ServiceId: 999, + Tag: "test-scan", + ServiceComponents: []ComponentMatch{}, + } + + result, err := importer.ImportSARIF(ctx, input) + + if err == nil { + t.Fatalf("Expected ImportSARIF to fail with missing service components, but got success") + } + + if result != nil { + t.Logf("Got error as expected: %v", err) + } +} + +func TestSARIFImportScannerNameMismatch(t *testing.T) { + exampleTrivySARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "Trivy", + "version": "0.49.1", + "rules": [] + } + }, + "results": [] + } + ] +}` + + importer := NewSARIFImporter() + ctx := context.Background() + + // Try to import with mismatched scanner name + input := &ImportInput{ + SARIFDocument: exampleTrivySARIF, + ScannerName: "ScannerNameThatDoesNotMatch", + ServiceId: 1, + Tag: "test-scan", + ServiceComponents: []ComponentMatch{{ComponentInstanceId: 1, PackageName: "test", Version: "1.0"}}, + } + + result, err := importer.ImportSARIF(ctx, input) + + if err == nil { + t.Fatalf("Expected ImportSARIF to fail with scanner name mismatch, but got success") + } + + if result == nil { + t.Fatalf("Expected result to be non-nil even on error") + } + + if err.Error() != "SARIFImporter.ImportSARIF: Scanner name mismatch: input specified 'Grype' but SARIF document specifies 'Trivy'" { + t.Errorf("Unexpected error message: %v", err) + } + + t.Logf("Scanner name mismatch correctly detected: %v", err) +} + +func TestSARIFValidationInvalidLevel(t *testing.T) { + // SARIF with invalid level (not one of: none, note, warning, error) + invalidLevelSARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "TestScanner", + "version": "1.0.0", + "rules": [ + { + "id": "TEST-001", + "name": "Test Rule", + "shortDescription": { "text": "Test" }, + "defaultConfiguration": { "level": "invalid_level" } + } + ] + } + }, + "results": [] + } + ] +}` + + parser := &Parser{} + _, err := parser.ParseSARIFDocument(invalidLevelSARIF) + + if err == nil { + t.Fatalf("Expected parsing to fail with invalid level, but got success") + } + + if err.Error() == "" { + t.Errorf("Error message should not be empty") + } + + t.Logf("Invalid level correctly detected: %v", err) +} + +func TestSARIFValidationMissingArtifactLocation(t *testing.T) { + // SARIF with result that has no artifact location in physical location + missingLocationSARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "TestScanner", + "version": "1.0.0", + "rules": [ + { + "id": "TEST-001", + "name": "Test Rule", + "shortDescription": { "text": "Test" } + } + ] + } + }, + "results": [ + { + "ruleId": "TEST-001", + "level": "warning", + "message": { "text": "Test message" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "" } + } + } + ] + } + ] + } + ] +}` + + parser := &Parser{} + _, err := parser.ParseSARIFDocument(missingLocationSARIF) + + if err == nil { + t.Fatalf("Expected parsing to fail with missing artifact location, but got success") + } + + t.Logf("Missing artifact location correctly detected: %v", err) +} diff --git a/internal/app/sarif/sarif_poc_test.go b/internal/app/sarif/sarif_poc_test.go new file mode 100644 index 000000000..fa47d0bd5 --- /dev/null +++ b/internal/app/sarif/sarif_poc_test.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "context" + "testing" +) + +func TestSARIFImportPOC(t *testing.T) { + exampleTrivySARIF := `{ + "version": "2.1.0", + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "Trivy", + "fullName": "Trivy Vulnerability Scanner", + "informationUri": "https://aquasecurity.github.io/trivy", + "version": "0.49.1", + "rules": [ + { + "id": "CVE-2024-58251", + "name": "CVE-2024-58251", + "shortDescription": { "text": "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')" }, + "fullDescription": { "text": "Cross-site Scripting (XSS) in some web applications." }, + "defaultConfiguration": { "level": "warning" }, + "properties": { "security-severity": "6.1" } + }, + { + "id": "CVE-2025-46394", + "name": "CVE-2025-46394", + "shortDescription": { "text": "Directory Traversal in ExampleLib" }, + "fullDescription": { "text": "Path traversal vulnerability in ExampleLib affects versions < 1.2.3." }, + "defaultConfiguration": { "level": "note" }, + "properties": { "security-severity": "3.7" } + }, + { + "id": "CVE-2025-15467", + "name": "CVE-2025-15467", + "shortDescription": { "text": "Deserialization of Untrusted Data" }, + "fullDescription": { "text": "Remote code execution due to deserialization of untrusted data." }, + "defaultConfiguration": { "level": "error" }, + "properties": { "security-severity": "9.8" } + } + ] + } + }, + "results": [ + { + "ruleId": "CVE-2024-58251", + "ruleIndex": 0, + "level": "warning", + "message": { "text": "Vulnerability CVE-2024-58251 found in package example/lib@1.0.0" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "pkg:deb/debian/example/lib@1.0.0?arch=amd64" }, + "region": { "startLine": 1 } + } + } + ], + "properties": { "PkgName": "example/lib", "InstalledVersion": "1.0.0", "VulnerabilityID": "CVE-2024-58251" } + }, + { + "ruleId": "CVE-2025-46394", + "ruleIndex": 1, + "level": "note", + "message": { "text": "Vulnerability CVE-2025-46394 found in package another/dep@2.1.0" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "pkg:npm/another/dep@2.1.0" }, + "region": { "startLine": 1 } + } + } + ], + "properties": { "PkgName": "another/dep", "InstalledVersion": "2.1.0", "VulnerabilityID": "CVE-2025-46394" } + }, + { + "ruleId": "CVE-2025-15467", + "ruleIndex": 2, + "level": "error", + "message": { "text": "Vulnerability CVE-2025-15467 found in package critical/app@0.5.0" }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "pkg:golang/critical/app@0.5.0" }, + "region": { "startLine": 1 } + } + } + ], + "properties": { "PkgName": "critical/app", "InstalledVersion": "0.5.0", "VulnerabilityID": "CVE-2025-15467" } + } + ] + } + ] +}` + + importer := NewSARIFImporter() + ctx := context.Background() + + // Define the service components that exist in the system + serviceComponents := []ComponentMatch{ + { + ComponentInstanceId: 10, + PackageName: "example/lib", + Version: "1.0.0", + Purl: "pkg:deb/debian/example/lib@1.0.0?arch=amd64", + }, + { + ComponentInstanceId: 11, + PackageName: "another/dep", + Version: "2.1.0", + Purl: "pkg:npm/another/dep@2.1.0", + }, + { + ComponentInstanceId: 12, + PackageName: "critical/app", + Version: "0.5.0", + Purl: "pkg:golang/critical/app@0.5.0", + }, + } + + input := &ImportInput{ + SARIFDocument: exampleTrivySARIF, + ScannerName: "Trivy", + ServiceId: 1, + Tag: "test-scan-1", + ServiceComponents: serviceComponents, + } + + result, err := importer.ImportSARIF(ctx, input) + if err != nil { + t.Fatalf("ImportSARIF failed: %v", err) + } + + if len(result.Errors) > 0 { + t.Errorf("ImportSARIF returned errors: %v", result.Errors) + } + + if result.IssueMatchesCreated != 3 { + t.Errorf("Expected 3 issue matches to be created, got %d", result.IssueMatchesCreated) + } + + if result.IssuesCreated != 3 { + t.Errorf("Expected 3 unique issues to be processed, got %d", result.IssuesCreated) + } + + t.Logf("SARIF Import POC successful. ScannerRunId: %d, IssuesCreated: %d, IssueMatchesCreated: %d", + result.ScannerRunId, result.IssuesCreated, result.IssueMatchesCreated) +} diff --git a/internal/app/sarif/severity_mapper.go b/internal/app/sarif/severity_mapper.go new file mode 100644 index 000000000..00370324d --- /dev/null +++ b/internal/app/sarif/severity_mapper.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "strconv" + + "github.com/cloudoperators/heureka/internal/entity" +) + +func MapSeverity(sarifLevel string, rule *SARIFRule, properties map[string]interface{}) entity.SeverityValues { + if cvss := extractCVSSScore(properties); cvss >= 0 { + return cvssToSeverity(cvss) + } + + return sarifLevelToSeverity(sarifLevel, rule.DefaultConfiguration.Level) +} + +func extractCVSSScore(properties map[string]interface{}) float64 { + if properties == nil { + return -1 + } + + if cvssStr, ok := properties["security-severity"].(string); ok { + if cvss, err := strconv.ParseFloat(cvssStr, 64); err == nil { + return cvss + } + } + + cvssNames := []string{"cvssScore", "cvss_score", "severity_score"} + for _, name := range cvssNames { + if cvssVal, ok := properties[name]; ok { + switch v := cvssVal.(type) { + case float64: + return v + case string: + if cvss, err := strconv.ParseFloat(v, 64); err == nil { + return cvss + } + } + } + } + + return -1 +} + +func cvssToSeverity(cvss float64) entity.SeverityValues { + switch { + case cvss >= 9.0: + return entity.SeverityValuesCritical + case cvss >= 7.0: + return entity.SeverityValuesHigh + case cvss >= 4.0: + return entity.SeverityValuesMedium + case cvss > 0: + return entity.SeverityValuesLow + default: + return entity.SeverityValuesNone + } +} + +func sarifLevelToSeverity(resultLevel, ruleLevel string) entity.SeverityValues { + level := resultLevel + if level == "" || level == "none" { + level = ruleLevel + } + + switch level { + case "error": + return entity.SeverityValuesHigh + case "warning": + return entity.SeverityValuesMedium + case "note": + return entity.SeverityValuesLow + default: + return entity.SeverityValuesMedium + } +} diff --git a/internal/app/sarif/types.go b/internal/app/sarif/types.go new file mode 100644 index 000000000..17faa52cb --- /dev/null +++ b/internal/app/sarif/types.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package sarif + +import ( + "github.com/cloudoperators/heureka/internal/entity" + "fmt" + ) + +type SARIFDocument struct { + Version string `json:"version"` + Schema string `json:"$schema"` + Runs []SARIFRun `json:"runs"` +} + +type SARIFRun struct { + Tool SARIFTool `json:"tool"` + Results []SARIFResult `json:"results"` +} + +type SARIFTool struct { + Driver SARIFDriver `json:"driver"` +} + +type SARIFDriver struct { + Name string `json:"name"` + FullName string `json:"fullName"` + InformationUri string `json:"informationUri"` + Version string `json:"version"` + Rules []SARIFRule `json:"rules"` + SemanticVersion string `json:"semanticVersion"` +} + +type SARIFRule struct { + Id string `json:"id"` + Name string `json:"name"` + ShortDescription SARIFDescription `json:"shortDescription"` + FullDescription SARIFDescription `json:"fullDescription"` + DefaultConfiguration SARIFConfiguration `json:"defaultConfiguration"` + HelpUri string `json:"helpUri"` + Help SARIFHelp `json:"help"` + Properties map[string]interface{} `json:"properties"` + Tags []string `json:"tags"` +} + +type SARIFDescription struct { + Text string `json:"text"` + Markdown string `json:"markdown"` +} + +type SARIFConfiguration struct { + Level string `json:"level"` +} + +type SARIFHelp struct { + Text string `json:"text"` + Markdown string `json:"markdown"` +} + +type SARIFResult struct { + RuleId string `json:"ruleId"` + RuleIndex int `json:"ruleIndex"` + Level string `json:"level"` + Message SARIFMessage `json:"message"` + Locations []SARIFLocation `json:"locations"` + Properties map[string]interface{} `json:"properties"` +} + +type SARIFMessage struct { + Text string `json:"text"` + Markdown string `json:"markdown"` + Id string `json:"id"` +} + +type SARIFLocation struct { + Id string `json:"id"` + PhysicalLocation SARIFPhysicalLocation `json:"physicalLocation"` + LogicalLocations []SARIFLogicalLocation `json:"logicalLocations"` +} + +type SARIFPhysicalLocation struct { + ArtifactLocation SARIFArtifactLocation `json:"artifactLocation"` + Region SARIFRegion `json:"region"` +} + +type SARIFArtifactLocation struct { + Uri string `json:"uri"` + UriBaseId string `json:"uriBaseId"` + Index int `json:"index"` +} + +type SARIFLogicalLocation struct { + Name string `json:"name"` + FullyQualifiedName string `json:"fullyQualifiedName"` + Kind string `json:"kind"` +} + +type SARIFRegion struct { + StartLine int `json:"startLine"` + EndLine int `json:"endLine"` + StartColumn int `json:"startColumn"` + EndColumn int `json:"endColumn"` +} + +type ParsedSARIFData struct { + ScannerName string + Rules map[string]*SARIFRule + Results []ParsedSARIFResult + Errors []ParseError +} + +type ParsedSARIFResult struct { + Rule *SARIFRule + Result *SARIFResult + ArtifactUri string + Severity entity.SeverityValues + Message string +} + +type ParseError struct { + Line int + Message string + Severity string +} +type PackageInfo struct { + Name string + Version string + Purl string +} + +func (pi PackageInfo) String() string { + if pi.Purl != "" { + return pi.Purl + } + return fmt.Sprintf("%s (version %s)", pi.Name, pi.Version) +} + +func (psr *ParsedSARIFResult) GetPackageInfo() (*PackageInfo, bool) { + if psr == nil || psr.Result == nil || psr.Result.Properties == nil { + return nil, false + } + + props := psr.Result.Properties + info := &PackageInfo{} + + if purl, ok := props["purl"].(string); ok && purl != "" { + info.Purl = purl + } else if purl, ok := props["Purl"].(string); ok && purl != "" { + info.Purl = purl + } + + nameKeys := []string{"PkgName", "pkgName", "packageName", "name"} + for _, key := range nameKeys { + if val, ok := props[key].(string); ok && val != "" { + info.Name = val + break + } + } + + versionKeys := []string{"InstalledVersion", "version", "installedVersion", "pkgVersion"} + for _, key := range versionKeys { + if val, ok := props[key].(string); ok && val != "" { + info.Version = val + break + } + } + + return info, info.Purl != "" || info.Name != "" +} \ No newline at end of file diff --git a/internal/app/scanner/asset_mapper.go b/internal/app/scanner/asset_mapper.go new file mode 100644 index 000000000..ce2fdf00b --- /dev/null +++ b/internal/app/scanner/asset_mapper.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "context" + "fmt" + + appErrors "github.com/cloudoperators/heureka/internal/errors" + "github.com/cloudoperators/heureka/internal/entity" +) + +type AssetMapperDB interface { + GetComponentInstance(id int64) (*entity.ComponentInstance, error) + CreateScannerAssetMapping(mapping *entity.ScannerAssetMapping) (*entity.ScannerAssetMapping, error) + GetScannerAssetMappingByUri(scannerName, artifactUri string) (*entity.ScannerAssetMapping, error) +} + +type AssetMapper interface { + ResolveAsset(ctx context.Context, artifactUri, serviceId string) (*entity.ComponentInstance, error) + RegisterAssetMapping(ctx context.Context, mapping *entity.ScannerAssetMapping) error + GetAssetMapping(ctx context.Context, scannerName, artifactUri string) (*entity.ScannerAssetMapping, error) +} + +type assetMapper struct { + db AssetMapperDB +} + +func NewAssetMapper(db AssetMapperDB) AssetMapper { + return &assetMapper{db: db} +} + +func (am *assetMapper) ResolveAsset(ctx context.Context, artifactUri, serviceId string) (*entity.ComponentInstance, error) { + op := appErrors.Op("AssetMapper.ResolveAsset") + + if artifactUri == "" { + return nil, appErrors.E(op, "artifact URI is required") + } + + if serviceId == "" { + return nil, appErrors.E(op, "service ID is required") + } + + // POC: Look up asset mapping by artifact URI and scanner name + // In production, this would implement more sophisticated resolution logic + return nil, appErrors.E(op, "Asset resolution requires pre-configured ScannerAssetMapping (use RegisterAssetMapping to configure)") +} + +func (am *assetMapper) RegisterAssetMapping(ctx context.Context, mapping *entity.ScannerAssetMapping) error { + op := appErrors.Op("AssetMapper.RegisterAssetMapping") + + if mapping.ArtifactUri == "" { + return appErrors.E(op, "artifact URI is required") + } + + if mapping.ComponentInstanceId == 0 { + return appErrors.E(op, "component instance ID is required") + } + + if mapping.ServiceId == 0 { + return appErrors.E(op, "service ID is required") + } + + compInst, err := am.db.GetComponentInstance(mapping.ComponentInstanceId) + if err != nil || compInst == nil { + return appErrors.E(op, fmt.Sprintf("Component instance not found: %d", mapping.ComponentInstanceId)) + } + + // TODO: Verify component instance belongs to service + + _, err = am.db.CreateScannerAssetMapping(mapping) + if err != nil { + return appErrors.E(op, "Failed to create asset mapping", err) + } + + return nil +} + +func (am *assetMapper) GetAssetMapping(ctx context.Context, scannerName, artifactUri string) (*entity.ScannerAssetMapping, error) { + op := appErrors.Op("AssetMapper.GetAssetMapping") + + mapping, err := am.db.GetScannerAssetMappingByUri(scannerName, artifactUri) + if err != nil { + return nil, appErrors.E(op, "Failed to retrieve asset mapping", err) + } + + return mapping, nil +} diff --git a/internal/entity/scanner_asset_mapping.go b/internal/entity/scanner_asset_mapping.go new file mode 100644 index 000000000..0141e617f --- /dev/null +++ b/internal/entity/scanner_asset_mapping.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import "time" + +type ScannerAssetMapping struct { + Id int64 `db:"id"` + ScannerName string `db:"scanner_name"` + ArtifactUri string `db:"artifact_uri"` + ComponentInstanceId int64 `db:"component_instance_id"` + ServiceId int64 `db:"service_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +}