pwn 0.5.739 → 0.5.741
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/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +163 -6
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +6 -2
- data/documentation/Recon-Findings-API.md +33 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
- data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
- data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
- data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
- data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
- data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
- data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/SKILL.md +2 -0
- data/lib/pwn/ai/agent/confirmation.rb +163 -0
- data/lib/pwn/ai/agent/dispatch.rb +43 -5
- data/lib/pwn/ai/agent/loop.rb +45 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/result.rb +76 -6
- data/lib/pwn/ai/agent/task_dag.rb +220 -0
- data/lib/pwn/ai/agent/tool_guard.rb +166 -24
- data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
- data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
- data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
- data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
- data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
- data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
- data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
- data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
- data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
- data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
- data/lib/pwn/ai/agent/tools/shell.rb +22 -10
- data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
- data/lib/pwn/ai/agent.rb +3 -1
- data/lib/pwn/ai/cli.rb +26 -2
- data/lib/pwn/ai/context.rb +4 -2
- data/lib/pwn/ai/router.rb +196 -0
- data/lib/pwn/ai/tools/README.md +5 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +10 -0
- data/lib/pwn/engagement.rb +45 -1
- data/lib/pwn/ffi/capstone.rb +120 -0
- data/lib/pwn/ffi/keystone.rb +107 -0
- data/lib/pwn/ffi.rb +2 -0
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/aflplusplus.rb +238 -13
- data/lib/pwn/plugins/ai_sandbox.rb +240 -0
- data/lib/pwn/plugins/artifact_registry.rb +188 -22
- data/lib/pwn/plugins/assembly.rb +73 -2
- data/lib/pwn/plugins/basic_auth.rb +17 -4
- data/lib/pwn/plugins/binary_parser.rb +439 -38
- data/lib/pwn/plugins/exploit_dev.rb +83 -14
- data/lib/pwn/plugins/findings.rb +208 -42
- data/lib/pwn/plugins/gdbmi.rb +265 -38
- data/lib/pwn/plugins/httpx.rb +93 -0
- data/lib/pwn/plugins/jobs.rb +260 -102
- data/lib/pwn/plugins/method_catalog.rb +328 -0
- data/lib/pwn/plugins/nmap_it.rb +157 -26
- data/lib/pwn/plugins/nuclei.rb +122 -29
- data/lib/pwn/plugins/preflight_checker.rb +3 -2
- data/lib/pwn/plugins/process_tube.rb +51 -2
- data/lib/pwn/plugins/radare2.rb +120 -26
- data/lib/pwn/plugins/recon.rb +33 -0
- data/lib/pwn/plugins/repl/mesh.rb +203 -13
- data/lib/pwn/plugins/rop.rb +121 -31
- data/lib/pwn/plugins/transparent_browser.rb +212 -14
- data/lib/pwn/plugins/vault.rb +182 -24
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +8 -2
- data/lib/pwn/reports/html.rb +28 -9
- data/lib/pwn/reports/json.rb +2 -1
- data/lib/pwn/reports/markdown.rb +49 -12
- data/lib/pwn/reports/sarif.rb +17 -3
- data/lib/pwn/reports.rb +184 -34
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reports_spec.rb +93 -0
- data/spec/integration/result_condition_spec.rb +11 -8
- data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
- data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
- data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
- data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
- data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
- data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
- data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
- data/spec/lib/pwn/ai/cli_spec.rb +12 -1
- data/spec/lib/pwn/ai/router_spec.rb +57 -0
- data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
- data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
- data/spec/lib/pwn/migrate_spec.rb +30 -2
- data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
- data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
- data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
- data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
- data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
- data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
- data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
- data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
- data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
- data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
- data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
- data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
- data/spec/lib/pwn/reports_spec.rb +196 -0
- data/spec/spec_helper.rb +3 -0
- data/third_party/pwn_rdoc.jsonl +230 -4
- metadata +47 -5
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'diffy'
|
|
4
|
+
require 'digest'
|
|
4
5
|
require 'fileutils'
|
|
6
|
+
require 'json'
|
|
5
7
|
require 'em/pure_ruby'
|
|
6
8
|
require 'faye/websocket'
|
|
7
9
|
require 'nokogiri'
|
|
@@ -11,7 +13,9 @@ require 'securerandom'
|
|
|
11
13
|
require 'selenium/devtools'
|
|
12
14
|
require 'selenium/webdriver'
|
|
13
15
|
require 'socksify'
|
|
16
|
+
require 'time'
|
|
14
17
|
require 'timeout'
|
|
18
|
+
require 'tmpdir'
|
|
15
19
|
require 'watir'
|
|
16
20
|
require 'yaml'
|
|
17
21
|
|
|
@@ -1560,25 +1564,87 @@ module PWN
|
|
|
1560
1564
|
raise e
|
|
1561
1565
|
end
|
|
1562
1566
|
|
|
1567
|
+
# Capture screenshot, DOM snapshot, and HAR into the artifact store.
|
|
1563
1568
|
public_class_method def self.evidence!(opts = {})
|
|
1564
1569
|
browser_obj = opts[:browser_obj]
|
|
1565
1570
|
label = (opts[:label] || 'capture').to_s.gsub(/[^\w.-]+/, '_')
|
|
1566
|
-
sid = (opts[:session_id] || '
|
|
1571
|
+
sid = (opts[:session_id] || 'web').to_s
|
|
1572
|
+
sid = 'web' if sid.empty?
|
|
1567
1573
|
dir = File.join(Dir.home, '.pwn', 'artifacts', sid, 'web', label)
|
|
1568
1574
|
FileUtils.mkdir_p(dir)
|
|
1569
|
-
browser = browser_obj.is_a?(Hash) ? browser_obj[:browser] : browser_obj
|
|
1570
1575
|
shot = File.join(dir, 'screenshot.png')
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1576
|
+
html_path = File.join(dir, 'dom.html')
|
|
1577
|
+
har_path = File.join(dir, 'har.json')
|
|
1578
|
+
png = png_bytes(opts)
|
|
1579
|
+
html = html_body(opts)
|
|
1580
|
+
har = har_document(opts)
|
|
1581
|
+
har_json = JSON.generate(har)
|
|
1582
|
+
File.binwrite(shot, png)
|
|
1583
|
+
File.write(html_path, html)
|
|
1584
|
+
File.write(har_path, har_json)
|
|
1585
|
+
shot_art = spill_web(bytes: png, session_id: sid)
|
|
1586
|
+
html_art = spill_web(bytes: html, session_id: sid)
|
|
1587
|
+
har_art = spill_web(bytes: har_json, session_id: sid)
|
|
1588
|
+
{
|
|
1589
|
+
screenshot: shot,
|
|
1590
|
+
dom: html_path,
|
|
1591
|
+
har: har_path,
|
|
1592
|
+
dir: dir,
|
|
1593
|
+
screenshot_handle: shot_art[:handle],
|
|
1594
|
+
dom_handle: html_art[:handle],
|
|
1595
|
+
har_handle: har_art[:handle],
|
|
1596
|
+
artifact_handles: [
|
|
1597
|
+
{ handle: shot_art[:handle], kind: 'screenshot', label: 'screenshot', sha256: shot_art[:sha256], path: shot_art[:path] },
|
|
1598
|
+
{ handle: html_art[:handle], kind: 'evidence', label: 'dom', sha256: html_art[:sha256], path: html_art[:path] },
|
|
1599
|
+
{ handle: har_art[:handle], kind: 'evidence', label: 'har', sha256: har_art[:sha256], path: har_art[:path] }
|
|
1600
|
+
]
|
|
1601
|
+
}
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
# Navigate a page and optionally record screenshot, DOM, HAR, and a finding.
|
|
1605
|
+
public_class_method def self.goto(opts = {})
|
|
1606
|
+
browser_obj = opts[:browser_obj]
|
|
1607
|
+
url = (opts[:url] || opts[:uri]).to_s
|
|
1608
|
+
browser = browser_handle(browser_obj: browser_obj)
|
|
1609
|
+
browser.goto(url) if browser.respond_to?(:goto) && !url.empty?
|
|
1610
|
+
url = browser.url.to_s if url.empty? && browser.respond_to?(:url)
|
|
1611
|
+
finding_opts = opts[:finding]
|
|
1612
|
+
finding_opts = finding_opts.transform_keys(&:to_sym) if finding_opts.is_a?(Hash)
|
|
1613
|
+
title = (opts[:title] || (finding_opts.is_a?(Hash) ? finding_opts[:title] : nil)).to_s
|
|
1614
|
+
capture = opts[:capture]
|
|
1615
|
+
capture = true if capture.nil? && !title.empty?
|
|
1616
|
+
capture = [true, 'true', 1, '1'].include?(capture)
|
|
1617
|
+
pack = nil
|
|
1618
|
+
if capture
|
|
1619
|
+
pack = evidence!(
|
|
1620
|
+
browser_obj: browser_obj,
|
|
1621
|
+
url: url,
|
|
1622
|
+
label: opts[:label],
|
|
1623
|
+
session_id: opts[:session_id],
|
|
1624
|
+
screenshot: opts[:screenshot],
|
|
1625
|
+
html: opts[:html],
|
|
1626
|
+
dom: opts[:dom],
|
|
1627
|
+
har: opts[:har],
|
|
1628
|
+
har_json: opts[:har_json],
|
|
1629
|
+
entries: opts[:entries]
|
|
1630
|
+
)
|
|
1631
|
+
end
|
|
1632
|
+
finding = nil
|
|
1633
|
+
unless title.empty?
|
|
1634
|
+
finding = persist_web_finding(
|
|
1635
|
+
title: title,
|
|
1636
|
+
url: url,
|
|
1637
|
+
severity: opts[:severity] || (finding_opts.is_a?(Hash) ? finding_opts[:severity] : nil),
|
|
1638
|
+
session_id: opts[:session_id],
|
|
1639
|
+
engagement_id: opts[:engagement_id],
|
|
1640
|
+
capture_pack: pack,
|
|
1641
|
+
browser_obj: browser_obj,
|
|
1642
|
+
screenshot: opts[:screenshot],
|
|
1643
|
+
html: opts[:html],
|
|
1644
|
+
har: opts[:har]
|
|
1645
|
+
)
|
|
1577
1646
|
end
|
|
1578
|
-
|
|
1579
|
-
File.write(html, browser.respond_to?(:html) ? browser.html.to_s : '')
|
|
1580
|
-
File.write(har, '[]')
|
|
1581
|
-
{ screenshot: shot, dom: html, har: har, dir: dir }
|
|
1647
|
+
{ url: url, evidence: pack, finding: finding }
|
|
1582
1648
|
end
|
|
1583
1649
|
|
|
1584
1650
|
public_class_method def self.intercept(opts = {})
|
|
@@ -1625,6 +1691,111 @@ module PWN
|
|
|
1625
1691
|
{ status: resp.code.to_i, headers: resp.to_hash, body: resp.body.to_s[0, 65_536], url: url, method: method }
|
|
1626
1692
|
end
|
|
1627
1693
|
|
|
1694
|
+
private_class_method def self.browser_handle(opts = {})
|
|
1695
|
+
obj = opts[:browser_obj] || opts[:browser]
|
|
1696
|
+
obj.is_a?(Hash) ? obj[:browser] : obj
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1699
|
+
private_class_method def self.png_bytes(opts = {})
|
|
1700
|
+
src = opts[:screenshot]
|
|
1701
|
+
if src.is_a?(String) && File.file?(src)
|
|
1702
|
+
return File.binread(src)
|
|
1703
|
+
elsif src.is_a?(String) && src.b.start_with?("\x89PNG".b)
|
|
1704
|
+
return src.b
|
|
1705
|
+
end
|
|
1706
|
+
|
|
1707
|
+
browser = browser_handle(opts)
|
|
1708
|
+
if browser.respond_to?(:screenshot)
|
|
1709
|
+
begin
|
|
1710
|
+
path = File.join(Dir.tmpdir, "pwn-tb-#{SecureRandom.hex(4)}.png")
|
|
1711
|
+
browser.screenshot.save(path)
|
|
1712
|
+
return File.binread(path) if File.file?(path)
|
|
1713
|
+
rescue StandardError
|
|
1714
|
+
nil
|
|
1715
|
+
end
|
|
1716
|
+
end
|
|
1717
|
+
"\x89PNG\r\n\x1a\n".b
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
private_class_method def self.html_body(opts = {})
|
|
1721
|
+
src = opts[:html] || opts[:dom]
|
|
1722
|
+
return File.read(src) if src.is_a?(String) && File.file?(src)
|
|
1723
|
+
return src.to_s unless src.nil?
|
|
1724
|
+
|
|
1725
|
+
browser = browser_handle(opts)
|
|
1726
|
+
browser.respond_to?(:html) ? browser.html.to_s : ''
|
|
1727
|
+
end
|
|
1728
|
+
|
|
1729
|
+
private_class_method def self.har_document(opts = {})
|
|
1730
|
+
src = opts[:har] || opts[:har_json]
|
|
1731
|
+
parsed = parse_har(src: src)
|
|
1732
|
+
return parsed if parsed
|
|
1733
|
+
|
|
1734
|
+
entries = Array(opts[:entries])
|
|
1735
|
+
browser_obj = opts[:browser_obj]
|
|
1736
|
+
proxy = browser_obj.is_a?(Hash) ? browser_obj[:capture_proxy] : nil
|
|
1737
|
+
if proxy.is_a?(Hash)
|
|
1738
|
+
path = proxy[:har_path].to_s
|
|
1739
|
+
parsed = parse_har(src: path) if File.file?(path)
|
|
1740
|
+
return parsed if parsed
|
|
1741
|
+
|
|
1742
|
+
entries = Array(proxy[:entries]) if entries.empty?
|
|
1743
|
+
end
|
|
1744
|
+
entries = Array(@intercepted) if entries.empty?
|
|
1745
|
+
url = opts[:url].to_s
|
|
1746
|
+
html = html_body(opts)
|
|
1747
|
+
if entries.empty? && !url.empty?
|
|
1748
|
+
entries = [{
|
|
1749
|
+
'startedDateTime' => Time.now.utc.iso8601,
|
|
1750
|
+
'request' => { 'method' => 'GET', 'url' => url, 'headers' => [] },
|
|
1751
|
+
'response' => { 'status' => 200, 'headers' => [], 'content' => { 'mimeType' => 'text/html', 'text' => html.to_s[0, 65_536] } }
|
|
1752
|
+
}]
|
|
1753
|
+
end
|
|
1754
|
+
{ 'log' => { 'version' => '1.2', 'creator' => { 'name' => 'PWN::Plugins::TransparentBrowser', 'version' => '1' }, 'entries' => entries } }
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
private_class_method def self.parse_har(opts = {})
|
|
1758
|
+
src = opts[:src]
|
|
1759
|
+
return nil if src.nil?
|
|
1760
|
+
|
|
1761
|
+
if src.is_a?(Hash)
|
|
1762
|
+
return src['log'] || src[:log] ? src : { 'log' => src }
|
|
1763
|
+
end
|
|
1764
|
+
|
|
1765
|
+
text = src.to_s
|
|
1766
|
+
return nil if text.empty?
|
|
1767
|
+
|
|
1768
|
+
text = File.read(text) if File.file?(text)
|
|
1769
|
+
JSON.parse(text)
|
|
1770
|
+
rescue JSON::ParserError
|
|
1771
|
+
nil
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
private_class_method def self.spill_web(opts = {})
|
|
1775
|
+
bytes = opts[:bytes].to_s
|
|
1776
|
+
sid = opts[:session_id].to_s
|
|
1777
|
+
sid = 'web' if sid.empty?
|
|
1778
|
+
PWN::Plugins::ArtifactRegistry.spill(bytes: bytes, session_id: sid)
|
|
1779
|
+
end
|
|
1780
|
+
|
|
1781
|
+
private_class_method def self.persist_web_finding(opts = {})
|
|
1782
|
+
pack = opts[:capture_pack]
|
|
1783
|
+
pack = evidence!(opts) unless pack.is_a?(Hash)
|
|
1784
|
+
url = opts[:url].to_s
|
|
1785
|
+
arts = [pack[:screenshot], pack[:har], pack[:dom]].select { |path| File.file?(path.to_s) }
|
|
1786
|
+
evidence = "pixel=#{pack[:screenshot]} har=#{pack[:har]} url=#{url} screenshot PNG and HAR log prove the navigation"
|
|
1787
|
+
PWN::Plugins::Findings.record(
|
|
1788
|
+
title: opts[:title].to_s,
|
|
1789
|
+
severity: opts[:severity] || 'info',
|
|
1790
|
+
host: url,
|
|
1791
|
+
url: url,
|
|
1792
|
+
poc_artifacts: arts,
|
|
1793
|
+
evidence: evidence,
|
|
1794
|
+
session_id: opts[:session_id],
|
|
1795
|
+
engagement_id: opts[:engagement_id]
|
|
1796
|
+
)
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1628
1799
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
1629
1800
|
|
|
1630
1801
|
public_class_method def self.authors
|
|
@@ -1773,9 +1944,16 @@ module PWN
|
|
|
1773
1944
|
|
|
1774
1945
|
# Save screenshot.png, dom.html, and har.json under ~/.pwn/artifacts.
|
|
1775
1946
|
#{self}.evidence!(
|
|
1776
|
-
browser_obj: '
|
|
1947
|
+
browser_obj: 'optional - browser_obj returned from open; omit when html or har fixtures are supplied',
|
|
1777
1948
|
label: 'optional - folder name for this capture (defaults to capture)',
|
|
1778
|
-
session_id: 'optional - artifacts session folder (defaults to
|
|
1949
|
+
session_id: 'optional - artifacts session folder (defaults to web)',
|
|
1950
|
+
screenshot: 'optional - PNG path used when no live screenshot is available',
|
|
1951
|
+
html: 'optional - DOM HTML string or file used when no live browser is available',
|
|
1952
|
+
dom: 'optional - alias for html',
|
|
1953
|
+
har: 'optional - HAR JSON string, Hash, or file used when no live capture is available',
|
|
1954
|
+
har_json: 'optional - alias for har',
|
|
1955
|
+
entries: 'optional - Array of HAR entries used when har is omitted',
|
|
1956
|
+
url: 'optional - navigated URL embedded into a synthetic HAR entry'
|
|
1779
1957
|
)
|
|
1780
1958
|
|
|
1781
1959
|
# Enable CDP Fetch interception (Burp-like hook without Burp).
|
|
@@ -1798,6 +1976,26 @@ module PWN
|
|
|
1798
1976
|
mutations: 'optional - Hash of url/method/headers/body overlays'
|
|
1799
1977
|
)
|
|
1800
1978
|
|
|
1979
|
+
# Navigate then optionally capture screenshot, DOM, and HAR; record a finding when title is set.
|
|
1980
|
+
#{self}.goto(
|
|
1981
|
+
browser_obj: 'optional - browser_obj returned from open; omit when html or har fixtures are supplied',
|
|
1982
|
+
url: 'optional - HTTP(S) URL to open; required unless uri is set',
|
|
1983
|
+
uri: 'optional - alias for url',
|
|
1984
|
+
capture: 'optional - true spills screenshot DOM HAR (defaults true when title is set)',
|
|
1985
|
+
title: 'optional - finding title; records pixel and HAR proof without a second call',
|
|
1986
|
+
finding: 'optional - Hash with title and severity overlay for the recorded finding',
|
|
1987
|
+
severity: 'optional - finding severity string (defaults to info)',
|
|
1988
|
+
session_id: 'optional - artifact session id (defaults to web)',
|
|
1989
|
+
engagement_id: 'optional - findings engagement id',
|
|
1990
|
+
label: 'optional - capture folder name under the session web dir',
|
|
1991
|
+
screenshot: 'optional - PNG path used when no live screenshot is available',
|
|
1992
|
+
html: 'optional - DOM HTML string or file used when no live browser is available',
|
|
1993
|
+
dom: 'optional - alias for html',
|
|
1994
|
+
har: 'optional - HAR JSON string, Hash, or file used when no live capture is available',
|
|
1995
|
+
har_json: 'optional - alias for har',
|
|
1996
|
+
entries: 'optional - Array of HAR entries used when har is omitted'
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1801
1999
|
# Print the AUTHOR(S) string for this module.
|
|
1802
2000
|
#{self}.authors
|
|
1803
2001
|
"
|
data/lib/pwn/plugins/vault.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'openssl'
|
|
|
5
5
|
require 'yaml'
|
|
6
6
|
require 'json'
|
|
7
7
|
require 'fileutils'
|
|
8
|
+
require 'securerandom'
|
|
9
|
+
require 'time'
|
|
8
10
|
|
|
9
11
|
module PWN
|
|
10
12
|
module Plugins
|
|
@@ -300,25 +302,83 @@ module PWN
|
|
|
300
302
|
raise e
|
|
301
303
|
end
|
|
302
304
|
|
|
305
|
+
# Store an engagement-scoped secret with host, finding, and source provenance.
|
|
303
306
|
public_class_method def self.store(opts = {})
|
|
304
|
-
label = opts[:label].to_s
|
|
305
307
|
secret = opts[:secret].to_s
|
|
306
|
-
raise 'ERROR:
|
|
308
|
+
raise 'ERROR: secret is required' if secret.empty?
|
|
307
309
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
310
|
+
eng = engagement_id(opts)
|
|
311
|
+
label = opts[:label].to_s
|
|
312
|
+
label = "#{opts[:host] || 'loot'}:#{opts[:username] || 'user'}:#{SecureRandom.hex(4)}" if label.empty?
|
|
313
|
+
records = load_records(engagement: eng)
|
|
314
|
+
record = {
|
|
315
|
+
id: SecureRandom.hex(6),
|
|
316
|
+
label: label,
|
|
317
|
+
kind: (opts[:kind] || 'password').to_s,
|
|
318
|
+
engagement: eng,
|
|
319
|
+
host: opts[:host].to_s,
|
|
320
|
+
service: opts[:service].to_s,
|
|
321
|
+
username: opts[:username].to_s,
|
|
322
|
+
secret: encrypt_secret(secret: secret),
|
|
323
|
+
source: (opts[:source] || 'manual').to_s,
|
|
324
|
+
where: (opts[:where] || opts[:path]).to_s,
|
|
325
|
+
finding_id: (opts[:finding_id] || opts[:finding]).to_s,
|
|
326
|
+
at: Time.now.utc.iso8601
|
|
327
|
+
}
|
|
328
|
+
records.reject! { |row| row[:label] == label }
|
|
329
|
+
records << record
|
|
330
|
+
save_records(engagement: eng, records: records)
|
|
331
|
+
record.except(:secret).merge(stored: true)
|
|
312
332
|
end
|
|
313
333
|
|
|
314
334
|
public_class_method def self.fetch(opts = {})
|
|
315
335
|
label = opts[:label].to_s
|
|
316
336
|
raise 'ERROR: label is required' if label.empty?
|
|
317
337
|
|
|
318
|
-
|
|
338
|
+
rows = opts[:engagement] || opts[:engagement_id] ? load_records(opts) : load_all_records
|
|
339
|
+
row = rows.find { |item| item[:label] == label }
|
|
319
340
|
return nil unless row
|
|
320
341
|
|
|
321
|
-
decrypt_secret(row: row)
|
|
342
|
+
decrypt_secret(row: row[:secret] || row)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# List loot metadata for lateral-movement planning (no plaintext secrets).
|
|
346
|
+
public_class_method def self.query(opts = {})
|
|
347
|
+
host = opts[:host].to_s.downcase
|
|
348
|
+
service = opts[:service].to_s
|
|
349
|
+
kind = opts[:kind].to_s
|
|
350
|
+
finding = (opts[:finding_id] || opts[:finding]).to_s
|
|
351
|
+
load_records(opts).filter_map do |row|
|
|
352
|
+
next if !host.empty? && !host_match?(host: host, row: row)
|
|
353
|
+
next if !service.empty? && !row[:service].to_s.empty? && row[:service].to_s != service
|
|
354
|
+
next if !kind.empty? && row[:kind].to_s != kind
|
|
355
|
+
next if !finding.empty? && row[:finding_id].to_s != finding
|
|
356
|
+
|
|
357
|
+
row.except(:secret).merge(has_secret: true)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Return decrypted creds that match a service auth prompt.
|
|
362
|
+
public_class_method def self.offer(opts = {})
|
|
363
|
+
query(
|
|
364
|
+
host: opts[:host],
|
|
365
|
+
service: opts[:service],
|
|
366
|
+
kind: opts[:kind],
|
|
367
|
+
finding_id: opts[:finding_id] || opts[:finding],
|
|
368
|
+
engagement: opts[:engagement] || opts[:engagement_id]
|
|
369
|
+
).map do |row|
|
|
370
|
+
row.merge(secret: fetch(label: row[:label], engagement: row[:engagement]))
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Parse username/password (or token) strings from recon evidence into the loot store.
|
|
375
|
+
public_class_method def self.ingest(opts = {})
|
|
376
|
+
text = opts[:text].to_s
|
|
377
|
+
username = text[/\buser(?:name)?\s*[=:]\s*(\S+)/i, 1]
|
|
378
|
+
password = text[/\b(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*(\S+)/i, 1]
|
|
379
|
+
return [] if password.to_s.empty?
|
|
380
|
+
|
|
381
|
+
[store(opts.merge(username: username, secret: password.to_s.sub(/[.,;]+$/, ''), kind: opts[:kind] || 'password'))]
|
|
322
382
|
end
|
|
323
383
|
|
|
324
384
|
public_class_method def self.expand(opts = {})
|
|
@@ -328,20 +388,41 @@ module PWN
|
|
|
328
388
|
|
|
329
389
|
public_class_method def self.redact(opts = {})
|
|
330
390
|
text = opts[:text].to_s
|
|
331
|
-
|
|
332
|
-
val = decrypt_secret(row: row).to_s
|
|
391
|
+
load_all_records.each do |row|
|
|
392
|
+
val = decrypt_secret(row: row[:secret] || row).to_s
|
|
333
393
|
next if val.empty?
|
|
334
394
|
|
|
335
|
-
text = text.gsub(val, "{{vault:#{label}}}")
|
|
395
|
+
text = text.gsub(val, "{{vault:#{row[:label]}}}")
|
|
336
396
|
end
|
|
337
397
|
text
|
|
338
398
|
end
|
|
339
399
|
|
|
400
|
+
private_class_method def self.engagement_id(opts = {})
|
|
401
|
+
value = (opts[:engagement] || opts[:engagement_id] || 'default').to_s
|
|
402
|
+
value = 'default' if value.empty?
|
|
403
|
+
raise ArgumentError, 'engagement_id must be a simple identifier' unless value.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
404
|
+
|
|
405
|
+
value
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
private_class_method def self.host_match?(opts = {})
|
|
409
|
+
want = opts[:host].to_s.downcase
|
|
410
|
+
have = opts[:row][:host].to_s.downcase
|
|
411
|
+
return false if have.empty?
|
|
412
|
+
|
|
413
|
+
have == want || have.end_with?(".#{want}") || want.end_with?(".#{have}")
|
|
414
|
+
end
|
|
415
|
+
|
|
340
416
|
private_class_method def self.key_path
|
|
341
417
|
File.join(Dir.home, '.pwn-vault.key')
|
|
342
418
|
end
|
|
343
419
|
|
|
344
|
-
private_class_method def self.box_path
|
|
420
|
+
private_class_method def self.box_path(opts = {})
|
|
421
|
+
File.join(Dir.home, '.pwn', 'engagements', engagement_id(opts), 'loot.json')
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private_class_method def self.legacy_box_path(opts = {})
|
|
425
|
+
_n = opts[:n]
|
|
345
426
|
File.join(Dir.home, '.pwn', 'vault-secrets.json')
|
|
346
427
|
end
|
|
347
428
|
|
|
@@ -376,21 +457,49 @@ module PWN
|
|
|
376
457
|
cipher.auth_tag = Base64.strict_decode64(row[:tag] || row['tag'].to_s)
|
|
377
458
|
cipher.auth_data = 'pwn-vault'
|
|
378
459
|
cipher.update(Base64.strict_decode64(row[:ct] || row['ct'].to_s)) + cipher.final
|
|
460
|
+
rescue StandardError
|
|
461
|
+
''
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private_class_method def self.load_records(opts = {})
|
|
465
|
+
path = box_path(opts)
|
|
466
|
+
rows = parse_box(path: path)
|
|
467
|
+
rows.concat(parse_box(path: legacy_box_path)) if engagement_id(opts) == 'default' && path != legacy_box_path
|
|
468
|
+
rows
|
|
379
469
|
end
|
|
380
470
|
|
|
381
|
-
private_class_method def self.
|
|
382
|
-
|
|
383
|
-
|
|
471
|
+
private_class_method def self.load_all_records(opts = {})
|
|
472
|
+
_n = opts[:n]
|
|
473
|
+
paths = Dir.glob(File.join(Dir.home, '.pwn', 'engagements', '*', 'loot.json'))
|
|
474
|
+
paths << legacy_box_path
|
|
475
|
+
paths.uniq.flat_map { |path| parse_box(path: path) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
private_class_method def self.parse_box(opts = {})
|
|
479
|
+
path = opts[:path].to_s
|
|
480
|
+
return [] unless File.file?(path)
|
|
384
481
|
|
|
385
|
-
JSON.parse(File.read(
|
|
482
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
483
|
+
if data.is_a?(Hash) && data[:records].is_a?(Array)
|
|
484
|
+
data[:records].map { |row| row.transform_keys(&:to_sym) }
|
|
485
|
+
elsif data.is_a?(Hash)
|
|
486
|
+
data.map do |label, row|
|
|
487
|
+
next unless row.is_a?(Hash)
|
|
488
|
+
|
|
489
|
+
{ label: label.to_s, secret: row.transform_keys(&:to_sym), engagement: 'default', host: '', source: 'legacy' }
|
|
490
|
+
end.compact
|
|
491
|
+
else
|
|
492
|
+
[]
|
|
493
|
+
end
|
|
386
494
|
rescue StandardError
|
|
387
|
-
|
|
495
|
+
[]
|
|
388
496
|
end
|
|
389
497
|
|
|
390
|
-
private_class_method def self.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
File.
|
|
498
|
+
private_class_method def self.save_records(opts = {})
|
|
499
|
+
path = box_path(opts)
|
|
500
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
501
|
+
File.write(path, JSON.pretty_generate(version: 2, records: opts[:records] || []))
|
|
502
|
+
File.chmod(0o600, path)
|
|
394
503
|
end
|
|
395
504
|
|
|
396
505
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
@@ -468,13 +577,62 @@ module PWN
|
|
|
468
577
|
|
|
469
578
|
# Store a secret outside the transcript (AES-GCM; key in ~/.pwn-vault.key).
|
|
470
579
|
#{self}.store(
|
|
471
|
-
|
|
472
|
-
|
|
580
|
+
secret: 'required - secret value (password, token, or key material)',
|
|
581
|
+
label: 'optional - vault label; defaults to host:user:hex',
|
|
582
|
+
engagement: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
583
|
+
engagement_id: 'optional - alias for engagement',
|
|
584
|
+
host: 'optional - hostname or IP this secret authenticates',
|
|
585
|
+
service: 'optional - service name such as http or ssh',
|
|
586
|
+
username: 'optional - account name recovered with the secret',
|
|
587
|
+
kind: 'optional - password, token, or key (defaults to password)',
|
|
588
|
+
source: 'optional - provenance source such as recon or redaction',
|
|
589
|
+
where: 'optional - URL, banner, or path where the secret was found',
|
|
590
|
+
path: 'optional - alias for where',
|
|
591
|
+
finding_id: 'optional - finding id this secret is linked to',
|
|
592
|
+
finding: 'optional - alias for finding_id'
|
|
473
593
|
)
|
|
474
594
|
|
|
475
595
|
# Fetch a stored secret by label.
|
|
476
596
|
#{self}.fetch(
|
|
477
|
-
label: 'required - vault label'
|
|
597
|
+
label: 'required - vault label',
|
|
598
|
+
engagement: 'optional - engagement id to search first',
|
|
599
|
+
engagement_id: 'optional - alias for engagement'
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# List loot metadata for lateral-movement planning without plaintext secrets.
|
|
603
|
+
#{self}.query(
|
|
604
|
+
host: 'optional - hostname or IP to match',
|
|
605
|
+
service: 'optional - service name such as http or ssh',
|
|
606
|
+
kind: 'optional - password, token, or key',
|
|
607
|
+
finding_id: 'optional - finding id this secret is linked to',
|
|
608
|
+
finding: 'optional - alias for finding_id',
|
|
609
|
+
engagement: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
610
|
+
engagement_id: 'optional - alias for engagement'
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Return decrypted creds that match a service auth prompt.
|
|
614
|
+
#{self}.offer(
|
|
615
|
+
host: 'optional - hostname or IP to match',
|
|
616
|
+
service: 'optional - service name such as http or ssh',
|
|
617
|
+
kind: 'optional - password, token, or key',
|
|
618
|
+
finding_id: 'optional - finding id this secret is linked to',
|
|
619
|
+
finding: 'optional - alias for finding_id',
|
|
620
|
+
engagement: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
621
|
+
engagement_id: 'optional - alias for engagement'
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Parse username/password strings from recon evidence into the loot store.
|
|
625
|
+
#{self}.ingest(
|
|
626
|
+
text: 'required - recon banner, .env, or HTML containing creds',
|
|
627
|
+
host: 'optional - hostname or IP this secret authenticates',
|
|
628
|
+
service: 'optional - service name such as http or ssh',
|
|
629
|
+
source: 'optional - provenance source such as recon',
|
|
630
|
+
where: 'optional - URL, banner, or path where the secret was found',
|
|
631
|
+
engagement: 'optional - engagement id scoping the loot file (defaults to default)',
|
|
632
|
+
engagement_id: 'optional - alias for engagement',
|
|
633
|
+
finding_id: 'optional - finding id this secret is linked to',
|
|
634
|
+
kind: 'optional - password, token, or key (defaults to password)',
|
|
635
|
+
label: 'optional - vault label; defaults to host:user:hex'
|
|
478
636
|
)
|
|
479
637
|
|
|
480
638
|
# Replace {{vault:label}} tokens with stored secrets.
|
data/lib/pwn/plugins.rb
CHANGED
|
@@ -7,6 +7,7 @@ module PWN
|
|
|
7
7
|
module Plugins
|
|
8
8
|
autoload :Android, 'pwn/plugins/android'
|
|
9
9
|
autoload :AFLplusplus, 'pwn/plugins/aflplusplus'
|
|
10
|
+
autoload :AISandbox, 'pwn/plugins/ai_sandbox'
|
|
10
11
|
autoload :ArtifactRegistry, 'pwn/plugins/artifact_registry'
|
|
11
12
|
autoload :Assembly, 'pwn/plugins/assembly'
|
|
12
13
|
autoload :BinaryParser, 'pwn/plugins/binary_parser'
|
|
@@ -45,6 +46,7 @@ module PWN
|
|
|
45
46
|
autoload :Git, 'pwn/plugins/git'
|
|
46
47
|
autoload :GDB, 'pwn/plugins/gdb'
|
|
47
48
|
autoload :GDBMI, 'pwn/plugins/gdbmi'
|
|
49
|
+
autoload :GDBMi, 'pwn/plugins/gdbmi'
|
|
48
50
|
autoload :Ghidra, 'pwn/plugins/ghidra'
|
|
49
51
|
autoload :GhidraHeadless, 'pwn/plugins/ghidra_headless'
|
|
50
52
|
autoload :Github, 'pwn/plugins/github'
|
|
@@ -52,6 +54,7 @@ module PWN
|
|
|
52
54
|
autoload :HackerOne, 'pwn/plugins/hacker_one'
|
|
53
55
|
autoload :Handler, 'pwn/plugins/handler'
|
|
54
56
|
autoload :Hunter, 'pwn/plugins/hunter'
|
|
57
|
+
autoload :Httpx, 'pwn/plugins/httpx'
|
|
55
58
|
autoload :IPInfo, 'pwn/plugins/ip_info'
|
|
56
59
|
autoload :IRC, 'pwn/plugins/irc'
|
|
57
60
|
autoload :Jenkins, 'pwn/plugins/jenkins'
|
|
@@ -62,6 +65,7 @@ module PWN
|
|
|
62
65
|
autoload :Log, 'pwn/plugins/log'
|
|
63
66
|
autoload :MailAgent, 'pwn/plugins/mail_agent'
|
|
64
67
|
autoload :Metasploit, 'pwn/plugins/metasploit'
|
|
68
|
+
autoload :MethodCatalog, 'pwn/plugins/method_catalog'
|
|
65
69
|
autoload :MitmProxy, 'pwn/plugins/mitm_proxy'
|
|
66
70
|
autoload :MonkeyPatch, 'pwn/plugins/monkey_patch'
|
|
67
71
|
autoload :MSR206, 'pwn/plugins/msr206'
|
|
@@ -19,10 +19,16 @@ module PWN
|
|
|
19
19
|
public_class_method def self.generate(opts = {})
|
|
20
20
|
name = (opts[:engagement] || opts[:name] || 'default').to_s
|
|
21
21
|
rows = if opts[:findings]
|
|
22
|
-
Array(opts[:findings])
|
|
22
|
+
Array(opts[:findings]).select do |row|
|
|
23
|
+
engagement = (row[:engagement_id] || row['engagement_id']).to_s
|
|
24
|
+
engagement.empty? || engagement == name
|
|
25
|
+
end
|
|
23
26
|
elsif defined?(PWN::Plugins::Findings)
|
|
24
27
|
rows = PWN::Plugins::Findings.report
|
|
25
|
-
rows
|
|
28
|
+
rows.select do |row|
|
|
29
|
+
engagement = (row[:engagement_id] || row['engagement_id']).to_s
|
|
30
|
+
engagement == name || (name == 'default' && engagement.empty?)
|
|
31
|
+
end
|
|
26
32
|
else
|
|
27
33
|
[]
|
|
28
34
|
end
|
data/lib/pwn/reports/html.rb
CHANGED
|
@@ -6,16 +6,34 @@ module PWN
|
|
|
6
6
|
module HTML
|
|
7
7
|
public_class_method def self.generate(opts = {})
|
|
8
8
|
out = PWN::Reports.resolve_path(opts.merge(ext: 'html'))
|
|
9
|
-
payload = PWN::Reports.report_payload(opts)
|
|
10
|
-
rows = payload[:
|
|
11
|
-
"<tr><td>#{
|
|
9
|
+
payload = PWN::Reports.package_evidence(payload: PWN::Reports.report_payload(opts), path: out)
|
|
10
|
+
rows = Array(payload[:priorities]).each_with_index.map do |priority, index|
|
|
11
|
+
"<tr><td>#{index + 1}</td><td>#{h(text: priority[:title])}</td><td>#{h(text: priority[:combined_severity])}</td><td>#{h(text: priority[:kind])}</td><td>#{h(text: priority[:rationale])}</td></tr>"
|
|
12
12
|
end
|
|
13
13
|
details = payload[:findings].map do |row|
|
|
14
|
-
fields = row.
|
|
15
|
-
|
|
14
|
+
fields = row.except('reproduction_steps', 'severity_justification', 'poc', 'evidence_artifacts', 'poc_export')
|
|
15
|
+
.map { |key, value| "<dt>#{h(text: key)}</dt><dd>#{h(text: value)}</dd>" }.join
|
|
16
|
+
steps = Array(row['reproduction_steps']).map { |step| "<li>#{h(text: step)}</li>" }.join
|
|
17
|
+
steps = steps.empty? ? '<p>Not supplied</p>' : "<ol>#{steps}</ol>"
|
|
18
|
+
justification = row['severity_justification'].to_s
|
|
19
|
+
code = PWN::Reports.poc_preview(text: row['poc'])
|
|
20
|
+
evidence = (Array(row['evidence_artifacts']) + [row['poc_export']].compact).map do |artifact|
|
|
21
|
+
link = "<a download href=\"#{h(text: artifact['attachment'])}\">#{h(text: artifact['label'])}</a>"
|
|
22
|
+
image = artifact['inline_image'] ? "<img src=\"#{h(text: artifact['attachment'])}\" alt=\"#{h(text: artifact['label'])}\">" : ''
|
|
23
|
+
"<li>#{link}#{image}<p>Kind: #{h(text: artifact['kind'])}; Handle: #{h(text: artifact['handle'])}; SHA-256: #{h(text: artifact['sha256'])}; Size: #{h(text: artifact['size'])} bytes</p></li>"
|
|
24
|
+
end.join
|
|
25
|
+
"<section><h2>#{h(text: row['title'])}</h2><dl>#{fields}</dl><h3>Reproduction steps</h3>#{steps}<h3>Severity justification</h3><p>#{h(text: justification.empty? ? 'Not supplied' : justification)}</p><h3>PoC command/code</h3><pre><code>#{h(text: code.empty? ? 'Not supplied' : code)}</code></pre><h3>Evidence</h3><ul>#{evidence}</ul></section>"
|
|
16
26
|
end.join
|
|
17
27
|
chains = payload[:attack_chains].map do |chain|
|
|
18
|
-
|
|
28
|
+
steps = Array(chain[:reproduction_steps]).map { |step| "<li>#{h(text: step)}</li>" }.join
|
|
29
|
+
links = Array(chain[:links]).map { |link| "<li>#{h(text: link[:from])} -> #{h(text: link[:to])}</li>" }.join
|
|
30
|
+
evidence = Array(chain[:evidence_artifacts]).map do |artifact|
|
|
31
|
+
artifact = artifact.transform_keys(&:to_s)
|
|
32
|
+
label = h(text: artifact['label'])
|
|
33
|
+
label = "<a download href=\"#{h(text: artifact['attachment'])}\">#{label}</a>" if artifact['attachment']
|
|
34
|
+
"<li>#{label}; Kind: #{h(text: artifact['kind'])}; Handle: #{h(text: artifact['handle'])}; SHA-256: #{h(text: artifact['sha256'])}; Size: #{h(text: artifact['size'])} bytes</li>"
|
|
35
|
+
end.join
|
|
36
|
+
"<section><h3>#{h(text: chain[:title])}</h3><p>Directed path: #{h(text: chain[:finding_ids].join(' -> '))}</p><p>Combined severity: #{h(text: chain[:combined_severity])}; Assessment: #{h(text: chain[:assessment_status])}</p><p>#{h(text: chain[:rationale])}</p><h4>Directed links</h4><ul>#{links}</ul><h4>PoC reproduction steps</h4><ol>#{steps}</ol><h4>Evidence</h4><ul>#{evidence}</ul></section>"
|
|
19
37
|
end.join
|
|
20
38
|
body = <<~HTML
|
|
21
39
|
<!DOCTYPE html>
|
|
@@ -27,16 +45,17 @@ module PWN
|
|
|
27
45
|
<body>
|
|
28
46
|
<h1>#{h(text: payload[:title])}</h1>
|
|
29
47
|
#{summary_html(text: payload[:executive_summary])}
|
|
48
|
+
<h2>Ranked priorities</h2>
|
|
30
49
|
<table>
|
|
31
50
|
<thead>
|
|
32
|
-
<tr><th>
|
|
51
|
+
<tr><th>rank</th><th>title</th><th>severity</th><th>kind</th><th>rationale</th></tr>
|
|
33
52
|
</thead>
|
|
34
53
|
<tbody>
|
|
35
54
|
#{rows.join("\n")}
|
|
36
55
|
</tbody>
|
|
37
56
|
</table>
|
|
38
|
-
|
|
39
|
-
<h2>
|
|
57
|
+
<h2>Attack chains</h2>#{chains}
|
|
58
|
+
<h2>Finding technical details</h2>#{details}
|
|
40
59
|
</body>
|
|
41
60
|
</html>
|
|
42
61
|
HTML
|