diff options
| author | hrbrmstr <bob@rud.is> | 2026-09-13 07:51:05 -0400 |
|---|---|---|
| committer | hrbrmstr <bob@rud.is> | 2026-09-13 07:51:05 -0400 |
| commit | 5102ce853135eff384b578fe218a0cb3d377d592 (patch) | |
| tree | 193ea089b48bed247ca2721b8d602a68e158e1d4 | |
Initial commit: brew-sploits Homebrew exploit scanner
| -rw-r--r-- | .gitignore | 140 | ||||
| -rw-r--r-- | Justfile | 44 | ||||
| -rw-r--r-- | README.md | 52 | ||||
| -rw-r--r-- | brew_vulns.go | 81 | ||||
| -rw-r--r-- | cmd/brew-sploits/main.go | 73 | ||||
| -rw-r--r-- | fprox.go | 257 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | report.go | 82 |
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"` +} @@ -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 +} |
