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 }