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

<objective>
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`.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/06-output-reporting/06-CONTEXT.md
@.planning/phases/06-output-reporting/06-01-PLAN.md
@pkg/engine/finding.go

<interfaces>
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 }
        }
      }]
    }]
  }]
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: SARIF 2.1.0 struct definitions + SARIFFormatter</name>
  <files>pkg/output/sarif.go, pkg/output/sarif_test.go</files>
  <read_first>
    - pkg/output/formatter.go
    - pkg/output/json.go (for consistent encoding style)
    - pkg/engine/finding.go
  </read_first>
  <behavior>
    - 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 <provider> API key".
    - results: one per finding. ruleId = providerName. level: high->"error", medium->"warning", low->"note", default "warning".
    - message.text: "Detected <provider> key (<confidence>): <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".
  </behavior>
  <action>
    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 <behavior>. Use json.Unmarshal into sarifDoc to assert structural fields.
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestSARIF" -count=1</automated>
  </verify>
  <done>
    - 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
  </done>
</task>

</tasks>

<verification>
- `go test ./pkg/output/... -count=1` all green
- All four formatters (table, json, csv, sarif) are registered
</verification>

<success_criteria>
- SARIFFormatter produces 2.1.0-compliant documents
- Rules deduped per provider
- Confidence -> level mapping is deterministic
- Ready for CI/CD integration in Phase 7
</success_criteria>

<output>
After completion, create `.planning/phases/06-output-reporting/06-03-SUMMARY.md`.
</output>
