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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
module Vulns
|
|
7
|
+
class Diff
|
|
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 diff [ref1] [ref2] [options]"
|
|
20
|
+
opts.separator ""
|
|
21
|
+
opts.separator "Compare vulnerability state between two commits."
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Arguments:"
|
|
24
|
+
opts.separator " ref1 First git ref (default: HEAD~1)"
|
|
25
|
+
opts.separator " ref2 Second git ref (default: HEAD)"
|
|
26
|
+
opts.separator ""
|
|
27
|
+
opts.separator "Examples:"
|
|
28
|
+
opts.separator " git pkgs vulns diff main feature-branch"
|
|
29
|
+
opts.separator " git pkgs vulns diff v1.0.0 v2.0.0"
|
|
30
|
+
opts.separator " git pkgs vulns diff HEAD~10"
|
|
31
|
+
opts.separator ""
|
|
32
|
+
opts.separator "Options:"
|
|
33
|
+
|
|
34
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
35
|
+
options[:ecosystem] = v
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
|
|
39
|
+
options[:severity] = v
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("-b", "--branch=NAME", "Branch context for finding commits") do |v|
|
|
43
|
+
options[:branch] = v
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
47
|
+
options[:format] = v
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("-h", "--help", "Show this help") do
|
|
51
|
+
puts opts
|
|
52
|
+
exit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
parser.parse!(@args)
|
|
57
|
+
options
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run
|
|
61
|
+
repo = Repository.new
|
|
62
|
+
|
|
63
|
+
unless Database.exists?(repo.git_dir)
|
|
64
|
+
error "No database found. Run 'git pkgs init' first. Diff requires commit history."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Database.connect(repo.git_dir)
|
|
68
|
+
|
|
69
|
+
ref1, ref2 = parse_diff_refs(repo)
|
|
70
|
+
commit1_sha = repo.rev_parse(ref1)
|
|
71
|
+
commit2_sha = repo.rev_parse(ref2)
|
|
72
|
+
|
|
73
|
+
commit1 = Models::Commit.first(sha: commit1_sha)
|
|
74
|
+
commit2 = Models::Commit.first(sha: commit2_sha)
|
|
75
|
+
|
|
76
|
+
error "Commit #{commit1_sha[0, 7]} not in database. Run 'git pkgs update' first." unless commit1
|
|
77
|
+
error "Commit #{commit2_sha[0, 7]} not in database. Run 'git pkgs update' first." unless commit2
|
|
78
|
+
|
|
79
|
+
deps1 = compute_dependencies_at_commit(commit1, repo)
|
|
80
|
+
deps2 = compute_dependencies_at_commit(commit2, repo)
|
|
81
|
+
|
|
82
|
+
supported_deps1 = deps1.select { |d| Ecosystems.supported?(d[:ecosystem]) }
|
|
83
|
+
supported_deps2 = deps2.select { |d| Ecosystems.supported?(d[:ecosystem]) }
|
|
84
|
+
|
|
85
|
+
vulns1 = scan_for_vulnerabilities(supported_deps1)
|
|
86
|
+
vulns2 = scan_for_vulnerabilities(supported_deps2)
|
|
87
|
+
|
|
88
|
+
if @options[:severity]
|
|
89
|
+
min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
|
|
90
|
+
vulns1 = vulns1.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
|
|
91
|
+
vulns2 = vulns2.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
vulns1_ids = vulns1.map { |v| v[:id] }.to_set
|
|
95
|
+
vulns2_ids = vulns2.map { |v| v[:id] }.to_set
|
|
96
|
+
|
|
97
|
+
added = vulns2.reject { |v| vulns1_ids.include?(v[:id]) }
|
|
98
|
+
removed = vulns1.reject { |v| vulns2_ids.include?(v[:id]) }
|
|
99
|
+
|
|
100
|
+
if added.empty? && removed.empty?
|
|
101
|
+
puts "No vulnerability changes between #{ref1} and #{ref2}"
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if @options[:format] == "json"
|
|
106
|
+
require "json"
|
|
107
|
+
puts JSON.pretty_generate({
|
|
108
|
+
from: ref1,
|
|
109
|
+
to: ref2,
|
|
110
|
+
added: added,
|
|
111
|
+
removed: removed
|
|
112
|
+
})
|
|
113
|
+
else
|
|
114
|
+
output_diff_text(added, removed, ref1, ref2)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_diff_refs(repo)
|
|
119
|
+
args = @args.dup
|
|
120
|
+
ref1 = args.shift
|
|
121
|
+
ref2 = args.shift
|
|
122
|
+
|
|
123
|
+
if ref1.nil?
|
|
124
|
+
ref1 = "HEAD~1"
|
|
125
|
+
ref2 = "HEAD"
|
|
126
|
+
elsif ref2.nil?
|
|
127
|
+
ref2 = ref1
|
|
128
|
+
ref1 = "HEAD"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if ref1.include?("...")
|
|
132
|
+
parts = ref1.split("...")
|
|
133
|
+
ref1 = parts[0]
|
|
134
|
+
ref2 = parts[1]
|
|
135
|
+
elsif ref1.include?("..")
|
|
136
|
+
parts = ref1.split("..")
|
|
137
|
+
ref1 = parts[0]
|
|
138
|
+
ref2 = parts[1]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
[ref1, ref2]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def output_diff_text(added, removed, ref1, ref2)
|
|
145
|
+
all_vulns = added.map { |v| v.merge(diff_type: :added) } +
|
|
146
|
+
removed.map { |v| v.merge(diff_type: :removed) }
|
|
147
|
+
|
|
148
|
+
all_vulns.sort_by! do |v|
|
|
149
|
+
[SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
max_severity = all_vulns.map { |v| (v[:severity] || "").length }.max || 8
|
|
153
|
+
max_id = all_vulns.map { |v| v[:id].length }.max || 15
|
|
154
|
+
max_pkg = all_vulns.map { |v| "#{v[:package_name]} #{v[:package_version]}".length }.max || 20
|
|
155
|
+
|
|
156
|
+
all_vulns.each do |vuln|
|
|
157
|
+
prefix = vuln[:diff_type] == :added ? "+" : "-"
|
|
158
|
+
severity = (vuln[:severity] || "unknown").upcase.ljust(max_severity)
|
|
159
|
+
id = vuln[:id].ljust(max_id)
|
|
160
|
+
pkg = "#{vuln[:package_name]} #{vuln[:package_version]}".ljust(max_pkg)
|
|
161
|
+
note = vuln[:diff_type] == :added ? "(introduced in #{ref2})" : "(fixed in #{ref2})"
|
|
162
|
+
|
|
163
|
+
color = vuln[:diff_type] == :added ? :red : :green
|
|
164
|
+
line = "#{prefix}#{severity} #{id} #{pkg} #{note}"
|
|
165
|
+
puts Color.send(color, line)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
module Vulns
|
|
7
|
+
class Exposure
|
|
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 exposure [ref] [options]"
|
|
20
|
+
opts.separator ""
|
|
21
|
+
opts.separator "Calculate exposure windows and remediation metrics."
|
|
22
|
+
opts.separator ""
|
|
23
|
+
opts.separator "Arguments:"
|
|
24
|
+
opts.separator " ref Git ref to analyze (default: HEAD)"
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Options:"
|
|
27
|
+
|
|
28
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
29
|
+
options[:ecosystem] = v
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
|
|
33
|
+
options[:severity] = v
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
opts.on("-r", "--ref=REF", "Git ref to analyze (default: HEAD)") do |v|
|
|
37
|
+
options[:ref] = v
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
opts.on("-b", "--branch=NAME", "Branch context for finding commits") do |v|
|
|
41
|
+
options[:branch] = v
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
45
|
+
options[:format] = v
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
opts.on("--summary", "Show aggregate metrics only") do
|
|
49
|
+
options[:summary] = true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
opts.on("--all-time", "Show stats for all historical vulnerabilities") do
|
|
53
|
+
options[:all_time] = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on("-h", "--help", "Show this help") do
|
|
57
|
+
puts opts
|
|
58
|
+
exit
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
parser.parse!(@args)
|
|
63
|
+
options[:ref] ||= @args.shift unless @args.empty?
|
|
64
|
+
options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run
|
|
68
|
+
repo = Repository.new
|
|
69
|
+
|
|
70
|
+
unless Database.exists?(repo.git_dir)
|
|
71
|
+
error "No database found. Run 'git pkgs init' first. Exposure analysis requires commit history."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Database.connect(repo.git_dir)
|
|
75
|
+
|
|
76
|
+
if @options[:all_time]
|
|
77
|
+
run_all_time(repo)
|
|
78
|
+
else
|
|
79
|
+
run_at_ref(repo)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run_at_ref(repo)
|
|
84
|
+
ref = @options[:ref] || "HEAD"
|
|
85
|
+
commit_sha = repo.rev_parse(ref)
|
|
86
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
87
|
+
|
|
88
|
+
unless target_commit
|
|
89
|
+
error "Commit #{commit_sha[0, 7]} not in database. Run 'git pkgs update' first."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
deps = compute_dependencies_at_commit(target_commit, repo)
|
|
93
|
+
|
|
94
|
+
if deps.empty?
|
|
95
|
+
empty_result "No dependencies found"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
supported_deps = deps.select { |d| Ecosystems.supported?(d[:ecosystem]) }
|
|
100
|
+
vulns = scan_for_vulnerabilities(supported_deps)
|
|
101
|
+
|
|
102
|
+
if @options[:severity]
|
|
103
|
+
min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
|
|
104
|
+
vulns = vulns.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if vulns.empty?
|
|
108
|
+
puts "No known vulnerabilities found"
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
exposure_data = vulns.map do |vuln|
|
|
113
|
+
calculate_exposure(vuln, target_commit)
|
|
114
|
+
end.compact
|
|
115
|
+
|
|
116
|
+
output_results(exposure_data)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_all_time(repo)
|
|
120
|
+
branch_name = @options[:branch] || repo.default_branch
|
|
121
|
+
branch = Models::Branch.first(name: branch_name)
|
|
122
|
+
|
|
123
|
+
unless branch&.last_analyzed_sha
|
|
124
|
+
error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
last_commit = Models::Commit.first(sha: branch.last_analyzed_sha)
|
|
128
|
+
|
|
129
|
+
# Get all unique packages from dependency changes
|
|
130
|
+
packages = Models::DependencyChange
|
|
131
|
+
.select(:ecosystem, :name)
|
|
132
|
+
.select_group(:ecosystem, :name)
|
|
133
|
+
.all
|
|
134
|
+
|
|
135
|
+
exposure_data = []
|
|
136
|
+
|
|
137
|
+
packages.each do |pkg|
|
|
138
|
+
next unless Ecosystems.supported?(pkg.ecosystem)
|
|
139
|
+
|
|
140
|
+
osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
|
|
141
|
+
next unless osv_ecosystem
|
|
142
|
+
|
|
143
|
+
vuln_pkgs = Models::VulnerabilityPackage
|
|
144
|
+
.for_package(osv_ecosystem, pkg.name)
|
|
145
|
+
.eager(:vulnerability)
|
|
146
|
+
.all
|
|
147
|
+
|
|
148
|
+
vuln_pkgs.each do |vp|
|
|
149
|
+
next if vp.vulnerability&.withdrawn?
|
|
150
|
+
|
|
151
|
+
exposure = calculate_historical_exposure(pkg.ecosystem, pkg.name, vp, last_commit)
|
|
152
|
+
next unless exposure
|
|
153
|
+
|
|
154
|
+
if @options[:severity]
|
|
155
|
+
min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
|
|
156
|
+
next unless (SEVERITY_ORDER[exposure[:severity]&.downcase] || 4) <= min_level
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
exposure_data << exposure
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if exposure_data.empty?
|
|
164
|
+
puts "No historical vulnerabilities found"
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
output_results(exposure_data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def calculate_historical_exposure(ecosystem, package_name, vuln_pkg, last_commit)
|
|
172
|
+
window = find_vulnerability_window(ecosystem, package_name, vuln_pkg)
|
|
173
|
+
return nil unless window
|
|
174
|
+
|
|
175
|
+
introducing_change = window[:introducing]
|
|
176
|
+
fixing_change = window[:fixing]
|
|
177
|
+
|
|
178
|
+
introduced_at = introducing_change.commit.committed_at
|
|
179
|
+
fixed_at = fixing_change&.commit&.committed_at
|
|
180
|
+
published_at = vuln_pkg.vulnerability&.published_at
|
|
181
|
+
now = Time.now
|
|
182
|
+
|
|
183
|
+
total_exposure_days = if introduced_at
|
|
184
|
+
end_time = fixed_at || now
|
|
185
|
+
((end_time - introduced_at) / 86400).round
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
post_disclosure_days = if published_at
|
|
189
|
+
start_time = [introduced_at, published_at].compact.max
|
|
190
|
+
end_time = fixed_at || now
|
|
191
|
+
if start_time && end_time > start_time
|
|
192
|
+
((end_time - start_time) / 86400).round
|
|
193
|
+
else
|
|
194
|
+
0
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
id: vuln_pkg.vulnerability_id,
|
|
200
|
+
severity: vuln_pkg.vulnerability&.severity,
|
|
201
|
+
package_name: package_name,
|
|
202
|
+
package_version: introducing_change.requirement,
|
|
203
|
+
published_at: published_at&.strftime("%Y-%m-%d"),
|
|
204
|
+
introduced_at: introduced_at&.strftime("%Y-%m-%d"),
|
|
205
|
+
introduced_by: format_commit_info(introducing_change.commit),
|
|
206
|
+
fixed_at: fixed_at&.strftime("%Y-%m-%d"),
|
|
207
|
+
fixed_by: fixing_change ? format_commit_info(fixing_change.commit) : nil,
|
|
208
|
+
status: window[:status],
|
|
209
|
+
total_exposure_days: total_exposure_days,
|
|
210
|
+
post_disclosure_days: post_disclosure_days
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def output_results(exposure_data)
|
|
215
|
+
if @options[:format] == "json"
|
|
216
|
+
require "json"
|
|
217
|
+
puts JSON.pretty_generate({
|
|
218
|
+
vulnerabilities: exposure_data,
|
|
219
|
+
summary: compute_exposure_summary(exposure_data)
|
|
220
|
+
})
|
|
221
|
+
elsif @options[:summary]
|
|
222
|
+
output_exposure_summary(exposure_data)
|
|
223
|
+
else
|
|
224
|
+
output_exposure_table(exposure_data)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def calculate_exposure(vuln, up_to_commit)
|
|
229
|
+
osv_ecosystem = Ecosystems.to_osv(vuln[:ecosystem])
|
|
230
|
+
vuln_pkg = Models::VulnerabilityPackage.first(
|
|
231
|
+
vulnerability_id: vuln[:id],
|
|
232
|
+
ecosystem: osv_ecosystem,
|
|
233
|
+
package_name: vuln[:package_name]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return nil unless vuln_pkg
|
|
237
|
+
|
|
238
|
+
vulnerability = vuln_pkg.vulnerability
|
|
239
|
+
published_at = vulnerability&.published_at
|
|
240
|
+
|
|
241
|
+
introduced_change = find_introducing_change(
|
|
242
|
+
vuln[:ecosystem],
|
|
243
|
+
vuln[:package_name],
|
|
244
|
+
vuln_pkg,
|
|
245
|
+
up_to_commit
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
introduced_at = introduced_change&.commit&.committed_at
|
|
249
|
+
|
|
250
|
+
fixed_change = find_fixing_change(
|
|
251
|
+
vuln[:ecosystem],
|
|
252
|
+
vuln[:package_name],
|
|
253
|
+
vuln_pkg,
|
|
254
|
+
up_to_commit,
|
|
255
|
+
introduced_at
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
fixed_at = fixed_change&.commit&.committed_at
|
|
259
|
+
now = Time.now
|
|
260
|
+
|
|
261
|
+
total_exposure_days = if introduced_at
|
|
262
|
+
end_time = fixed_at || now
|
|
263
|
+
((end_time - introduced_at) / 86400).round
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
post_disclosure_days = if published_at
|
|
267
|
+
start_time = [introduced_at, published_at].compact.max
|
|
268
|
+
end_time = fixed_at || now
|
|
269
|
+
if start_time && end_time > start_time
|
|
270
|
+
((end_time - start_time) / 86400).round
|
|
271
|
+
else
|
|
272
|
+
0
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
{
|
|
277
|
+
id: vuln[:id],
|
|
278
|
+
severity: vuln[:severity],
|
|
279
|
+
package_name: vuln[:package_name],
|
|
280
|
+
package_version: vuln[:package_version],
|
|
281
|
+
published_at: published_at&.strftime("%Y-%m-%d"),
|
|
282
|
+
introduced_at: introduced_at&.strftime("%Y-%m-%d"),
|
|
283
|
+
introduced_by: introduced_change ? format_commit_info(introduced_change.commit) : nil,
|
|
284
|
+
fixed_at: fixed_at&.strftime("%Y-%m-%d"),
|
|
285
|
+
fixed_by: fixed_change ? format_commit_info(fixed_change.commit) : nil,
|
|
286
|
+
status: fixed_at ? "fixed" : "ongoing",
|
|
287
|
+
total_exposure_days: total_exposure_days,
|
|
288
|
+
post_disclosure_days: post_disclosure_days
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def compute_exposure_summary(data)
|
|
293
|
+
return {} if data.empty?
|
|
294
|
+
|
|
295
|
+
fixed = data.select { |d| d[:status] == "fixed" }
|
|
296
|
+
ongoing = data.select { |d| d[:status] == "ongoing" }
|
|
297
|
+
|
|
298
|
+
post_disclosure_times = fixed.map { |d| d[:post_disclosure_days] }.compact
|
|
299
|
+
mean_remediation = post_disclosure_times.empty? ? nil : (post_disclosure_times.sum.to_f / post_disclosure_times.size).round(1)
|
|
300
|
+
median_remediation = median(post_disclosure_times)
|
|
301
|
+
|
|
302
|
+
oldest_ongoing = ongoing.map { |d| d[:post_disclosure_days] }.compact.max
|
|
303
|
+
|
|
304
|
+
by_severity = {}
|
|
305
|
+
%w[critical high medium low].each do |sev|
|
|
306
|
+
sev_fixed = fixed.select { |d| d[:severity]&.downcase == sev }
|
|
307
|
+
sev_times = sev_fixed.map { |d| d[:post_disclosure_days] }.compact
|
|
308
|
+
next if sev_times.empty?
|
|
309
|
+
|
|
310
|
+
by_severity[sev] = (sev_times.sum.to_f / sev_times.size).round(1)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
total_vulnerabilities: data.size,
|
|
315
|
+
fixed_count: fixed.size,
|
|
316
|
+
ongoing_count: ongoing.size,
|
|
317
|
+
mean_remediation_days: mean_remediation,
|
|
318
|
+
median_remediation_days: median_remediation,
|
|
319
|
+
oldest_ongoing_days: oldest_ongoing,
|
|
320
|
+
by_severity: by_severity
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def output_exposure_summary(data)
|
|
325
|
+
summary = compute_exposure_summary(data)
|
|
326
|
+
|
|
327
|
+
# Build stats rows
|
|
328
|
+
rows = []
|
|
329
|
+
rows << ["Total vulnerabilities", summary[:total_vulnerabilities].to_s]
|
|
330
|
+
rows << ["Fixed", summary[:fixed_count].to_s]
|
|
331
|
+
rows << ["Ongoing", summary[:ongoing_count].to_s]
|
|
332
|
+
|
|
333
|
+
if summary[:fixed_count].positive?
|
|
334
|
+
rows << ["Median remediation", "#{summary[:median_remediation_days] || 'N/A'} days"]
|
|
335
|
+
rows << ["Mean remediation", "#{summary[:mean_remediation_days] || 'N/A'} days"]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
if summary[:oldest_ongoing_days]
|
|
339
|
+
rows << ["Oldest unpatched", "#{summary[:oldest_ongoing_days]} days"]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Add severity breakdown
|
|
343
|
+
summary[:by_severity].each do |sev, avg|
|
|
344
|
+
rows << ["#{sev.capitalize} (avg)", "#{avg} days"]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
output_stats_table(rows)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def output_stats_table(rows)
|
|
351
|
+
return if rows.empty?
|
|
352
|
+
|
|
353
|
+
max_label = rows.map { |r| r[0].length }.max || 20
|
|
354
|
+
max_value = rows.map { |r| r[1].length }.max || 10
|
|
355
|
+
|
|
356
|
+
width = max_label + max_value + 7
|
|
357
|
+
border = "+" + ("-" * (width - 2)) + "+"
|
|
358
|
+
|
|
359
|
+
puts border
|
|
360
|
+
rows.each do |label, value|
|
|
361
|
+
puts "| #{label.ljust(max_label)} | #{value.rjust(max_value)} |"
|
|
362
|
+
end
|
|
363
|
+
puts border
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def output_exposure_table(data)
|
|
367
|
+
max_pkg = data.map { |d| d[:package_name].length }.max || 10
|
|
368
|
+
max_id = data.map { |d| d[:id].length }.max || 15
|
|
369
|
+
|
|
370
|
+
header = "#{"Package".ljust(max_pkg)} #{"CVE".ljust(max_id)} Introduced Fixed Exposed Post-Disclosure"
|
|
371
|
+
puts header
|
|
372
|
+
puts "-" * header.length
|
|
373
|
+
|
|
374
|
+
data.sort_by { |d| [SEVERITY_ORDER[d[:severity]&.downcase] || 4, d[:package_name]] }.each do |row|
|
|
375
|
+
pkg = row[:package_name].ljust(max_pkg)
|
|
376
|
+
id = row[:id].ljust(max_id)
|
|
377
|
+
introduced = (row[:introduced_at] || "unknown").ljust(10)
|
|
378
|
+
fixed = row[:status] == "fixed" ? row[:fixed_at].ljust(10) : "-".ljust(10)
|
|
379
|
+
exposed = row[:total_exposure_days] ? "#{row[:total_exposure_days]}d".ljust(7) : "?".ljust(7)
|
|
380
|
+
|
|
381
|
+
post = if row[:status] == "ongoing" && row[:post_disclosure_days]
|
|
382
|
+
"#{row[:post_disclosure_days]}d (ongoing)"
|
|
383
|
+
elsif row[:post_disclosure_days]
|
|
384
|
+
"#{row[:post_disclosure_days]}d"
|
|
385
|
+
else
|
|
386
|
+
"?"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
line = "#{pkg} #{id} #{introduced} #{fixed} #{exposed} #{post}"
|
|
390
|
+
colored_line = case row[:severity]&.downcase
|
|
391
|
+
when "critical", "high" then Color.red(line)
|
|
392
|
+
when "medium" then Color.yellow(line)
|
|
393
|
+
when "low" then Color.cyan(line)
|
|
394
|
+
else line
|
|
395
|
+
end
|
|
396
|
+
puts colored_line
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
puts ""
|
|
400
|
+
output_exposure_summary(data)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def median(values)
|
|
404
|
+
return nil if values.empty?
|
|
405
|
+
|
|
406
|
+
sorted = values.sort
|
|
407
|
+
mid = sorted.size / 2
|
|
408
|
+
if sorted.size.odd?
|
|
409
|
+
sorted[mid]
|
|
410
|
+
else
|
|
411
|
+
((sorted[mid - 1] + sorted[mid]) / 2.0).round(1)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|