---
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