Module: PWN::Reports
- Defined in:
- lib/pwn/reports.rb,
lib/pwn/reports/csv.rb,
lib/pwn/reports/pdf.rb,
lib/pwn/reports/xml.rb,
lib/pwn/reports/fuzz.rb,
lib/pwn/reports/html.rb,
lib/pwn/reports/json.rb,
lib/pwn/reports/sast.rb,
lib/pwn/reports/phone.rb,
lib/pwn/reports/sarif.rb,
lib/pwn/reports/markdown.rb,
lib/pwn/reports/engagement.rb,
lib/pwn/reports/uri_buster.rb,
lib/pwn/reports/ai_red_team.rb,
lib/pwn/reports/html_footer.rb,
lib/pwn/reports/html_header.rb
Overview
Defined Under Namespace
Modules: AIRedTeam, CSV, Engagement, Fuzz, HTML, HTMLFooter, HTMLHeader, JSON, Markdown, PDF, Phone, SARIF, SAST, URIBuster, XML
Class Method Summary
collapse
Class Method Details
.attack_chains(opts = {}) ⇒ Object
Directed maximal paths; only scoped, evidenced impact can override member severity.
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
# File 'lib/pwn/reports.rb', line 72
public_class_method def self.attack_chains(opts = {})
rows = Array(opts[:findings]).map { |row| stringify_keys(hash: row) }
identified = rows.reject { |row| row['id'].to_s.empty? }
by_id = identified.to_h { |row| [row['id'].to_s, row] }
raise ArgumentError, 'duplicate finding IDs in attack graph' unless by_id.length == identified.length
adjacency = by_id.keys.to_h { |id| [id, []] }
by_id.each do |id, row|
legacy = %w[attack_chain_refs chain_refs chain_parent_id].flat_map { |key| Array(row[key]) }
edges = Array(row['enables']).map { |target| [id, target.to_s] } + legacy.map { |source| [source.to_s, id] }
edges.each do |source, target|
next unless by_id.key?(source) && by_id.key?(target)
engagements = [source, target].map { |key| by_id[key]['engagement_id'].to_s }.map { |value| value.empty? ? 'default' : value }
next unless engagements.uniq.length == 1
raise ArgumentError, 'cycle in attack graph' if source == target
adjacency[source] << target unless adjacency[source].include?(target)
end
end
incoming = by_id.keys.to_h { |id| [id, 0] }
adjacency.each_value { |targets| targets.each { |target| incoming[target] += 1 } }
roots = incoming.select { |_id, count| count.zero? }.keys.sort
pending = roots.dup
visited = 0
until pending.empty?
source = pending.shift
visited += 1
adjacency[source].each do |target|
incoming[target] -= 1
pending << target if incoming[target].zero?
end
end
raise ArgumentError, 'cycle in attack graph' unless visited == by_id.length
paths = []
pending = roots.select { |id| adjacency[id].any? }.map { |id| [id] }
until pending.empty?
path = pending.pop
targets = adjacency[path.last]
if targets.empty?
paths << assess_path(path: path, by_id: by_id) if path.length > 1
else
targets.sort.reverse_each { |target| pending << (path + [target]) }
end
raise ArgumentError, 'attack graph exceeds 1000 reportable paths' if paths.length > 1000 || pending.length > 1000
end
paths.sort_by { |path| [-impact_rank(severity: path[:combined_severity]), path[:finding_ids]] }
end
|
.authors ⇒ Object
269
270
271
|
# File 'lib/pwn/reports.rb', line 269
public_class_method def self.authors
"AUTHOR(S):\n 0day Inc. <[email protected]>\n"
end
|
.help ⇒ Object
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
|
# File 'lib/pwn/reports.rb', line 273
public_class_method def self.help
puts "USAGE:
# Run resolve path and return its result
#{self}.resolve_path(
path: 'required - filesystem path to read or write',
ext: 'optional - ext value consumed by #resolve_path',
dir_path: 'optional - dir path value consumed by #resolve_path',
report_name: 'optional - report name value consumed by #resolve_path'
)
# Verify and copy evidence to portable report-relative attachments; raises on missing or altered bytes.
#{self}.package_evidence(
payload: 'required - normalized report payload Hash',
path: 'required - actual output report path'
)
# Bound displayed PoC text; never execute it or read it as a filename.
#{self}.poc_preview(text: 'optional - PoC text to preview, limited to 16384 characters')
# Rank directed same-engagement paths using scoped, hash-checked combined-impact assessments.
#{self}.attack_chains(findings: 'required - Array of finding hashes')
# Run report payload and return its result
#{self}.report_payload(
results_hash: 'optional - results hash value consumed by #report_payload',
title: 'optional - title value consumed by #report_payload',
executive_summary: 'optional - executive summary value consumed by #report_payload'
)
# Print the AUTHOR(S) string for this module.
#{self}.authors
"
constants.sort
end
|
.package_evidence(opts = {}) ⇒ Object
Package verified bytes, never source filenames or caller-provided URLs.
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
|
# File 'lib/pwn/reports.rb', line 193
public_class_method def self.package_evidence(opts = {})
payload = opts[:payload]
directory = File.join(File.dirname(File.expand_path(opts[:path])), 'attachments')
chains = Array(payload[:attack_chains])
chain_rows = chains.map { |chain| { 'evidence_artifacts' => Array(chain[:evidence_artifacts]).map { |artifact| stringify_keys(hash: artifact) } } }
(payload[:findings] + chain_rows).each do |row|
Array(row['evidence_artifacts']).each do |artifact|
source = artifact['stored']
digest = artifact['sha256'].to_s
size = artifact['size']
raise ArgumentError, 'Evidence requires full SHA-256 and integer size' unless digest.match?(/\A[a-fA-F0-9]{64}\z/) && size.is_a?(Integer) && size >= 0
raise IOError, "Evidence missing: #{source}" unless source && File.file?(source)
bytes = File.binread(source)
raise IOError, "Evidence integrity mismatch: #{source}" unless bytes.bytesize == size && Digest::SHA256.hexdigest(bytes) == digest.downcase
extension = { 'pcap' => 'pcap', 'poc' => 'txt', 'crash' => 'bin' }.fetch(artifact['kind'], 'bin')
artifact.delete('inline_image')
if artifact['kind'] == 'screenshot'
extension = if bytes.start_with?("\x89PNG\r\n\x1a\n".b)
'png'
elsif bytes.start_with?("\xff\xd8\xff".b)
'jpg'
elsif bytes.start_with?('GIF87a', 'GIF89a')
'gif'
else
'bin'
end
artifact['inline_image'] = true unless extension == 'bin'
end
artifact['attachment'] = write_attachment(directory: directory, bytes: bytes, extension: extension)
end
row.delete('poc_export')
code = row['poc'].to_s
next if code.empty?
row['poc_export'] = {
'kind' => 'poc', 'label' => 'Full PoC text (not executed)', 'finding_id' => row['id'],
'sha256' => Digest::SHA256.hexdigest(code), 'size' => code.bytesize,
'attachment' => write_attachment(directory: directory, bytes: code, extension: 'txt')
}
end
chains.zip(chain_rows).each do |chain, row|
chain[:evidence_artifacts] = row['evidence_artifacts'].map { |artifact| artifact.transform_keys(&:to_sym) }
end
payload[:priorities] = rank_priorities(findings: payload[:findings], chains: chains)
payload
end
|
.poc_preview(opts = {}) ⇒ Object
261
262
263
264
265
266
267
|
# File 'lib/pwn/reports.rb', line 261
public_class_method def self.poc_preview(opts = {})
code = opts[:text].to_s
return 'Not supplied' if code.empty?
return code if code.length <= 16_384
"#{code[0, 16_384]}\n[Preview truncated; download full PoC text below.]"
end
|
.report_payload(opts = {}) ⇒ Object
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
|
# File 'lib/pwn/reports.rb', line 43
public_class_method def self.report_payload(opts = {})
raw = opts[:results_hash]
raw = {} unless raw.is_a?(Hash)
title = (
opts[:title] ||
raw[:title] || raw['title'] ||
raw[:report_name] || raw['report_name'] ||
'PWN Report'
).to_s
summary = (
opts[:executive_summary] ||
raw[:executive_summary] || raw['executive_summary']
).to_s
findings = raw[:findings] || raw['findings'] || raw[:data] || raw['data'] || []
findings = [] unless findings.is_a?(Array)
findings = findings.map { |row| stringify_keys(hash: row) }
chains = attack_chains(findings: findings)
refuse_unproven_combined!(findings: findings, chains: chains)
{
title: title,
executive_summary: summary,
findings: findings,
attack_chains: chains,
priorities: rank_priorities(findings: findings, chains: chains),
raw: raw
}
end
|
.resolve_path(opts = {}) ⇒ Object
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# File 'lib/pwn/reports.rb', line 27
public_class_method def self.resolve_path(opts = {})
path = opts[:path].to_s
ext = opts[:ext].to_s.sub(/\A\./, '')
unless path.empty?
FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path).to_s.empty? || File.dirname(path) == '.'
return path
end
dir = opts[:dir_path].to_s
dir = '.' if dir.empty?
FileUtils.mkdir_p(dir)
name = opts[:report_name].to_s
name = File.basename(Dir.pwd) if name.empty?
File.join(dir, "#{name}.#{ext}")
end
|