Files
FloandClaude Opus 4.8 5a610ff5c4 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
2026-06-23 23:58:42 +02:00

138 lines
4.3 KiB
Go

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
}