From 5a610ff5c4d2b4b997bb9f90b76ffcdb525ba1e5 Mon Sep 17 00:00:00 2001 From: Flo Date: Tue, 23 Jun 2026 23:58:42 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01GVbEgdgcC1w6qwSq3zC4kg --- cmd/audit/main.go | 3 + internal/security/brewvulns.go | 137 ++++++++++++++++++++++++++++ internal/security/brewvulns_test.go | 107 ++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 internal/security/brewvulns.go create mode 100644 internal/security/brewvulns_test.go diff --git a/cmd/audit/main.go b/cmd/audit/main.go index c99b3f2..e567f06 100644 --- a/cmd/audit/main.go +++ b/cmd/audit/main.go @@ -62,6 +62,9 @@ Examples: if err := audit.ScanOSV(); err != nil { log.Fatalf("Error scanning OSV.dev: %v", err) } + if err := audit.ScanHomebrew(); err != nil { + log.Fatalf("Error scanning Homebrew packages: %v", err) + } // Get high-severity findings highSev := audit.FilterByMinSeverity(7.0) diff --git a/internal/security/brewvulns.go b/internal/security/brewvulns.go new file mode 100644 index 0000000..ff25046 --- /dev/null +++ b/internal/security/brewvulns.go @@ -0,0 +1,137 @@ +package security + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// brewVulnsResult mirrors a single element of the JSON array emitted by +// `brew vulns --json` (the homebrew/brew-vulns tap). Only formulae that have +// at least one vulnerability are included in the output. +type brewVulnsResult struct { + Formula string `json:"formula"` + Version string `json:"version"` + Tag string `json:"tag"` + RepoURL string `json:"repo_url"` + Vulnerabilities []struct { + ID string `json:"id"` + Severity string `json:"severity"` // CRITICAL/HIGH/MEDIUM/LOW/UNKNOWN + Summary string `json:"summary"` + Aliases []string `json:"aliases"` + FixedVersions []string `json:"fixed_versions"` + } `json:"vulnerabilities"` +} + +// ScanHomebrew scans installed Homebrew formulae for known vulnerabilities by +// shelling out to `brew vulns --json`. That subcommand resolves each formula's +// source repository and version tag and queries OSV.dev via the GIT ecosystem +// — covering the Homebrew packages that ScanOSV deliberately skips. +// +// brew-vulns is an optional external dependency: if it is not installed (or +// otherwise fails to produce JSON), the Homebrew scan is skipped with a notice +// rather than failing the whole audit. Note that `brew vulns` exits with code +// 1 when vulnerabilities are found, so the exit code is not used to detect +// failure — the presence of parseable JSON on stdout is. +func (a *Audit) ScanHomebrew() error { + fmt.Println(" Scanning Homebrew packages via brew-vulns...") + + cmd := exec.Command("brew", "vulns", "--json") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + out := bytes.TrimSpace(stdout.Bytes()) + + findings, n, err := parseBrewVulns(out) + if err != nil { + // No JSON on stdout: brew-vulns is likely not installed, or it errored + // (e.g. an OSV outage). Treat as optional and skip. + detail := strings.TrimSpace(stderr.String()) + if detail == "" && runErr != nil { + detail = runErr.Error() + } + if detail == "" { + detail = "no output" + } + fmt.Printf(" ⏭️ Skipping Homebrew scan (brew-vulns unavailable): %s\n", firstLine(detail)) + fmt.Println(" Install with: brew install homebrew/brew-vulns/brew-vulns") + return nil + } + + a.Findings = append(a.Findings, findings...) + fmt.Printf(" Homebrew scan: %d vulnerabilities across %d formula(e)\n", len(findings), n) + return nil +} + +// parseBrewVulns converts `brew vulns --json` output into Findings. It returns +// the findings and the number of affected formulae, or an error if the output +// is not a parseable brew-vulns JSON array. +func parseBrewVulns(out []byte) ([]Finding, int, error) { + out = bytes.TrimSpace(out) + if len(out) == 0 { + return nil, 0, fmt.Errorf("empty output") + } + + var results []brewVulnsResult + if err := json.Unmarshal(out, &results); err != nil { + return nil, 0, err + } + + var findings []Finding + for _, r := range results { + for _, v := range r.Vulnerabilities { + cvss := severityLabelToScore(v.Severity) + findings = append(findings, Finding{ + PackageName: r.Formula, + PackageVersion: r.Version, + CVE: preferCVE(v.ID, v.Aliases), + CVSS: cvss, + Summary: v.Summary, + Link: advisoryLink(v.ID), + Severity: cvssToSeverity(cvss), + }) + } + } + return findings, len(results), nil +} + +// preferCVE returns a CVE identifier when one is available, since CVE IDs are +// the most widely recognized. brew-vulns reports the OSV record ID (which may +// be a GHSA or OSV ID) plus any aliases. +func preferCVE(id string, aliases []string) string { + if strings.HasPrefix(id, "CVE-") { + return id + } + for _, a := range aliases { + if strings.HasPrefix(a, "CVE-") { + return a + } + } + return id +} + +// advisoryLink builds a human-facing advisory URL for an OSV record ID. +func advisoryLink(id string) string { + switch { + case strings.HasPrefix(id, "CVE-"): + return "https://nvd.nist.gov/vuln/detail/" + id + case strings.HasPrefix(id, "GHSA-"): + return "https://github.com/advisories/" + id + case id == "": + return "" + default: + return "https://osv.dev/vulnerability/" + id + } +} + +// firstLine returns the first non-empty line of s, for compact error display. +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i != -1 { + return strings.TrimSpace(s[:i]) + } + return s +} diff --git a/internal/security/brewvulns_test.go b/internal/security/brewvulns_test.go new file mode 100644 index 0000000..c6d6dec --- /dev/null +++ b/internal/security/brewvulns_test.go @@ -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") + } +}