go-devops/devkit/vulncheck.go
Snider e20083d51b feat(devkit): Phase 4 — vulnerability scanning, complexity analysis, coverage trending
- govulncheck JSON output parsing with structured VulnFinding types (13 tests)
- Cyclomatic complexity analysis via go/ast with configurable threshold (21 tests)
- Coverage snapshot persistence and regression detection with CoverageStore (19 tests)

53 new tests, 68 total devkit tests. All pass with -race.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 06:28:37 +00:00

181 lines
5 KiB
Go

// Package devkit provides a developer toolkit for common automation commands.
// LEK-1 | lthn.ai | EUPL-1.2
package devkit
import (
"encoding/json"
"fmt"
"strings"
)
// VulnFinding represents a single vulnerability found by govulncheck.
type VulnFinding struct {
ID string // e.g. GO-2024-1234
Aliases []string // CVE/GHSA aliases
Package string // Affected package path
CalledFunction string // Function in call stack (empty if not called)
Description string // Human-readable summary
Severity string // "HIGH", "MEDIUM", "LOW", or empty
FixedVersion string // Version that contains the fix
ModulePath string // Go module path
}
// VulnResult holds the complete output of a vulnerability scan.
type VulnResult struct {
Findings []VulnFinding
Module string // Module path that was scanned
}
// --- govulncheck JSON wire types ---
// govulncheckMessage represents a single JSON line from govulncheck -json output.
type govulncheckMessage struct {
Config *govulncheckConfig `json:"config,omitempty"`
OSV *govulncheckOSV `json:"osv,omitempty"`
Finding *govulncheckFind `json:"finding,omitempty"`
Progress *json.RawMessage `json:"progress,omitempty"`
}
type govulncheckConfig struct {
GoVersion string `json:"go_version"`
ModulePath string `json:"module_path"`
}
type govulncheckOSV struct {
ID string `json:"id"`
Aliases []string `json:"aliases"`
Summary string `json:"summary"`
Affected []govulncheckAffect `json:"affected"`
}
type govulncheckAffect struct {
Package *govulncheckPkg `json:"package,omitempty"`
Ranges []govulncheckRange `json:"ranges,omitempty"`
Severity []govulncheckSeverity `json:"database_specific,omitempty"`
}
type govulncheckPkg struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
}
type govulncheckRange struct {
Events []govulncheckEvent `json:"events"`
}
type govulncheckEvent struct {
Fixed string `json:"fixed,omitempty"`
}
type govulncheckSeverity struct {
Severity string `json:"severity,omitempty"`
}
type govulncheckFind struct {
OSV string `json:"osv"`
Trace []govulncheckTrace `json:"trace"`
}
type govulncheckTrace struct {
Module string `json:"module,omitempty"`
Package string `json:"package,omitempty"`
Function string `json:"function,omitempty"`
Version string `json:"version,omitempty"`
}
// VulnCheck runs govulncheck -json on the given module path and parses
// the output into structured VulnFindings.
func (t *Toolkit) VulnCheck(modulePath string) (*VulnResult, error) {
if modulePath == "" {
modulePath = "./..."
}
stdout, stderr, exitCode, err := t.Run("govulncheck", "-json", modulePath)
if err != nil && exitCode == -1 {
return nil, fmt.Errorf("govulncheck not installed or not available: %w", err)
}
return ParseVulnCheckJSON(stdout, stderr)
}
// ParseVulnCheckJSON parses govulncheck -json output (newline-delimited JSON messages).
func ParseVulnCheckJSON(stdout, stderr string) (*VulnResult, error) {
result := &VulnResult{}
// Collect OSV entries and findings separately, then correlate.
osvMap := make(map[string]*govulncheckOSV)
var findings []govulncheckFind
// Parse line-by-line to gracefully skip malformed entries.
// json.Decoder.More() hangs on non-JSON input, so we split first.
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var msg govulncheckMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
// Skip malformed lines — govulncheck sometimes emits progress text
continue
}
if msg.Config != nil {
result.Module = msg.Config.ModulePath
}
if msg.OSV != nil {
osvMap[msg.OSV.ID] = msg.OSV
}
if msg.Finding != nil {
findings = append(findings, *msg.Finding)
}
}
// Build VulnFindings by correlating findings with OSV metadata.
for _, f := range findings {
finding := VulnFinding{
ID: f.OSV,
}
// Extract package, function, and module from trace.
if len(f.Trace) > 0 {
// The first trace entry is the called function in user code;
// the last is the vulnerable symbol.
last := f.Trace[len(f.Trace)-1]
finding.Package = last.Package
finding.CalledFunction = last.Function
finding.ModulePath = last.Module
// If the trace has a version, capture it.
for _, tr := range f.Trace {
if tr.Version != "" {
finding.FixedVersion = tr.Version
break
}
}
}
// Enrich from OSV entry.
if osv, ok := osvMap[f.OSV]; ok {
finding.Description = osv.Summary
finding.Aliases = osv.Aliases
// Extract fixed version and severity from affected entries.
for _, aff := range osv.Affected {
for _, r := range aff.Ranges {
for _, ev := range r.Events {
if ev.Fixed != "" && finding.FixedVersion == "" {
finding.FixedVersion = ev.Fixed
}
}
}
}
}
result.Findings = append(result.Findings, finding)
}
return result, nil
}
// LEK-1 | lthn.ai | EUPL-1.2