brew-vulns 0.1.0 → 0.2.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: d1e6ed689e85382d79c401a23c21c55e393038dba7b5f3ec52f90196904effe1
4
- data.tar.gz: 55400c9cafeb368de7f8079363d0e46ae851e0f4cd16f4984139da655e84a36d
3
+ metadata.gz: 72efd9c1dafe1b2b35c1f0ade267d45827870a12ad5d3d39104edbf18737d458
4
+ data.tar.gz: 893b6844b30213daa63a16e0aa89b2c9fab619615b4fc467c8172f8303b91533
5
5
  SHA512:
6
- metadata.gz: b2f086436e55e908a72b5e22b07e7890f99f5ff34a98c4db863f4121f3a7961f86036ca772e5a35d171beaa4c70f29425f5b224513c931f612a8ed3f183eeb0f
7
- data.tar.gz: dd7ba735193a41955bca5e1a66cfc6340441bcf4baad3cca231b1490b51f0efede7436e1cb564eb6a02f889d682241ef4c73a904754121ea7080044abbf90931
6
+ metadata.gz: 673a4f8eb760b9e12eccab25ae324f514a480580497e5629c57483351f3a8e5fb93a9fe6573ce55e19e03430e9daebdf836335e2485c6f7f059cb5579e817c53
7
+ data.tar.gz: eda56de2b35ab3406f6d1c74de3e4df9ad075e227cd76d08e534e5fa2326373f3bc3e520044bf3f1b98103dde0b5ac5e65ba0d087b8f112c9ab798b7b4709548
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-01-08
4
+
5
+ - Add CycloneDX SBOM output with vulnerabilities (`--cyclonedx`)
6
+ - Add Brewfile scanning support (`--brewfile`) to check packages from a Brewfile
7
+ - Add SARIF output for GitHub code scanning integration (`--sarif`)
8
+ - Add severity filtering to only show vulnerabilities at or above a threshold (`--severity`)
9
+ - Add configurable summary truncation length (`--max-summary`)
10
+ - Fetch vulnerability details in parallel for faster scans
11
+ - Add GitLab and Codeberg support alongside GitHub
12
+ - Log warnings when version parsing fails instead of silently ignoring errors
13
+
3
14
  ## [0.1.0] - 2026-01-08
4
15
 
5
16
  - Initial release
data/README.md CHANGED
@@ -21,6 +21,25 @@ Once installed, the command is available as `brew vulns`.
21
21
 
22
22
  ## Usage
23
23
 
24
+ ```bash
25
+ brew vulns [formula] [options]
26
+ ```
27
+
28
+ ### Options
29
+
30
+ | Flag | Long form | Description |
31
+ |------|-----------|-------------|
32
+ | `-b PATH` | `--brewfile PATH` | Scan packages from a Brewfile (default: ./Brewfile) |
33
+ | `-d` | `--deps` | Include dependencies when checking a specific formula or Brewfile |
34
+ | `-j` | `--json` | Output results as JSON |
35
+ | | `--cyclonedx` | Output results as CycloneDX SBOM with vulnerabilities |
36
+ | | `--sarif` | Output results as SARIF for GitHub code scanning |
37
+ | `-m N` | `--max-summary N` | Truncate summaries to N characters (default: 60, 0 for no limit) |
38
+ | `-s LEVEL` | `--severity LEVEL` | Only show vulnerabilities at or above LEVEL (low, medium, high, critical) |
39
+ | `-h` | `--help` | Show help message |
40
+
41
+ ### Examples
42
+
24
43
  ```bash
25
44
  # Check all installed packages
26
45
  brew vulns
@@ -31,9 +50,33 @@ brew vulns openssl
31
50
  # Check a formula and its dependencies
32
51
  brew vulns python --deps
33
52
 
53
+ # Scan packages from a Brewfile
54
+ brew vulns --brewfile
55
+
56
+ # Scan a specific Brewfile
57
+ brew vulns -b ~/project/Brewfile
58
+
59
+ # Scan Brewfile packages and their dependencies
60
+ brew vulns --brewfile --deps
61
+
34
62
  # Output as JSON (useful for CI/CD)
35
63
  brew vulns --json
36
64
 
65
+ # Show longer summaries
66
+ brew vulns --max-summary 100
67
+
68
+ # Show full summaries (no truncation)
69
+ brew vulns -m 0
70
+
71
+ # Only show HIGH and CRITICAL vulnerabilities
72
+ brew vulns --severity high
73
+
74
+ # Output as CycloneDX SBOM with vulnerabilities
75
+ brew vulns --cyclonedx > sbom.cdx.json
76
+
77
+ # Output as SARIF for GitHub code scanning
78
+ brew vulns --sarif > results.sarif
79
+
37
80
  # Show help
38
81
  brew vulns --help
39
82
  ```
@@ -41,17 +84,17 @@ brew vulns --help
41
84
  ## How it works
42
85
 
43
86
  1. Reads installed Homebrew formulae via `brew info --json=v2 --installed`
44
- 2. Extracts the GitHub repository URL and version tag from each formula's source URL
87
+ 2. Extracts the repository URL and version tag from each formula's source URL
45
88
  3. Queries the OSV API using the GIT ecosystem to find known vulnerabilities
46
89
  4. Reports any vulnerabilities found with their severity and CVE identifiers
47
90
 
48
- Only packages with GitHub source URLs can be checked. Packages from other sources are skipped.
91
+ Packages with GitHub, GitLab, or Codeberg source URLs are checked. Packages from other sources are skipped.
49
92
 
50
93
  ## Example output
51
94
 
52
95
  ```
53
96
  Checking 104 packages for vulnerabilities...
54
- (119 packages skipped - no GitHub source URL)
97
+ (119 packages skipped - no supported source URL)
55
98
 
56
99
  expat (2.7.3)
57
100
  CVE-2025-66382 (HIGH) - XML parsing vulnerability...
@@ -70,6 +113,72 @@ Found 15 vulnerabilities in 3 packages
70
113
 
71
114
  This makes it suitable for use in CI/CD pipelines.
72
115
 
116
+ ## GitHub Actions
117
+
118
+ Use the `--sarif` flag to integrate with GitHub code scanning:
119
+
120
+ ```yaml
121
+ name: Vulnerability Scan
122
+
123
+ on:
124
+ schedule:
125
+ - cron: '0 0 * * *'
126
+ workflow_dispatch:
127
+
128
+ jobs:
129
+ scan:
130
+ runs-on: macos-latest
131
+ steps:
132
+ - uses: actions/checkout@v4
133
+
134
+ - name: Install brew-vulns
135
+ run: gem install brew-vulns
136
+
137
+ - name: Run vulnerability scan
138
+ run: brew vulns --sarif > results.sarif
139
+ continue-on-error: true
140
+
141
+ - name: Upload SARIF results
142
+ uses: github/codeql-action/upload-sarif@v3
143
+ with:
144
+ sarif_file: results.sarif
145
+ ```
146
+
147
+ ### Dependency graph integration
148
+
149
+ Use the `--cyclonedx` flag to submit an SBOM to GitHub's dependency graph:
150
+
151
+ ```yaml
152
+ name: SBOM Submission
153
+
154
+ on:
155
+ schedule:
156
+ - cron: '0 0 * * *'
157
+ workflow_dispatch:
158
+
159
+ jobs:
160
+ sbom:
161
+ runs-on: macos-latest
162
+ permissions:
163
+ contents: write
164
+ steps:
165
+ - uses: actions/checkout@v4
166
+
167
+ - name: Install brew-vulns
168
+ run: gem install brew-vulns
169
+
170
+ - name: Generate SBOM
171
+ run: brew vulns --cyclonedx > sbom.cdx.json
172
+ continue-on-error: true
173
+
174
+ - name: Submit to dependency graph
175
+ uses: evryfs/sbom-dependency-submission-action@v0
176
+ with:
177
+ sbom-files: sbom.cdx.json
178
+ ```
179
+
180
+ This adds your Homebrew packages to the repository's dependency graph, enabling Dependabot alerts.
181
+
73
182
  ## Development
74
183
 
75
184
  ```bash
@@ -7,12 +7,58 @@ module Brew
7
7
  new(args).run
8
8
  end
9
9
 
10
+ DEFAULT_MAX_SUMMARY = 60
11
+ SEVERITY_LEVELS = { "low" => 1, "medium" => 2, "high" => 3, "critical" => 4 }.freeze
12
+
10
13
  def initialize(args)
11
14
  @args = args
12
15
  @formula_filter = args.first unless args.first&.start_with?("-")
13
16
  @include_deps = args.include?("--deps") || args.include?("-d")
14
17
  @json_output = args.include?("--json") || args.include?("-j")
18
+ @sarif_output = args.include?("--sarif")
19
+ @cyclonedx_output = args.include?("--cyclonedx")
15
20
  @help = args.include?("--help") || args.include?("-h")
21
+ @max_summary = parse_max_summary(args)
22
+ @min_severity = parse_severity(args)
23
+ @brewfile = parse_brewfile_path(args)
24
+ end
25
+
26
+ def parse_max_summary(args)
27
+ args.each_with_index do |arg, idx|
28
+ if arg == "--max-summary" || arg == "-m"
29
+ value = args[idx + 1]
30
+ return value.to_i if value && !value.start_with?("-")
31
+ elsif arg.start_with?("--max-summary=")
32
+ return arg.split("=", 2).last.to_i
33
+ end
34
+ end
35
+ DEFAULT_MAX_SUMMARY
36
+ end
37
+
38
+ def parse_severity(args)
39
+ args.each_with_index do |arg, idx|
40
+ if arg == "--severity" || arg == "-s"
41
+ value = args[idx + 1]
42
+ return SEVERITY_LEVELS[value&.downcase] || 0 if value && !value.start_with?("-")
43
+ elsif arg.start_with?("--severity=")
44
+ value = arg.split("=", 2).last
45
+ return SEVERITY_LEVELS[value&.downcase] || 0
46
+ end
47
+ end
48
+ 0
49
+ end
50
+
51
+ def parse_brewfile_path(args)
52
+ args.each_with_index do |arg, idx|
53
+ if arg == "--brewfile" || arg == "-b"
54
+ value = args[idx + 1]
55
+ return value if value && !value.start_with?("-")
56
+ return "Brewfile"
57
+ elsif arg.start_with?("--brewfile=")
58
+ return arg.split("=", 2).last
59
+ end
60
+ end
61
+ nil
16
62
  end
17
63
 
18
64
  def run
@@ -27,12 +73,12 @@ module Brew
27
73
  return 0
28
74
  end
29
75
 
30
- queryable = formulae.select(&:github?).select(&:tag)
76
+ queryable = formulae.select(&:supported_forge?).select(&:tag)
31
77
  skipped = formulae.size - queryable.size
32
78
 
33
- unless @json_output
79
+ unless @json_output || @sarif_output || @cyclonedx_output
34
80
  puts "Checking #{queryable.size} packages for vulnerabilities..."
35
- puts "(#{skipped} packages skipped - no GitHub source URL)" if skipped > 0
81
+ puts "(#{skipped} packages skipped - no supported source URL)" if skipped > 0
36
82
  puts
37
83
  end
38
84
 
@@ -52,7 +98,9 @@ module Brew
52
98
  private
53
99
 
54
100
  def load_formulae
55
- if @include_deps && @formula_filter
101
+ if @brewfile
102
+ Formula.load_from_brewfile(@brewfile, include_deps: @include_deps)
103
+ elsif @include_deps && @formula_filter
56
104
  Formula.load_with_dependencies(@formula_filter)
57
105
  else
58
106
  Formula.load_installed(@formula_filter)
@@ -67,7 +115,19 @@ module Brew
67
115
 
68
116
  results = {}
69
117
  formulae.each_with_index do |formula, idx|
70
- vulns = Vulnerability.from_osv_list(vuln_results[idx] || [])
118
+ batch_vulns = vuln_results[idx] || []
119
+ next if batch_vulns.empty?
120
+
121
+ threads = batch_vulns.map do |v|
122
+ Thread.new { client.get_vulnerability(v["id"]) }
123
+ end
124
+ full_vulns = threads.map(&:value)
125
+ vulns = Vulnerability.from_osv_list(full_vulns)
126
+
127
+ version = formula.tag || formula.version
128
+ vulns = vulns.select { |v| v.affects_version?(version) }
129
+ vulns = vulns.select { |v| v.severity_level >= @min_severity } if @min_severity > 0
130
+
71
131
  results[formula] = vulns if vulns.any?
72
132
  end
73
133
 
@@ -75,7 +135,11 @@ module Brew
75
135
  end
76
136
 
77
137
  def output_results(results, all_formulae)
78
- if @json_output
138
+ if @cyclonedx_output
139
+ output_cyclonedx(results, all_formulae)
140
+ elsif @sarif_output
141
+ output_sarif(results)
142
+ elsif @json_output
79
143
  output_json(results)
80
144
  else
81
145
  output_text(results, all_formulae)
@@ -105,6 +169,110 @@ module Brew
105
169
  results.empty? ? 0 : 1
106
170
  end
107
171
 
172
+ def output_cyclonedx(results, all_formulae)
173
+ components = all_formulae.map do |formula|
174
+ {
175
+ type: "library",
176
+ name: formula.name,
177
+ version: formula.version,
178
+ purl: "pkg:brew/#{formula.name}@#{formula.version}"
179
+ }
180
+ end
181
+
182
+ vulnerabilities = []
183
+ results.each do |formula, vulns|
184
+ 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
+ }
192
+ end
193
+ end
194
+
195
+ generator = Sbom::Cyclonedx::Generator.new(format: :json)
196
+ generator.generate("brew-vulns", {
197
+ packages: components,
198
+ vulnerabilities: vulnerabilities
199
+ })
200
+
201
+ puts generator.output
202
+ results.empty? ? 0 : 1
203
+ end
204
+
205
+ def output_sarif(results)
206
+ rules = []
207
+ sarif_results = []
208
+
209
+ results.each do |formula, vulns|
210
+ vulns.each do |vuln|
211
+ rule_id = vuln.id
212
+ rules << Sarif::ReportingDescriptor.new(
213
+ id: rule_id,
214
+ name: rule_id,
215
+ short_description: Sarif::MultiformatMessageString.new(
216
+ text: vuln.summary || "Security vulnerability"
217
+ ),
218
+ help_uri: vuln.advisory_url,
219
+ default_configuration: Sarif::ReportingConfiguration.new(
220
+ level: sarif_level(vuln.severity_display)
221
+ )
222
+ )
223
+
224
+ sarif_results << Sarif::Result.new(
225
+ rule_id: rule_id,
226
+ level: sarif_level(vuln.severity_display),
227
+ message: Sarif::Message.new(
228
+ text: "#{formula.name}@#{formula.version}: #{vuln.summary || vuln.id}"
229
+ ),
230
+ locations: [
231
+ Sarif::Location.new(
232
+ physical_location: Sarif::PhysicalLocation.new(
233
+ artifact_location: Sarif::ArtifactLocation.new(
234
+ uri: formula.repo_url || formula.name
235
+ )
236
+ ),
237
+ message: Sarif::Message.new(
238
+ text: "Affected package: #{formula.name} version #{formula.version}"
239
+ )
240
+ )
241
+ ]
242
+ )
243
+ end
244
+ end
245
+
246
+ log = Sarif::Log.new(
247
+ version: "2.1.0",
248
+ runs: [
249
+ Sarif::Run.new(
250
+ tool: Sarif::Tool.new(
251
+ driver: Sarif::ToolComponent.new(
252
+ name: "brew-vulns",
253
+ version: VERSION,
254
+ information_uri: "https://github.com/andrew/brew-vulns",
255
+ rules: rules.uniq { |r| r.id }
256
+ )
257
+ ),
258
+ results: sarif_results
259
+ )
260
+ ]
261
+ )
262
+
263
+ puts JSON.pretty_generate(log.to_h)
264
+ results.empty? ? 0 : 1
265
+ end
266
+
267
+ def sarif_level(severity)
268
+ case severity&.downcase
269
+ when "critical", "high" then "error"
270
+ when "medium" then "warning"
271
+ when "low" then "note"
272
+ else "warning"
273
+ end
274
+ end
275
+
108
276
  def output_text(results, all_formulae)
109
277
  if results.empty?
110
278
  puts "No vulnerabilities found."
@@ -122,7 +290,11 @@ module Brew
122
290
 
123
291
  line = " #{vuln.id} (#{severity})"
124
292
  if vuln.summary
125
- summary = vuln.summary.length > 60 ? "#{vuln.summary.slice(0, 60)}..." : vuln.summary
293
+ summary = if @max_summary > 0 && vuln.summary.length > @max_summary
294
+ "#{vuln.summary.slice(0, @max_summary)}..."
295
+ else
296
+ vuln.summary
297
+ end
126
298
  line = "#{line} - #{summary}"
127
299
  end
128
300
  puts line
@@ -157,18 +329,29 @@ module Brew
157
329
  Check installed Homebrew packages for known vulnerabilities via osv.dev.
158
330
 
159
331
  Arguments:
160
- formula Check only this formula (optional)
332
+ formula Check only this formula (optional)
161
333
 
162
334
  Options:
163
- -d, --deps Include dependencies when checking a specific formula
164
- -j, --json Output results as JSON
165
- -h, --help Show this help message
335
+ -b, --brewfile PATH Scan packages from a Brewfile (default: ./Brewfile)
336
+ -d, --deps Include dependencies when checking a specific formula or Brewfile
337
+ -j, --json Output results as JSON
338
+ --cyclonedx Output results as CycloneDX SBOM with vulnerabilities
339
+ --sarif Output results as SARIF for GitHub code scanning
340
+ -m, --max-summary N Truncate summaries to N characters (default: 60, 0 for no limit)
341
+ -s, --severity LEVEL Only show vulnerabilities at or above LEVEL (low, medium, high, critical)
342
+ -h, --help Show this help message
166
343
 
167
344
  Examples:
168
345
  brew vulns Check all installed packages
169
346
  brew vulns openssl Check only openssl
170
347
  brew vulns vim --deps Check vim and its dependencies
348
+ brew vulns --brewfile Scan packages listed in ./Brewfile
349
+ brew vulns -b ~/project/Brewfile Scan a specific Brewfile
350
+ brew vulns -b Brewfile --deps Scan Brewfile packages and their dependencies
171
351
  brew vulns --json Output as JSON for CI/CD
352
+ brew vulns --cyclonedx Output as CycloneDX SBOM
353
+ brew vulns --sarif Output as SARIF for GitHub Actions
354
+ brew vulns --severity high Only show HIGH and CRITICAL vulnerabilities
172
355
  HELP
173
356
  end
174
357
  end
@@ -32,6 +32,18 @@ module Brew
32
32
  repo_url&.include?("github.com")
33
33
  end
34
34
 
35
+ def gitlab?
36
+ repo_url&.include?("gitlab.com")
37
+ end
38
+
39
+ def codeberg?
40
+ repo_url&.include?("codeberg.org")
41
+ end
42
+
43
+ def supported_forge?
44
+ github? || gitlab? || codeberg?
45
+ end
46
+
35
47
  def to_osv_query
36
48
  return nil unless repo_url && tag
37
49
 
@@ -46,7 +58,7 @@ module Brew
46
58
  formulae = data["formulae"].map { |f| new(f) }
47
59
 
48
60
  if formula_filter
49
- formulae.select! { |f| f.name == formula_filter || f.name.start_with?("#{formula_filter}@") }
61
+ formulae.select! { |f| f.name == formula_filter || f.name.split("@").first == formula_filter }
50
62
  end
51
63
 
52
64
  formulae
@@ -61,7 +73,7 @@ module Brew
61
73
  formulae_by_name = all_formulae.each_with_object({}) { |f, h| h[f.name] = f }
62
74
 
63
75
  if formula_filter
64
- filtered = all_formulae.select { |f| f.name == formula_filter || f.name.start_with?("#{formula_filter}@") }
76
+ filtered = all_formulae.select { |f| f.name == formula_filter || f.name.split("@").first == formula_filter }
65
77
  return [] if filtered.empty?
66
78
 
67
79
  deps_output, = Open3.capture2("brew", "deps", "--installed", formula_filter)
@@ -79,16 +91,59 @@ module Brew
79
91
  end
80
92
  end
81
93
 
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)
98
+ return [] if formula_names.empty?
99
+
100
+ json, status = Open3.capture2("brew", "info", "--json=v2", *formula_names)
101
+ raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
102
+
103
+ data = JSON.parse(json)
104
+ formulae = data["formulae"].map { |f| new(f) }
105
+
106
+ if include_deps
107
+ dep_names = []
108
+ formula_names.each do |name|
109
+ deps_output, = Open3.capture2("brew", "deps", name)
110
+ dep_names.concat(deps_output.split("\n").map(&:strip))
111
+ end
112
+ dep_names.uniq!
113
+ dep_names -= formula_names
114
+
115
+ if dep_names.any?
116
+ deps_json, deps_status = Open3.capture2("brew", "info", "--json=v2", *dep_names)
117
+ if deps_status.success?
118
+ deps_data = JSON.parse(deps_json)
119
+ formulae.concat(deps_data["formulae"].map { |f| new(f) })
120
+ end
121
+ end
122
+ end
123
+
124
+ formulae.uniq { |f| f.name }
125
+ end
126
+
127
+ def self.parse_brewfile(brewfile_path)
128
+ output, status = Open3.capture2("brew", "bundle", "list", "--file=#{brewfile_path}", "--formula")
129
+ raise Error, "brew bundle list failed with status #{status.exitstatus}" unless status.success?
130
+
131
+ output.split("\n").map(&:strip).reject(&:empty?)
132
+ end
133
+
82
134
  private
83
135
 
84
136
  def extract_repo_url(url)
85
137
  return nil unless url
86
- return nil unless url.include?("github.com")
87
138
 
88
- match = url.match(%r{https?://github\.com/([^/]+/[^/]+)})
139
+ forges = %w[github.com gitlab.com codeberg.org]
140
+ forge = forges.find { |f| url.include?(f) }
141
+ return nil unless forge
142
+
143
+ match = url.match(%r{https?://#{Regexp.escape(forge)}/([^/]+/[^/]+)})
89
144
  if match
90
- repo_path = match[1].sub(/\.git$/, "")
91
- return "https://github.com/#{repo_path}"
145
+ repo_path = match[1].sub(/\.git$/, "").sub(%r{/-/.*}, "")
146
+ return "https://#{forge}/#{repo_path}"
92
147
  end
93
148
 
94
149
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Brew
4
4
  module Vulns
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "purl"
4
+ require "vers"
5
+
3
6
  module Brew
4
7
  module Vulns
5
8
  class Vulnerability
@@ -54,12 +57,23 @@ module Brew
54
57
  versions.uniq
55
58
  end
56
59
 
60
+ def affects_version?(version, default_ecosystem = "gem")
61
+ return true if affected.empty?
62
+
63
+ normalized_version = normalize_version(version)
64
+
65
+ affected.any? do |aff|
66
+ ecosystem = extract_ecosystem(aff, default_ecosystem)
67
+
68
+ in_explicit_versions?(aff, normalized_version) ||
69
+ in_semver_ranges?(aff, normalized_version, ecosystem)
70
+ end
71
+ end
72
+
57
73
  def self.from_osv_list(vulns_data)
58
74
  vulns_data.map { |data| new(data) }
59
75
  end
60
76
 
61
- private
62
-
63
77
  def extract_severity(data)
64
78
  if data["severity"]&.any?
65
79
  sev = data["severity"].first
@@ -121,6 +135,64 @@ module Brew
121
135
  end
122
136
  metrics
123
137
  end
138
+
139
+ def normalize_version(version)
140
+ version.sub(/^v/, "")
141
+ end
142
+
143
+ def extract_ecosystem(aff, default_ecosystem)
144
+ purl_str = aff.dig("package", "purl")
145
+ return default_ecosystem unless purl_str
146
+
147
+ purl = Purl.parse(purl_str)
148
+ purl.type
149
+ rescue StandardError => e
150
+ warn "Warning: Failed to parse purl '#{purl_str}': #{e.message}"
151
+ default_ecosystem
152
+ end
153
+
154
+ def in_explicit_versions?(aff, version)
155
+ versions = aff["versions"] || []
156
+ versions.any? { |v| normalize_version(v) == version }
157
+ end
158
+
159
+ def in_semver_ranges?(aff, version, ecosystem)
160
+ ranges = aff["ranges"] || []
161
+ semver_ranges = ranges.select { |r| r["type"] == "SEMVER" }
162
+
163
+ semver_ranges.any? do |range|
164
+ version_in_range?(version, range["events"], ecosystem)
165
+ end
166
+ end
167
+
168
+ def version_in_range?(version, events, ecosystem)
169
+ return false if events.nil? || events.empty?
170
+
171
+ constraints = build_constraints(events)
172
+ return false if constraints.empty?
173
+
174
+ Vers.satisfies?(version, constraints.join(","), ecosystem)
175
+ rescue StandardError => e
176
+ warn "Warning: Failed to check version '#{version}' against constraints: #{e.message}"
177
+ false
178
+ end
179
+
180
+ def build_constraints(events)
181
+ constraints = []
182
+ events.each do |event|
183
+ if event["introduced"]
184
+ intro = normalize_version(event["introduced"])
185
+ constraints << ">=#{intro}" unless intro == "0"
186
+ end
187
+ if event["fixed"]
188
+ constraints << "<#{normalize_version(event["fixed"])}"
189
+ end
190
+ if event["last_affected"]
191
+ constraints << "<=#{normalize_version(event["last_affected"])}"
192
+ end
193
+ end
194
+ constraints
195
+ end
124
196
  end
125
197
  end
126
198
  end
data/lib/brew/vulns.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "sarif"
4
+ require "sbom"
5
+
3
6
  require_relative "vulns/version"
4
7
  require_relative "vulns/osv_client"
5
8
  require_relative "vulns/formula"
metadata CHANGED
@@ -1,14 +1,70 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brew-vulns
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: purl
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sarif-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sbom
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.4'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.4'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vers
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
12
68
  description: A Homebrew subcommand that checks installed packages for vulnerabilities
13
69
  via osv.dev
14
70
  email: