feat(security): scan Homebrew pkgs via brew-vulns

Add Audit.ScanHomebrew, which shells out to "brew vulns --json" (the
homebrew/brew-vulns tap) to cover the Homebrew formulae that ScanOSV
skips. It resolves each formula's source repo and version tag and
queries OSV via the GIT ecosystem.

brew-vulns exits 1 when vulnerabilities are found, so success is
detected by parseable JSON on stdout rather than the exit code; if the
tool is not installed the scan is skipped with a notice instead of
failing the audit. Qualitative severities are mapped through the
existing severityLabelToScore/cvssToSeverity helpers for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbEgdgcC1w6qwSq3zC4kg
This commit is contained in:
Flo
2026-06-23 23:58:42 +02:00
co-authored by Claude Opus 4.8
parent aac1fa7351
commit 5a610ff5c4
3 changed files with 247 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
package security
import "testing"
func TestParseBrewVulns(t *testing.T) {
// Shape matches `brew vulns --json` (homebrew/brew-vulns output_json).
out := []byte(`[
{
"formula": "expat",
"version": "2.5.0",
"tag": "R_2_5_0",
"repo_url": "https://github.com/libexpat/libexpat",
"vulnerabilities": [
{
"id": "GHSA-xxxx-yyyy-zzzz",
"severity": "HIGH",
"summary": "XML parsing vulnerability",
"aliases": ["CVE-2024-12345"],
"fixed_versions": ["2.6.0"]
}
]
},
{
"formula": "somelib",
"version": "1.0.0",
"tag": "v1.0.0",
"repo_url": "https://github.com/foo/somelib",
"vulnerabilities": [
{
"id": "CVE-2023-0001",
"severity": "CRITICAL",
"summary": "RCE",
"aliases": [],
"fixed_versions": ["1.0.1"]
},
{
"id": "OSV-2023-9",
"severity": "UNKNOWN",
"summary": "unspecified",
"aliases": [],
"fixed_versions": []
}
]
}
]`)
findings, n, err := parseBrewVulns(out)
if err != nil {
t.Fatalf("parseBrewVulns error: %v", err)
}
if n != 2 {
t.Errorf("affected formulae = %d, want 2", n)
}
if len(findings) != 3 {
t.Fatalf("findings = %d, want 3", len(findings))
}
// First finding: GHSA id, CVE alias preferred, HIGH -> 7.0 -> HIGH.
f := findings[0]
if f.PackageName != "expat" || f.PackageVersion != "2.5.0" {
t.Errorf("pkg = %s %s, want expat 2.5.0", f.PackageName, f.PackageVersion)
}
if f.CVE != "CVE-2024-12345" {
t.Errorf("CVE = %q, want CVE-2024-12345 (alias preferred over GHSA id)", f.CVE)
}
if f.Severity != "HIGH" || f.CVSS != 7.0 {
t.Errorf("severity = %q / %.1f, want HIGH / 7.0", f.Severity, f.CVSS)
}
if f.Link != "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz" {
t.Errorf("link = %q, want GHSA advisory URL (built from id)", f.Link)
}
// CRITICAL maps to 9.0 -> CRITICAL, link via NVD for a CVE id.
if findings[1].Severity != "CRITICAL" || findings[1].CVSS != 9.0 {
t.Errorf("finding[1] = %q / %.1f, want CRITICAL / 9.0", findings[1].Severity, findings[1].CVSS)
}
if findings[1].Link != "https://nvd.nist.gov/vuln/detail/CVE-2023-0001" {
t.Errorf("finding[1] link = %q, want NVD URL", findings[1].Link)
}
// UNKNOWN -> 0 -> UNKNOWN, osv.dev link.
if findings[2].Severity != "UNKNOWN" || findings[2].CVSS != 0 {
t.Errorf("finding[2] = %q / %.1f, want UNKNOWN / 0", findings[2].Severity, findings[2].CVSS)
}
if findings[2].Link != "https://osv.dev/vulnerability/OSV-2023-9" {
t.Errorf("finding[2] link = %q, want osv.dev URL", findings[2].Link)
}
}
func TestParseBrewVulnsEmpty(t *testing.T) {
// `brew vulns --json` prints "[]" when nothing is affected.
findings, n, err := parseBrewVulns([]byte("[]\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 0 || len(findings) != 0 {
t.Errorf("got %d findings / %d formulae, want 0/0", len(findings), n)
}
// Non-JSON (e.g. an "Unknown command" error on stderr, empty stdout) errors.
if _, _, err := parseBrewVulns([]byte("")); err == nil {
t.Error("expected error for empty output")
}
if _, _, err := parseBrewVulns([]byte("Error: something")); err == nil {
t.Error("expected error for non-JSON output")
}
}