brew-vulns 0.1.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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/Formula/brew-vulns.rb +25 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/Rakefile +8 -0
- data/exe/brew-vulns +6 -0
- data/lib/brew/vulns/cli.rb +176 -0
- data/lib/brew/vulns/formula.rb +118 -0
- data/lib/brew/vulns/osv_client.rb +120 -0
- data/lib/brew/vulns/version.rb +7 -0
- data/lib/brew/vulns/vulnerability.rb +126 -0
- data/lib/brew/vulns.rb +13 -0
- data/sig/brew/vulns.rbs +6 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d1e6ed689e85382d79c401a23c21c55e393038dba7b5f3ec52f90196904effe1
|
|
4
|
+
data.tar.gz: 55400c9cafeb368de7f8079363d0e46ae851e0f4cd16f4984139da655e84a36d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b2f086436e55e908a72b5e22b07e7890f99f5ff34a98c4db863f4121f3a7961f86036ca772e5a35d171beaa4c70f29425f5b224513c931f612a8ed3f183eeb0f
|
|
7
|
+
data.tar.gz: dd7ba735193a41955bca5e1a66cfc6340441bcf4baad3cca231b1490b51f0efede7436e1cb564eb6a02f889d682241ef4c73a904754121ea7080044abbf90931
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.0
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"brew-vulns" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["andrewnez@gmail.com"](mailto:"andrewnez@gmail.com").
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class BrewVulns < Formula
|
|
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.1.0.tar.gz"
|
|
5
|
+
sha256 "UPDATE_WITH_SHA256_AFTER_RELEASE"
|
|
6
|
+
license "MIT"
|
|
7
|
+
|
|
8
|
+
depends_on "ruby"
|
|
9
|
+
|
|
10
|
+
def install
|
|
11
|
+
ENV["GEM_HOME"] = libexec
|
|
12
|
+
|
|
13
|
+
system "git", "init"
|
|
14
|
+
system "git", "add", "."
|
|
15
|
+
|
|
16
|
+
system "gem", "build", "brew-vulns.gemspec"
|
|
17
|
+
system "gem", "install", "--no-document", "brew-vulns-#{version}.gem"
|
|
18
|
+
bin.install libexec/"bin/brew-vulns"
|
|
19
|
+
bin.env_script_all_files(libexec/"bin", GEM_HOME: ENV.fetch("GEM_HOME", nil))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
test do
|
|
23
|
+
system bin/"brew-vulns", "--help"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Andrew Nesbitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# brew-vulns
|
|
2
|
+
|
|
3
|
+
A Homebrew subcommand that checks installed packages for known vulnerabilities using the [OSV.dev](https://osv.dev) database.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Via Homebrew:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
brew tap andrew/brew-vulns https://github.com/andrew/brew-vulns
|
|
11
|
+
brew install brew-vulns
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or via RubyGems:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
gem install brew-vulns
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Once installed, the command is available as `brew vulns`.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Check all installed packages
|
|
26
|
+
brew vulns
|
|
27
|
+
|
|
28
|
+
# Check a specific formula
|
|
29
|
+
brew vulns openssl
|
|
30
|
+
|
|
31
|
+
# Check a formula and its dependencies
|
|
32
|
+
brew vulns python --deps
|
|
33
|
+
|
|
34
|
+
# Output as JSON (useful for CI/CD)
|
|
35
|
+
brew vulns --json
|
|
36
|
+
|
|
37
|
+
# Show help
|
|
38
|
+
brew vulns --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
1. Reads installed Homebrew formulae via `brew info --json=v2 --installed`
|
|
44
|
+
2. Extracts the GitHub repository URL and version tag from each formula's source URL
|
|
45
|
+
3. Queries the OSV API using the GIT ecosystem to find known vulnerabilities
|
|
46
|
+
4. Reports any vulnerabilities found with their severity and CVE identifiers
|
|
47
|
+
|
|
48
|
+
Only packages with GitHub source URLs can be checked. Packages from other sources are skipped.
|
|
49
|
+
|
|
50
|
+
## Example output
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Checking 104 packages for vulnerabilities...
|
|
54
|
+
(119 packages skipped - no GitHub source URL)
|
|
55
|
+
|
|
56
|
+
expat (2.7.3)
|
|
57
|
+
CVE-2025-66382 (HIGH) - XML parsing vulnerability...
|
|
58
|
+
|
|
59
|
+
hdf5 (1.14.6)
|
|
60
|
+
OSV-2023-1091 (MEDIUM) - Buffer overflow in...
|
|
61
|
+
OSV-2023-1223 (MEDIUM) - ...
|
|
62
|
+
|
|
63
|
+
Found 15 vulnerabilities in 3 packages
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Exit codes
|
|
67
|
+
|
|
68
|
+
- `0` - No vulnerabilities found
|
|
69
|
+
- `1` - Vulnerabilities found (or error occurred)
|
|
70
|
+
|
|
71
|
+
This makes it suitable for use in CI/CD pipelines.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://github.com/andrewnesbitt/brew-vulns
|
|
77
|
+
cd brew-vulns
|
|
78
|
+
bin/setup
|
|
79
|
+
rake test
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
data/Rakefile
ADDED
data/exe/brew-vulns
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brew
|
|
4
|
+
module Vulns
|
|
5
|
+
class CLI
|
|
6
|
+
def self.run(args)
|
|
7
|
+
new(args).run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(args)
|
|
11
|
+
@args = args
|
|
12
|
+
@formula_filter = args.first unless args.first&.start_with?("-")
|
|
13
|
+
@include_deps = args.include?("--deps") || args.include?("-d")
|
|
14
|
+
@json_output = args.include?("--json") || args.include?("-j")
|
|
15
|
+
@help = args.include?("--help") || args.include?("-h")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
if @help
|
|
20
|
+
print_help
|
|
21
|
+
return 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
formulae = load_formulae
|
|
25
|
+
if formulae.empty?
|
|
26
|
+
puts "No installed formulae found."
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
queryable = formulae.select(&:github?).select(&:tag)
|
|
31
|
+
skipped = formulae.size - queryable.size
|
|
32
|
+
|
|
33
|
+
unless @json_output
|
|
34
|
+
puts "Checking #{queryable.size} packages for vulnerabilities..."
|
|
35
|
+
puts "(#{skipped} packages skipped - no GitHub source URL)" if skipped > 0
|
|
36
|
+
puts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
results = scan_vulnerabilities(queryable)
|
|
40
|
+
output_results(results, formulae)
|
|
41
|
+
rescue OsvClient::Error => e
|
|
42
|
+
$stderr.puts "Error querying OSV: #{e.message}"
|
|
43
|
+
1
|
|
44
|
+
rescue Error => e
|
|
45
|
+
$stderr.puts "Error: #{e.message}"
|
|
46
|
+
1
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
$stderr.puts "Error parsing brew output: #{e.message}"
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def load_formulae
|
|
55
|
+
if @include_deps && @formula_filter
|
|
56
|
+
Formula.load_with_dependencies(@formula_filter)
|
|
57
|
+
else
|
|
58
|
+
Formula.load_installed(@formula_filter)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan_vulnerabilities(formulae)
|
|
63
|
+
client = OsvClient.new
|
|
64
|
+
queries = formulae.map(&:to_osv_query).compact
|
|
65
|
+
|
|
66
|
+
vuln_results = client.query_batch(queries)
|
|
67
|
+
|
|
68
|
+
results = {}
|
|
69
|
+
formulae.each_with_index do |formula, idx|
|
|
70
|
+
vulns = Vulnerability.from_osv_list(vuln_results[idx] || [])
|
|
71
|
+
results[formula] = vulns if vulns.any?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
results
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def output_results(results, all_formulae)
|
|
78
|
+
if @json_output
|
|
79
|
+
output_json(results)
|
|
80
|
+
else
|
|
81
|
+
output_text(results, all_formulae)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def output_json(results)
|
|
86
|
+
data = results.map do |formula, vulns|
|
|
87
|
+
{
|
|
88
|
+
formula: formula.name,
|
|
89
|
+
version: formula.version,
|
|
90
|
+
tag: formula.tag,
|
|
91
|
+
repo_url: formula.repo_url,
|
|
92
|
+
vulnerabilities: vulns.map do |v|
|
|
93
|
+
{
|
|
94
|
+
id: v.id,
|
|
95
|
+
severity: v.severity_display,
|
|
96
|
+
summary: v.summary,
|
|
97
|
+
aliases: v.aliases,
|
|
98
|
+
fixed_versions: v.fixed_versions
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts JSON.pretty_generate(data)
|
|
105
|
+
results.empty? ? 0 : 1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def output_text(results, all_formulae)
|
|
109
|
+
if results.empty?
|
|
110
|
+
puts "No vulnerabilities found."
|
|
111
|
+
return 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
total_vulns = 0
|
|
115
|
+
sorted = results.sort_by { |_, vulns| -vulns.map(&:severity_level).max }
|
|
116
|
+
|
|
117
|
+
sorted.each do |formula, vulns|
|
|
118
|
+
puts "#{formula.name} (#{formula.version})"
|
|
119
|
+
vulns.sort_by { |v| -v.severity_level }.each do |vuln|
|
|
120
|
+
total_vulns += 1
|
|
121
|
+
severity = colorize_severity(vuln.severity_display)
|
|
122
|
+
|
|
123
|
+
line = " #{vuln.id} (#{severity})"
|
|
124
|
+
if vuln.summary
|
|
125
|
+
summary = vuln.summary.length > 60 ? "#{vuln.summary.slice(0, 60)}..." : vuln.summary
|
|
126
|
+
line = "#{line} - #{summary}"
|
|
127
|
+
end
|
|
128
|
+
puts line
|
|
129
|
+
|
|
130
|
+
if vuln.fixed_versions.any?
|
|
131
|
+
puts " Fixed in: #{vuln.fixed_versions.join(", ")}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
puts
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
puts "Found #{total_vulns} vulnerabilities in #{results.size} packages"
|
|
138
|
+
1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def colorize_severity(severity)
|
|
142
|
+
return severity unless $stdout.tty?
|
|
143
|
+
|
|
144
|
+
case severity
|
|
145
|
+
when "CRITICAL" then "\e[1;31m#{severity}\e[0m"
|
|
146
|
+
when "HIGH" then "\e[31m#{severity}\e[0m"
|
|
147
|
+
when "MEDIUM" then "\e[33m#{severity}\e[0m"
|
|
148
|
+
when "LOW" then "\e[32m#{severity}\e[0m"
|
|
149
|
+
else severity
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def print_help
|
|
154
|
+
puts <<~HELP
|
|
155
|
+
Usage: brew vulns [formula] [options]
|
|
156
|
+
|
|
157
|
+
Check installed Homebrew packages for known vulnerabilities via osv.dev.
|
|
158
|
+
|
|
159
|
+
Arguments:
|
|
160
|
+
formula Check only this formula (optional)
|
|
161
|
+
|
|
162
|
+
Options:
|
|
163
|
+
-d, --deps Include dependencies when checking a specific formula
|
|
164
|
+
-j, --json Output results as JSON
|
|
165
|
+
-h, --help Show this help message
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
brew vulns Check all installed packages
|
|
169
|
+
brew vulns openssl Check only openssl
|
|
170
|
+
brew vulns vim --deps Check vim and its dependencies
|
|
171
|
+
brew vulns --json Output as JSON for CI/CD
|
|
172
|
+
HELP
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Brew
|
|
7
|
+
module Vulns
|
|
8
|
+
class Formula
|
|
9
|
+
attr_reader :name, :version, :source_url, :head_url, :dependencies
|
|
10
|
+
|
|
11
|
+
def initialize(data)
|
|
12
|
+
@name = data["name"] || data["full_name"]
|
|
13
|
+
@version = data.dig("versions", "stable") || data["version"]
|
|
14
|
+
@source_url = data.dig("urls", "stable", "url")
|
|
15
|
+
@head_url = data.dig("urls", "head", "url")
|
|
16
|
+
@dependencies = data["dependencies"] || []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def repo_url
|
|
20
|
+
return @repo_url if defined?(@repo_url)
|
|
21
|
+
|
|
22
|
+
@repo_url = extract_repo_url(source_url) || extract_repo_url(head_url)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def tag
|
|
26
|
+
return @tag if defined?(@tag)
|
|
27
|
+
|
|
28
|
+
@tag = extract_tag_from_url(source_url)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def github?
|
|
32
|
+
repo_url&.include?("github.com")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_osv_query
|
|
36
|
+
return nil unless repo_url && tag
|
|
37
|
+
|
|
38
|
+
{ repo_url: repo_url, version: tag, name: name }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.load_installed(formula_filter = nil)
|
|
42
|
+
json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
|
|
43
|
+
raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
|
|
44
|
+
|
|
45
|
+
data = JSON.parse(json)
|
|
46
|
+
formulae = data["formulae"].map { |f| new(f) }
|
|
47
|
+
|
|
48
|
+
if formula_filter
|
|
49
|
+
formulae.select! { |f| f.name == formula_filter || f.name.start_with?("#{formula_filter}@") }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
formulae
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.load_with_dependencies(formula_filter = nil)
|
|
56
|
+
json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
|
|
57
|
+
raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?
|
|
58
|
+
|
|
59
|
+
data = JSON.parse(json)
|
|
60
|
+
all_formulae = data["formulae"].map { |f| new(f) }
|
|
61
|
+
formulae_by_name = all_formulae.each_with_object({}) { |f, h| h[f.name] = f }
|
|
62
|
+
|
|
63
|
+
if formula_filter
|
|
64
|
+
filtered = all_formulae.select { |f| f.name == formula_filter || f.name.start_with?("#{formula_filter}@") }
|
|
65
|
+
return [] if filtered.empty?
|
|
66
|
+
|
|
67
|
+
deps_output, = Open3.capture2("brew", "deps", "--installed", formula_filter)
|
|
68
|
+
dep_names = deps_output.split("\n").map(&:strip)
|
|
69
|
+
|
|
70
|
+
result = filtered.each_with_object({}) { |f, h| h[f.name] = f }
|
|
71
|
+
dep_names.each do |dep_name|
|
|
72
|
+
dep = formulae_by_name[dep_name]
|
|
73
|
+
result[dep_name] = dep if dep && !result[dep_name]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result.values
|
|
77
|
+
else
|
|
78
|
+
all_formulae
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def extract_repo_url(url)
|
|
85
|
+
return nil unless url
|
|
86
|
+
return nil unless url.include?("github.com")
|
|
87
|
+
|
|
88
|
+
match = url.match(%r{https?://github\.com/([^/]+/[^/]+)})
|
|
89
|
+
if match
|
|
90
|
+
repo_path = match[1].sub(/\.git$/, "")
|
|
91
|
+
return "https://github.com/#{repo_path}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def extract_tag_from_url(url)
|
|
98
|
+
return nil unless url
|
|
99
|
+
|
|
100
|
+
patterns = [
|
|
101
|
+
%r{/archive/refs/tags/([^/]+)\.tar\.gz$},
|
|
102
|
+
%r{/archive/refs/tags/([^/]+)\.zip$},
|
|
103
|
+
%r{/archive/([^/]+)\.tar\.gz$},
|
|
104
|
+
%r{/archive/([^/]+)\.zip$},
|
|
105
|
+
%r{/releases/download/([^/]+)/},
|
|
106
|
+
%r{/tarball/([^/]+)$}
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
patterns.each do |pattern|
|
|
110
|
+
match = url.match(pattern)
|
|
111
|
+
return match[1] if match
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Brew
|
|
8
|
+
module Vulns
|
|
9
|
+
class OsvClient
|
|
10
|
+
API_BASE = "https://api.osv.dev/v1"
|
|
11
|
+
BATCH_SIZE = 1000
|
|
12
|
+
OPEN_TIMEOUT = 10
|
|
13
|
+
READ_TIMEOUT = 30
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class ApiError < Error; end
|
|
17
|
+
|
|
18
|
+
def query(repo_url:, version:)
|
|
19
|
+
payload = {
|
|
20
|
+
package: {
|
|
21
|
+
name: repo_url,
|
|
22
|
+
ecosystem: "GIT"
|
|
23
|
+
},
|
|
24
|
+
version: version
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
response = post("/query", payload)
|
|
28
|
+
fetch_all_pages(response, payload)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query_batch(packages)
|
|
32
|
+
return [] if packages.empty?
|
|
33
|
+
|
|
34
|
+
results = Array.new(packages.size) { [] }
|
|
35
|
+
|
|
36
|
+
packages.each_slice(BATCH_SIZE).with_index do |batch, batch_idx|
|
|
37
|
+
queries = batch.map do |pkg|
|
|
38
|
+
{
|
|
39
|
+
package: {
|
|
40
|
+
name: pkg[:repo_url],
|
|
41
|
+
ecosystem: "GIT"
|
|
42
|
+
},
|
|
43
|
+
version: pkg[:version]
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
response = post("/querybatch", { queries: queries })
|
|
48
|
+
batch_results = response["results"] || []
|
|
49
|
+
|
|
50
|
+
batch_results.each_with_index do |result, idx|
|
|
51
|
+
global_idx = batch_idx * BATCH_SIZE + idx
|
|
52
|
+
results[global_idx] = result["vulns"] || []
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
results
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def get_vulnerability(vuln_id)
|
|
60
|
+
get("/vulns/#{URI.encode_uri_component(vuln_id)}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def post(path, payload)
|
|
64
|
+
uri = URI("#{API_BASE}#{path}")
|
|
65
|
+
request = Net::HTTP::Post.new(uri)
|
|
66
|
+
request["Content-Type"] = "application/json"
|
|
67
|
+
request.body = JSON.generate(payload)
|
|
68
|
+
|
|
69
|
+
execute_request(uri, request)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get(path)
|
|
73
|
+
uri = URI("#{API_BASE}#{path}")
|
|
74
|
+
request = Net::HTTP::Get.new(uri)
|
|
75
|
+
request["Content-Type"] = "application/json"
|
|
76
|
+
|
|
77
|
+
execute_request(uri, request)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute_request(uri, request)
|
|
81
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
|
+
http.use_ssl = uri.scheme == "https"
|
|
83
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
84
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
85
|
+
http.read_timeout = READ_TIMEOUT
|
|
86
|
+
|
|
87
|
+
response = http.request(request)
|
|
88
|
+
|
|
89
|
+
case response
|
|
90
|
+
when Net::HTTPSuccess
|
|
91
|
+
JSON.parse(response.body)
|
|
92
|
+
else
|
|
93
|
+
raise ApiError, "OSV API error: #{response.code} #{response.message}"
|
|
94
|
+
end
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
raise ApiError, "Invalid JSON response from OSV API: #{e.message}"
|
|
97
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
98
|
+
raise ApiError, "OSV API timeout: #{e.message}"
|
|
99
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
100
|
+
raise ApiError, "OSV API connection error: #{e.message}"
|
|
101
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
102
|
+
raise ApiError, "OSV API SSL error: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fetch_all_pages(response, original_payload)
|
|
106
|
+
vulns = response["vulns"] || []
|
|
107
|
+
page_token = response["next_page_token"]
|
|
108
|
+
|
|
109
|
+
while page_token
|
|
110
|
+
payload = original_payload.merge(page_token: page_token)
|
|
111
|
+
response = post("/query", payload)
|
|
112
|
+
vulns.concat(response["vulns"] || [])
|
|
113
|
+
page_token = response["next_page_token"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
vulns
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brew
|
|
4
|
+
module Vulns
|
|
5
|
+
class Vulnerability
|
|
6
|
+
attr_reader :id, :summary, :details, :severity, :aliases, :references, :affected
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@id = data["id"]
|
|
10
|
+
@summary = data["summary"]
|
|
11
|
+
@details = data["details"]
|
|
12
|
+
@aliases = data["aliases"] || []
|
|
13
|
+
@references = data["references"] || []
|
|
14
|
+
@affected = data["affected"] || []
|
|
15
|
+
@severity = extract_severity(data)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def severity_display
|
|
19
|
+
severity&.upcase || "UNKNOWN"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def severity_level
|
|
23
|
+
case severity&.downcase
|
|
24
|
+
when "critical" then 4
|
|
25
|
+
when "high" then 3
|
|
26
|
+
when "medium" then 2
|
|
27
|
+
when "low" then 1
|
|
28
|
+
else 0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cve_ids
|
|
33
|
+
([id] + aliases).select { |a| a.start_with?("CVE-") }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def advisory_url
|
|
37
|
+
ref = references.find { |r| r["type"] == "ADVISORY" }
|
|
38
|
+
ref&.dig("url")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fix_urls
|
|
42
|
+
references.select { |r| r["type"] == "FIX" }.map { |r| r["url"] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fixed_versions
|
|
46
|
+
versions = []
|
|
47
|
+
affected.each do |aff|
|
|
48
|
+
(aff["ranges"] || []).each do |range|
|
|
49
|
+
(range["events"] || []).each do |event|
|
|
50
|
+
versions << event["fixed"] if event["fixed"]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
versions.uniq
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.from_osv_list(vulns_data)
|
|
58
|
+
vulns_data.map { |data| new(data) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def extract_severity(data)
|
|
64
|
+
if data["severity"]&.any?
|
|
65
|
+
sev = data["severity"].first
|
|
66
|
+
if sev["score"]&.include?("CVSS")
|
|
67
|
+
return severity_from_cvss(sev["score"])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if data.dig("database_specific", "severity")
|
|
72
|
+
return normalize_severity(data.dig("database_specific", "severity"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
data["affected"]&.each do |aff|
|
|
76
|
+
db_sev = aff.dig("database_specific", "severity")
|
|
77
|
+
return normalize_severity(db_sev) if db_sev
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_severity(severity)
|
|
84
|
+
return nil unless severity
|
|
85
|
+
|
|
86
|
+
case severity.downcase
|
|
87
|
+
when "critical" then "critical"
|
|
88
|
+
when "high" then "high"
|
|
89
|
+
when "moderate", "medium" then "medium"
|
|
90
|
+
when "low" then "low"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def severity_from_cvss(vector)
|
|
95
|
+
return nil unless vector
|
|
96
|
+
return nil unless vector.include?("CVSS:3")
|
|
97
|
+
|
|
98
|
+
metrics = parse_cvss_metrics(vector)
|
|
99
|
+
return nil if metrics.empty?
|
|
100
|
+
|
|
101
|
+
impact_high = %w[C I A].count { |m| metrics[m] == "H" }
|
|
102
|
+
network_attack = metrics["AV"] == "N"
|
|
103
|
+
no_privs = metrics["PR"] == "N"
|
|
104
|
+
no_interaction = metrics["UI"] == "N"
|
|
105
|
+
|
|
106
|
+
if impact_high >= 2 && network_attack && no_privs
|
|
107
|
+
"critical"
|
|
108
|
+
elsif impact_high >= 1 && network_attack
|
|
109
|
+
"high"
|
|
110
|
+
elsif impact_high >= 1 || (network_attack && no_privs && no_interaction)
|
|
111
|
+
"medium"
|
|
112
|
+
else
|
|
113
|
+
"low"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_cvss_metrics(vector)
|
|
118
|
+
metrics = {}
|
|
119
|
+
vector.scan(%r{([A-Z]+):([A-Z])}).each do |key, value|
|
|
120
|
+
metrics[key] = value
|
|
121
|
+
end
|
|
122
|
+
metrics
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/brew/vulns.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vulns/version"
|
|
4
|
+
require_relative "vulns/osv_client"
|
|
5
|
+
require_relative "vulns/formula"
|
|
6
|
+
require_relative "vulns/vulnerability"
|
|
7
|
+
require_relative "vulns/cli"
|
|
8
|
+
|
|
9
|
+
module Brew
|
|
10
|
+
module Vulns
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/sig/brew/vulns.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brew-vulns
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrew Nesbitt
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A Homebrew subcommand that checks installed packages for vulnerabilities
|
|
13
|
+
via osv.dev
|
|
14
|
+
email:
|
|
15
|
+
- andrewnez@gmail.com
|
|
16
|
+
executables:
|
|
17
|
+
- brew-vulns
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- ".ruby-version"
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- CODE_OF_CONDUCT.md
|
|
24
|
+
- Formula/brew-vulns.rb
|
|
25
|
+
- LICENSE
|
|
26
|
+
- README.md
|
|
27
|
+
- Rakefile
|
|
28
|
+
- exe/brew-vulns
|
|
29
|
+
- lib/brew/vulns.rb
|
|
30
|
+
- lib/brew/vulns/cli.rb
|
|
31
|
+
- lib/brew/vulns/formula.rb
|
|
32
|
+
- lib/brew/vulns/osv_client.rb
|
|
33
|
+
- lib/brew/vulns/version.rb
|
|
34
|
+
- lib/brew/vulns/vulnerability.rb
|
|
35
|
+
- sig/brew/vulns.rbs
|
|
36
|
+
homepage: https://github.com/andrewnesbitt/brew-vulns
|
|
37
|
+
licenses:
|
|
38
|
+
- MIT
|
|
39
|
+
metadata:
|
|
40
|
+
homepage_uri: https://github.com/andrewnesbitt/brew-vulns
|
|
41
|
+
source_code_uri: https://github.com/andrewnesbitt/brew-vulns
|
|
42
|
+
changelog_uri: https://github.com/andrewnesbitt/brew-vulns/blob/main/CHANGELOG.md
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: 3.2.0
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 4.0.3
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Check Homebrew packages for known vulnerabilities
|
|
60
|
+
test_files: []
|