git-pkgs 0.6.2 → 0.7.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/.gitattributes +28 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/Dockerfile +18 -0
- data/Formula/git-pkgs.rb +28 -0
- data/README.md +36 -4
- data/lib/git/pkgs/analyzer.rb +141 -9
- data/lib/git/pkgs/cli.rb +16 -6
- data/lib/git/pkgs/commands/blame.rb +0 -18
- data/lib/git/pkgs/commands/diff.rb +122 -5
- data/lib/git/pkgs/commands/diff_driver.rb +24 -4
- data/lib/git/pkgs/commands/init.rb +5 -0
- data/lib/git/pkgs/commands/list.rb +60 -15
- data/lib/git/pkgs/commands/show.rb +126 -3
- data/lib/git/pkgs/commands/stale.rb +6 -2
- data/lib/git/pkgs/commands/update.rb +3 -0
- data/lib/git/pkgs/commands/vulns/base.rb +354 -0
- data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
- data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
- data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
- data/lib/git/pkgs/commands/vulns/history.rb +345 -0
- data/lib/git/pkgs/commands/vulns/log.rb +218 -0
- data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
- data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
- data/lib/git/pkgs/commands/vulns/show.rb +216 -0
- data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
- data/lib/git/pkgs/commands/vulns.rb +50 -0
- data/lib/git/pkgs/config.rb +8 -1
- data/lib/git/pkgs/database.rb +135 -5
- data/lib/git/pkgs/ecosystems.rb +83 -0
- data/lib/git/pkgs/models/package.rb +54 -0
- data/lib/git/pkgs/models/vulnerability.rb +300 -0
- data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
- data/lib/git/pkgs/osv_client.rb +151 -0
- data/lib/git/pkgs/output.rb +22 -0
- data/lib/git/pkgs/version.rb +1 -1
- data/lib/git/pkgs.rb +6 -0
- metadata +66 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
module Vulns
|
|
7
|
+
class Praise
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
def initialize(args)
|
|
11
|
+
@args = args.dup
|
|
12
|
+
@options = parse_options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse_options
|
|
16
|
+
options = {}
|
|
17
|
+
|
|
18
|
+
parser = OptionParser.new do |opts|
|
|
19
|
+
opts.banner = "Usage: git pkgs vulns praise [options]"
|
|
20
|
+
opts.separator ""
|
|
21
|
+
opts.separator "Show who fixed vulnerabilities (opposite of blame)."
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Options:"
|
|
24
|
+
|
|
25
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
26
|
+
options[:ecosystem] = v
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
|
|
30
|
+
options[:severity] = v
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts.on("-b", "--branch=NAME", "Branch to analyze") do |v|
|
|
34
|
+
options[:branch] = v
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
38
|
+
options[:format] = v
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
opts.on("--summary", "Show author leaderboard") do
|
|
42
|
+
options[:summary] = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
opts.on("-h", "--help", "Show this help") do
|
|
46
|
+
puts opts
|
|
47
|
+
exit
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
parser.parse!(@args)
|
|
52
|
+
options
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run
|
|
56
|
+
repo = Repository.new
|
|
57
|
+
|
|
58
|
+
unless Database.exists?(repo.git_dir)
|
|
59
|
+
error "No database found. Run 'git pkgs init' first. Praise requires commit history."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Database.connect(repo.git_dir)
|
|
63
|
+
|
|
64
|
+
branch_name = @options[:branch] || repo.default_branch
|
|
65
|
+
branch = Models::Branch.first(name: branch_name)
|
|
66
|
+
|
|
67
|
+
unless branch&.last_analyzed_sha
|
|
68
|
+
error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get all unique packages from dependency changes
|
|
72
|
+
packages = Models::DependencyChange
|
|
73
|
+
.select(:ecosystem, :name)
|
|
74
|
+
.select_group(:ecosystem, :name)
|
|
75
|
+
.all
|
|
76
|
+
|
|
77
|
+
praise_results = []
|
|
78
|
+
|
|
79
|
+
packages.each do |pkg|
|
|
80
|
+
next unless Ecosystems.supported?(pkg.ecosystem)
|
|
81
|
+
|
|
82
|
+
osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
|
|
83
|
+
next unless osv_ecosystem
|
|
84
|
+
|
|
85
|
+
vuln_pkgs = Models::VulnerabilityPackage
|
|
86
|
+
.for_package(osv_ecosystem, pkg.name)
|
|
87
|
+
.eager(:vulnerability)
|
|
88
|
+
.all
|
|
89
|
+
|
|
90
|
+
vuln_pkgs.each do |vp|
|
|
91
|
+
next if vp.vulnerability&.withdrawn?
|
|
92
|
+
|
|
93
|
+
fix_info = find_fixing_commit_info(pkg.ecosystem, pkg.name, vp)
|
|
94
|
+
next unless fix_info
|
|
95
|
+
|
|
96
|
+
severity = vp.vulnerability&.severity
|
|
97
|
+
|
|
98
|
+
if @options[:severity]
|
|
99
|
+
min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
|
|
100
|
+
next unless (SEVERITY_ORDER[severity&.downcase] || 4) <= min_level
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
praise_results << {
|
|
104
|
+
id: vp.vulnerability_id,
|
|
105
|
+
severity: severity,
|
|
106
|
+
package_name: pkg.name,
|
|
107
|
+
from_version: fix_info[:from_version],
|
|
108
|
+
to_version: fix_info[:to_version],
|
|
109
|
+
summary: vp.vulnerability&.summary,
|
|
110
|
+
fixing_commit: fix_info[:commit_info],
|
|
111
|
+
days_exposed: fix_info[:days_exposed],
|
|
112
|
+
days_after_disclosure: fix_info[:days_after_disclosure]
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if praise_results.empty?
|
|
118
|
+
puts "No fixed vulnerabilities found"
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
praise_results.sort_by! do |v|
|
|
123
|
+
[SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if @options[:format] == "json"
|
|
127
|
+
require "json"
|
|
128
|
+
if @options[:summary]
|
|
129
|
+
puts JSON.pretty_generate(compute_author_summary(praise_results))
|
|
130
|
+
else
|
|
131
|
+
puts JSON.pretty_generate(praise_results)
|
|
132
|
+
end
|
|
133
|
+
elsif @options[:summary]
|
|
134
|
+
output_author_summary(praise_results)
|
|
135
|
+
else
|
|
136
|
+
output_praise_text(praise_results)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compute_author_summary(results)
|
|
141
|
+
by_author = results.group_by { |r| r[:fixing_commit][:author] }
|
|
142
|
+
|
|
143
|
+
summaries = by_author.map do |author, fixes|
|
|
144
|
+
times = fixes.map { |f| f[:days_after_disclosure] }.compact
|
|
145
|
+
avg_time = times.empty? ? nil : (times.sum.to_f / times.size).round(1)
|
|
146
|
+
|
|
147
|
+
by_sev = {}
|
|
148
|
+
%w[critical high medium low].each do |sev|
|
|
149
|
+
count = fixes.count { |f| f[:severity]&.downcase == sev }
|
|
150
|
+
by_sev[sev] = count if count > 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
author: author,
|
|
155
|
+
total_fixes: fixes.size,
|
|
156
|
+
avg_days_to_fix: avg_time,
|
|
157
|
+
by_severity: by_sev
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
summaries.sort_by { |s| -s[:total_fixes] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def output_author_summary(results)
|
|
165
|
+
summaries = compute_author_summary(results)
|
|
166
|
+
|
|
167
|
+
max_author = summaries.map { |s| s[:author].length }.max || 20
|
|
168
|
+
max_fixes = summaries.map { |s| s[:total_fixes].to_s.length }.max || 3
|
|
169
|
+
|
|
170
|
+
puts "Author".ljust(max_author) + " Fixes Avg Days Critical High Medium Low"
|
|
171
|
+
puts "-" * (max_author + 50)
|
|
172
|
+
|
|
173
|
+
summaries.each do |s|
|
|
174
|
+
author = s[:author].ljust(max_author)
|
|
175
|
+
fixes = s[:total_fixes].to_s.rjust(max_fixes)
|
|
176
|
+
avg = s[:avg_days_to_fix] ? "#{s[:avg_days_to_fix]}d".rjust(8) : "N/A".rjust(8)
|
|
177
|
+
crit = (s[:by_severity]["critical"] || 0).to_s.rjust(8)
|
|
178
|
+
high = (s[:by_severity]["high"] || 0).to_s.rjust(4)
|
|
179
|
+
med = (s[:by_severity]["medium"] || 0).to_s.rjust(6)
|
|
180
|
+
low = (s[:by_severity]["low"] || 0).to_s.rjust(4)
|
|
181
|
+
|
|
182
|
+
puts "#{author} #{fixes} #{avg} #{crit} #{high} #{med} #{low}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def find_fixing_commit_info(ecosystem, package_name, vuln_pkg)
|
|
187
|
+
window = find_vulnerability_window(ecosystem, package_name, vuln_pkg)
|
|
188
|
+
return nil unless window && window[:fixing]
|
|
189
|
+
|
|
190
|
+
introducing_change = window[:introducing]
|
|
191
|
+
fixing_change = window[:fixing]
|
|
192
|
+
|
|
193
|
+
introduced_at = introducing_change.commit.committed_at
|
|
194
|
+
fixed_at = fixing_change.commit.committed_at
|
|
195
|
+
published_at = vuln_pkg.vulnerability&.published_at
|
|
196
|
+
|
|
197
|
+
days_exposed = ((fixed_at - introduced_at) / 86400).round
|
|
198
|
+
days_after_disclosure = if published_at && fixed_at > published_at
|
|
199
|
+
((fixed_at - published_at) / 86400).round
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
commit_info: format_commit_info(fixing_change.commit),
|
|
204
|
+
from_version: introducing_change.requirement,
|
|
205
|
+
to_version: fixing_change.change_type == "removed" ? "(removed)" : fixing_change.requirement,
|
|
206
|
+
days_exposed: days_exposed,
|
|
207
|
+
days_after_disclosure: days_after_disclosure
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def output_praise_text(results)
|
|
212
|
+
max_severity = results.map { |v| (v[:severity] || "").length }.max || 8
|
|
213
|
+
max_id = results.map { |v| v[:id].length }.max || 15
|
|
214
|
+
max_pkg = results.map { |v| v[:package_name].length }.max || 20
|
|
215
|
+
|
|
216
|
+
results.each do |result|
|
|
217
|
+
severity = (result[:severity] || "unknown").upcase.ljust(max_severity)
|
|
218
|
+
id = result[:id].ljust(max_id)
|
|
219
|
+
pkg = result[:package_name].ljust(max_pkg)
|
|
220
|
+
|
|
221
|
+
fix = result[:fixing_commit]
|
|
222
|
+
commit_info = "#{fix[:sha]} #{fix[:date]} #{fix[:author]} \"#{fix[:message]}\""
|
|
223
|
+
|
|
224
|
+
days_info = if result[:days_after_disclosure]
|
|
225
|
+
"(#{result[:days_after_disclosure]}d after disclosure)"
|
|
226
|
+
else
|
|
227
|
+
"(#{result[:days_exposed]}d total)"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
line = "#{severity} #{id} #{pkg} #{commit_info} #{days_info}"
|
|
231
|
+
puts Color.green(line)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
module Vulns
|
|
7
|
+
class Scan
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
def initialize(args)
|
|
11
|
+
@args = args.dup
|
|
12
|
+
@options = parse_options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse_options
|
|
16
|
+
options = {}
|
|
17
|
+
|
|
18
|
+
parser = OptionParser.new do |opts|
|
|
19
|
+
opts.banner = "Usage: git pkgs vulns [ref] [options]"
|
|
20
|
+
opts.separator ""
|
|
21
|
+
opts.separator "Scan dependencies for known vulnerabilities."
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Arguments:"
|
|
24
|
+
opts.separator " ref Git ref to scan (default: HEAD)"
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Subcommands:"
|
|
27
|
+
opts.separator " sync Sync vulnerability data from OSV"
|
|
28
|
+
opts.separator " blame Show who introduced each vulnerability"
|
|
29
|
+
opts.separator " praise Show who fixed vulnerabilities"
|
|
30
|
+
opts.separator " exposure Calculate exposure windows and remediation metrics"
|
|
31
|
+
opts.separator " diff Compare vulnerability state between commits"
|
|
32
|
+
opts.separator " log Show commits that introduced or fixed vulns"
|
|
33
|
+
opts.separator " history Show vulnerability timeline for a package or CVE"
|
|
34
|
+
opts.separator " show Show details about a specific CVE"
|
|
35
|
+
opts.separator ""
|
|
36
|
+
opts.separator "Options:"
|
|
37
|
+
|
|
38
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
39
|
+
options[:ecosystem] = v
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
|
|
43
|
+
options[:severity] = v
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("-r", "--ref=REF", "Git ref to scan (default: HEAD)") do |v|
|
|
47
|
+
options[:ref] = v
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
|
|
51
|
+
options[:branch] = v
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json, sarif)") do |v|
|
|
55
|
+
options[:format] = v
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on("--no-pager", "Do not pipe output into a pager") do
|
|
59
|
+
options[:no_pager] = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
63
|
+
options[:stateless] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on("-h", "--help", "Show this help") do
|
|
67
|
+
puts opts
|
|
68
|
+
exit
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parser.parse!(@args)
|
|
73
|
+
options[:ref] ||= @args.shift unless @args.empty?
|
|
74
|
+
options
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run
|
|
78
|
+
repo = Repository.new
|
|
79
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
80
|
+
|
|
81
|
+
if use_stateless
|
|
82
|
+
# Use in-memory database for vuln caching in stateless mode
|
|
83
|
+
Database.connect_memory
|
|
84
|
+
deps = get_dependencies_stateless(repo)
|
|
85
|
+
else
|
|
86
|
+
Database.connect(repo.git_dir)
|
|
87
|
+
deps = get_dependencies_with_database(repo)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if deps.empty?
|
|
91
|
+
empty_result "No dependencies found"
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
supported_deps = deps.select { |d| Ecosystems.supported?(d[:ecosystem]) }
|
|
96
|
+
|
|
97
|
+
if supported_deps.empty?
|
|
98
|
+
empty_result "No dependencies from supported ecosystems (#{Ecosystems.supported_ecosystems.join(", ")})"
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
vulns = scan_for_vulnerabilities(supported_deps)
|
|
103
|
+
|
|
104
|
+
if @options[:severity]
|
|
105
|
+
min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
|
|
106
|
+
vulns = vulns.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if vulns.empty?
|
|
110
|
+
puts "No known vulnerabilities found"
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
vulns.sort_by! { |v| [SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]] }
|
|
115
|
+
|
|
116
|
+
case @options[:format]
|
|
117
|
+
when "json"
|
|
118
|
+
require "json"
|
|
119
|
+
puts JSON.pretty_generate(vulns)
|
|
120
|
+
when "sarif"
|
|
121
|
+
output_sarif(vulns, deps)
|
|
122
|
+
else
|
|
123
|
+
output_text(vulns)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def output_sarif(vulns, deps)
|
|
128
|
+
require "sarif"
|
|
129
|
+
|
|
130
|
+
rules = vulns.map do |vuln|
|
|
131
|
+
Sarif::ReportingDescriptor.new(
|
|
132
|
+
id: vuln[:id],
|
|
133
|
+
name: vuln[:id],
|
|
134
|
+
short_description: Sarif::MultiformatMessageString.new(text: vuln[:summary] || vuln[:id]),
|
|
135
|
+
help_uri: "https://osv.dev/vulnerability/#{vuln[:id]}",
|
|
136
|
+
properties: {
|
|
137
|
+
security_severity: severity_score(vuln[:cvss_score], vuln[:severity])
|
|
138
|
+
}.compact
|
|
139
|
+
)
|
|
140
|
+
end.uniq(&:id)
|
|
141
|
+
|
|
142
|
+
results = vulns.map do |vuln|
|
|
143
|
+
locations = deps
|
|
144
|
+
.select { |d| d[:name].downcase == vuln[:package_name].downcase && d[:ecosystem] == vuln[:ecosystem] }
|
|
145
|
+
.map do |dep|
|
|
146
|
+
Sarif::Location.new(
|
|
147
|
+
physical_location: Sarif::PhysicalLocation.new(
|
|
148
|
+
artifact_location: Sarif::ArtifactLocation.new(uri: dep[:manifest_path])
|
|
149
|
+
),
|
|
150
|
+
message: Sarif::Message.new(text: "#{dep[:name]} #{dep[:requirement]}")
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
Sarif::Result.new(
|
|
155
|
+
rule_id: vuln[:id],
|
|
156
|
+
level: severity_to_sarif_level(vuln[:severity]),
|
|
157
|
+
message: Sarif::Message.new(
|
|
158
|
+
text: "#{vuln[:package_name]} #{vuln[:package_version]} has a known vulnerability: #{vuln[:summary] || vuln[:id]}"
|
|
159
|
+
),
|
|
160
|
+
locations: locations.empty? ? nil : locations
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
log = Sarif::Log.new(
|
|
165
|
+
version: "2.1.0",
|
|
166
|
+
runs: [
|
|
167
|
+
Sarif::Run.new(
|
|
168
|
+
tool: Sarif::Tool.new(
|
|
169
|
+
driver: Sarif::ToolComponent.new(
|
|
170
|
+
name: "git-pkgs",
|
|
171
|
+
version: Git::Pkgs::VERSION,
|
|
172
|
+
information_uri: "https://github.com/andrew/git-pkgs",
|
|
173
|
+
rules: rules
|
|
174
|
+
)
|
|
175
|
+
),
|
|
176
|
+
results: results
|
|
177
|
+
)
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
puts log.to_json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def severity_to_sarif_level(severity)
|
|
185
|
+
case severity&.downcase
|
|
186
|
+
when "critical", "high" then "error"
|
|
187
|
+
when "medium" then "warning"
|
|
188
|
+
when "low" then "note"
|
|
189
|
+
else "warning"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def severity_score(cvss_score, severity)
|
|
194
|
+
return cvss_score.to_s if cvss_score
|
|
195
|
+
|
|
196
|
+
case severity&.downcase
|
|
197
|
+
when "critical" then "9.0"
|
|
198
|
+
when "high" then "7.0"
|
|
199
|
+
when "medium" then "4.0"
|
|
200
|
+
when "low" then "1.0"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def output_text(vulns)
|
|
205
|
+
max_severity = vulns.map { |v| (v[:severity] || "").length }.max || 8
|
|
206
|
+
max_id = vulns.map { |v| v[:id].length }.max || 15
|
|
207
|
+
max_pkg = vulns.map { |v| v[:package_name].length }.max || 20
|
|
208
|
+
|
|
209
|
+
vulns.each do |vuln|
|
|
210
|
+
severity = (vuln[:severity] || "unknown").upcase.ljust(max_severity)
|
|
211
|
+
id = vuln[:id].ljust(max_id)
|
|
212
|
+
pkg = "#{vuln[:package_name]} #{vuln[:package_version]}".ljust(max_pkg + 10)
|
|
213
|
+
fixed = vuln[:fixed_versions] ? "(fixed in #{vuln[:fixed_versions]})" : ""
|
|
214
|
+
|
|
215
|
+
line = "#{severity} #{id} #{pkg} #{fixed}"
|
|
216
|
+
|
|
217
|
+
colored_line = case vuln[:severity]&.downcase
|
|
218
|
+
when "critical", "high" then Color.red(line)
|
|
219
|
+
when "medium" then Color.yellow(line)
|
|
220
|
+
when "low" then Color.cyan(line)
|
|
221
|
+
else line
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
puts colored_line
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
module Vulns
|
|
7
|
+
class Show
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
def initialize(args)
|
|
11
|
+
@args = args.dup
|
|
12
|
+
@options = parse_options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse_options
|
|
16
|
+
options = {}
|
|
17
|
+
|
|
18
|
+
parser = OptionParser.new do |opts|
|
|
19
|
+
opts.banner = "Usage: git pkgs vulns show <cve> [options]"
|
|
20
|
+
opts.separator ""
|
|
21
|
+
opts.separator "Show details about a specific CVE."
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Arguments:"
|
|
24
|
+
opts.separator " cve CVE or GHSA ID (e.g., CVE-2024-1234)"
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Options:"
|
|
27
|
+
|
|
28
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
29
|
+
options[:format] = v
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
opts.on("-r", "--ref=REF", "Git ref for exposure analysis (default: HEAD)") do |v|
|
|
33
|
+
options[:ref] = v
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
|
|
37
|
+
options[:branch] = v
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
opts.on("-h", "--help", "Show this help") do
|
|
41
|
+
puts opts
|
|
42
|
+
exit
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
parser.parse!(@args)
|
|
47
|
+
options[:target] = @args.shift
|
|
48
|
+
options
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run
|
|
52
|
+
repo = Repository.new
|
|
53
|
+
|
|
54
|
+
cve_id = @options[:target]
|
|
55
|
+
error "Usage: git pkgs vulns show <cve>" unless cve_id
|
|
56
|
+
cve_id = cve_id.upcase
|
|
57
|
+
|
|
58
|
+
has_db = Database.exists?(repo.git_dir)
|
|
59
|
+
Database.connect(repo.git_dir) if has_db
|
|
60
|
+
|
|
61
|
+
ensure_vulns_synced if has_db
|
|
62
|
+
|
|
63
|
+
vuln = Models::Vulnerability.first(id: cve_id)
|
|
64
|
+
unless vuln
|
|
65
|
+
error "Vulnerability #{cve_id} not found. Try 'git pkgs vulns sync' first."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
vuln_pkgs = Models::VulnerabilityPackage.where(vulnerability_id: cve_id).eager(:vulnerability).all
|
|
69
|
+
|
|
70
|
+
if @options[:format] == "json"
|
|
71
|
+
require "json"
|
|
72
|
+
output = build_show_json(vuln, vuln_pkgs, repo, has_db)
|
|
73
|
+
puts JSON.pretty_generate(output)
|
|
74
|
+
else
|
|
75
|
+
output_show_text(vuln, vuln_pkgs, repo, has_db)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_show_json(vuln, vuln_pkgs, repo, has_db)
|
|
80
|
+
output = {
|
|
81
|
+
id: vuln.id,
|
|
82
|
+
severity: vuln.severity,
|
|
83
|
+
summary: vuln.summary,
|
|
84
|
+
details: vuln.details,
|
|
85
|
+
published_at: vuln.published_at&.strftime("%Y-%m-%d"),
|
|
86
|
+
affected_packages: vuln_pkgs.map do |vp|
|
|
87
|
+
{
|
|
88
|
+
ecosystem: vp.ecosystem,
|
|
89
|
+
package: vp.package_name,
|
|
90
|
+
affected_versions: vp.affected_versions,
|
|
91
|
+
fixed_versions: vp.fixed_versions
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if has_db
|
|
97
|
+
output[:your_exposure] = find_exposure_for_vuln(vuln, vuln_pkgs, repo)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
output
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def output_show_text(vuln, vuln_pkgs, repo, has_db)
|
|
104
|
+
header = "#{vuln.id} (#{vuln.severity || "unknown"} severity)"
|
|
105
|
+
colored_header = case vuln.severity&.downcase
|
|
106
|
+
when "critical", "high" then Color.red(header)
|
|
107
|
+
when "medium" then Color.yellow(header)
|
|
108
|
+
when "low" then Color.cyan(header)
|
|
109
|
+
else header
|
|
110
|
+
end
|
|
111
|
+
puts colored_header
|
|
112
|
+
puts vuln.summary if vuln.summary
|
|
113
|
+
puts ""
|
|
114
|
+
|
|
115
|
+
puts "Affected packages:"
|
|
116
|
+
vuln_pkgs.each do |vp|
|
|
117
|
+
fixed_info = vp.fixed_versions.to_s.empty? ? "" : " (fixed in #{vp.fixed_versions})"
|
|
118
|
+
puts " #{vp.ecosystem}/#{vp.package_name}: #{vp.affected_versions}#{fixed_info}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
puts ""
|
|
122
|
+
puts "Published: #{vuln.published_at&.strftime("%Y-%m-%d") || "unknown"}"
|
|
123
|
+
|
|
124
|
+
if vuln.references && !vuln.references.empty?
|
|
125
|
+
puts ""
|
|
126
|
+
puts "References:"
|
|
127
|
+
refs = begin
|
|
128
|
+
JSON.parse(vuln.references)
|
|
129
|
+
rescue JSON::ParserError => e
|
|
130
|
+
$stderr.puts "Warning: Could not parse references for #{vuln.id}: #{e.message}" unless Git::Pkgs.quiet
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
refs.each do |ref|
|
|
134
|
+
puts " #{ref["url"]}" if ref["url"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return unless has_db
|
|
139
|
+
|
|
140
|
+
exposures = find_exposure_for_vuln(vuln, vuln_pkgs, repo)
|
|
141
|
+
return if exposures.empty?
|
|
142
|
+
|
|
143
|
+
puts ""
|
|
144
|
+
puts "Your exposure:"
|
|
145
|
+
exposures.each do |exposure|
|
|
146
|
+
pkg_line = " #{exposure[:package]} #{exposure[:version]} in #{exposure[:manifest_path]}"
|
|
147
|
+
puts Color.send(:red, pkg_line)
|
|
148
|
+
|
|
149
|
+
if exposure[:introduced_by]
|
|
150
|
+
intro = exposure[:introduced_by]
|
|
151
|
+
puts " Added: #{intro[:sha]} #{intro[:date]} #{intro[:author]} \"#{intro[:message]}\""
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if exposure[:fixed_by]
|
|
155
|
+
fix = exposure[:fixed_by]
|
|
156
|
+
puts Color.send(:green, " Fixed: #{fix[:sha]} #{fix[:date]} #{fix[:author]} \"#{fix[:message]}\"")
|
|
157
|
+
elsif exposure[:status] == "ongoing"
|
|
158
|
+
puts Color.send(:yellow, " Status: Still vulnerable")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def find_exposure_for_vuln(vuln, vuln_pkgs, repo)
|
|
164
|
+
exposures = []
|
|
165
|
+
ref = @options[:ref] || "HEAD"
|
|
166
|
+
|
|
167
|
+
begin
|
|
168
|
+
commit_sha = repo.rev_parse(ref)
|
|
169
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
170
|
+
rescue Rugged::ReferenceError
|
|
171
|
+
return exposures
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
return exposures unless target_commit
|
|
175
|
+
|
|
176
|
+
deps = compute_dependencies_at_commit(target_commit, repo)
|
|
177
|
+
|
|
178
|
+
vuln_pkgs.each do |vp|
|
|
179
|
+
ecosystem = Ecosystems.from_osv(vp.ecosystem) || vp.ecosystem.downcase
|
|
180
|
+
|
|
181
|
+
matching_deps = deps.select do |dep|
|
|
182
|
+
dep[:ecosystem] == ecosystem &&
|
|
183
|
+
dep[:name].downcase == vp.package_name.downcase &&
|
|
184
|
+
vp.affects_version?(dep[:requirement])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
matching_deps.each do |dep|
|
|
188
|
+
exposure = {
|
|
189
|
+
package: dep[:name],
|
|
190
|
+
version: dep[:requirement],
|
|
191
|
+
ecosystem: dep[:ecosystem],
|
|
192
|
+
manifest_path: dep[:manifest_path]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
intro_change = find_introducing_change(dep[:ecosystem], dep[:name], vp, target_commit)
|
|
196
|
+
exposure[:introduced_by] = format_commit_info(intro_change&.commit) if intro_change
|
|
197
|
+
|
|
198
|
+
fix_change = find_fixing_change(dep[:ecosystem], dep[:name], vp, target_commit, intro_change&.commit&.committed_at)
|
|
199
|
+
if fix_change
|
|
200
|
+
exposure[:fixed_by] = format_commit_info(fix_change.commit)
|
|
201
|
+
exposure[:status] = "fixed"
|
|
202
|
+
else
|
|
203
|
+
exposure[:status] = "ongoing"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
exposures << exposure
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
exposures
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|