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

This file, using the autoload directive loads Report modules into memory only when they're needed. For more information, see: http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html

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.

Raises:

  • (ArgumentError)


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