brew-vulns 0.3.0 → 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: 393c81aa55ad74bc34018843abde797c2067c055fd669d8669712a32c2e6313c
4
- data.tar.gz: a95b21cd1a777076b06b0a6b507f0429c7fde11476b193a712aae67784332d49
3
+ metadata.gz: 80d99be04bbb5f94ac9b6ce0572699c06291a38d3cad5643f40fcc5991a11bcf
4
+ data.tar.gz: 50af431e418e7af72b006acbdbc3d88dcf177726f0796eb2ead936406c5242ba
5
5
  SHA512:
6
- metadata.gz: 718026189245d3ed3bcc08fc1829610ab1d5752b44bead5f9ac9b4c7718aac88074c0f99acc77c6e058cc182932a3ee05593efd9c26035411d4199044686b017
7
- data.tar.gz: fe75aa4ff545a67a8cfa361512393a4b9c64a9ad2edeaa7958fd4495424e28287c5363e2f99595ff751bdcb14a0cb618703773e5874ccda4271917a9289fe31c
6
+ metadata.gz: c45915f201f9b804298836a2ccf16f815eeb48899253499c315ae74a7684e52da207b344682fe8b48af14afe4003bbcdeffb233a11a984cca5821554e687626d
7
+ data.tar.gz: 4aea92e8df8fe2143d8e1f4268d0173c98d11f721ce1195b79e68c5c67e41357a2f9b96ded95d5b57d62baf821bf20e5cd43beebf18995fc039d8da6cb2b746c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.3.0] - 2026-05-29
4
11
 
5
12
  - Add `--all` flag to scan every formula in homebrew-core
@@ -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.3.tar.gz"
5
- sha256 "1f1bdc60daeeded30d22026ba80e66854a95a299f92392c8624997b75d0f971e"
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
@@ -31,6 +31,7 @@ brew vulns [formula...] [options]
31
31
  | | `--all` | Scan every formula in homebrew-core |
32
32
  | `-b PATH` | `--brewfile PATH` | Scan packages from a Brewfile (default: ./Brewfile) |
33
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 |
34
35
  | `-j` | `--json` | Output results as JSON |
35
36
  | | `--cyclonedx` | Output results as CycloneDX SBOM with vulnerabilities |
36
37
  | | `--sarif` | Output results as SARIF for GitHub code scanning |
@@ -96,6 +97,12 @@ brew vulns --help
96
97
 
97
98
  Packages with GitHub, GitLab, or Codeberg source URLs are checked. Packages from other sources are skipped.
98
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
+
99
106
  ## Example output
100
107
 
101
108
  ```
@@ -17,6 +17,7 @@ module Brew
17
17
  @formula_names = parse_formula_names(args)
18
18
  @all = args.include?("--all")
19
19
  @include_deps = args.include?("--deps") || args.include?("-d")
20
+ @ignore_patches = !args.include?("--no-ignore-patches")
20
21
  @json_output = args.include?("--json") || args.include?("-j")
21
22
  @sarif_output = args.include?("--sarif")
22
23
  @cyclonedx_output = args.include?("--cyclonedx")
@@ -104,8 +105,8 @@ module Brew
104
105
  puts
105
106
  end
106
107
 
107
- results = scan_vulnerabilities(queryable)
108
- output_results(results, formulae)
108
+ results, patched = scan_vulnerabilities(queryable)
109
+ output_results(results, patched, formulae)
109
110
  rescue OsvClient::Error => e
110
111
  $stderr.puts "Error querying OSV: #{e.message}"
111
112
  2
@@ -138,6 +139,7 @@ module Brew
138
139
  vuln_results = client.query_batch(queries)
139
140
 
140
141
  results = {}
142
+ patched = {}
141
143
  formulae.each_with_index do |formula, idx|
142
144
  batch_vulns = vuln_results[idx] || []
143
145
  next if batch_vulns.empty?
@@ -155,40 +157,49 @@ module Brew
155
157
  vulns = vulns.select { |v| v.affects_version?(version) }
156
158
  vulns = vulns.select { |v| v.severity_level >= @min_severity } if @min_severity > 0
157
159
 
160
+ if @ignore_patches
161
+ resolved, vulns = vulns.partition { |v| formula.resolves?(v) }
162
+ patched[formula] = resolved if resolved.any?
163
+ end
164
+
158
165
  results[formula] = vulns if vulns.any?
159
166
  end
160
167
 
161
- results
168
+ [results, patched]
162
169
  end
163
170
 
164
- def output_results(results, all_formulae)
171
+ def output_results(results, patched, all_formulae)
165
172
  if @cyclonedx_output
166
- output_cyclonedx(results, all_formulae)
173
+ output_cyclonedx(results, patched, all_formulae)
167
174
  elsif @sarif_output
168
175
  output_sarif(results)
169
176
  elsif @json_output
170
- output_json(results)
177
+ output_json(results, patched)
171
178
  else
172
- output_text(results, all_formulae)
179
+ output_text(results, patched, all_formulae)
173
180
  end
174
181
  end
175
182
 
176
- def output_json(results)
177
- 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|
178
196
  {
179
- formula: formula.name,
180
- version: formula.version,
181
- tag: formula.tag,
182
- repo_url: formula.repo_url,
183
- vulnerabilities: vulns.map do |v|
184
- {
185
- id: v.id,
186
- severity: v.severity_display,
187
- summary: v.summary,
188
- aliases: v.aliases,
189
- fixed_versions: v.fixed_versions
190
- }
191
- 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) },
192
203
  }
193
204
  end
194
205
 
@@ -196,39 +207,58 @@ module Brew
196
207
  results.empty? ? 0 : 1
197
208
  end
198
209
 
199
- def output_cyclonedx(results, all_formulae)
210
+ def output_cyclonedx(results, patched, all_formulae)
200
211
  components = all_formulae.map do |formula|
201
- {
202
- type: "library",
203
- name: formula.name,
212
+ component = {
213
+ type: "library",
214
+ name: formula.name,
204
215
  version: formula.version,
205
- purl: "pkg:brew/#{formula.name}@#{formula.version}"
216
+ purl: "pkg:brew/#{formula.name}@#{formula.version}",
206
217
  }
218
+ pedigree = formula.cyclonedx_pedigree
219
+ component[:pedigree] = pedigree if pedigree
220
+ component
207
221
  end
208
222
 
209
223
  vulnerabilities = []
210
224
  results.each do |formula, vulns|
211
225
  vulns.each do |vuln|
212
- vulnerabilities << {
213
- id: vuln.id,
214
- source: { name: "OSV", url: "https://osv.dev" },
215
- ratings: [{ severity: vuln.severity_display&.downcase }],
216
- description: vuln.summary,
217
- affects: [{ ref: "pkg:brew/#{formula.name}@#{formula.version}" }]
218
- }
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
+ )
219
239
  end
220
240
  end
221
241
 
222
242
  generator = Sbom::Cyclonedx::Generator.new(format: :json)
223
243
  generator.generate("brew-vulns", {
224
- packages: components,
225
- vulnerabilities: vulnerabilities
244
+ packages: components,
245
+ vulnerabilities: vulnerabilities,
226
246
  })
227
247
 
228
248
  puts generator.output
229
249
  results.empty? ? 0 : 1
230
250
  end
231
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
+
232
262
  def output_sarif(results)
233
263
  rules = []
234
264
  sarif_results = []
@@ -300,9 +330,10 @@ module Brew
300
330
  end
301
331
  end
302
332
 
303
- def output_text(results, all_formulae)
333
+ def output_text(results, patched, all_formulae)
304
334
  if results.empty?
305
- puts "No vulnerabilities found."
335
+ puts patched.empty? ? "No vulnerabilities found." : "No open vulnerabilities found."
336
+ output_patched_summary(patched)
306
337
  return 0
307
338
  end
308
339
 
@@ -336,9 +367,22 @@ module Brew
336
367
  end
337
368
 
338
369
  puts "Found #{total_vulns} vulnerabilities in #{results.size} packages"
370
+ output_patched_summary(patched)
339
371
  1
340
372
  end
341
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
+
342
386
  def sanitize_terminal_escapes(text)
343
387
  text.to_s
344
388
  .gsub(/\e\][^\a\e]*(?:\a|\e\\)/, "")
@@ -373,6 +417,7 @@ module Brew
373
417
  --all Scan every formula in homebrew-core
374
418
  -b, --brewfile PATH Scan packages from a Brewfile (default: ./Brewfile)
375
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
376
421
  -j, --json Output results as JSON
377
422
  --cyclonedx Output results as CycloneDX SBOM with vulnerabilities
378
423
  --sarif Output results as SARIF for GitHub code scanning
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Brew
4
4
  module Vulns
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -39,8 +39,12 @@ module Brew
39
39
  end
40
40
  end
41
41
 
42
+ def identifiers
43
+ ([id] + aliases).compact
44
+ end
45
+
42
46
  def cve_ids
43
- ([id] + aliases).select { |a| a.start_with?("CVE-") }
47
+ identifiers.select { |a| a.start_with?("CVE-") }
44
48
  end
45
49
 
46
50
  def advisory_url
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -44,6 +44,9 @@ dependencies:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '0.1'
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 0.1.1
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
@@ -51,20 +54,23 @@ dependencies:
51
54
  - - "~>"
52
55
  - !ruby/object:Gem::Version
53
56
  version: '0.1'
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 0.1.1
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: sbom
56
62
  requirement: !ruby/object:Gem::Requirement
57
63
  requirements:
58
64
  - - "~>"
59
65
  - !ruby/object:Gem::Version
60
- version: '0.4'
66
+ version: '0.5'
61
67
  type: :runtime
62
68
  prerelease: false
63
69
  version_requirements: !ruby/object:Gem::Requirement
64
70
  requirements:
65
71
  - - "~>"
66
72
  - !ruby/object:Gem::Version
67
- version: '0.4'
73
+ version: '0.5'
68
74
  - !ruby/object:Gem::Dependency
69
75
  name: vers
70
76
  requirement: !ruby/object:Gem::Requirement