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