docs(06): create phase 6 plans — output formats + key management
This commit is contained in:
302
.planning/phases/06-output-reporting/06-03-PLAN.md
Normal file
302
.planning/phases/06-output-reporting/06-03-PLAN.md
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user