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:
@@ -62,6 +62,9 @@ Examples:
|
|||||||
if err := audit.ScanOSV(); err != nil {
|
if err := audit.ScanOSV(); err != nil {
|
||||||
log.Fatalf("Error scanning OSV.dev: %v", err)
|
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
|
// Get high-severity findings
|
||||||
highSev := audit.FilterByMinSeverity(7.0)
|
highSev := audit.FilterByMinSeverity(7.0)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user