aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhrbrmstr <bob@rud.is>2026-09-13 07:51:05 -0400
committerhrbrmstr <bob@rud.is>2026-09-13 07:51:05 -0400
commit5102ce853135eff384b578fe218a0cb3d377d592 (patch)
tree193ea089b48bed247ca2721b8d602a68e158e1d4
Initial commit: brew-sploits Homebrew exploit scanner
-rw-r--r--.gitignore140
-rw-r--r--Justfile44
-rw-r--r--README.md52
-rw-r--r--brew_vulns.go81
-rw-r--r--cmd/brew-sploits/main.go73
-rw-r--r--fprox.go257
-rw-r--r--go.mod3
-rw-r--r--report.go82
8 files changed, 732 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9302c25
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,140 @@
+###-----------------###
+### TopTal: macos ###
+###-----------------###
+
+# Created by https://www.toptal.com/developers/gitignore/api/macos
+# Edit at https://www.toptal.com/developers/gitignore?templates=macos
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+# End of https://www.toptal.com/developers/gitignore/api/macos
+
+###------------------------###
+### GitHub Global: macOS ###
+###------------------------###
+
+.localized
+__MACOSX/
+Icon[
+]
+
+# Resource forks
+
+# Files and directories that might appear in the root of a volume
+.com.apple.timemachine.supported
+.PKInstallSandboxManager
+.PKInstallSandboxManager-SystemSoftware
+.hotfiles.btree
+.vol
+.file
+.disk_label*
+lost+found
+.HFS+ Private Directory Data[
+]
+
+# Mac OS 6 to 9
+Desktop DB
+Desktop DF
+TheFindByContentFolder
+TheVolumeSettingsFolder
+.FBCIndex
+.FBCSemaphoreFile
+.FBCLockFolder
+
+# Quota system
+.quota.group
+.quota.user
+.quota.ops.group
+.quota.ops.user
+
+# TimeMachine
+Backups.backupdb
+.MobileBackups
+.MobileBackups.trash
+MobileBackups.trash
+tmbootpicker.efi
+
+###--------------###
+### GitHub: Go ###
+###--------------###
+
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Code coverage profiles and other test artifacts
+*.out
+coverage.*
+*.coverprofile
+profile.cov
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# env file
+.env
+
+# Editor/IDE
+# .idea/
+# .vscode/
+
+###--------------###
+### TopTal: go ###
+###--------------###
+
+# Created by https://www.toptal.com/developers/gitignore/api/go
+# Edit at https://www.toptal.com/developers/gitignore?templates=go
+
+### Go ###
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+
+# End of https://www.toptal.com/developers/gitignore/api/go
+
+### brew-sploits ###
+
+# Binary built by `just build`
+/brew-sploits
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..be6f8fb
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,44 @@
+# Command runner for brew-sploits.
+# Recipes use sh. Run `just --list` to view them.
+
+name := "brew-sploits"
+
+# List the recipes and their purpose
+default:
+ just --list
+
+# Build the binary into ./brew-sploits
+build:
+ go build -o ./{{name}} ./cmd/brew-sploits
+
+# Format the Go source
+fmt:
+ gofmt -w ./cmd ./fprox.go ./brew_vulns.go ./report.go
+
+# Check that the Go source is formatted
+fmt-check:
+ test -z "$(gofmt -l ./cmd ./fprox.go ./brew_vulns.go ./report.go)"
+
+# Run go vet across all packages
+vet:
+ go vet ./...
+
+# Build every package without running anything
+check: vet fmt-check
+ go build ./...
+
+# Build and run the scan
+run: build
+ ./{{name}}
+
+# Build, run the scan, and print only the exploited CVEs
+run-exploited: build
+ ./{{name}} | jq '.exploited'
+
+# Print the CLI help text
+help:
+ go run ./cmd/brew-sploits --help
+
+# Remove build artifacts
+clean:
+ rm -f ./{{name}}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ead53b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# brew-sploits
+
+Scan Homebrew for vulnerabilities that already have known exploits.
+
+`brew-sploits` reads the vulnerabilities that `brew vulns --json` reports for
+your installed formulae. For every real CVE identifier, it asks the fprox
+service whether public proof-of-concepts or exploitation activity exist. It
+prints one JSON report to stdout.
+
+## Output
+
+The report has four fields.
+
+- `exploited` lists the CVEs with known exploits.
+- `not_exploited` lists the CVEs with no known exploits.
+- `non_cve` lists the vulnerability IDs that are not CVEs, such as `OSV-*`.
+- `vulnerable_packages` lists the unique Homebrew formulae that the CVEs affect.
+
+For a single CVE, the tool marks it as exploited when either
+`.props.pageProps.cveInfo.proofOfConcepts` or
+`.props.pageProps.cveInfo.exploitedAt` holds at least one entry.
+
+## Requirements
+
+- Go 1.26 or later
+- Homebrew, with `brew` on `PATH`
+- Network access to `fprox.hrbrmstr.app`
+
+## Build
+
+Run `just build`. Or build the binary directly:
+
+```sh
+go build -o brew-sploits ./cmd/brew-sploits
+```
+
+## Usage
+
+Run the binary to scan and print the report. Use `--help` for the option list
+and `--version` for the version.
+
+```sh
+./brew-sploits
+./brew-sploits | jq '.exploited'
+./brew-sploits --help
+```
+
+## Limitations
+
+The tool skips any CVE that the fprox lookup fails to resolve. It writes a
+warning to stderr for each skipped CVE. It never labels a skipped CVE as
+exploited or not exploited.
diff --git a/brew_vulns.go b/brew_vulns.go
new file mode 100644
index 0000000..7816e47
--- /dev/null
+++ b/brew_vulns.go
@@ -0,0 +1,81 @@
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+)
+
+// This file was generated from JSON Schema using quicktype, do not modify it directly.
+// To parse and unparse this JSON data, add this code to your project and do:
+//
+// brewVulns, err := UnmarshalBrewVulns(bytes)
+// bytes, err = brewVulns.Marshal()
+
+func UnmarshalBrewVulns(data []byte) (BrewVulns, error) {
+ var r BrewVulns
+ err := json.Unmarshal(data, &r)
+ return r, err
+}
+
+func (r *BrewVulns) Marshal() ([]byte, error) {
+ return json.Marshal(r)
+}
+
+type BrewVulns struct {
+ Findings []Finding `json:"findings"`
+ SkippedFormulae []string `json:"skipped_formulae"`
+}
+
+type Finding struct {
+ Formula string `json:"formula"`
+ Version string `json:"version"`
+ Tag string `json:"tag"`
+ RepoURL string `json:"repo_url"`
+ Vulnerabilities []Patched `json:"vulnerabilities"`
+ Patched []Patched `json:"patched"`
+}
+
+type Patched struct {
+ ID string `json:"id"`
+ Severity Severity `json:"severity"`
+ Summary *string `json:"summary"`
+ Aliases []string `json:"aliases"`
+ FixedVersions []string `json:"fixed_versions"`
+}
+
+type Severity string
+
+const (
+ High Severity = "HIGH"
+ Low Severity = "LOW"
+ Medium Severity = "MEDIUM"
+ Unknown Severity = "UNKNOWN"
+)
+
+// runBrewVulns executes `brew vulns --json` and decodes the findings.
+func runBrewVulns() (BrewVulns, error) {
+ cmd := exec.Command("brew", "vulns", "--json")
+ var out, errBuf bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errBuf
+
+ if err := cmd.Run(); err != nil && out.Len() == 0 {
+ msg := errBuf.String()
+ if msg == "" {
+ msg = err.Error()
+ }
+ return BrewVulns{}, fmt.Errorf("brew vulns: %v: %s", err, msg)
+ }
+
+ r, err := UnmarshalBrewVulns(out.Bytes())
+ if err != nil {
+ if msg := errBuf.String(); msg != "" {
+ return BrewVulns{}, fmt.Errorf("decode brew vulns output: %v: %s", err, msg)
+ }
+ return BrewVulns{}, fmt.Errorf("decode brew vulns output: %w", err)
+ }
+
+ return r, nil
+}
diff --git a/cmd/brew-sploits/main.go b/cmd/brew-sploits/main.go
new file mode 100644
index 0000000..534084d
--- /dev/null
+++ b/cmd/brew-sploits/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+
+ app "brew-sploits"
+)
+
+const version = "1.0.0"
+
+const about = `brew-sploits
+
+Scan Homebrew for vulnerabilities that already have known exploits.
+
+For every vulnerability Homebrew reports against your installed formulae,
+brew-sploits consults the fprox service to determine whether public
+proof-of-concepts or exploitation activity are known. It prints a JSON
+report to stdout with four fields:
+
+ exploited CVEs that have known exploits
+ not_exploited CVEs that have no known exploits
+ non_cve vulnerability IDs that are not CVEs (e.g. OSV-*)
+ vulnerable_packages unique Homebrew formulae affected
+
+CVEs that could not be checked (network or lookup failure) are skipped and
+reported as warnings on stderr; they are never mislabeled.
+
+Usage:
+ brew-sploits [options]
+
+Options:
+ -h, --help show this help and exit
+ --version print the version and exit
+
+Examples:
+ brew-sploits
+ brew-sploits | jq '.exploited'
+`
+
+func main() {
+ var showHelp bool
+ var showVersion bool
+
+ flag.Usage = func() { fmt.Fprint(os.Stderr, about) }
+ flag.BoolVar(&showHelp, "help", false, "show help and exit")
+ flag.BoolVar(&showVersion, "version", false, "print the version and exit")
+ flag.Parse()
+
+ if showHelp {
+ fmt.Fprint(os.Stdout, about)
+ return
+ }
+ if showVersion {
+ fmt.Printf("brew-sploits %s\n", version)
+ return
+ }
+
+ report, err := app.BuildBrewVulnsReport()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(report); err != nil {
+ fmt.Fprintf(os.Stderr, "encode output: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/fprox.go b/fprox.go
new file mode 100644
index 0000000..214ef9a
--- /dev/null
+++ b/fprox.go
@@ -0,0 +1,257 @@
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// CveHasExploits fetches the fprox report for the given CVE and reports
+// whether it contains any proof-of-concept or exploitation artifacts.
+func CveHasExploits(cve string) (bool, error) {
+ url := fmt.Sprintf("https://fprox.hrbrmstr.app/cve/%s", cve)
+ resp, err := http.Get(url)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, err
+ }
+
+ r, err := UnmarshalFproxResponse(body)
+ if err != nil {
+ return false, err
+ }
+
+ if len(r.Props.PageProps.CveInfo.ProofOfConcepts) > 0 {
+ return true, nil
+ }
+
+ if len(r.Props.PageProps.CveInfo.ExploitedAt) > 0 {
+ return true, nil
+ }
+
+ return false, nil
+}
+
+func UnmarshalFproxResponse(data []byte) (FproxResponse, error) {
+ var r FproxResponse
+ err := json.Unmarshal(data, &r)
+ return r, err
+}
+
+func (r *FproxResponse) Marshal() ([]byte, error) {
+ return json.Marshal(r)
+}
+
+type FproxResponse struct {
+ Props Props `json:"props"`
+ Page string `json:"page"`
+ Query Query `json:"query"`
+ BuildID string `json:"buildId"`
+ AssetPrefix string `json:"assetPrefix"`
+ IsFallback bool `json:"isFallback"`
+ IsExperimentalCompile bool `json:"isExperimentalCompile"`
+ Gsp bool `json:"gsp"`
+ ScriptLoader []interface{} `json:"scriptLoader"`
+}
+
+type Props struct {
+ PageProps PageProps `json:"pageProps"`
+ NSsg bool `json:"__N_SSG"`
+}
+
+type PageProps struct {
+ AdvisoryEntry Entry `json:"advisoryEntry"`
+ Categories []Category `json:"categories"`
+ ChatterEntries []Entry `json:"chatterEntries"`
+ CveInfo CveInfo `json:"cveInfo"`
+ Events []Event `json:"events"`
+ GraphMarkup string `json:"graphMarkup"`
+ ReferenceEntries []interface{} `json:"referenceEntries"`
+ TotalChatterEntries int64 `json:"totalChatterEntries"`
+ TotalReferenceEntries int64 `json:"totalReferenceEntries"`
+}
+
+type Entry struct {
+ Crawled int64 `json:"crawled"`
+ ID string `json:"id"`
+ Origin Origin `json:"origin"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ SourceLink string `json:"sourceLink"`
+ Visual *Visual `json:"visual,omitempty"`
+}
+
+type Origin struct {
+ StreamID string `json:"streamId"`
+ Title string `json:"title"`
+ HTMLURL string `json:"htmlUrl"`
+}
+
+type Visual struct {
+ URL string `json:"url"`
+ ContentType *string `json:"contentType,omitempty"`
+ Height *int64 `json:"height,omitempty"`
+ Processor *string `json:"processor,omitempty"`
+ Width *int64 `json:"width,omitempty"`
+}
+
+type Category struct {
+ NumSimilarVulnerabilities int64 `json:"numSimilarVulnerabilities"`
+ Label string `json:"label"`
+ URI string `json:"uri"`
+}
+
+type CveInfo struct {
+ Cveid string `json:"cveid"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Label string `json:"label"`
+ HasSalience bool `json:"hasSalience"`
+ AdvisoryURL string `json:"advisoryUrl"`
+ CveStatus string `json:"cveStatus"`
+ Description string `json:"description"`
+ CvssCategoryEstimate string `json:"cvssCategoryEstimate"`
+ CvssV3 CvssV3 `json:"cvssV3"`
+ CweIDS []CweID `json:"cweIds"`
+ SmallGraphURL string `json:"smallGraphUrl"`
+ GraphURL string `json:"graphUrl"`
+ EpssScore string `json:"epssScore"`
+ PatchDetails []PatchDetail `json:"patchDetails"`
+ Patched bool `json:"patched"`
+ DetectedBy []DetectedBy `json:"detectedBy"`
+ FeedlyInsertedDate string `json:"feedlyInsertedDate"`
+ FeedlyUpdatedDate string `json:"feedlyUpdatedDate"`
+ PublishedDate string `json:"publishedDate"`
+ PublicationDateInfo []PublicationDateInfo `json:"publicationDateInfo"`
+ AffectedProductsEstimate []AffectedProductsEstimate `json:"affectedProductsEstimate"`
+ IDMapping []interface{} `json:"idMapping"`
+ Timeline []Timeline `json:"timeline"`
+ ExecutiveSummary ExecutiveSummary `json:"executiveSummary"`
+ Stats Stats `json:"stats"`
+ ExploitedAt []interface{} `json:"exploitedAt"`
+ ProofOfConcepts []interface{} `json:"proofOfConcepts"`
+ Products []VendorElement `json:"products"`
+ Vendors []VendorElement `json:"vendors"`
+}
+
+type AffectedProductsEstimate struct {
+ Products []AffectedProductsEstimateProduct `json:"products"`
+ Vendor string `json:"vendor"`
+}
+
+type AffectedProductsEstimateProduct struct {
+ Name string `json:"name"`
+}
+
+type CvssV3 struct {
+ PrivilegesRequired string `json:"privilegesRequired"`
+ VectorString string `json:"vectorString"`
+ BaseScore float64 `json:"baseScore"`
+ Scope string `json:"scope"`
+ UserInteraction string `json:"userInteraction"`
+ ConfidentialityImpact string `json:"confidentialityImpact"`
+ AvailabilityImpact string `json:"availabilityImpact"`
+ AttackComplexity string `json:"attackComplexity"`
+ AttackVector string `json:"attackVector"`
+ Version string `json:"version"`
+ IntegrityImpact string `json:"integrityImpact"`
+}
+
+type CweID struct {
+ CweID string `json:"cweID"`
+ Name string `json:"name"`
+}
+
+type DetectedBy struct {
+ ScannerName string `json:"scannerName"`
+ DetectionID string `json:"detectionId"`
+}
+
+type ExecutiveSummary struct {
+ Description string `json:"description"`
+ Patch string `json:"patch"`
+ Mitigation string `json:"mitigation"`
+ Exploitation string `json:"exploitation"`
+ Impact string `json:"impact"`
+}
+
+type PatchDetail struct {
+ Title string `json:"title"`
+ Source string `json:"source"`
+ PatchAddedDate string `json:"patchAddedDate"`
+ URL string `json:"url"`
+ FeedlyPatchAddedDate string `json:"feedlyPatchAddedDate"`
+}
+
+type VendorElement struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Origin string `json:"origin"`
+}
+
+type PublicationDateInfo struct {
+ FeedlyUpdatedDate *string `json:"feedlyUpdatedDate,omitempty"`
+ Source string `json:"source"`
+ PublishedDate string `json:"publishedDate"`
+ FeedlyInsertedDate *string `json:"feedlyInsertedDate,omitempty"`
+ LastModifiedDate string `json:"lastModifiedDate"`
+}
+
+type Stats struct {
+ FirstEntryID string `json:"firstEntryId"`
+ TimeSeries map[string][]TimeSery `json:"timeSeries"`
+ AdvisoryEntryID string `json:"advisoryEntryId"`
+ FirstTimestamp int64 `json:"firstTimestamp"`
+}
+
+type TimeSery struct {
+ URL string `json:"url"`
+ FirstEntryID string `json:"firstEntryId"`
+ Age int64 `json:"age"`
+ Timestamp int64 `json:"timestamp"`
+ SourceType []string `json:"sourceType,omitempty"`
+}
+
+type Timeline struct {
+ Event string `json:"event"`
+ Date string `json:"date"`
+ Source string `json:"source"`
+}
+
+type Event struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Ts int64 `json:"ts"`
+ Updated int64 `json:"updated"`
+ EntryID *string `json:"entryId,omitempty"`
+ SourceName *string `json:"sourceName,omitempty"`
+ CvssCategoryEstimate *string `json:"cvssCategoryEstimate,omitempty"`
+ Score *float64 `json:"score,omitempty"`
+ Update *bool `json:"update,omitempty"`
+ Percentile *float64 `json:"percentile,omitempty"`
+ URL *string `json:"url,omitempty"`
+ VendorName *string `json:"vendorName,omitempty"`
+ ModuleID *string `json:"moduleId,omitempty"`
+ ScannerName *string `json:"scannerName,omitempty"`
+ GroupedEvents []GroupedEvent `json:"groupedEvents,omitempty"`
+ AdvisoryID *string `json:"advisoryId,omitempty"`
+}
+
+type GroupedEvent struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Ts int64 `json:"ts"`
+ Updated int64 `json:"updated"`
+ ModuleID string `json:"moduleId"`
+ ScannerName string `json:"scannerName"`
+}
+
+type Query struct {
+ CveID string `json:"cveId"`
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9f67aed
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module brew-sploits
+
+go 1.26.3
diff --git a/report.go b/report.go
new file mode 100644
index 0000000..e52c9d4
--- /dev/null
+++ b/report.go
@@ -0,0 +1,82 @@
+package app
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+)
+
+// BrewVulnsReport is the JSON structure emitted on stdout.
+type BrewVulnsReport struct {
+ Exploited []string `json:"exploited"`
+ NotExploited []string `json:"not_exploited"`
+ NonCVE []string `json:"non_cve"`
+ VulnerablePackages []string `json:"vulnerable_packages"`
+}
+
+// BuildBrewVulnsReport checks each brew-reported CVE against fprox and
+// classifies it as exploited or not, alongside the vulnerable packages.
+func BuildBrewVulnsReport() (BrewVulnsReport, error) {
+ r, err := runBrewVulns()
+ if err != nil {
+ return BrewVulnsReport{}, err
+ }
+
+ // Maintain a CVE -> homebrew package mapping while accumulating the
+ // vulnerable packages and non-CVE identifiers so the report references all.
+ cvePkg := make(map[string]string)
+ pkgSeen := make(map[string]struct{})
+ nonCveSeen := make(map[string]struct{})
+ for _, finding := range r.Findings {
+ for _, v := range finding.Vulnerabilities {
+ if v.ID == "" {
+ continue
+ }
+ if strings.HasPrefix(v.ID, "CVE-") {
+ if _, ok := cvePkg[v.ID]; !ok {
+ cvePkg[v.ID] = finding.Formula
+ }
+ pkgSeen[finding.Formula] = struct{}{}
+ } else {
+ nonCveSeen[v.ID] = struct{}{}
+ }
+ }
+ }
+
+ var exploited, notExploited []string
+ for cve := range cvePkg {
+ has, err := CveHasExploits(cve)
+ if err != nil {
+ // Skip CVEs we could not resolve so they are never mislabeled.
+ fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", cve, err)
+ continue
+ }
+ if has {
+ exploited = append(exploited, cve)
+ } else {
+ notExploited = append(notExploited, cve)
+ }
+ }
+ sort.Strings(exploited)
+ sort.Strings(notExploited)
+
+ pkgs := make([]string, 0, len(pkgSeen))
+ for pkg := range pkgSeen {
+ pkgs = append(pkgs, pkg)
+ }
+ sort.Strings(pkgs)
+
+ nonCves := make([]string, 0, len(nonCveSeen))
+ for id := range nonCveSeen {
+ nonCves = append(nonCves, id)
+ }
+ sort.Strings(nonCves)
+
+ return BrewVulnsReport{
+ Exploited: exploited,
+ NotExploited: notExploited,
+ NonCVE: nonCves,
+ VulnerablePackages: pkgs,
+ }, nil
+}