Files
gohoarder/pkg/scanner/govulncheck/govulncheck.go
T
2026-01-02 23:14:23 +00:00

195 lines
5.5 KiB
Go

package govulncheck
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/rs/zerolog/log"
)
// ScannerName is the name of this scanner
const ScannerName = "govulncheck"
// Scanner implements the govulncheck vulnerability scanner for Go modules
type Scanner struct {
config config.GovulncheckConfig
}
// New creates a new govulncheck scanner
func New(cfg config.GovulncheckConfig) *Scanner {
return &Scanner{
config: cfg,
}
}
// Name returns the scanner name
func (s *Scanner) Name() string {
return ScannerName
}
// Scan scans a Go module using govulncheck
func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) {
// Only scan Go packages
if registry != "go" {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{
"skipped": "govulncheck only supports Go modules",
},
}, nil
}
log.Info().
Str("scanner", ScannerName).
Str("package", packageName).
Str("version", version).
Msg("Starting govulncheck scan")
// Create a temporary directory for extraction
tmpDir, err := os.MkdirTemp("", "govulncheck-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
// Extract the .zip file
if err := s.extractZip(filePath, tmpDir); err != nil {
return nil, fmt.Errorf("failed to extract zip: %w", err)
}
// Run govulncheck
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "-mode=binary", tmpDir) // #nosec G204 -- govulncheck command with temp directory
output, _ := cmd.CombinedOutput()
// govulncheck returns non-zero when vulnerabilities are found
// Parse output regardless of error
var vulns []GovulncheckVuln
if len(output) > 0 {
// Parse line-delimited JSON
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var entry GovulncheckEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
log.Warn().Err(err).Str("line", line).Msg("Failed to parse govulncheck line")
continue
}
if entry.Finding != nil && entry.Finding.OSV != "" {
vulns = append(vulns, GovulncheckVuln{
OSV: entry.Finding.OSV,
FixedVersion: entry.Finding.FixedVersion,
})
}
}
}
// Convert to our format
result := s.convertResult(vulns, registry, packageName, version)
log.Info().
Str("scanner", ScannerName).
Str("package", packageName).
Int("vulnerabilities", result.VulnerabilityCount).
Msg("govulncheck scan completed")
return result, nil
}
// Health checks if govulncheck is available
func (s *Scanner) Health(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "govulncheck", "-version")
if err := cmd.Run(); err != nil {
return fmt.Errorf("govulncheck not available: %w (install with: go install golang.org/x/vuln/cmd/govulncheck@latest)", err)
}
return nil
}
// extractZip extracts a zip file to destination
func (s *Scanner) extractZip(zipPath, destDir string) error {
cmd := exec.Command("unzip", "-q", zipPath, "-d", destDir)
return cmd.Run()
}
// convertResult converts govulncheck findings to our ScanResult format
func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0)
severityCounts := make(map[string]int)
seen := make(map[string]bool)
for _, vuln := range vulns {
// Deduplicate by OSV ID
if seen[vuln.OSV] {
continue
}
seen[vuln.OSV] = true
// govulncheck doesn't provide severity in output
// Default to HIGH for found vulnerabilities
severity := metadata.NormalizeSeverity("HIGH")
severityCounts[severity]++
vulnerabilities = append(vulnerabilities, metadata.Vulnerability{
ID: vuln.OSV,
Severity: severity,
Title: vuln.OSV,
Description: fmt.Sprintf("Vulnerability %s found by govulncheck", vuln.OSV),
References: []string{fmt.Sprintf("https://pkg.go.dev/vuln/%s", vuln.OSV)},
FixedIn: vuln.FixedVersion,
})
}
status := metadata.ScanStatusClean
if len(vulnerabilities) > 0 {
status = metadata.ScanStatusVulnerable
}
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: status,
VulnerabilityCount: len(vulnerabilities),
Vulnerabilities: vulnerabilities,
Details: map[string]interface{}{
"severity_counts": severityCounts,
"note": "govulncheck provides reachability analysis for Go modules",
},
}
}
// GovulncheckEntry represents a single line of govulncheck JSON output
type GovulncheckEntry struct {
Finding *GovulncheckFinding `json:"finding,omitempty"`
}
type GovulncheckFinding struct {
OSV string `json:"osv"`
FixedVersion string `json:"fixed_version,omitempty"`
}
type GovulncheckVuln struct {
OSV string
FixedVersion string
}