- 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>
181 lines
5 KiB
Go
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
|