brew-vulns 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be255fe2f38fdc4f92a35543e5164f3d4ebca6e8fdcbd18226d2f71b9fc630de
4
- data.tar.gz: e0ee3ea8b1327ebff1fef3dbfc441fafe661d26482d59e37c1574c472d58b365
3
+ metadata.gz: 80d99be04bbb5f94ac9b6ce0572699c06291a38d3cad5643f40fcc5991a11bcf
4
+ data.tar.gz: 50af431e418e7af72b006acbdbc3d88dcf177726f0796eb2ead936406c5242ba
5
5
  SHA512:
6
- metadata.gz: 0e19f82e0b1b075245c696ddbf8ffb09c8beab175ec157214037182005b9af6fae8c942235b43694c4d7e54a5e79ba6735709d3e684d3441b9238e9d2d09d53d
7
- data.tar.gz: 705abcdef8c8bb4b7795f7f5478751d0025397e5b64def2682cccce4bf63fa88d9d6bbfa55d7dd15a34826f3375ef6b6177f48a1ef384289a5d8fb917788f15d
6
+ metadata.gz: c45915f201f9b804298836a2ccf16f815eeb48899253499c315ae74a7684e52da207b344682fe8b48af14afe4003bbcdeffb233a11a984cca5821554e687626d
7
+ data.tar.gz: 4aea92e8df8fe2143d8e1f4268d0173c98d11f721ce1195b79e68c5c67e41357a2f9b96ded95d5b57d62baf821bf20e5cd43beebf18995fc039d8da6cb2b746c
data/.rubocop.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # This file is synced from `Homebrew/brew` by the `.github` repository, do not modify it directly.
2
2
  ---
3
3
  AllCops:
4
- TargetRubyVersion: 3.4
4
+ TargetRubyVersion: 4.0
5
5
  NewCops: enable
6
6
  Include:
7
7
  - "**/*.rbi"
@@ -31,8 +31,6 @@ Layout/EndAlignment:
31
31
  Layout/HashAlignment:
32
32
  EnforcedHashRocketStyle: table
33
33
  EnforcedColonStyle: table
34
- Layout/IndentationWidth:
35
- Enabled: false
36
34
  Layout/LeadingCommentSpace:
37
35
  Exclude:
38
36
  - Taps/*/*/cmd/*.rb
@@ -149,6 +147,16 @@ Style/FetchEnvVar:
149
147
  - Taps/*/*/*.rb
150
148
  - "/**/Formula/**/*.rb"
151
149
  - "**/Formula/**/*.rb"
150
+ Style/OneClassPerFile:
151
+ Exclude:
152
+ - "**/*.rbi"
153
+ - Taps/*/*/*.rb
154
+ - "/**/Abstract/**/*.rb"
155
+ - "**/Abstract/**/*.rb"
156
+ - "/**/Formula/**/*.rb"
157
+ - "**/Formula/**/*.rb"
158
+ - "/**/developer/bin/*"
159
+ - "**/developer/bin/*"
152
160
  Style/FrozenStringLiteralComment:
153
161
  EnforcedStyle: always
154
162
  Exclude:
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.0
1
+ 4.0.5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-06-28
4
+
5
+ - Suppress vulnerabilities that a formula's patches declare as resolved (via `patches[].resolves` in `brew info --json=v2`, Homebrew 6.0.4+); list them separately in text/JSON output, exclude them from SARIF output, and exclude them from the exit code
6
+ - CycloneDX output: emit patched vulnerabilities with `analysis.state = resolved` and include formula patches as `pedigree.patches` on each component
7
+ - Add `--no-ignore-patches` to report patched vulnerabilities as open findings
8
+ - Fix invalid SARIF output when no vulnerabilities are found (GitHub code scanning rejected the file)
9
+
10
+ ## [0.3.0] - 2026-05-29
11
+
12
+ - Add `--all` flag to scan every formula in homebrew-core
13
+ - Accept one or more formula names as arguments to scan specific formulae, including ones that are not installed
14
+ - Exit with status 2 on errors so callers can distinguish errors from "vulnerabilities found" (exit 1)
15
+ - Add example GitHub Actions workflows for tap PR checks and full homebrew-core scans
16
+ - Compute severity bands from CVSS vector strings when OSV data does not provide a severity label
17
+ - Improve CVSS severity fallback handling when multiple score sources are present
18
+ - Handle unbounded `introduced: 0` OSV ranges and multi-interval SEMVER ranges correctly
19
+ - Fail closed (report as affected) when a version range comparison raises instead of silently skipping
20
+ - Sanitize ANSI/terminal escape sequences, carriage returns and backspaces from text output
21
+ - Cap concurrent requests when fetching vulnerability details to avoid unbounded thread spawning
22
+ - Cap OSV pagination at a fixed page limit to avoid unbounded loops on bad responses
23
+ - Set a `User-Agent` header on OSV API requests
24
+
25
+ ## [0.2.3] - 2026-02-05
26
+
27
+ - Move repository to the Homebrew organisation and update install instructions, formula and links accordingly
28
+ - Internal: shared CI/lint configuration sync and dependency updates
29
+
3
30
  ## [0.2.2] - 2026-01-25
4
31
 
5
32
  - Add retry logic to OSV API requests (up to 3 attempts on timeout or connection errors)
@@ -1,8 +1,8 @@
1
1
  class BrewVulns < Formula
2
2
  desc "Check Homebrew packages for known vulnerabilities via osv.dev"
3
3
  homepage "https://github.com/Homebrew/homebrew-brew-vulns"
4
- url "https://github.com/Homebrew/homebrew-brew-vulns/archive/refs/tags/v0.2.2.tar.gz"
5
- sha256 "64abf7791eb7d04312c1fda9dc49a73f3702f5716ce18506324ed9f401fe2514"
4
+ url "https://github.com/Homebrew/homebrew-brew-vulns/archive/refs/tags/v0.3.0.tar.gz"
5
+ sha256 "1b9fbb03d46192350ad7dcac4279b382debb917d40688a4b816319214a2570d4"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "ruby"
data/README.md CHANGED
@@ -21,15 +21,17 @@ Once installed, the command is available as `brew vulns`.
21
21
  ## Usage
22
22
 
23
23
  ```bash
24
- brew vulns [formula] [options]
24
+ brew vulns [formula...] [options]
25
25
  ```
26
26
 
27
27
  ### Options
28
28
 
29
29
  | Flag | Long form | Description |
30
30
  |------|-----------|-------------|
31
+ | | `--all` | Scan every formula in homebrew-core |
31
32
  | `-b PATH` | `--brewfile PATH` | Scan packages from a Brewfile (default: ./Brewfile) |
32
33
  | `-d` | `--deps` | Include dependencies when checking a specific formula or Brewfile |
34
+ | | `--no-ignore-patches` | Report vulnerabilities even when the formula applies a patch that resolves them |
33
35
  | `-j` | `--json` | Output results as JSON |
34
36
  | | `--cyclonedx` | Output results as CycloneDX SBOM with vulnerabilities |
35
37
  | | `--sarif` | Output results as SARIF for GitHub code scanning |
@@ -43,9 +45,15 @@ brew vulns [formula] [options]
43
45
  # Check all installed packages
44
46
  brew vulns
45
47
 
46
- # Check a specific formula
48
+ # Scan every formula in homebrew-core
49
+ brew vulns --all --json > vulns.json
50
+
51
+ # Check a specific formula (does not need to be installed)
47
52
  brew vulns openssl
48
53
 
54
+ # Check several formulae at once
55
+ brew vulns vim curl jq
56
+
49
57
  # Check a formula and its dependencies
50
58
  brew vulns python --deps
51
59
 
@@ -82,13 +90,19 @@ brew vulns --help
82
90
 
83
91
  ## How it works
84
92
 
85
- 1. Reads installed Homebrew formulae via `brew info --json=v2 --installed`
93
+ 1. Reads Homebrew formulae via `brew info --json=v2` (installed packages by default, or any named formulae passed as arguments)
86
94
  2. Extracts the repository URL and version tag from each formula's source URL
87
95
  3. Queries the OSV API using the GIT ecosystem to find known vulnerabilities
88
96
  4. Reports any vulnerabilities found with their severity and CVE identifiers
89
97
 
90
98
  Packages with GitHub, GitLab, or Codeberg source URLs are checked. Packages from other sources are skipped.
91
99
 
100
+ ### Patched vulnerabilities
101
+
102
+ Some Homebrew formulae apply patches that fix CVEs without changing the upstream version number. Where a formula's `patch` block declares (or infers) a `resolves` entry for a CVE or GHSA identifier, `brew vulns` treats matching OSV results as already resolved: they are listed separately in text and `--json` output, omitted from `--sarif` output, emitted in `--cyclonedx` output with `analysis.state` set to `resolved`, and do not affect the exit code. The CycloneDX SBOM also records each formula's patches under `components[].pedigree.patches`. Pass `--no-ignore-patches` to report them as open findings instead.
103
+
104
+ This relies on `patches[].resolves` data in `brew info --json=v2`, available from Homebrew 6.0.4 onwards. With an older Homebrew, or for formulae whose patches are not yet annotated, no suppression happens.
105
+
92
106
  ## Example output
93
107
 
94
108
  ```
@@ -108,9 +122,10 @@ Found 15 vulnerabilities in 3 packages
108
122
  ## Exit codes
109
123
 
110
124
  - `0` - No vulnerabilities found
111
- - `1` - Vulnerabilities found (or error occurred)
125
+ - `1` - Vulnerabilities found
126
+ - `2` - An error occurred (network failure, `brew` failure, parse error)
112
127
 
113
- This makes it suitable for use in CI/CD pipelines.
128
+ This makes it suitable for use in CI/CD pipelines. To let a job continue when vulnerabilities are found but still fail on scan errors, use `brew vulns ... || [ $? -eq 1 ]`.
114
129
 
115
130
  ## GitHub Actions
116
131
 
@@ -134,8 +149,7 @@ jobs:
134
149
  run: gem install brew-vulns
135
150
 
136
151
  - name: Run vulnerability scan
137
- run: brew vulns --sarif > results.sarif
138
- continue-on-error: true
152
+ run: brew vulns --sarif > results.sarif || [ $? -eq 1 ]
139
153
 
140
154
  - name: Upload SARIF results
141
155
  uses: github/codeql-action/upload-sarif@v3
@@ -167,8 +181,7 @@ jobs:
167
181
  run: gem install brew-vulns
168
182
 
169
183
  - name: Generate SBOM
170
- run: brew vulns --cyclonedx > sbom.cdx.json
171
- continue-on-error: true
184
+ run: brew vulns --cyclonedx > sbom.cdx.json || [ $? -eq 1 ]
172
185
 
173
186
  - name: Submit to dependency graph
174
187
  uses: evryfs/sbom-dependency-submission-action@v0
@@ -178,6 +191,8 @@ jobs:
178
191
 
179
192
  This adds your Homebrew packages to the repository's dependency graph, enabling Dependabot alerts.
180
193
 
194
+ See [examples/](examples/) for workflows that check changed formulae on tap pull requests and publish a daily scan of all of homebrew-core.
195
+
181
196
  ## Development
182
197
 
183
198
  ```bash
data/Rakefile CHANGED
@@ -5,7 +5,9 @@ require "minitest/test_task"
5
5
  require "digest"
6
6
  require "open-uri"
7
7
 
8
- Minitest::TestTask.create
8
+ Minitest::TestTask.create do |t|
9
+ t.framework = %(require "test/test_helper.rb")
10
+ end
9
11
 
10
12
  task default: :test
11
13
 
@@ -0,0 +1,9 @@
1
+ # Example workflows
2
+
3
+ GitHub Actions workflows showing how `brew vulns` can be wired into a Homebrew tap.
4
+
5
+ `pr-vuln-check.yml` runs on pull requests that touch `Formula/**/*.rb`. It works out which formulae changed, runs `brew vulns <names> --sarif` against them, and uploads the result to GitHub code scanning so vulnerabilities appear as annotations on the PR.
6
+
7
+ `scan-all.yml` runs daily, scans every formula in homebrew-core with `brew vulns --all --json`, and publishes `vulns.json` as an asset on a rolling `vulns` release. Downstream tooling (including Homebrew itself) can fetch that file rather than querying OSV directly.
8
+
9
+ Copy whichever you need into `.github/workflows/` in the target repository and adjust to taste.
@@ -0,0 +1,55 @@
1
+ # Check formulae changed in a pull request for known vulnerabilities.
2
+ # Intended for use in a Homebrew tap (e.g. homebrew-core).
3
+ name: Vulnerability check
4
+
5
+ on:
6
+ pull_request:
7
+ paths:
8
+ - "Formula/**/*.rb"
9
+
10
+ permissions: {}
11
+
12
+ jobs:
13
+ vulns:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: read
17
+ security-events: write
18
+ steps:
19
+ - name: Set up Homebrew
20
+ id: set-up-homebrew
21
+ uses: Homebrew/actions/setup-homebrew@main
22
+
23
+ - name: Install brew-vulns
24
+ run: brew install homebrew/brew-vulns/brew-vulns
25
+
26
+ - name: Detect changed formulae
27
+ id: changed
28
+ env:
29
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
30
+ run: |
31
+ cd "$(brew --repository "${GITHUB_REPOSITORY}")"
32
+ git fetch --quiet --depth=1 origin "${BASE_SHA}"
33
+ names=$(git diff --name-only --diff-filter=AM "${BASE_SHA}" -- Formula/ \
34
+ | xargs -r -n1 basename \
35
+ | sed 's/\.rb$//' \
36
+ | tr '\n' ' ')
37
+ echo "names=${names}" >> "${GITHUB_OUTPUT}"
38
+
39
+ - name: Check vulnerabilities
40
+ id: scan
41
+ if: steps.changed.outputs.names != ''
42
+ env:
43
+ FORMULAE: ${{ steps.changed.outputs.names }}
44
+ run: |
45
+ brew vulns ${FORMULAE} --sarif > vulns.sarif || [ $? -eq 1 ]
46
+ if [ -s vulns.sarif ]; then
47
+ echo "sarif=true" >> "${GITHUB_OUTPUT}"
48
+ fi
49
+
50
+ - name: Upload SARIF
51
+ if: steps.scan.outputs.sarif == 'true'
52
+ uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
53
+ with:
54
+ sarif_file: vulns.sarif
55
+ category: brew-vulns
@@ -0,0 +1,45 @@
1
+ # Scan every formula in homebrew-core for known vulnerabilities and
2
+ # publish the result as vulns.json on a rolling release.
3
+ name: Scan all formulae
4
+
5
+ on:
6
+ schedule:
7
+ - cron: "0 6 * * *"
8
+ workflow_dispatch:
9
+
10
+ permissions: {}
11
+
12
+ jobs:
13
+ scan:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: write
17
+ steps:
18
+ - name: Set up Homebrew
19
+ uses: Homebrew/actions/setup-homebrew@main
20
+
21
+ - name: Install brew-vulns
22
+ run: brew install homebrew/brew-vulns/brew-vulns
23
+
24
+ - name: Scan
25
+ run: brew vulns --all --json > vulns.json
26
+ continue-on-error: true
27
+
28
+ - name: Validate output
29
+ run: ruby -rjson -e 'abort "vulns.json is not a JSON array" unless JSON.load_file("vulns.json").is_a?(Array)'
30
+
31
+ - name: Upload artifact
32
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
33
+ with:
34
+ name: vulns
35
+ path: vulns.json
36
+
37
+ - name: Publish to release
38
+ env:
39
+ GH_TOKEN: ${{ github.token }}
40
+ GH_REPO: ${{ github.repository }}
41
+ run: |
42
+ if ! gh release view vulns >/dev/null 2>&1; then
43
+ gh release create vulns --title "Vulnerability data" --notes "Rolling vulnerability scan of homebrew-core"
44
+ fi
45
+ gh release upload vulns vulns.json --clobber
@@ -9,11 +9,15 @@ module Brew
9
9
 
10
10
  DEFAULT_MAX_SUMMARY = 60
11
11
  SEVERITY_LEVELS = { "low" => 1, "medium" => 2, "high" => 3, "critical" => 4 }.freeze
12
+ MAX_VULN_FETCH_THREADS = 15
13
+ FLAGS_WITH_VALUE = %w[-b --brewfile -m --max-summary -s --severity].freeze
12
14
 
13
15
  def initialize(args)
14
16
  @args = args
15
- @formula_filter = args.first unless args.first&.start_with?("-")
17
+ @formula_names = parse_formula_names(args)
18
+ @all = args.include?("--all")
16
19
  @include_deps = args.include?("--deps") || args.include?("-d")
20
+ @ignore_patches = !args.include?("--no-ignore-patches")
17
21
  @json_output = args.include?("--json") || args.include?("-j")
18
22
  @sarif_output = args.include?("--sarif")
19
23
  @cyclonedx_output = args.include?("--cyclonedx")
@@ -23,6 +27,25 @@ module Brew
23
27
  @brewfile = parse_brewfile_path(args)
24
28
  end
25
29
 
30
+ def parse_formula_names(args)
31
+ names = []
32
+ skip_next = false
33
+ args.each do |arg|
34
+ if skip_next
35
+ skip_next = false
36
+ next
37
+ end
38
+ if FLAGS_WITH_VALUE.include?(arg)
39
+ skip_next = true
40
+ next
41
+ end
42
+ next if arg.start_with?("-")
43
+
44
+ names << arg
45
+ end
46
+ names
47
+ end
48
+
26
49
  def parse_max_summary(args)
27
50
  args.each_with_index do |arg, idx|
28
51
  if arg == "--max-summary" || arg == "-m"
@@ -69,7 +92,7 @@ module Brew
69
92
 
70
93
  formulae = load_formulae
71
94
  if formulae.empty?
72
- puts "No installed formulae found."
95
+ puts "No formulae found."
73
96
  return 0
74
97
  end
75
98
 
@@ -82,28 +105,30 @@ module Brew
82
105
  puts
83
106
  end
84
107
 
85
- results = scan_vulnerabilities(queryable)
86
- output_results(results, formulae)
108
+ results, patched = scan_vulnerabilities(queryable)
109
+ output_results(results, patched, formulae)
87
110
  rescue OsvClient::Error => e
88
111
  $stderr.puts "Error querying OSV: #{e.message}"
89
- 1
112
+ 2
90
113
  rescue Error => e
91
114
  $stderr.puts "Error: #{e.message}"
92
- 1
115
+ 2
93
116
  rescue JSON::ParserError => e
94
117
  $stderr.puts "Error parsing brew output: #{e.message}"
95
- 1
118
+ 2
96
119
  end
97
120
 
98
121
  private
99
122
 
100
123
  def load_formulae
101
- if @brewfile
124
+ if @all
125
+ Formula.load_all
126
+ elsif @brewfile
102
127
  Formula.load_from_brewfile(@brewfile, include_deps: @include_deps)
103
- elsif @include_deps && @formula_filter
104
- Formula.load_with_dependencies(@formula_filter)
128
+ elsif @formula_names.any?
129
+ Formula.load_named(@formula_names, include_deps: @include_deps)
105
130
  else
106
- Formula.load_installed(@formula_filter)
131
+ Formula.load_installed
107
132
  end
108
133
  end
109
134
 
@@ -114,54 +139,67 @@ module Brew
114
139
  vuln_results = client.query_batch(queries)
115
140
 
116
141
  results = {}
142
+ patched = {}
117
143
  formulae.each_with_index do |formula, idx|
118
144
  batch_vulns = vuln_results[idx] || []
119
145
  next if batch_vulns.empty?
120
146
 
121
- threads = batch_vulns.map do |v|
122
- Thread.new { client.get_vulnerability(v["id"]) }
147
+ full_vulns = batch_vulns.each_slice(MAX_VULN_FETCH_THREADS).flat_map do |slice|
148
+ threads = slice.map do |v|
149
+ Thread.new { client.get_vulnerability(v["id"]) }
150
+ end
151
+ threads.map(&:value)
123
152
  end
124
- full_vulns = threads.map(&:value)
153
+
125
154
  vulns = Vulnerability.from_osv_list(full_vulns)
126
155
 
127
156
  version = formula.tag || formula.version
128
157
  vulns = vulns.select { |v| v.affects_version?(version) }
129
158
  vulns = vulns.select { |v| v.severity_level >= @min_severity } if @min_severity > 0
130
159
 
160
+ if @ignore_patches
161
+ resolved, vulns = vulns.partition { |v| formula.resolves?(v) }
162
+ patched[formula] = resolved if resolved.any?
163
+ end
164
+
131
165
  results[formula] = vulns if vulns.any?
132
166
  end
133
167
 
134
- results
168
+ [results, patched]
135
169
  end
136
170
 
137
- def output_results(results, all_formulae)
171
+ def output_results(results, patched, all_formulae)
138
172
  if @cyclonedx_output
139
- output_cyclonedx(results, all_formulae)
173
+ output_cyclonedx(results, patched, all_formulae)
140
174
  elsif @sarif_output
141
175
  output_sarif(results)
142
176
  elsif @json_output
143
- output_json(results)
177
+ output_json(results, patched)
144
178
  else
145
- output_text(results, all_formulae)
179
+ output_text(results, patched, all_formulae)
146
180
  end
147
181
  end
148
182
 
149
- def output_json(results)
150
- data = results.map do |formula, vulns|
183
+ def vuln_json(vuln)
184
+ {
185
+ id: vuln.id,
186
+ severity: vuln.severity_display,
187
+ summary: vuln.summary,
188
+ aliases: vuln.aliases,
189
+ fixed_versions: vuln.fixed_versions,
190
+ }
191
+ end
192
+
193
+ def output_json(results, patched)
194
+ formulae = (results.keys + patched.keys).uniq
195
+ data = formulae.map do |formula|
151
196
  {
152
- formula: formula.name,
153
- version: formula.version,
154
- tag: formula.tag,
155
- repo_url: formula.repo_url,
156
- vulnerabilities: vulns.map do |v|
157
- {
158
- id: v.id,
159
- severity: v.severity_display,
160
- summary: v.summary,
161
- aliases: v.aliases,
162
- fixed_versions: v.fixed_versions
163
- }
164
- end
197
+ formula: formula.name,
198
+ version: formula.version,
199
+ tag: formula.tag,
200
+ repo_url: formula.repo_url,
201
+ vulnerabilities: (results[formula] || []).map { |v| vuln_json(v) },
202
+ patched: (patched[formula] || []).map { |v| vuln_json(v) },
165
203
  }
166
204
  end
167
205
 
@@ -169,39 +207,58 @@ module Brew
169
207
  results.empty? ? 0 : 1
170
208
  end
171
209
 
172
- def output_cyclonedx(results, all_formulae)
210
+ def output_cyclonedx(results, patched, all_formulae)
173
211
  components = all_formulae.map do |formula|
174
- {
175
- type: "library",
176
- name: formula.name,
212
+ component = {
213
+ type: "library",
214
+ name: formula.name,
177
215
  version: formula.version,
178
- purl: "pkg:brew/#{formula.name}@#{formula.version}"
216
+ purl: "pkg:brew/#{formula.name}@#{formula.version}",
179
217
  }
218
+ pedigree = formula.cyclonedx_pedigree
219
+ component[:pedigree] = pedigree if pedigree
220
+ component
180
221
  end
181
222
 
182
223
  vulnerabilities = []
183
224
  results.each do |formula, vulns|
184
225
  vulns.each do |vuln|
185
- vulnerabilities << {
186
- id: vuln.id,
187
- source: { name: "OSV", url: "https://osv.dev" },
188
- ratings: [{ severity: vuln.severity_display&.downcase }],
189
- description: vuln.summary,
190
- affects: [{ ref: "pkg:brew/#{formula.name}@#{formula.version}" }]
191
- }
226
+ vulnerabilities << cyclonedx_vulnerability(formula, vuln)
227
+ end
228
+ end
229
+ patched.each do |formula, vulns|
230
+ vulns.each do |vuln|
231
+ matched = (formula.resolved_vulnerability_ids & vuln.identifiers.map { |i| i.to_s.upcase }).join(", ")
232
+ vulnerabilities << cyclonedx_vulnerability(formula, vuln).merge(
233
+ analysis: {
234
+ state: "resolved",
235
+ response: ["update"],
236
+ detail: "Resolved by Homebrew formula patch (#{matched}).",
237
+ },
238
+ )
192
239
  end
193
240
  end
194
241
 
195
242
  generator = Sbom::Cyclonedx::Generator.new(format: :json)
196
243
  generator.generate("brew-vulns", {
197
- packages: components,
198
- vulnerabilities: vulnerabilities
244
+ packages: components,
245
+ vulnerabilities: vulnerabilities,
199
246
  })
200
247
 
201
248
  puts generator.output
202
249
  results.empty? ? 0 : 1
203
250
  end
204
251
 
252
+ def cyclonedx_vulnerability(formula, vuln)
253
+ {
254
+ id: vuln.id,
255
+ source: { name: "OSV", url: "https://osv.dev" },
256
+ ratings: [{ severity: vuln.severity_display&.downcase }],
257
+ description: vuln.summary,
258
+ affects: [{ ref: "pkg:brew/#{formula.name}@#{formula.version}" }],
259
+ }
260
+ end
261
+
205
262
  def output_sarif(results)
206
263
  rules = []
207
264
  sarif_results = []
@@ -273,9 +330,10 @@ module Brew
273
330
  end
274
331
  end
275
332
 
276
- def output_text(results, all_formulae)
333
+ def output_text(results, patched, all_formulae)
277
334
  if results.empty?
278
- puts "No vulnerabilities found."
335
+ puts patched.empty? ? "No vulnerabilities found." : "No open vulnerabilities found."
336
+ output_patched_summary(patched)
279
337
  return 0
280
338
  end
281
339
 
@@ -283,33 +341,57 @@ module Brew
283
341
  sorted = results.sort_by { |_, vulns| -vulns.map(&:severity_level).max }
284
342
 
285
343
  sorted.each do |formula, vulns|
286
- puts "#{formula.name} (#{formula.version})"
344
+ puts "#{sanitize_terminal_escapes(formula.name)} (#{sanitize_terminal_escapes(formula.version)})"
287
345
  vulns.sort_by { |v| -v.severity_level }.each do |vuln|
288
346
  total_vulns += 1
289
347
  severity = colorize_severity(vuln.severity_display)
290
348
 
291
- line = " #{vuln.id} (#{severity})"
349
+ line = " #{sanitize_terminal_escapes(vuln.id)} (#{severity})"
292
350
  if vuln.summary
293
- summary = if @max_summary > 0 && vuln.summary.length > @max_summary
294
- "#{vuln.summary.slice(0, @max_summary)}..."
351
+ sanitized_summary = sanitize_terminal_escapes(vuln.summary)
352
+ summary = if @max_summary > 0 && sanitized_summary.length > @max_summary
353
+ "#{sanitized_summary.slice(0, @max_summary)}..."
295
354
  else
296
- vuln.summary
355
+ sanitized_summary
297
356
  end
298
357
  line = "#{line} - #{summary}"
299
358
  end
300
359
  puts line
301
360
 
302
361
  if vuln.fixed_versions.any?
303
- puts " Fixed in: #{vuln.fixed_versions.join(", ")}"
362
+ fixed_versions = vuln.fixed_versions.map { |version| sanitize_terminal_escapes(version) }
363
+ puts " Fixed in: #{fixed_versions.join(", ")}"
304
364
  end
305
365
  end
306
366
  puts
307
367
  end
308
368
 
309
369
  puts "Found #{total_vulns} vulnerabilities in #{results.size} packages"
370
+ output_patched_summary(patched)
310
371
  1
311
372
  end
312
373
 
374
+ def output_patched_summary(patched)
375
+ return if patched.empty?
376
+
377
+ total = patched.values.sum(&:size)
378
+ puts
379
+ puts "#{total} resolved by formula patches (not counted; pass --no-ignore-patches to include):"
380
+ patched.sort_by { |f, _| f.name }.each do |formula, vulns|
381
+ ids = vulns.map { |v| sanitize_terminal_escapes(v.id) }.join(", ")
382
+ puts " #{sanitize_terminal_escapes(formula.name)}: #{ids}"
383
+ end
384
+ end
385
+
386
+ def sanitize_terminal_escapes(text)
387
+ text.to_s
388
+ .gsub(/\e\][^\a\e]*(?:\a|\e\\)/, "")
389
+ .gsub(/\u009d[^\a\u009c]*(?:\a|\u009c)/, "")
390
+ .gsub(/\u009b[0-?]*[ -\/]*[@-~]/, "")
391
+ .gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "")
392
+ .delete("\e\b\r\u0007\u0080-\u009f")
393
+ end
394
+
313
395
  def colorize_severity(severity)
314
396
  return severity unless $stdout.tty?
315
397
 
@@ -324,16 +406,18 @@ module Brew
324
406
 
325
407
  def print_help
326
408
  puts <<~HELP
327
- Usage: brew vulns [formula] [options]
409
+ Usage: brew vulns [formula...] [options]
328
410
 
329
- Check installed Homebrew packages for known vulnerabilities via osv.dev.
411
+ Check Homebrew packages for known vulnerabilities via osv.dev.
330
412
 
331
413
  Arguments:
332
- formula Check only this formula (optional)
414
+ formula Check only the named formulae (optional, does not need to be installed)
333
415
 
334
416
  Options:
417
+ --all Scan every formula in homebrew-core
335
418
  -b, --brewfile PATH Scan packages from a Brewfile (default: ./Brewfile)
336
419
  -d, --deps Include dependencies when checking a specific formula or Brewfile
420
+ --no-ignore-patches Report vulnerabilities even when the formula applies a patch that resolves them
337
421
  -j, --json Output results as JSON
338
422
  --cyclonedx Output results as CycloneDX SBOM with vulnerabilities
339
423
  --sarif Output results as SARIF for GitHub code scanning
@@ -343,7 +427,9 @@ module Brew
343
427
 
344
428
  Examples:
345
429
  brew vulns Check all installed packages
430
+ brew vulns --all --json Scan every homebrew-core formula, output JSON
346
431
  brew vulns openssl Check only openssl
432
+ brew vulns vim curl jq Check several formulae at once
347
433
  brew vulns vim --deps Check vim and its dependencies
348
434
  brew vulns --brewfile Scan packages listed in ./Brewfile
349
435
  brew vulns -b ~/project/Brewfile Scan a specific Brewfile
@@ -6,7 +6,7 @@ require "open3"
6
6
  module Brew
7
7
  module Vulns
8
8
  class Formula
9
- attr_reader :name, :version, :source_url, :head_url, :dependencies
9
+ attr_reader :name, :version, :source_url, :head_url, :dependencies, :patches
10
10
 
11
11
  def initialize(data)
12
12
  @name = data["name"] || data["full_name"]
@@ -14,6 +14,56 @@ module Brew
14
14
  @source_url = data.dig("urls", "stable", "url")
15
15
  @head_url = data.dig("urls", "head", "url")
16
16
  @dependencies = data["dependencies"] || []
17
+ @patches = data["patches"] || []
18
+ end
19
+
20
+ # CVE/GHSA identifiers declared as resolved by this formula's patches.
21
+ # Populated from `brew info --json=v2` `patches[].resolves[]` (Homebrew >= 6.0.4);
22
+ # empty on older Homebrew versions or for formulae with no annotated patches.
23
+ def resolved_vulnerability_ids
24
+ return @resolved_vulnerability_ids if defined?(@resolved_vulnerability_ids)
25
+
26
+ @resolved_vulnerability_ids = patches
27
+ .flat_map { |p| Array(p["resolves"]) }
28
+ .select { |r| r.is_a?(Hash) && r["type"] == "security" }
29
+ .map { |r| r["id"].to_s.upcase }
30
+ .reject(&:empty?)
31
+ .uniq
32
+ end
33
+
34
+ def resolves?(vulnerability)
35
+ ids = resolved_vulnerability_ids
36
+ return false if ids.empty?
37
+
38
+ vulnerability.identifiers.any? { |id| ids.include?(id.to_s.upcase) }
39
+ end
40
+
41
+ CYCLONEDX_PATCH_TYPES = {
42
+ "backport" => "backport",
43
+ "cherry_pick" => "cherry-pick",
44
+ "unofficial" => "unofficial",
45
+ }.freeze
46
+
47
+ # Maps `brew info --json=v2` patch data to a CycloneDX `pedigree` hash
48
+ # (symbol-keyed for the `sbom` gem). Returns nil when there are no patches.
49
+ def cyclonedx_pedigree
50
+ return nil if patches.empty?
51
+
52
+ cdx_patches = patches.map do |p|
53
+ patch = { type: CYCLONEDX_PATCH_TYPES.fetch(p["type"].to_s, "unofficial") }
54
+ patch[:diff] = { url: p["url"] } if p["url"]
55
+
56
+ resolves = Array(p["resolves"]).filter_map do |r|
57
+ next unless r.is_a?(Hash) && r["type"] && r["id"]
58
+
59
+ { type: r["type"], id: r["id"] }
60
+ end
61
+ patch[:resolves] = resolves if resolves.any?
62
+
63
+ patch
64
+ end
65
+
66
+ { patches: cdx_patches }
17
67
  end
18
68
 
19
69
  def repo_url
@@ -50,51 +100,23 @@ module Brew
50
100
  { repo_url: repo_url, version: tag, name: name }
51
101
  end
52
102
 
53
- def self.load_installed(formula_filter = nil)
54
- json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
103
+ def self.load_all
104
+ json, status = Open3.capture2("brew", "info", "--json=v2", "--eval-all")
55
105
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
56
106
 
57
107
  data = JSON.parse(json)
58
- formulae = data["formulae"].map { |f| new(f) }
59
-
60
- if formula_filter
61
- formulae.select! { |f| f.name == formula_filter || f.name.split("@").first == formula_filter }
62
- end
63
-
64
- formulae
108
+ data["formulae"].map { |f| new(f) }
65
109
  end
66
110
 
67
- def self.load_with_dependencies(formula_filter = nil)
111
+ def self.load_installed
68
112
  json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
69
113
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
70
114
 
71
115
  data = JSON.parse(json)
72
- all_formulae = data["formulae"].map { |f| new(f) }
73
- formulae_by_name = all_formulae.each_with_object({}) { |f, h| h[f.name] = f }
74
-
75
- if formula_filter
76
- filtered = all_formulae.select { |f| f.name == formula_filter || f.name.split("@").first == formula_filter }
77
- return [] if filtered.empty?
78
-
79
- deps_output, = Open3.capture2("brew", "deps", "--installed", formula_filter)
80
- dep_names = deps_output.split("\n").map(&:strip)
81
-
82
- result = filtered.each_with_object({}) { |f, h| h[f.name] = f }
83
- dep_names.each do |dep_name|
84
- dep = formulae_by_name[dep_name]
85
- result[dep_name] = dep if dep && !result[dep_name]
86
- end
87
-
88
- result.values
89
- else
90
- all_formulae
91
- end
116
+ data["formulae"].map { |f| new(f) }
92
117
  end
93
118
 
94
- def self.load_from_brewfile(brewfile_path, include_deps: false)
95
- raise Error, "Brewfile not found: #{brewfile_path}" unless File.exist?(brewfile_path)
96
-
97
- formula_names = parse_brewfile(brewfile_path)
119
+ def self.load_named(formula_names, include_deps: false)
98
120
  return [] if formula_names.empty?
99
121
 
100
122
  json, status = Open3.capture2("brew", "info", "--json=v2", *formula_names)
@@ -124,6 +146,13 @@ module Brew
124
146
  formulae.uniq { |f| f.name }
125
147
  end
126
148
 
149
+ def self.load_from_brewfile(brewfile_path, include_deps: false)
150
+ raise Error, "Brewfile not found: #{brewfile_path}" unless File.exist?(brewfile_path)
151
+
152
+ formula_names = parse_brewfile(brewfile_path)
153
+ load_named(formula_names, include_deps: include_deps)
154
+ end
155
+
127
156
  def self.parse_brewfile(brewfile_path)
128
157
  output, status = Open3.capture2("brew", "bundle", "list", "--file=#{brewfile_path}", "--formula")
129
158
  raise Error, "brew bundle list failed with status #{status.exitstatus}" unless status.success?
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require "brew/vulns/version"
6
7
 
7
8
  module Brew
8
9
  module Vulns
@@ -17,6 +18,8 @@ module Brew
17
18
  class Error < StandardError; end
18
19
  class ApiError < Error; end
19
20
 
21
+ USER_AGENT = "brew-vulns/#{Brew::Vulns::VERSION} (+https://github.com/Homebrew/homebrew-brew-vulns)"
22
+
20
23
  def query(repo_url:, version:)
21
24
  payload = {
22
25
  package: {
@@ -66,6 +69,7 @@ module Brew
66
69
  uri = URI("#{API_BASE}#{path}")
67
70
  request = Net::HTTP::Post.new(uri)
68
71
  request["Content-Type"] = "application/json"
72
+ request["User-Agent"] = USER_AGENT
69
73
  request.body = JSON.generate(payload)
70
74
 
71
75
  execute_request(uri, request)
@@ -75,6 +79,7 @@ module Brew
75
79
  uri = URI("#{API_BASE}#{path}")
76
80
  request = Net::HTTP::Get.new(uri)
77
81
  request["Content-Type"] = "application/json"
82
+ request["User-Agent"] = USER_AGENT
78
83
 
79
84
  execute_request(uri, request)
80
85
  end
@@ -117,15 +122,24 @@ module Brew
117
122
  end
118
123
  end
119
124
 
125
+ MAX_PAGES = 100
126
+
120
127
  def fetch_all_pages(response, original_payload)
121
128
  vulns = response["vulns"] || []
122
129
  page_token = response["next_page_token"]
130
+ page_count = 1
123
131
 
124
132
  while page_token
133
+ if page_count >= MAX_PAGES
134
+ raise ApiError,
135
+ "OSV API returned more than #{MAX_PAGES} pages of results; aborting to avoid an unbounded loop"
136
+ end
137
+
125
138
  payload = original_payload.merge(page_token: page_token)
126
139
  response = post("/query", payload)
127
140
  vulns.concat(response["vulns"] || [])
128
141
  page_token = response["next_page_token"]
142
+ page_count += 1
129
143
  end
130
144
 
131
145
  vulns
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Brew
4
4
  module Vulns
5
- VERSION = "0.2.3"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -2,10 +2,17 @@
2
2
 
3
3
  require "purl"
4
4
  require "vers"
5
+ require "cvss_suite"
5
6
 
6
7
  module Brew
7
8
  module Vulns
8
9
  class Vulnerability
10
+ CVSS_TYPE_PRIORITY = {
11
+ "CVSS_V4" => 4,
12
+ "CVSS_V3" => 3,
13
+ "CVSS_V2" => 2
14
+ }.freeze
15
+
9
16
  attr_reader :id, :summary, :details, :severity, :aliases, :references, :affected
10
17
 
11
18
  def initialize(data)
@@ -32,8 +39,12 @@ module Brew
32
39
  end
33
40
  end
34
41
 
42
+ def identifiers
43
+ ([id] + aliases).compact
44
+ end
45
+
35
46
  def cve_ids
36
- ([id] + aliases).select { |a| a.start_with?("CVE-") }
47
+ identifiers.select { |a| a.start_with?("CVE-") }
37
48
  end
38
49
 
39
50
  def advisory_url
@@ -76,9 +87,9 @@ module Brew
76
87
 
77
88
  def extract_severity(data)
78
89
  if data["severity"]&.any?
79
- sev = data["severity"].first
80
- if sev["score"]&.include?("CVSS")
81
- return severity_from_cvss(sev["score"])
90
+ cvss_severities(data["severity"]).each do |sev|
91
+ cvss_severity = severity_from_cvss(sev["score"])
92
+ return cvss_severity if cvss_severity
82
93
  end
83
94
  end
84
95
 
@@ -97,6 +108,12 @@ module Brew
97
108
  nil
98
109
  end
99
110
 
111
+ def cvss_severities(severities)
112
+ severities
113
+ .select { |sev| CVSS_TYPE_PRIORITY.key?(sev["type"]) }
114
+ .sort_by { |sev| -CVSS_TYPE_PRIORITY.fetch(sev["type"], 0) }
115
+ end
116
+
100
117
  def normalize_severity(severity)
101
118
  return nil unless severity
102
119
 
@@ -109,34 +126,15 @@ module Brew
109
126
  end
110
127
 
111
128
  def severity_from_cvss(vector)
112
- return nil unless vector
113
- return nil unless vector.include?("CVSS:3")
114
-
115
- metrics = parse_cvss_metrics(vector)
116
- return nil if metrics.empty?
117
-
118
- impact_high = %w[C I A].count { |m| metrics[m] == "H" }
119
- network_attack = metrics["AV"] == "N"
120
- no_privs = metrics["PR"] == "N"
121
- no_interaction = metrics["UI"] == "N"
122
-
123
- if impact_high >= 2 && network_attack && no_privs
124
- "critical"
125
- elsif impact_high >= 1 && network_attack
126
- "high"
127
- elsif impact_high >= 1 || (network_attack && no_privs && no_interaction)
128
- "medium"
129
- else
130
- "low"
131
- end
132
- end
129
+ return nil if vector.to_s.empty?
133
130
 
134
- def parse_cvss_metrics(vector)
135
- metrics = {}
136
- vector.scan(%r{([A-Z]+):([A-Z])}).each do |key, value|
137
- metrics[key] = value
138
- end
139
- metrics
131
+ cvss = CvssSuite.new(vector)
132
+
133
+ normalize_severity(cvss.severity)
134
+ rescue StandardError
135
+ warn "Warning: Failed to determine severity from CVSS vector " \
136
+ "'#{vector}' for '#{id}'"
137
+ nil
140
138
  end
141
139
 
142
140
  def normalize_version(version)
@@ -171,30 +169,51 @@ module Brew
171
169
  def version_in_range?(version, events, ecosystem)
172
170
  return false if events.nil? || events.empty?
173
171
 
174
- constraints = build_constraints(events)
175
- return false if constraints.empty?
176
-
177
- Vers.satisfies?(version, constraints.join(","), ecosystem)
172
+ build_constraint_sets(events).any? do |constraints|
173
+ constraints.empty? || Vers.satisfies?(version, constraints.join(","), ecosystem)
174
+ end
178
175
  rescue StandardError => e
179
176
  warn "Warning: Failed to check version '#{version}' against constraints: #{e.message}"
180
- false
177
+ true
181
178
  end
182
179
 
183
- def build_constraints(events)
184
- constraints = []
180
+ def build_constraint_sets(events)
181
+ constraint_sets = []
182
+ constraints = nil
183
+
185
184
  events.each do |event|
186
185
  if event["introduced"]
186
+ constraints = []
187
187
  intro = normalize_version(event["introduced"])
188
188
  constraints << ">=#{intro}" unless intro == "0"
189
- end
190
- if event["fixed"]
189
+ elsif event["fixed"]
190
+ constraints ||= []
191
191
  constraints << "<#{normalize_version(event["fixed"])}"
192
- end
193
- if event["last_affected"]
192
+ constraint_sets << constraints
193
+ constraints = nil
194
+ elsif event["last_affected"]
195
+ constraints ||= []
194
196
  constraints << "<=#{normalize_version(event["last_affected"])}"
197
+ constraint_sets << constraints
198
+ constraints = nil
199
+ elsif event["limit"]
200
+ constraints ||= []
201
+ limit_constraint = build_limit_constraint(event["limit"])
202
+ constraints << limit_constraint if limit_constraint
203
+ constraint_sets << constraints
204
+ constraints = nil
195
205
  end
196
206
  end
197
- constraints
207
+
208
+ constraint_sets << constraints if constraints
209
+ constraint_sets
210
+ end
211
+
212
+ def build_limit_constraint(limit)
213
+ limit = limit.to_s
214
+ return if limit == "*"
215
+
216
+ "<#{normalize_version(limit)}"
198
217
  end
199
218
  end
200
219
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brew-vulns
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cvss-suite
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: purl
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -30,6 +44,9 @@ dependencies:
30
44
  - - "~>"
31
45
  - !ruby/object:Gem::Version
32
46
  version: '0.1'
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 0.1.1
33
50
  type: :runtime
34
51
  prerelease: false
35
52
  version_requirements: !ruby/object:Gem::Requirement
@@ -37,20 +54,23 @@ dependencies:
37
54
  - - "~>"
38
55
  - !ruby/object:Gem::Version
39
56
  version: '0.1'
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 0.1.1
40
60
  - !ruby/object:Gem::Dependency
41
61
  name: sbom
42
62
  requirement: !ruby/object:Gem::Requirement
43
63
  requirements:
44
64
  - - "~>"
45
65
  - !ruby/object:Gem::Version
46
- version: '0.4'
66
+ version: '0.5'
47
67
  type: :runtime
48
68
  prerelease: false
49
69
  version_requirements: !ruby/object:Gem::Requirement
50
70
  requirements:
51
71
  - - "~>"
52
72
  - !ruby/object:Gem::Version
53
- version: '0.4'
73
+ version: '0.5'
54
74
  - !ruby/object:Gem::Dependency
55
75
  name: vers
56
76
  requirement: !ruby/object:Gem::Requirement
@@ -82,6 +102,9 @@ files:
82
102
  - LICENSE
83
103
  - README.md
84
104
  - Rakefile
105
+ - examples/README.md
106
+ - examples/pr-vuln-check.yml
107
+ - examples/scan-all.yml
85
108
  - exe/brew-vulns
86
109
  - lib/brew/vulns.rb
87
110
  - lib/brew/vulns/cli.rb
@@ -111,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
134
  - !ruby/object:Gem::Version
112
135
  version: '0'
113
136
  requirements: []
114
- rubygems_version: 4.0.3
137
+ rubygems_version: 4.0.10
115
138
  specification_version: 4
116
139
  summary: Check Homebrew packages for known vulnerabilities
117
140
  test_files: []