diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 13dc50c..dc6d3b0 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -37,7 +37,7 @@ func init() { scanCmd.Flags().StringVar(&scanTool, "tool", "", "Specific tool to run (semgrep, gitleaks, trivy)") scanCmd.Flags().BoolVar(&scanFailOnThreshold, "fail-on-threshold", false, "Exit with code 1 if findings exceed thresholds") - scanCmd.Flags().StringVar(&scanOutputFormat, "format", "terminal", "Output format: terminal, json, html") + scanCmd.Flags().StringVar(&scanOutputFormat, "format", "terminal", "Output format: terminal, json, html, sarif") scanCmd.Flags().StringVar(&scanConfigPath, "config", "security-config.yml", "Path to security-config.yml") scanCmd.Flags().BoolVar(&scanOpenReport, "open", false, "Auto-open HTML report in browser (requires --format=html)") } @@ -71,9 +71,15 @@ func runScan() error { EnableGitleaks: secConfig.Tools.Gitleaks, EnableTrivy: secConfig.Tools.Trivy, EnableTrivyImage: secConfig.Tools.Trivy && projectInfo.HasDocker, + EnableLicenses: secConfig.Licenses.Enabled, DockerImages: projectInfo.DockerImages, ExcludePaths: secConfig.ExcludePaths, FailOnThresholds: secConfig.FailOn, + LicenseConfig: scanners.LicenseConfig{ + Enabled: secConfig.Licenses.Enabled, + Deny: secConfig.Licenses.Deny, + Allow: secConfig.Licenses.Allow, + }, Verbose: false, } @@ -82,6 +88,7 @@ func runScan() error { options.EnableSemgrep = scanTool == "semgrep" options.EnableGitleaks = scanTool == "gitleaks" options.EnableTrivy = scanTool == "trivy" + options.EnableLicenses = scanTool == "licenses" } // Run orchestrator @@ -97,6 +104,8 @@ func runScan() error { return outputJSON(report) case "html": return outputHTML(report, scanOpenReport) + case "sarif": + return outputSARIF(report) case "terminal": fallthrough default: @@ -123,6 +132,19 @@ func outputJSON(report *scanners.ScanReport) error { return nil } +// outputSARIF generates a SARIF report +func outputSARIF(report *scanners.ScanReport) error { + sarifReporter := reporters.NewSARIFReporter(report) + + reportPath := "security-report.sarif" + if err := sarifReporter.WriteFile(reportPath); err != nil { + return err + } + + fmt.Printf("✅ SARIF report generated: %s\n", reportPath) + return nil +} + // outputHTML generates and optionally opens an HTML report func outputHTML(report *scanners.ScanReport, openBrowser bool) error { htmlReporter := reporters.NewHTMLReporter(report) diff --git a/cli/reporters/sarif.go b/cli/reporters/sarif.go new file mode 100644 index 0000000..690e41f --- /dev/null +++ b/cli/reporters/sarif.go @@ -0,0 +1,268 @@ +package reporters + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/edgarpsda/devsecops-kit/cli/scanners" +) + +// SARIF schema version +const sarifVersion = "2.1.0" +const sarifSchemaURI = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json" + +// SARIFReport represents the top-level SARIF document +type SARIFReport struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []SARIFRun `json:"runs"` +} + +// SARIFRun represents a single analysis run +type SARIFRun struct { + Tool SARIFTool `json:"tool"` + Results []SARIFResult `json:"results"` +} + +// SARIFTool describes the analysis tool +type SARIFTool struct { + Driver SARIFDriver `json:"driver"` +} + +// SARIFDriver describes the primary analysis tool component +type SARIFDriver struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + InformationURI string `json:"informationUri,omitempty"` + Rules []SARIFRule `json:"rules,omitempty"` +} + +// SARIFRule describes a rule that produced a result +type SARIFRule struct { + ID string `json:"id"` + ShortDescription SARIFMessage `json:"shortDescription,omitempty"` + HelpURI string `json:"helpUri,omitempty"` + DefaultConfig *SARIFRuleConfig `json:"defaultConfiguration,omitempty"` +} + +// SARIFRuleConfig represents the default configuration for a rule +type SARIFRuleConfig struct { + Level string `json:"level"` +} + +// SARIFResult represents a single finding +type SARIFResult struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message SARIFMessage `json:"message"` + Locations []SARIFLocation `json:"locations,omitempty"` +} + +// SARIFMessage holds a text message +type SARIFMessage struct { + Text string `json:"text"` +} + +// SARIFLocation describes where a result was found +type SARIFLocation struct { + PhysicalLocation SARIFPhysicalLocation `json:"physicalLocation"` +} + +// SARIFPhysicalLocation points to a file and region +type SARIFPhysicalLocation struct { + ArtifactLocation SARIFArtifactLocation `json:"artifactLocation"` + Region *SARIFRegion `json:"region,omitempty"` +} + +// SARIFArtifactLocation identifies a file +type SARIFArtifactLocation struct { + URI string `json:"uri"` +} + +// SARIFRegion identifies a line/column range +type SARIFRegion struct { + StartLine int `json:"startLine,omitempty"` + StartColumn int `json:"startColumn,omitempty"` +} + +// SARIFReporter generates SARIF-formatted output from scan results +type SARIFReporter struct { + report *scanners.ScanReport +} + +// NewSARIFReporter creates a new SARIF reporter +func NewSARIFReporter(report *scanners.ScanReport) *SARIFReporter { + return &SARIFReporter{report: report} +} + +// Generate produces the SARIF JSON output as bytes +func (sr *SARIFReporter) Generate() ([]byte, error) { + sarifReport := SARIFReport{ + Schema: sarifSchemaURI, + Version: sarifVersion, + Runs: sr.buildRuns(), + } + + return json.MarshalIndent(sarifReport, "", " ") +} + +// WriteFile writes the SARIF report to a file +func (sr *SARIFReporter) WriteFile(path string) error { + data, err := sr.Generate() + if err != nil { + return fmt.Errorf("failed to generate SARIF report: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("failed to write SARIF file: %w", err) + } + + return nil +} + +// buildRuns creates one SARIF run per tool +func (sr *SARIFReporter) buildRuns() []SARIFRun { + // Group findings by tool + findingsByTool := make(map[string][]scanners.Finding) + for _, finding := range sr.report.AllFindings { + findingsByTool[finding.Tool] = append(findingsByTool[finding.Tool], finding) + } + + var runs []SARIFRun + for tool, findings := range findingsByTool { + runs = append(runs, sr.buildRun(tool, findings)) + } + + // If no findings, add a single empty run for DevSecOps Kit + if len(runs) == 0 { + runs = append(runs, SARIFRun{ + Tool: SARIFTool{ + Driver: SARIFDriver{ + Name: "devsecops-kit", + }, + }, + Results: []SARIFResult{}, + }) + } + + return runs +} + +// buildRun creates a SARIF run for a single tool +func (sr *SARIFReporter) buildRun(tool string, findings []scanners.Finding) SARIFRun { + // Collect unique rules + rulesMap := make(map[string]SARIFRule) + var results []SARIFResult + + for _, finding := range findings { + ruleID := finding.RuleID + if ruleID == "" { + ruleID = fmt.Sprintf("%s-finding", tool) + } + + // Add rule if not seen before + if _, exists := rulesMap[ruleID]; !exists { + rule := SARIFRule{ + ID: ruleID, + DefaultConfig: &SARIFRuleConfig{ + Level: severityToSARIFLevel(finding.Severity), + }, + } + if finding.RemoteURL != "" { + rule.HelpURI = finding.RemoteURL + } + rulesMap[ruleID] = rule + } + + // Build result + result := SARIFResult{ + RuleID: ruleID, + Level: severityToSARIFLevel(finding.Severity), + Message: SARIFMessage{Text: finding.Message}, + } + + // Add location if file is present + if finding.File != "" { + location := SARIFLocation{ + PhysicalLocation: SARIFPhysicalLocation{ + ArtifactLocation: SARIFArtifactLocation{ + URI: finding.File, + }, + }, + } + + if finding.Line > 0 { + region := &SARIFRegion{StartLine: finding.Line} + if finding.Column > 0 { + region.StartColumn = finding.Column + } + location.PhysicalLocation.Region = region + } + + result.Locations = []SARIFLocation{location} + } + + results = append(results, result) + } + + // Convert rules map to slice + var rules []SARIFRule + for _, rule := range rulesMap { + rules = append(rules, rule) + } + + return SARIFRun{ + Tool: SARIFTool{ + Driver: SARIFDriver{ + Name: toolDisplayName(tool), + InformationURI: toolInfoURI(tool), + Rules: rules, + }, + }, + Results: results, + } +} + +// severityToSARIFLevel maps severity strings to SARIF levels +func severityToSARIFLevel(severity string) string { + switch strings.ToUpper(severity) { + case "CRITICAL", "HIGH": + return "error" + case "MEDIUM": + return "warning" + case "LOW": + return "note" + default: + return "warning" + } +} + +// toolDisplayName returns a display name for a tool +func toolDisplayName(tool string) string { + switch tool { + case "semgrep": + return "Semgrep" + case "gitleaks": + return "Gitleaks" + case "trivy": + return "Trivy" + default: + return tool + } +} + +// toolInfoURI returns the tool's homepage URL +func toolInfoURI(tool string) string { + switch tool { + case "semgrep": + return "https://semgrep.dev" + case "gitleaks": + return "https://gitleaks.io" + case "trivy": + return "https://trivy.dev" + default: + return "" + } +} diff --git a/cli/reporters/sarif_test.go b/cli/reporters/sarif_test.go new file mode 100644 index 0000000..334bfe7 --- /dev/null +++ b/cli/reporters/sarif_test.go @@ -0,0 +1,118 @@ +package reporters + +import ( + "encoding/json" + "testing" + + "github.com/edgarpsda/devsecops-kit/cli/scanners" +) + +func TestSARIFReporter_Generate_WithFindings(t *testing.T) { + report := &scanners.ScanReport{ + Status: "FAIL", + BlockingCount: 2, + AllFindings: []scanners.Finding{ + { + File: "src/main.go", + Line: 42, + Column: 10, + Severity: "HIGH", + Message: "Hardcoded secret detected", + RuleID: "generic-api-key", + Tool: "gitleaks", + }, + { + File: "src/handler.go", + Line: 15, + Severity: "CRITICAL", + Message: "SQL injection vulnerability", + RuleID: "go.lang.security.audit.sqli", + Tool: "semgrep", + }, + { + File: "go.mod", + Severity: "MEDIUM", + Message: "CVE-2024-1234: Buffer overflow in lib", + RuleID: "CVE-2024-1234", + Tool: "trivy", + }, + }, + } + + reporter := NewSARIFReporter(report) + data, err := reporter.Generate() + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + // Parse and validate structure + var sarifReport SARIFReport + if err := json.Unmarshal(data, &sarifReport); err != nil { + t.Fatalf("failed to parse SARIF output: %v", err) + } + + if sarifReport.Version != "2.1.0" { + t.Errorf("expected SARIF version 2.1.0, got %s", sarifReport.Version) + } + + // Should have runs for each tool with findings + if len(sarifReport.Runs) == 0 { + t.Fatal("expected at least one run") + } + + // Count total results across all runs + totalResults := 0 + for _, run := range sarifReport.Runs { + totalResults += len(run.Results) + } + + if totalResults != 3 { + t.Errorf("expected 3 total results, got %d", totalResults) + } +} + +func TestSARIFReporter_Generate_NoFindings(t *testing.T) { + report := &scanners.ScanReport{ + Status: "PASS", + AllFindings: []scanners.Finding{}, + } + + reporter := NewSARIFReporter(report) + data, err := reporter.Generate() + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + var sarifReport SARIFReport + if err := json.Unmarshal(data, &sarifReport); err != nil { + t.Fatalf("failed to parse SARIF output: %v", err) + } + + if len(sarifReport.Runs) != 1 { + t.Errorf("expected 1 empty run, got %d", len(sarifReport.Runs)) + } + + if len(sarifReport.Runs[0].Results) != 0 { + t.Errorf("expected 0 results, got %d", len(sarifReport.Runs[0].Results)) + } +} + +func TestSeverityToSARIFLevel(t *testing.T) { + tests := []struct { + severity string + expected string + }{ + {"CRITICAL", "error"}, + {"HIGH", "error"}, + {"MEDIUM", "warning"}, + {"LOW", "note"}, + {"UNKNOWN", "warning"}, + } + + for _, tt := range tests { + got := severityToSARIFLevel(tt.severity) + if got != tt.expected { + t.Errorf("severityToSARIFLevel(%q) = %q, want %q", tt.severity, got, tt.expected) + } + } +}