|
| 1 | +package reporters |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/edgarpsda/devsecops-kit/cli/scanners" |
| 10 | +) |
| 11 | + |
| 12 | +// SARIF schema version |
| 13 | +const sarifVersion = "2.1.0" |
| 14 | +const sarifSchemaURI = "https://github.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json" |
| 15 | + |
| 16 | +// SARIFReport represents the top-level SARIF document |
| 17 | +type SARIFReport struct { |
| 18 | + Schema string `json:"$schema"` |
| 19 | + Version string `json:"version"` |
| 20 | + Runs []SARIFRun `json:"runs"` |
| 21 | +} |
| 22 | + |
| 23 | +// SARIFRun represents a single analysis run |
| 24 | +type SARIFRun struct { |
| 25 | + Tool SARIFTool `json:"tool"` |
| 26 | + Results []SARIFResult `json:"results"` |
| 27 | +} |
| 28 | + |
| 29 | +// SARIFTool describes the analysis tool |
| 30 | +type SARIFTool struct { |
| 31 | + Driver SARIFDriver `json:"driver"` |
| 32 | +} |
| 33 | + |
| 34 | +// SARIFDriver describes the primary analysis tool component |
| 35 | +type SARIFDriver struct { |
| 36 | + Name string `json:"name"` |
| 37 | + Version string `json:"version,omitempty"` |
| 38 | + InformationURI string `json:"informationUri,omitempty"` |
| 39 | + Rules []SARIFRule `json:"rules,omitempty"` |
| 40 | +} |
| 41 | + |
| 42 | +// SARIFRule describes a rule that produced a result |
| 43 | +type SARIFRule struct { |
| 44 | + ID string `json:"id"` |
| 45 | + ShortDescription SARIFMessage `json:"shortDescription,omitempty"` |
| 46 | + HelpURI string `json:"helpUri,omitempty"` |
| 47 | + DefaultConfig *SARIFRuleConfig `json:"defaultConfiguration,omitempty"` |
| 48 | +} |
| 49 | + |
| 50 | +// SARIFRuleConfig represents the default configuration for a rule |
| 51 | +type SARIFRuleConfig struct { |
| 52 | + Level string `json:"level"` |
| 53 | +} |
| 54 | + |
| 55 | +// SARIFResult represents a single finding |
| 56 | +type SARIFResult struct { |
| 57 | + RuleID string `json:"ruleId"` |
| 58 | + Level string `json:"level"` |
| 59 | + Message SARIFMessage `json:"message"` |
| 60 | + Locations []SARIFLocation `json:"locations,omitempty"` |
| 61 | +} |
| 62 | + |
| 63 | +// SARIFMessage holds a text message |
| 64 | +type SARIFMessage struct { |
| 65 | + Text string `json:"text"` |
| 66 | +} |
| 67 | + |
| 68 | +// SARIFLocation describes where a result was found |
| 69 | +type SARIFLocation struct { |
| 70 | + PhysicalLocation SARIFPhysicalLocation `json:"physicalLocation"` |
| 71 | +} |
| 72 | + |
| 73 | +// SARIFPhysicalLocation points to a file and region |
| 74 | +type SARIFPhysicalLocation struct { |
| 75 | + ArtifactLocation SARIFArtifactLocation `json:"artifactLocation"` |
| 76 | + Region *SARIFRegion `json:"region,omitempty"` |
| 77 | +} |
| 78 | + |
| 79 | +// SARIFArtifactLocation identifies a file |
| 80 | +type SARIFArtifactLocation struct { |
| 81 | + URI string `json:"uri"` |
| 82 | +} |
| 83 | + |
| 84 | +// SARIFRegion identifies a line/column range |
| 85 | +type SARIFRegion struct { |
| 86 | + StartLine int `json:"startLine,omitempty"` |
| 87 | + StartColumn int `json:"startColumn,omitempty"` |
| 88 | +} |
| 89 | + |
| 90 | +// SARIFReporter generates SARIF-formatted output from scan results |
| 91 | +type SARIFReporter struct { |
| 92 | + report *scanners.ScanReport |
| 93 | +} |
| 94 | + |
| 95 | +// NewSARIFReporter creates a new SARIF reporter |
| 96 | +func NewSARIFReporter(report *scanners.ScanReport) *SARIFReporter { |
| 97 | + return &SARIFReporter{report: report} |
| 98 | +} |
| 99 | + |
| 100 | +// Generate produces the SARIF JSON output as bytes |
| 101 | +func (sr *SARIFReporter) Generate() ([]byte, error) { |
| 102 | + sarifReport := SARIFReport{ |
| 103 | + Schema: sarifSchemaURI, |
| 104 | + Version: sarifVersion, |
| 105 | + Runs: sr.buildRuns(), |
| 106 | + } |
| 107 | + |
| 108 | + return json.MarshalIndent(sarifReport, "", " ") |
| 109 | +} |
| 110 | + |
| 111 | +// WriteFile writes the SARIF report to a file |
| 112 | +func (sr *SARIFReporter) WriteFile(path string) error { |
| 113 | + data, err := sr.Generate() |
| 114 | + if err != nil { |
| 115 | + return fmt.Errorf("failed to generate SARIF report: %w", err) |
| 116 | + } |
| 117 | + |
| 118 | + if err := os.WriteFile(path, data, 0o644); err != nil { |
| 119 | + return fmt.Errorf("failed to write SARIF file: %w", err) |
| 120 | + } |
| 121 | + |
| 122 | + return nil |
| 123 | +} |
| 124 | + |
| 125 | +// buildRuns creates one SARIF run per tool |
| 126 | +func (sr *SARIFReporter) buildRuns() []SARIFRun { |
| 127 | + // Group findings by tool |
| 128 | + findingsByTool := make(map[string][]scanners.Finding) |
| 129 | + for _, finding := range sr.report.AllFindings { |
| 130 | + findingsByTool[finding.Tool] = append(findingsByTool[finding.Tool], finding) |
| 131 | + } |
| 132 | + |
| 133 | + var runs []SARIFRun |
| 134 | + for tool, findings := range findingsByTool { |
| 135 | + runs = append(runs, sr.buildRun(tool, findings)) |
| 136 | + } |
| 137 | + |
| 138 | + // If no findings, add a single empty run for DevSecOps Kit |
| 139 | + if len(runs) == 0 { |
| 140 | + runs = append(runs, SARIFRun{ |
| 141 | + Tool: SARIFTool{ |
| 142 | + Driver: SARIFDriver{ |
| 143 | + Name: "devsecops-kit", |
| 144 | + }, |
| 145 | + }, |
| 146 | + Results: []SARIFResult{}, |
| 147 | + }) |
| 148 | + } |
| 149 | + |
| 150 | + return runs |
| 151 | +} |
| 152 | + |
| 153 | +// buildRun creates a SARIF run for a single tool |
| 154 | +func (sr *SARIFReporter) buildRun(tool string, findings []scanners.Finding) SARIFRun { |
| 155 | + // Collect unique rules |
| 156 | + rulesMap := make(map[string]SARIFRule) |
| 157 | + var results []SARIFResult |
| 158 | + |
| 159 | + for _, finding := range findings { |
| 160 | + ruleID := finding.RuleID |
| 161 | + if ruleID == "" { |
| 162 | + ruleID = fmt.Sprintf("%s-finding", tool) |
| 163 | + } |
| 164 | + |
| 165 | + // Add rule if not seen before |
| 166 | + if _, exists := rulesMap[ruleID]; !exists { |
| 167 | + rule := SARIFRule{ |
| 168 | + ID: ruleID, |
| 169 | + DefaultConfig: &SARIFRuleConfig{ |
| 170 | + Level: severityToSARIFLevel(finding.Severity), |
| 171 | + }, |
| 172 | + } |
| 173 | + if finding.RemoteURL != "" { |
| 174 | + rule.HelpURI = finding.RemoteURL |
| 175 | + } |
| 176 | + rulesMap[ruleID] = rule |
| 177 | + } |
| 178 | + |
| 179 | + // Build result |
| 180 | + result := SARIFResult{ |
| 181 | + RuleID: ruleID, |
| 182 | + Level: severityToSARIFLevel(finding.Severity), |
| 183 | + Message: SARIFMessage{Text: finding.Message}, |
| 184 | + } |
| 185 | + |
| 186 | + // Add location if file is present |
| 187 | + if finding.File != "" { |
| 188 | + location := SARIFLocation{ |
| 189 | + PhysicalLocation: SARIFPhysicalLocation{ |
| 190 | + ArtifactLocation: SARIFArtifactLocation{ |
| 191 | + URI: finding.File, |
| 192 | + }, |
| 193 | + }, |
| 194 | + } |
| 195 | + |
| 196 | + if finding.Line > 0 { |
| 197 | + region := &SARIFRegion{StartLine: finding.Line} |
| 198 | + if finding.Column > 0 { |
| 199 | + region.StartColumn = finding.Column |
| 200 | + } |
| 201 | + location.PhysicalLocation.Region = region |
| 202 | + } |
| 203 | + |
| 204 | + result.Locations = []SARIFLocation{location} |
| 205 | + } |
| 206 | + |
| 207 | + results = append(results, result) |
| 208 | + } |
| 209 | + |
| 210 | + // Convert rules map to slice |
| 211 | + var rules []SARIFRule |
| 212 | + for _, rule := range rulesMap { |
| 213 | + rules = append(rules, rule) |
| 214 | + } |
| 215 | + |
| 216 | + return SARIFRun{ |
| 217 | + Tool: SARIFTool{ |
| 218 | + Driver: SARIFDriver{ |
| 219 | + Name: toolDisplayName(tool), |
| 220 | + InformationURI: toolInfoURI(tool), |
| 221 | + Rules: rules, |
| 222 | + }, |
| 223 | + }, |
| 224 | + Results: results, |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +// severityToSARIFLevel maps severity strings to SARIF levels |
| 229 | +func severityToSARIFLevel(severity string) string { |
| 230 | + switch strings.ToUpper(severity) { |
| 231 | + case "CRITICAL", "HIGH": |
| 232 | + return "error" |
| 233 | + case "MEDIUM": |
| 234 | + return "warning" |
| 235 | + case "LOW": |
| 236 | + return "note" |
| 237 | + default: |
| 238 | + return "warning" |
| 239 | + } |
| 240 | +} |
| 241 | + |
| 242 | +// toolDisplayName returns a display name for a tool |
| 243 | +func toolDisplayName(tool string) string { |
| 244 | + switch tool { |
| 245 | + case "semgrep": |
| 246 | + return "Semgrep" |
| 247 | + case "gitleaks": |
| 248 | + return "Gitleaks" |
| 249 | + case "trivy": |
| 250 | + return "Trivy" |
| 251 | + default: |
| 252 | + return tool |
| 253 | + } |
| 254 | +} |
| 255 | + |
| 256 | +// toolInfoURI returns the tool's homepage URL |
| 257 | +func toolInfoURI(tool string) string { |
| 258 | + switch tool { |
| 259 | + case "semgrep": |
| 260 | + return "https://semgrep.dev" |
| 261 | + case "gitleaks": |
| 262 | + return "https://gitleaks.io" |
| 263 | + case "trivy": |
| 264 | + return "https://trivy.dev" |
| 265 | + default: |
| 266 | + return "" |
| 267 | + } |
| 268 | +} |
0 commit comments