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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. 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] || 'default').to_s
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
- html = File.join(dir, 'dom.html')
1572
- har = File.join(dir, 'har.json')
1573
- begin
1574
- browser.screenshot.save(shot) if browser.respond_to?(:screenshot)
1575
- rescue StandardError
1576
- File.binwrite(shot, "\x89PNG\r\n\x1a\n")
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
- File.binwrite(shot, "\x89PNG\r\n\x1a\n") unless File.file?(shot)
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: 'required - browser_obj returned from #open',
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 default)'
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
  "
@@ -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: label and secret are required' if label.empty? || secret.empty?
308
+ raise 'ERROR: secret is required' if secret.empty?
307
309
 
308
- box = load_box
309
- box[label] = encrypt_secret(secret: secret)
310
- save_box(box: box)
311
- { label: label, stored: true }
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
- row = load_box[label]
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
- load_box.each do |label, row|
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.load_box(opts = {})
382
- return {} unless opts.is_a?(Hash)
383
- return {} unless File.file?(box_path)
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(box_path))
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.save_box(opts = {})
391
- FileUtils.mkdir_p(File.dirname(box_path))
392
- File.write(box_path, JSON.pretty_generate(opts[:box] || {}))
393
- File.chmod(0o600, box_path)
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
- label: 'required - vault label',
472
- secret: 'required - secret value'
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 = rows.select { |row| row[:engagement_id].to_s == name || name == 'default' }
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
@@ -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[:findings].map do |row|
11
- "<tr><td>#{h(text: row['id'])}</td><td>#{h(text: row['title'])}</td><td>#{h(text: row['severity'])}</td><td>#{h(text: row['cvss'])}</td><td>#{h(text: row['description'])}</td><td>#{h(text: row['poc'])}</td><td>#{h(text: row['recommendation'])}</td></tr>"
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.map { |key, value| "<dt>#{h(text: key)}</dt><dd>#{h(text: value)}</dd>" }.join
15
- "<section><h2>#{h(text: row['title'])}</h2><dl>#{fields}</dl></section>"
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
- "<li>#{h(text: chain[:finding_ids].join(' -> '))}: #{h(text: chain[:combined_severity])}. #{h(text: chain[:rationale])}</li>"
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])} -&gt; #{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>id</th><th>title</th><th>severity</th><th>cvss</th><th>description</th><th>poc</th><th>recommendation</th></tr>
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
- #{details}
39
- <h2>Attack chains</h2><ul>#{chains}</ul>
57
+ <h2>Attack chains</h2>#{chains}
58
+ <h2>Finding technical details</h2>#{details}
40
59
  </body>
41
60
  </html>
42
61
  HTML