Skip to content

Commit 882ca60

Browse files
authored
Merge pull request #7 from EdgarPsda/v0.5.0/sarif-output
Add SARIF output format for scan results
2 parents b2d89cf + 4f5b8ef commit 882ca60

3 files changed

Lines changed: 409 additions & 1 deletion

File tree

cli/cmd/scan.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func init() {
3737

3838
scanCmd.Flags().StringVar(&scanTool, "tool", "", "Specific tool to run (semgrep, gitleaks, trivy)")
3939
scanCmd.Flags().BoolVar(&scanFailOnThreshold, "fail-on-threshold", false, "Exit with code 1 if findings exceed thresholds")
40-
scanCmd.Flags().StringVar(&scanOutputFormat, "format", "terminal", "Output format: terminal, json, html")
40+
scanCmd.Flags().StringVar(&scanOutputFormat, "format", "terminal", "Output format: terminal, json, html, sarif")
4141
scanCmd.Flags().StringVar(&scanConfigPath, "config", "security-config.yml", "Path to security-config.yml")
4242
scanCmd.Flags().BoolVar(&scanOpenReport, "open", false, "Auto-open HTML report in browser (requires --format=html)")
4343
}
@@ -71,9 +71,15 @@ func runScan() error {
7171
EnableGitleaks: secConfig.Tools.Gitleaks,
7272
EnableTrivy: secConfig.Tools.Trivy,
7373
EnableTrivyImage: secConfig.Tools.Trivy && projectInfo.HasDocker,
74+
EnableLicenses: secConfig.Licenses.Enabled,
7475
DockerImages: projectInfo.DockerImages,
7576
ExcludePaths: secConfig.ExcludePaths,
7677
FailOnThresholds: secConfig.FailOn,
78+
LicenseConfig: scanners.LicenseConfig{
79+
Enabled: secConfig.Licenses.Enabled,
80+
Deny: secConfig.Licenses.Deny,
81+
Allow: secConfig.Licenses.Allow,
82+
},
7783
Verbose: false,
7884
}
7985

@@ -82,6 +88,7 @@ func runScan() error {
8288
options.EnableSemgrep = scanTool == "semgrep"
8389
options.EnableGitleaks = scanTool == "gitleaks"
8490
options.EnableTrivy = scanTool == "trivy"
91+
options.EnableLicenses = scanTool == "licenses"
8592
}
8693

8794
// Run orchestrator
@@ -97,6 +104,8 @@ func runScan() error {
97104
return outputJSON(report)
98105
case "html":
99106
return outputHTML(report, scanOpenReport)
107+
case "sarif":
108+
return outputSARIF(report)
100109
case "terminal":
101110
fallthrough
102111
default:
@@ -123,6 +132,19 @@ func outputJSON(report *scanners.ScanReport) error {
123132
return nil
124133
}
125134

135+
// outputSARIF generates a SARIF report
136+
func outputSARIF(report *scanners.ScanReport) error {
137+
sarifReporter := reporters.NewSARIFReporter(report)
138+
139+
reportPath := "security-report.sarif"
140+
if err := sarifReporter.WriteFile(reportPath); err != nil {
141+
return err
142+
}
143+
144+
fmt.Printf("✅ SARIF report generated: %s\n", reportPath)
145+
return nil
146+
}
147+
126148
// outputHTML generates and optionally opens an HTML report
127149
func outputHTML(report *scanners.ScanReport, openBrowser bool) error {
128150
htmlReporter := reporters.NewHTMLReporter(report)

cli/reporters/sarif.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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

Comments
 (0)