303 lines
9.6 KiB
Markdown
303 lines
9.6 KiB
Markdown
---
|
|
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>
|