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