--- phase: 06-output-reporting plan: 03 type: execute wave: 1 depends_on: [06-01] files_modified: - pkg/output/sarif.go - pkg/output/sarif_test.go autonomous: true requirements: [OUT-03] must_haves: truths: - "scan results can be rendered as SARIF 2.1.0 JSON suitable for GitHub Security upload" - "one SARIF rule is emitted per distinct provider observed in the findings" - "each result maps confidence to SARIF level (high=error, medium=warning, low=note)" - "each result has a physicalLocation with artifactLocation.uri and region.startLine" artifacts: - path: pkg/output/sarif.go provides: "SARIFFormatter + SARIF 2.1.0 structs" contains: "SARIFFormatter" key_links: - from: pkg/output/sarif.go to: pkg/output/formatter.go via: "init() Register(\"sarif\", SARIFFormatter{})" pattern: "Register\\(\"sarif\"" - from: pkg/output/sarif.go to: "SARIF 2.1.0 schema" via: "$schema + version fields" pattern: "2.1.0" --- Implement a SARIF 2.1.0 formatter using hand-rolled structs (CLAUDE.md constraint: no SARIF library). Emits a schema-valid report that GitHub's code scanning accepts on upload. Purpose: CI/CD integration (CICD-02 downstream in Phase 7 depends on this). Addresses OUT-03. Output: `pkg/output/sarif.go`, `pkg/output/sarif_test.go`. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/06-output-reporting/06-CONTEXT.md @.planning/phases/06-output-reporting/06-01-PLAN.md @pkg/engine/finding.go From Plan 06-01: ```go type Formatter interface { Format(findings []engine.Finding, w io.Writer, opts Options) error } type Options struct { Unmask bool ToolName string // "keyhunter" ToolVersion string // e.g. "0.6.0" } ``` SARIF 2.1.0 reference minimal shape: ```json { "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", "runs": [{ "tool": { "driver": { "name": "...", "version": "...", "rules": [{"id":"...","name":"...","shortDescription":{"text":"..."}}] } }, "results": [{ "ruleId": "...", "level": "error|warning|note", "message": { "text": "..." }, "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "..." }, "region": { "startLine": 1 } } }] }] }] } ``` Task 1: SARIF 2.1.0 struct definitions + SARIFFormatter pkg/output/sarif.go, pkg/output/sarif_test.go - pkg/output/formatter.go - pkg/output/json.go (for consistent encoding style) - pkg/engine/finding.go - SARIFFormatter implements Formatter. - Output JSON contains top-level $schema="https://json.schemastore.org/sarif-2.1.0.json" and version="2.1.0". - runs[0].tool.driver.name = opts.ToolName (fallback "keyhunter"), version = opts.ToolVersion (fallback "dev"). - rules are deduped by provider name; rule.id == provider name; rule.name == provider name; rule.shortDescription.text = "Leaked API key". - results: one per finding. ruleId = providerName. level: high->"error", medium->"warning", low->"note", default "warning". - message.text: "Detected key (): " where key is masked unless opts.Unmask. - locations[0].physicalLocation.artifactLocation.uri = f.Source (unchanged path). - locations[0].physicalLocation.region.startLine = max(1, f.LineNumber) (SARIF requires >= 1). - Empty findings: still emit a valid SARIF doc with empty rules and empty results. - Tests: * TestSARIF_Empty: parse output, assert version=="2.1.0", len(runs)==1, len(results)==0, len(rules)==0. * TestSARIF_DedupRules: two findings same provider -> len(rules)==1. * TestSARIF_LevelMapping: high/medium/low -> error/warning/note. * TestSARIF_LineFloor: f.LineNumber=0 -> region.startLine==1. * TestSARIF_Masking: opts.Unmask=false -> message.text contains KeyMasked, not KeyValue. * TestSARIF_ToolVersionFallback: empty Options uses "keyhunter"/"dev". Create pkg/output/sarif.go: ```go package output import ( "encoding/json" "fmt" "io" "github.com/salvacybersec/keyhunter/pkg/engine" ) func init() { Register("sarif", SARIFFormatter{}) } // SARIFFormatter emits SARIF 2.1.0 JSON suitable for CI uploads. type SARIFFormatter struct{} type sarifDoc struct { Schema string `json:"$schema"` Version string `json:"version"` 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"` Version string `json:"version"` Rules []sarifRule `json:"rules"` } type sarifRule struct { ID string `json:"id"` Name string `json:"name"` ShortDescription sarifText `json:"shortDescription"` } type sarifText struct { Text string `json:"text"` } type sarifResult struct { RuleID string `json:"ruleId"` Level string `json:"level"` Message sarifText `json:"message"` Locations []sarifLocation `json:"locations"` } type sarifLocation struct { PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"` } type sarifPhysicalLocation struct { ArtifactLocation sarifArtifactLocation `json:"artifactLocation"` Region sarifRegion `json:"region"` } type sarifArtifactLocation struct { URI string `json:"uri"` } type sarifRegion struct { StartLine int `json:"startLine"` } func (SARIFFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error { toolName := opts.ToolName if toolName == "" { toolName = "keyhunter" } toolVersion := opts.ToolVersion if toolVersion == "" { toolVersion = "dev" } // Dedup rules by provider, preserving first-seen order. seen := map[string]bool{} rules := make([]sarifRule, 0) for _, f := range findings { if seen[f.ProviderName] { continue } seen[f.ProviderName] = true rules = append(rules, sarifRule{ ID: f.ProviderName, Name: f.ProviderName, ShortDescription: sarifText{Text: fmt.Sprintf("Leaked %s API key", f.ProviderName)}, }) } results := make([]sarifResult, 0, len(findings)) for _, f := range findings { key := f.KeyMasked if opts.Unmask { key = f.KeyValue } startLine := f.LineNumber if startLine < 1 { startLine = 1 } results = append(results, sarifResult{ RuleID: f.ProviderName, Level: sarifLevel(f.Confidence), Message: sarifText{Text: fmt.Sprintf("Detected %s key (%s): %s", f.ProviderName, f.Confidence, key)}, Locations: []sarifLocation{{ PhysicalLocation: sarifPhysicalLocation{ ArtifactLocation: sarifArtifactLocation{URI: f.Source}, Region: sarifRegion{StartLine: startLine}, }, }}, }) } doc := sarifDoc{ Schema: "https://json.schemastore.org/sarif-2.1.0.json", Version: "2.1.0", Runs: []sarifRun{{ Tool: sarifTool{Driver: sarifDriver{ Name: toolName, Version: toolVersion, Rules: rules, }}, Results: results, }}, } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(doc) } func sarifLevel(confidence string) string { switch confidence { case "high": return "error" case "medium": return "warning" case "low": return "note" default: return "warning" } } ``` Create pkg/output/sarif_test.go implementing all six test cases listed in . Use json.Unmarshal into sarifDoc to assert structural fields. cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestSARIF" -count=1 - All TestSARIF_* tests pass - `grep -q "Register(\"sarif\"" pkg/output/sarif.go` - `grep -q '"2.1.0"' pkg/output/sarif.go` - `grep -q "sarifLevel" pkg/output/sarif.go` and covers high/medium/low - `go build ./...` succeeds - `go test ./pkg/output/... -count=1` all green - All four formatters (table, json, csv, sarif) are registered - SARIFFormatter produces 2.1.0-compliant documents - Rules deduped per provider - Confidence -> level mapping is deterministic - Ready for CI/CD integration in Phase 7 After completion, create `.planning/phases/06-output-reporting/06-03-SUMMARY.md`.