brew-vulns 0.2.3 → 0.3.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: 393c81aa55ad74bc34018843abde797c2067c055fd669d8669712a32c2e6313c
4
+ data.tar.gz: a95b21cd1a777076b06b0a6b507f0429c7fde11476b193a712aae67784332d49
5
5
  SHA512:
6
- metadata.gz: 0e19f82e0b1b075245c696ddbf8ffb09c8beab175ec157214037182005b9af6fae8c942235b43694c4d7e54a5e79ba6735709d3e684d3441b9238e9d2d09d53d
7
- data.tar.gz: 705abcdef8c8bb4b7795f7f5478751d0025397e5b64def2682cccce4bf63fa88d9d6bbfa55d7dd15a34826f3375ef6b6177f48a1ef384289a5d8fb917788f15d
6
+ metadata.gz: 718026189245d3ed3bcc08fc1829610ab1d5752b44bead5f9ac9b4c7718aac88074c0f99acc77c6e058cc182932a3ee05593efd9c26035411d4199044686b017
7
+ data.tar.gz: fe75aa4ff545a67a8cfa361512393a4b9c64a9ad2edeaa7958fd4495424e28287c5363e2f99595ff751bdcb14a0cb618703773e5874ccda4271917a9289fe31c
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,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-29
4
+
5
+ - Add `--all` flag to scan every formula in homebrew-core
6
+ - Accept one or more formula names as arguments to scan specific formulae, including ones that are not installed
7
+ - Exit with status 2 on errors so callers can distinguish errors from "vulnerabilities found" (exit 1)
8
+ - Add example GitHub Actions workflows for tap PR checks and full homebrew-core scans
9
+ - Compute severity bands from CVSS vector strings when OSV data does not provide a severity label
10
+ - Improve CVSS severity fallback handling when multiple score sources are present
11
+ - Handle unbounded `introduced: 0` OSV ranges and multi-interval SEMVER ranges correctly
12
+ - Fail closed (report as affected) when a version range comparison raises instead of silently skipping
13
+ - Sanitize ANSI/terminal escape sequences, carriage returns and backspaces from text output
14
+ - Cap concurrent requests when fetching vulnerability details to avoid unbounded thread spawning
15
+ - Cap OSV pagination at a fixed page limit to avoid unbounded loops on bad responses
16
+ - Set a `User-Agent` header on OSV API requests
17
+
18
+ ## [0.2.3] - 2026-02-05
19
+
20
+ - Move repository to the Homebrew organisation and update install instructions, formula and links accordingly
21
+ - Internal: shared CI/lint configuration sync and dependency updates
22
+
3
23
  ## [0.2.2] - 2026-01-25
4
24
 
5
25
  - 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.2.3.tar.gz"
5
+ sha256 "1f1bdc60daeeded30d22026ba80e66854a95a299f92392c8624997b75d0f971e"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "ruby"
data/README.md CHANGED
@@ -21,13 +21,14 @@ 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 |
33
34
  | `-j` | `--json` | Output results as JSON |
@@ -43,9 +44,15 @@ brew vulns [formula] [options]
43
44
  # Check all installed packages
44
45
  brew vulns
45
46
 
46
- # Check a specific formula
47
+ # Scan every formula in homebrew-core
48
+ brew vulns --all --json > vulns.json
49
+
50
+ # Check a specific formula (does not need to be installed)
47
51
  brew vulns openssl
48
52
 
53
+ # Check several formulae at once
54
+ brew vulns vim curl jq
55
+
49
56
  # Check a formula and its dependencies
50
57
  brew vulns python --deps
51
58
 
@@ -82,7 +89,7 @@ brew vulns --help
82
89
 
83
90
  ## How it works
84
91
 
85
- 1. Reads installed Homebrew formulae via `brew info --json=v2 --installed`
92
+ 1. Reads Homebrew formulae via `brew info --json=v2` (installed packages by default, or any named formulae passed as arguments)
86
93
  2. Extracts the repository URL and version tag from each formula's source URL
87
94
  3. Queries the OSV API using the GIT ecosystem to find known vulnerabilities
88
95
  4. Reports any vulnerabilities found with their severity and CVE identifiers
@@ -108,9 +115,10 @@ Found 15 vulnerabilities in 3 packages
108
115
  ## Exit codes
109
116
 
110
117
  - `0` - No vulnerabilities found
111
- - `1` - Vulnerabilities found (or error occurred)
118
+ - `1` - Vulnerabilities found
119
+ - `2` - An error occurred (network failure, `brew` failure, parse error)
112
120
 
113
- This makes it suitable for use in CI/CD pipelines.
121
+ 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
122
 
115
123
  ## GitHub Actions
116
124
 
@@ -134,8 +142,7 @@ jobs:
134
142
  run: gem install brew-vulns
135
143
 
136
144
  - name: Run vulnerability scan
137
- run: brew vulns --sarif > results.sarif
138
- continue-on-error: true
145
+ run: brew vulns --sarif > results.sarif || [ $? -eq 1 ]
139
146
 
140
147
  - name: Upload SARIF results
141
148
  uses: github/codeql-action/upload-sarif@v3
@@ -167,8 +174,7 @@ jobs:
167
174
  run: gem install brew-vulns
168
175
 
169
176
  - name: Generate SBOM
170
- run: brew vulns --cyclonedx > sbom.cdx.json
171
- continue-on-error: true
177
+ run: brew vulns --cyclonedx > sbom.cdx.json || [ $? -eq 1 ]
172
178
 
173
179
  - name: Submit to dependency graph
174
180
  uses: evryfs/sbom-dependency-submission-action@v0
@@ -178,6 +184,8 @@ jobs:
178
184
 
179
185
  This adds your Homebrew packages to the repository's dependency graph, enabling Dependabot alerts.
180
186
 
187
+ See [examples/](examples/) for workflows that check changed formulae on tap pull requests and publish a daily scan of all of homebrew-core.
188
+
181
189
  ## Development
182
190
 
183
191
  ```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,10 +9,13 @@ 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")
17
20
  @json_output = args.include?("--json") || args.include?("-j")
18
21
  @sarif_output = args.include?("--sarif")
@@ -23,6 +26,25 @@ module Brew
23
26
  @brewfile = parse_brewfile_path(args)
24
27
  end
25
28
 
29
+ def parse_formula_names(args)
30
+ names = []
31
+ skip_next = false
32
+ args.each do |arg|
33
+ if skip_next
34
+ skip_next = false
35
+ next
36
+ end
37
+ if FLAGS_WITH_VALUE.include?(arg)
38
+ skip_next = true
39
+ next
40
+ end
41
+ next if arg.start_with?("-")
42
+
43
+ names << arg
44
+ end
45
+ names
46
+ end
47
+
26
48
  def parse_max_summary(args)
27
49
  args.each_with_index do |arg, idx|
28
50
  if arg == "--max-summary" || arg == "-m"
@@ -69,7 +91,7 @@ module Brew
69
91
 
70
92
  formulae = load_formulae
71
93
  if formulae.empty?
72
- puts "No installed formulae found."
94
+ puts "No formulae found."
73
95
  return 0
74
96
  end
75
97
 
@@ -86,24 +108,26 @@ module Brew
86
108
  output_results(results, formulae)
87
109
  rescue OsvClient::Error => e
88
110
  $stderr.puts "Error querying OSV: #{e.message}"
89
- 1
111
+ 2
90
112
  rescue Error => e
91
113
  $stderr.puts "Error: #{e.message}"
92
- 1
114
+ 2
93
115
  rescue JSON::ParserError => e
94
116
  $stderr.puts "Error parsing brew output: #{e.message}"
95
- 1
117
+ 2
96
118
  end
97
119
 
98
120
  private
99
121
 
100
122
  def load_formulae
101
- if @brewfile
123
+ if @all
124
+ Formula.load_all
125
+ elsif @brewfile
102
126
  Formula.load_from_brewfile(@brewfile, include_deps: @include_deps)
103
- elsif @include_deps && @formula_filter
104
- Formula.load_with_dependencies(@formula_filter)
127
+ elsif @formula_names.any?
128
+ Formula.load_named(@formula_names, include_deps: @include_deps)
105
129
  else
106
- Formula.load_installed(@formula_filter)
130
+ Formula.load_installed
107
131
  end
108
132
  end
109
133
 
@@ -118,10 +142,13 @@ module Brew
118
142
  batch_vulns = vuln_results[idx] || []
119
143
  next if batch_vulns.empty?
120
144
 
121
- threads = batch_vulns.map do |v|
122
- Thread.new { client.get_vulnerability(v["id"]) }
145
+ full_vulns = batch_vulns.each_slice(MAX_VULN_FETCH_THREADS).flat_map do |slice|
146
+ threads = slice.map do |v|
147
+ Thread.new { client.get_vulnerability(v["id"]) }
148
+ end
149
+ threads.map(&:value)
123
150
  end
124
- full_vulns = threads.map(&:value)
151
+
125
152
  vulns = Vulnerability.from_osv_list(full_vulns)
126
153
 
127
154
  version = formula.tag || formula.version
@@ -283,24 +310,26 @@ module Brew
283
310
  sorted = results.sort_by { |_, vulns| -vulns.map(&:severity_level).max }
284
311
 
285
312
  sorted.each do |formula, vulns|
286
- puts "#{formula.name} (#{formula.version})"
313
+ puts "#{sanitize_terminal_escapes(formula.name)} (#{sanitize_terminal_escapes(formula.version)})"
287
314
  vulns.sort_by { |v| -v.severity_level }.each do |vuln|
288
315
  total_vulns += 1
289
316
  severity = colorize_severity(vuln.severity_display)
290
317
 
291
- line = " #{vuln.id} (#{severity})"
318
+ line = " #{sanitize_terminal_escapes(vuln.id)} (#{severity})"
292
319
  if vuln.summary
293
- summary = if @max_summary > 0 && vuln.summary.length > @max_summary
294
- "#{vuln.summary.slice(0, @max_summary)}..."
320
+ sanitized_summary = sanitize_terminal_escapes(vuln.summary)
321
+ summary = if @max_summary > 0 && sanitized_summary.length > @max_summary
322
+ "#{sanitized_summary.slice(0, @max_summary)}..."
295
323
  else
296
- vuln.summary
324
+ sanitized_summary
297
325
  end
298
326
  line = "#{line} - #{summary}"
299
327
  end
300
328
  puts line
301
329
 
302
330
  if vuln.fixed_versions.any?
303
- puts " Fixed in: #{vuln.fixed_versions.join(", ")}"
331
+ fixed_versions = vuln.fixed_versions.map { |version| sanitize_terminal_escapes(version) }
332
+ puts " Fixed in: #{fixed_versions.join(", ")}"
304
333
  end
305
334
  end
306
335
  puts
@@ -310,6 +339,15 @@ module Brew
310
339
  1
311
340
  end
312
341
 
342
+ def sanitize_terminal_escapes(text)
343
+ text.to_s
344
+ .gsub(/\e\][^\a\e]*(?:\a|\e\\)/, "")
345
+ .gsub(/\u009d[^\a\u009c]*(?:\a|\u009c)/, "")
346
+ .gsub(/\u009b[0-?]*[ -\/]*[@-~]/, "")
347
+ .gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "")
348
+ .delete("\e\b\r\u0007\u0080-\u009f")
349
+ end
350
+
313
351
  def colorize_severity(severity)
314
352
  return severity unless $stdout.tty?
315
353
 
@@ -324,14 +362,15 @@ module Brew
324
362
 
325
363
  def print_help
326
364
  puts <<~HELP
327
- Usage: brew vulns [formula] [options]
365
+ Usage: brew vulns [formula...] [options]
328
366
 
329
- Check installed Homebrew packages for known vulnerabilities via osv.dev.
367
+ Check Homebrew packages for known vulnerabilities via osv.dev.
330
368
 
331
369
  Arguments:
332
- formula Check only this formula (optional)
370
+ formula Check only the named formulae (optional, does not need to be installed)
333
371
 
334
372
  Options:
373
+ --all Scan every formula in homebrew-core
335
374
  -b, --brewfile PATH Scan packages from a Brewfile (default: ./Brewfile)
336
375
  -d, --deps Include dependencies when checking a specific formula or Brewfile
337
376
  -j, --json Output results as JSON
@@ -343,7 +382,9 @@ module Brew
343
382
 
344
383
  Examples:
345
384
  brew vulns Check all installed packages
385
+ brew vulns --all --json Scan every homebrew-core formula, output JSON
346
386
  brew vulns openssl Check only openssl
387
+ brew vulns vim curl jq Check several formulae at once
347
388
  brew vulns vim --deps Check vim and its dependencies
348
389
  brew vulns --brewfile Scan packages listed in ./Brewfile
349
390
  brew vulns -b ~/project/Brewfile Scan a specific Brewfile
@@ -50,51 +50,23 @@ module Brew
50
50
  { repo_url: repo_url, version: tag, name: name }
51
51
  end
52
52
 
53
- def self.load_installed(formula_filter = nil)
54
- json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
53
+ def self.load_all
54
+ json, status = Open3.capture2("brew", "info", "--json=v2", "--eval-all")
55
55
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
56
56
 
57
57
  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
58
+ data["formulae"].map { |f| new(f) }
65
59
  end
66
60
 
67
- def self.load_with_dependencies(formula_filter = nil)
61
+ def self.load_installed
68
62
  json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
69
63
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
70
64
 
71
65
  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
66
+ data["formulae"].map { |f| new(f) }
92
67
  end
93
68
 
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)
69
+ def self.load_named(formula_names, include_deps: false)
98
70
  return [] if formula_names.empty?
99
71
 
100
72
  json, status = Open3.capture2("brew", "info", "--json=v2", *formula_names)
@@ -124,6 +96,13 @@ module Brew
124
96
  formulae.uniq { |f| f.name }
125
97
  end
126
98
 
99
+ def self.load_from_brewfile(brewfile_path, include_deps: false)
100
+ raise Error, "Brewfile not found: #{brewfile_path}" unless File.exist?(brewfile_path)
101
+
102
+ formula_names = parse_brewfile(brewfile_path)
103
+ load_named(formula_names, include_deps: include_deps)
104
+ end
105
+
127
106
  def self.parse_brewfile(brewfile_path)
128
107
  output, status = Open3.capture2("brew", "bundle", "list", "--file=#{brewfile_path}", "--formula")
129
108
  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.3.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)
@@ -76,9 +83,9 @@ module Brew
76
83
 
77
84
  def extract_severity(data)
78
85
  if data["severity"]&.any?
79
- sev = data["severity"].first
80
- if sev["score"]&.include?("CVSS")
81
- return severity_from_cvss(sev["score"])
86
+ cvss_severities(data["severity"]).each do |sev|
87
+ cvss_severity = severity_from_cvss(sev["score"])
88
+ return cvss_severity if cvss_severity
82
89
  end
83
90
  end
84
91
 
@@ -97,6 +104,12 @@ module Brew
97
104
  nil
98
105
  end
99
106
 
107
+ def cvss_severities(severities)
108
+ severities
109
+ .select { |sev| CVSS_TYPE_PRIORITY.key?(sev["type"]) }
110
+ .sort_by { |sev| -CVSS_TYPE_PRIORITY.fetch(sev["type"], 0) }
111
+ end
112
+
100
113
  def normalize_severity(severity)
101
114
  return nil unless severity
102
115
 
@@ -109,34 +122,15 @@ module Brew
109
122
  end
110
123
 
111
124
  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
125
+ return nil if vector.to_s.empty?
133
126
 
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
127
+ cvss = CvssSuite.new(vector)
128
+
129
+ normalize_severity(cvss.severity)
130
+ rescue StandardError
131
+ warn "Warning: Failed to determine severity from CVSS vector " \
132
+ "'#{vector}' for '#{id}'"
133
+ nil
140
134
  end
141
135
 
142
136
  def normalize_version(version)
@@ -171,30 +165,51 @@ module Brew
171
165
  def version_in_range?(version, events, ecosystem)
172
166
  return false if events.nil? || events.empty?
173
167
 
174
- constraints = build_constraints(events)
175
- return false if constraints.empty?
176
-
177
- Vers.satisfies?(version, constraints.join(","), ecosystem)
168
+ build_constraint_sets(events).any? do |constraints|
169
+ constraints.empty? || Vers.satisfies?(version, constraints.join(","), ecosystem)
170
+ end
178
171
  rescue StandardError => e
179
172
  warn "Warning: Failed to check version '#{version}' against constraints: #{e.message}"
180
- false
173
+ true
181
174
  end
182
175
 
183
- def build_constraints(events)
184
- constraints = []
176
+ def build_constraint_sets(events)
177
+ constraint_sets = []
178
+ constraints = nil
179
+
185
180
  events.each do |event|
186
181
  if event["introduced"]
182
+ constraints = []
187
183
  intro = normalize_version(event["introduced"])
188
184
  constraints << ">=#{intro}" unless intro == "0"
189
- end
190
- if event["fixed"]
185
+ elsif event["fixed"]
186
+ constraints ||= []
191
187
  constraints << "<#{normalize_version(event["fixed"])}"
192
- end
193
- if event["last_affected"]
188
+ constraint_sets << constraints
189
+ constraints = nil
190
+ elsif event["last_affected"]
191
+ constraints ||= []
194
192
  constraints << "<=#{normalize_version(event["last_affected"])}"
193
+ constraint_sets << constraints
194
+ constraints = nil
195
+ elsif event["limit"]
196
+ constraints ||= []
197
+ limit_constraint = build_limit_constraint(event["limit"])
198
+ constraints << limit_constraint if limit_constraint
199
+ constraint_sets << constraints
200
+ constraints = nil
195
201
  end
196
202
  end
197
- constraints
203
+
204
+ constraint_sets << constraints if constraints
205
+ constraint_sets
206
+ end
207
+
208
+ def build_limit_constraint(limit)
209
+ limit = limit.to_s
210
+ return if limit == "*"
211
+
212
+ "<#{normalize_version(limit)}"
198
213
  end
199
214
  end
200
215
  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.3.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
@@ -82,6 +96,9 @@ files:
82
96
  - LICENSE
83
97
  - README.md
84
98
  - Rakefile
99
+ - examples/README.md
100
+ - examples/pr-vuln-check.yml
101
+ - examples/scan-all.yml
85
102
  - exe/brew-vulns
86
103
  - lib/brew/vulns.rb
87
104
  - lib/brew/vulns/cli.rb
@@ -111,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
128
  - !ruby/object:Gem::Version
112
129
  version: '0'
113
130
  requirements: []
114
- rubygems_version: 4.0.3
131
+ rubygems_version: 4.0.10
115
132
  specification_version: 4
116
133
  summary: Check Homebrew packages for known vulnerabilities
117
134
  test_files: []