brew-vulns 0.2.2 → 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: def22990a8238cc399cb69619db068f1f21d556629fe25a3981d0982d61b01ef
4
- data.tar.gz: 3d312ead98bf8ead3107c96bb3ab5281a19c84149ceb36cc94b28ed7bb55959e
3
+ metadata.gz: 393c81aa55ad74bc34018843abde797c2067c055fd669d8669712a32c2e6313c
4
+ data.tar.gz: a95b21cd1a777076b06b0a6b507f0429c7fde11476b193a712aae67784332d49
5
5
  SHA512:
6
- metadata.gz: b08361df563690652cfdf979a25b745330dd3eb3b4f907f3a801f3aaca4ddf59650cf715796bd1fb9327e88a1d06b18743ce694c1008c373e7dad5ce7108f818
7
- data.tar.gz: a44bb1deeeed685c2453a2a12eec32585c09d1351fedff501dd5b35e638e7416b71bdeea4a6b88ebb903309b4bc332ed403579032c2488e607b703cfe7eb6cb4
6
+ metadata.gz: 718026189245d3ed3bcc08fc1829610ab1d5752b44bead5f9ac9b4c7718aac88074c0f99acc77c6e058cc182932a3ee05593efd9c26035411d4199044686b017
7
+ data.tar.gz: fe75aa4ff545a67a8cfa361512393a4b9c64a9ad2edeaa7958fd4495424e28287c5363e2f99595ff751bdcb14a0cb618703773e5874ccda4271917a9289fe31c
data/.rubocop.yml ADDED
@@ -0,0 +1,242 @@
1
+ # This file is synced from `Homebrew/brew` by the `.github` repository, do not modify it directly.
2
+ ---
3
+ AllCops:
4
+ TargetRubyVersion: 4.0
5
+ NewCops: enable
6
+ Include:
7
+ - "**/*.rbi"
8
+ Exclude:
9
+ - Homebrew/sorbet/rbi/{annotations,dsl,gems}/**/*.rbi
10
+ - Homebrew/sorbet/rbi/parser*.rbi
11
+ - Homebrew/bin/*
12
+ - Homebrew/vendor/**/*
13
+ - Taps/*/*/vendor/**/*
14
+ - "**/.github/copilot-instructions.md"
15
+ - "**/vendor/**/*"
16
+ SuggestExtensions:
17
+ rubocop-minitest: false
18
+ Layout/ArgumentAlignment:
19
+ Exclude:
20
+ - Taps/*/*/*.rb
21
+ - "/**/Formula/**/*.rb"
22
+ - "**/Formula/**/*.rb"
23
+ Layout/CaseIndentation:
24
+ EnforcedStyle: end
25
+ Layout/FirstArrayElementIndentation:
26
+ EnforcedStyle: consistent
27
+ Layout/FirstHashElementIndentation:
28
+ EnforcedStyle: consistent
29
+ Layout/EndAlignment:
30
+ EnforcedStyleAlignWith: start_of_line
31
+ Layout/HashAlignment:
32
+ EnforcedHashRocketStyle: table
33
+ EnforcedColonStyle: table
34
+ Layout/LeadingCommentSpace:
35
+ Exclude:
36
+ - Taps/*/*/cmd/*.rb
37
+ Layout/LineLength:
38
+ Max: 118
39
+ AllowedPatterns:
40
+ - "#: "
41
+ - ' url "'
42
+ - ' mirror "'
43
+ - " plist_options "
44
+ - ' executable: "'
45
+ - ' font "'
46
+ - ' homepage "'
47
+ - ' name "'
48
+ - ' pkg "'
49
+ - ' pkgutil: "'
50
+ - " sha256 cellar: "
51
+ - " sha256 "
52
+ - "#{language}"
53
+ - "#{version."
54
+ - ' "/Library/Application Support/'
55
+ - "\"/Library/Caches/"
56
+ - "\"/Library/PreferencePanes/"
57
+ - ' "~/Library/Application Support/'
58
+ - "\"~/Library/Caches/"
59
+ - "\"~/Library/Containers"
60
+ - "\"~/Application Support"
61
+ - " was verified as official when first introduced to the cask"
62
+ Layout/SpaceAroundOperators:
63
+ Enabled: false
64
+ Layout/SpaceBeforeBrackets:
65
+ Exclude:
66
+ - "**/*_spec.rb"
67
+ - Taps/*/*/*.rb
68
+ - "/**/{Formula,Casks}/**/*.rb"
69
+ - "**/{Formula,Casks}/**/*.rb"
70
+ Lint/AmbiguousBlockAssociation:
71
+ Enabled: false
72
+ Lint/DuplicateBranch:
73
+ Exclude:
74
+ - Taps/*/*/*.rb
75
+ - "/**/{Formula,Casks}/**/*.rb"
76
+ - "**/{Formula,Casks}/**/*.rb"
77
+ Lint/ParenthesesAsGroupedExpression:
78
+ Exclude:
79
+ - Taps/*/*/*.rb
80
+ - "/**/Formula/**/*.rb"
81
+ - "**/Formula/**/*.rb"
82
+ Lint/UnusedMethodArgument:
83
+ AllowUnusedKeywordArguments: true
84
+ Metrics:
85
+ Enabled: false
86
+ Naming/BlockForwarding:
87
+ Enabled: false
88
+ Naming/FileName:
89
+ Regex: !ruby/regexp /^[\w\@\-\+\.]+(\.rb)?$/
90
+ Naming/HeredocDelimiterNaming:
91
+ ForbiddenDelimiters:
92
+ - END, EOD, EOF
93
+ Naming/InclusiveLanguage:
94
+ CheckStrings: true
95
+ FlaggedTerms:
96
+ slave:
97
+ AllowedRegex:
98
+ - gitslave
99
+ - log_slave
100
+ - ssdb_slave
101
+ - var_slave
102
+ - patches/13_fix_scope_for_show_slave_status_data.patch
103
+ Naming/MethodName:
104
+ AllowedPatterns:
105
+ - "\\A(fetch_)?HEAD\\?\\Z"
106
+ Naming/MethodParameterName:
107
+ inherit_mode:
108
+ merge:
109
+ - AllowedNames
110
+ Naming/PredicateMethod:
111
+ AllowBangMethods: true
112
+ Naming/VariableNumber:
113
+ Enabled: false
114
+ Style/AndOr:
115
+ EnforcedStyle: always
116
+ Style/ArgumentsForwarding:
117
+ Enabled: false
118
+ Style/AutoResourceCleanup:
119
+ Enabled: true
120
+ Style/BarePercentLiterals:
121
+ EnforcedStyle: percent_q
122
+ Style/BlockDelimiters:
123
+ BracesRequiredMethods:
124
+ - sig
125
+ Style/ClassAndModuleChildren:
126
+ Exclude:
127
+ - "**/*.rbi"
128
+ Style/CollectionMethods:
129
+ Enabled: true
130
+ Style/DisableCopsWithinSourceCodeDirective:
131
+ Enabled: true
132
+ Include:
133
+ - Taps/*/*/*.rb
134
+ - "/**/{Formula,Casks}/**/*.rb"
135
+ - "**/{Formula,Casks}/**/*.rb"
136
+ Style/Documentation:
137
+ Exclude:
138
+ - Taps/**/*
139
+ - "/**/{Formula,Casks}/**/*.rb"
140
+ - "**/{Formula,Casks}/**/*.rb"
141
+ - "**/*.rbi"
142
+ Style/EmptyMethod:
143
+ Exclude:
144
+ - "**/*.rbi"
145
+ Style/FetchEnvVar:
146
+ Exclude:
147
+ - Taps/*/*/*.rb
148
+ - "/**/Formula/**/*.rb"
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/*"
160
+ Style/FrozenStringLiteralComment:
161
+ EnforcedStyle: always
162
+ Exclude:
163
+ - Taps/*/*/*.rb
164
+ - "/**/{Formula,Casks}/**/*.rb"
165
+ - "**/{Formula,Casks}/**/*.rb"
166
+ - Homebrew/test/**/Casks/**/*.rb
167
+ - "**/*.rbi"
168
+ - "**/Brewfile"
169
+ Style/GuardClause:
170
+ Exclude:
171
+ - Taps/*/*/*.rb
172
+ - "/**/{Formula,Casks}/**/*.rb"
173
+ - "**/{Formula,Casks}/**/*.rb"
174
+ Style/HashAsLastArrayItem:
175
+ Exclude:
176
+ - Taps/*/*/*.rb
177
+ - "/**/Formula/**/*.rb"
178
+ - "**/Formula/**/*.rb"
179
+ Style/InverseMethods:
180
+ InverseMethods:
181
+ :blank?: :present?
182
+ Style/InvertibleUnlessCondition:
183
+ Enabled: true
184
+ InverseMethods:
185
+ :==: :!=
186
+ :zero?:
187
+ :blank?: :present?
188
+ Style/ItBlockParameter:
189
+ EnforcedStyle: only_numbered_parameters
190
+ Style/MutableConstant:
191
+ EnforcedStyle: strict
192
+ Style/NumericLiteralPrefix:
193
+ EnforcedOctalStyle: zero_only
194
+ Style/NumericLiterals:
195
+ MinDigits: 11
196
+ Strict: true
197
+ Style/OpenStructUse:
198
+ Exclude:
199
+ - Taps/**/*
200
+ Style/OptionalBooleanParameter:
201
+ AllowedMethods:
202
+ - respond_to?
203
+ - respond_to_missing?
204
+ Style/RescueStandardError:
205
+ EnforcedStyle: implicit
206
+ Style/ReturnNil:
207
+ Enabled: true
208
+ Style/StderrPuts:
209
+ Enabled: false
210
+ Style/StringConcatenation:
211
+ Exclude:
212
+ - Taps/*/*/*.rb
213
+ - "/**/{Formula,Casks}/**/*.rb"
214
+ - "**/{Formula,Casks}/**/*.rb"
215
+ Style/StringLiterals:
216
+ EnforcedStyle: double_quotes
217
+ Style/StringLiteralsInInterpolation:
218
+ EnforcedStyle: double_quotes
219
+ Style/StringMethods:
220
+ Enabled: true
221
+ Style/SuperWithArgsParentheses:
222
+ Enabled: false
223
+ Style/SymbolArray:
224
+ EnforcedStyle: brackets
225
+ Style/TernaryParentheses:
226
+ EnforcedStyle: require_parentheses_when_complex
227
+ Style/TopLevelMethodDefinition:
228
+ Enabled: true
229
+ Exclude:
230
+ - Taps/**/*
231
+ Style/TrailingCommaInArguments:
232
+ EnforcedStyleForMultiline: comma
233
+ Style/TrailingCommaInArrayLiteral:
234
+ EnforcedStyleForMultiline: comma
235
+ Style/TrailingCommaInHashLiteral:
236
+ EnforcedStyleForMultiline: comma
237
+ Style/UnlessLogicalOperators:
238
+ Enabled: true
239
+ EnforcedStyle: forbid_logical_operators
240
+ Style/WordArray:
241
+ MinSize: 4
242
+
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
- homepage "https://github.com/andrew/brew-vulns"
4
- url "https://github.com/andrew/brew-vulns/archive/refs/tags/v0.2.2.tar.gz"
5
- sha256 "64abf7791eb7d04312c1fda9dc49a73f3702f5716ce18506324ed9f401fe2514"
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"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "ruby"
data/README.md CHANGED
@@ -7,8 +7,7 @@ A Homebrew subcommand that checks installed packages for known vulnerabilities u
7
7
  Via Homebrew:
8
8
 
9
9
  ```bash
10
- brew tap andrew/brew-vulns https://github.com/andrew/brew-vulns
11
- brew install brew-vulns
10
+ brew install homebrew/brew-vulns/brew-vulns
12
11
  ```
13
12
 
14
13
  Or via RubyGems:
@@ -22,13 +21,14 @@ Once installed, the command is available as `brew vulns`.
22
21
  ## Usage
23
22
 
24
23
  ```bash
25
- brew vulns [formula] [options]
24
+ brew vulns [formula...] [options]
26
25
  ```
27
26
 
28
27
  ### Options
29
28
 
30
29
  | Flag | Long form | Description |
31
30
  |------|-----------|-------------|
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
34
  | `-j` | `--json` | Output results as JSON |
@@ -44,9 +44,15 @@ brew vulns [formula] [options]
44
44
  # Check all installed packages
45
45
  brew vulns
46
46
 
47
- # 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)
48
51
  brew vulns openssl
49
52
 
53
+ # Check several formulae at once
54
+ brew vulns vim curl jq
55
+
50
56
  # Check a formula and its dependencies
51
57
  brew vulns python --deps
52
58
 
@@ -83,7 +89,7 @@ brew vulns --help
83
89
 
84
90
  ## How it works
85
91
 
86
- 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)
87
93
  2. Extracts the repository URL and version tag from each formula's source URL
88
94
  3. Queries the OSV API using the GIT ecosystem to find known vulnerabilities
89
95
  4. Reports any vulnerabilities found with their severity and CVE identifiers
@@ -109,9 +115,10 @@ Found 15 vulnerabilities in 3 packages
109
115
  ## Exit codes
110
116
 
111
117
  - `0` - No vulnerabilities found
112
- - `1` - Vulnerabilities found (or error occurred)
118
+ - `1` - Vulnerabilities found
119
+ - `2` - An error occurred (network failure, `brew` failure, parse error)
113
120
 
114
- 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 ]`.
115
122
 
116
123
  ## GitHub Actions
117
124
 
@@ -135,8 +142,7 @@ jobs:
135
142
  run: gem install brew-vulns
136
143
 
137
144
  - name: Run vulnerability scan
138
- run: brew vulns --sarif > results.sarif
139
- continue-on-error: true
145
+ run: brew vulns --sarif > results.sarif || [ $? -eq 1 ]
140
146
 
141
147
  - name: Upload SARIF results
142
148
  uses: github/codeql-action/upload-sarif@v3
@@ -168,8 +174,7 @@ jobs:
168
174
  run: gem install brew-vulns
169
175
 
170
176
  - name: Generate SBOM
171
- run: brew vulns --cyclonedx > sbom.cdx.json
172
- continue-on-error: true
177
+ run: brew vulns --cyclonedx > sbom.cdx.json || [ $? -eq 1 ]
173
178
 
174
179
  - name: Submit to dependency graph
175
180
  uses: evryfs/sbom-dependency-submission-action@v0
@@ -179,10 +184,12 @@ jobs:
179
184
 
180
185
  This adds your Homebrew packages to the repository's dependency graph, enabling Dependabot alerts.
181
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
+
182
189
  ## Development
183
190
 
184
191
  ```bash
185
- git clone https://github.com/andrewnesbitt/brew-vulns
192
+ git clone https://github.com/Homebrew/homebrew-brew-vulns
186
193
  cd brew-vulns
187
194
  bin/setup
188
195
  rake test
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
 
@@ -14,7 +16,7 @@ task :update_formula do
14
16
  require_relative "lib/brew/vulns/version"
15
17
 
16
18
  version = Brew::Vulns::VERSION
17
- url = "https://github.com/andrew/brew-vulns/archive/refs/tags/v#{version}.tar.gz"
19
+ url = "https://github.com/Homebrew/homebrew-brew-vulns/archive/refs/tags/v#{version}.tar.gz"
18
20
  formula_path = File.expand_path("Formula/brew-vulns.rb", __dir__)
19
21
 
20
22
  puts "Downloading #{url}..."
@@ -23,7 +25,7 @@ task :update_formula do
23
25
  puts "SHA256: #{sha256}"
24
26
 
25
27
  formula = File.read(formula_path)
26
- formula.gsub!(%r{url "https://github.com/andrew/brew-vulns/archive/refs/tags/v[^"]+\.tar\.gz"},
28
+ formula.gsub!(%r{url "https://github.com/Homebrew/homebrew-brew-vulns/archive/refs/tags/v[^"]+\.tar\.gz"},
27
29
  "url \"#{url}\"")
28
30
  formula.gsub!(/sha256 "[^"]+"/, "sha256 \"#{sha256}\"")
29
31
  File.write(formula_path, formula)
@@ -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
@@ -251,7 +278,7 @@ module Brew
251
278
  driver: Sarif::ToolComponent.new(
252
279
  name: "brew-vulns",
253
280
  version: VERSION,
254
- information_uri: "https://github.com/andrew/brew-vulns",
281
+ information_uri: "https://github.com/Homebrew/homebrew-brew-vulns",
255
282
  rules: rules.uniq { |r| r.id }
256
283
  )
257
284
  ),
@@ -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.2"
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.2
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
@@ -74,6 +88,7 @@ executables:
74
88
  extensions: []
75
89
  extra_rdoc_files: []
76
90
  files:
91
+ - ".rubocop.yml"
77
92
  - ".ruby-version"
78
93
  - CHANGELOG.md
79
94
  - CODE_OF_CONDUCT.md
@@ -81,6 +96,9 @@ files:
81
96
  - LICENSE
82
97
  - README.md
83
98
  - Rakefile
99
+ - examples/README.md
100
+ - examples/pr-vuln-check.yml
101
+ - examples/scan-all.yml
84
102
  - exe/brew-vulns
85
103
  - lib/brew/vulns.rb
86
104
  - lib/brew/vulns/cli.rb
@@ -89,13 +107,13 @@ files:
89
107
  - lib/brew/vulns/version.rb
90
108
  - lib/brew/vulns/vulnerability.rb
91
109
  - sig/brew/vulns.rbs
92
- homepage: https://github.com/andrewnesbitt/brew-vulns
110
+ homepage: https://github.com/Homebrew/homebrew-brew-vulns
93
111
  licenses:
94
112
  - MIT
95
113
  metadata:
96
- homepage_uri: https://github.com/andrewnesbitt/brew-vulns
97
- source_code_uri: https://github.com/andrewnesbitt/brew-vulns
98
- changelog_uri: https://github.com/andrewnesbitt/brew-vulns/blob/main/CHANGELOG.md
114
+ homepage_uri: https://github.com/Homebrew/homebrew-brew-vulns
115
+ source_code_uri: https://github.com/Homebrew/homebrew-brew-vulns
116
+ changelog_uri: https://github.com/Homebrew/homebrew-brew-vulns/blob/main/CHANGELOG.md
99
117
  rdoc_options: []
100
118
  require_paths:
101
119
  - lib
@@ -110,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
110
128
  - !ruby/object:Gem::Version
111
129
  version: '0'
112
130
  requirements: []
113
- rubygems_version: 4.0.3
131
+ rubygems_version: 4.0.10
114
132
  specification_version: 4
115
133
  summary: Check Homebrew packages for known vulnerabilities
116
134
  test_files: []