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
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'tmpdir'
5
+ require 'open3'
6
+ require 'fileutils'
5
7
 
6
8
  describe PWN::Plugins::AFLplusplus do
7
9
  it 'should display information for authors' do
@@ -19,4 +21,42 @@ describe PWN::Plugins::AFLplusplus do
19
21
  expect(stats[:execs_per_sec] || stats['execs_per_sec']).to eq('1234.5')
20
22
  end
21
23
  end
24
+
25
+ it 'builds an AFL dictionary from interesting binary strings' do
26
+ Dir.mktmpdir('pwn-fuzz-dict-') do |dir|
27
+ src = File.join(dir, 't.c')
28
+ bin = File.join(dir, 't')
29
+ File.write(src, "#include <stdio.h>\nint main(void) { puts(\"https://fuzz.example/token\"); return 0; }\n")
30
+ out, status = Open3.capture2e('cc', '-O0', '-o', bin, src)
31
+ expect(status.success?).to eq(true), out
32
+ dict = described_class.dictionary_from_binary(path: bin)
33
+ blob = Array(dict[:tokens] || dict).join("\n")
34
+ expect(blob).to include('https://fuzz.example/token')
35
+ end
36
+ end
37
+
38
+ it 'dedups crashes by backtrace hash and hands unique ones to RE triage' do
39
+ Dir.mktmpdir('pwn-fuzz-dedup-') do |dir|
40
+ crashes = File.join(dir, 'crashes')
41
+ FileUtils.mkdir_p(crashes)
42
+ File.binwrite(File.join(crashes, 'id:000000,sig:11'), 'AAAA')
43
+ File.binwrite(File.join(crashes, 'id:000001,sig:11'), 'BBBB')
44
+ File.binwrite(File.join(crashes, 'id:000002,sig:11'), 'CCCC')
45
+ reports = {
46
+ 'AAAA' => { signal: 'SIGSEGV', pc: '0x401136', fault_addr: '0x401136', backtrace: [{ func: 'vuln' }] },
47
+ 'BBBB' => { signal: 'SIGSEGV', pc: '0x401136', fault_addr: '0x401136', backtrace: [{ func: 'vuln' }] },
48
+ 'CCCC' => { signal: 'SIGABRT', pc: '0x401200', fault_addr: '0x0', backtrace: [{ func: 'abort' }] }
49
+ }
50
+ allow(PWN::Plugins::GDBMI).to receive(:run_to_crash) do |opts|
51
+ reports.fetch(opts[:stdin].to_s)
52
+ end
53
+ allow(PWN::Plugins::BinaryParser).to receive(:triage).and_return(file_type: 'elf', protections: { nx: true })
54
+ allow(PWN::Plugins::ExploitDev).to receive(:from_crash).and_return(offset: 64)
55
+ row = described_class.crash_triage(out_dir: dir, target: '/bin/true', handoff: true)
56
+ expect(row[:unique].length).to eq(2)
57
+ expect(row[:triaged].length).to eq(2)
58
+ expect(row[:triaged]).to all(include(:backtrace_hash, :crash, :binary_triage))
59
+ expect(row[:pipeline]).to eq('pwn-re-003')
60
+ end
61
+ end
22
62
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'tmpdir'
7
+
8
+ describe PWN::Plugins::AISandbox do
9
+ it 'should display information for authors' do
10
+ expect(described_class).to respond_to :authors
11
+ end
12
+
13
+ it 'should display information for existing help method' do
14
+ expect(described_class).to respond_to :help
15
+ end
16
+
17
+ it 'denies rm -rf /tmp/fake_root in strict mode without touching disk' do
18
+ root = '/tmp/fake_root'
19
+ FileUtils.mkdir_p(root)
20
+ marker = File.join(root, 'keep')
21
+ File.write(marker, 'safe')
22
+ begin
23
+ out = described_class.exec(kind: :shell, payload: 'rm -rf /tmp/fake_root', mode: 'strict')
24
+ expect(out[:error].to_s).to match(/sandbox violation/i)
25
+ expect(File.file?(marker)).to be true
26
+ ensure
27
+ FileUtils.rm_rf(root)
28
+ end
29
+ end
30
+
31
+ it 'denies FileUtils.rm_rf("/tmp/fake_root") in strict mode without touching disk' do
32
+ root = '/tmp/fake_root'
33
+ FileUtils.mkdir_p(root)
34
+ marker = File.join(root, 'keep')
35
+ File.write(marker, 'safe')
36
+ begin
37
+ out = described_class.exec(kind: :ruby, payload: "FileUtils.rm_rf('/tmp/fake_root')", mode: 'strict')
38
+ expect(out[:error].to_s).to match(/sandbox violation/i)
39
+ expect(File.file?(marker)).to be true
40
+ ensure
41
+ FileUtils.rm_rf(root)
42
+ end
43
+ end
44
+ end
@@ -12,6 +12,120 @@ describe PWN::Plugins::ArtifactRegistry do
12
12
  expect(described_class).to respond_to :help
13
13
  end
14
14
 
15
+ describe 'spill' do
16
+ before do
17
+ @artifact_dir = Dir.mktmpdir
18
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(@artifact_dir, 'artifacts'))
19
+ end
20
+
21
+ after do
22
+ FileUtils.remove_entry(@artifact_dir)
23
+ end
24
+
25
+ it 'stores exact bytes privately under a session and short digest' do
26
+ bytes = "\x00\xffhello".b
27
+ stored = described_class.spill(bytes: bytes, session_id: 'session-1')
28
+ expect(stored).to include(handle: "session-1/#{Digest::SHA256.hexdigest(bytes)[0, 8]}.bin", bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes))
29
+ expect(File.binread(stored[:path])).to eq(bytes)
30
+ expect(File.stat(stored[:path]).mode & 0o777).to eq(0o600)
31
+ expect(File.stat(File.dirname(stored[:path])).mode & 0o777).to eq(0o700)
32
+ expect(File.stat(described_class::ROOT).mode & 0o777).to eq(0o700)
33
+ expect(described_class.spill(bytes: bytes, session_id: 'session-1')).to eq(stored)
34
+ end
35
+
36
+ it 'pages 50 MiB losslessly with bounded reads and a streaming digest' do
37
+ bytes = (0..255).to_a.pack('C*') * (50 * 1024 * 1024 / 256)
38
+ stored = described_class.spill(bytes: bytes, session_id: 'large')
39
+ expected = Digest::SHA256.hexdigest(bytes)
40
+ digest = Digest::SHA256.new
41
+ offset = 0
42
+ pages = 0
43
+ loop do
44
+ page = described_class.read_page(handle: stored[:handle], offset: offset, length: 1_000_000)
45
+ expect(page[:bytes]).to be <= 2048
46
+ expect(page).to include(handle: stored[:handle], total_bytes: bytes.bytesize, encoding: 'base64', mode: 'base64')
47
+ decoded = Base64.strict_decode64(page[:body])
48
+ digest.update(decoded)
49
+ offset = page[:next_offset]
50
+ pages += 1
51
+ break if page[:eof]
52
+ end
53
+ expect(offset).to eq(bytes.bytesize)
54
+ expect(digest.hexdigest).to eq(expected)
55
+ puts "artifact paging verified: bytes=#{offset} pages=#{pages} sha256=#{digest.hexdigest}"
56
+ end
57
+
58
+ it 'preserves split UTF-8 bytes, explicit encodings, refs and EOF metadata' do
59
+ stored = described_class.spill(bytes: 'éabc', session_id: 'text')
60
+ first = described_class.read_page(handle: stored[:handle], length: 1)
61
+ expect(Base64.strict_decode64(first[:body])).to eq("\xc3".b)
62
+ expect(described_class.read_page(ref: stored[:path], offset: 2, length: 99, max_length: 2)).to include(body: 'ab', bytes: 2, eof: false, next_offset: 4)
63
+ expect(described_class.read_page(path: stored[:path], mode: 'hex', length: 2)).to include(hex: 'c3a9', body: 'c3a9')
64
+ expect(described_class.read_page(path: stored[:path], mode: 'base64', offset: 99)).to include(body: '', bytes: 0, eof: true, next_offset: 99)
65
+ expect { described_class.read_page(handle: '../text/12345678.bin') }.to raise_error(ArgumentError)
66
+ expect { described_class.read_page(path: stored[:path], offset: -1) }.to raise_error(ArgumentError)
67
+ expect { described_class.read_page(path: stored[:path], sha256: 'bad') }.to raise_error(/sha256 mismatch/)
68
+ end
69
+
70
+ it 'bounds get reads rather than loading the entire file' do
71
+ stored = described_class.spill(bytes: 'x' * 100_000, session_id: 'get')
72
+ expect(File).to receive(:binread).with(stored[:path], 65_536).and_call_original
73
+ expect(described_class.get(path: stored[:path])[:body].bytesize).to eq(65_536)
74
+ end
75
+
76
+ it 'greps complete lines across page boundaries with bounded previews and resumable byte offsets' do
77
+ bytes = "first\n#{'x' * 2046}BOUNDARY#{'y' * 2_100_000}\nlast\xff\n".b
78
+ stored = described_class.spill(bytes: bytes, session_id: 'grep')
79
+ first = described_class.grep(handle: stored[:handle], regex: 'x', limit: 1)
80
+ expect(first[:matches].first).to include(offset: 6, fragment_offset: 6, bytes: 2048, continued: true)
81
+ expect(first).to include(next_offset: bytes.index('last'), eof: false, semantics: 'binary_lines')
82
+ offsets = []
83
+ cursor = 0
84
+ loop do
85
+ result = described_class.grep(handle: stored[:handle], regex: 'last', offset: cursor)
86
+
87
+ offsets.concat(result[:matches].map { |match| match[:offset] })
88
+ expect(result[:next_offset]).to be > cursor
89
+ cursor = result[:next_offset]
90
+ break if result[:eof]
91
+ end
92
+ expect(offsets).to eq([bytes.index('last')])
93
+ boundary = described_class.grep(handle: stored[:handle], regex: 'BOUNDARY')
94
+ expect(boundary[:matches].first[:offset]).to eq(bytes.index('BOUNDARY'))
95
+ bounded = described_class.grep(handle: stored[:handle], regex: 'BOUNDARY', max_bytes: 32)
96
+ expect(bounded[:matches].first[:text]).to include('BOUNDARY')
97
+ expect(bounded[:matches].sum { |hit| hit[:bytes] }).to be <= 32
98
+ last = described_class.grep(path: stored[:path], regex: 'last', offset: bytes.index('last'))
99
+ expect(Base64.strict_decode64(last[:matches].first[:text])).to eq("last\xff\n".b)
100
+ end
101
+
102
+ it 'caps grep match counts and regex runtime and rejects invalid regexes' do
103
+ stored = described_class.spill(bytes: "hit\n" * 100, session_id: 'grep-limit')
104
+ allow(Regexp).to receive(:new).and_call_original
105
+ expect(Regexp).to receive(:new).with('hit'.b, Regexp::NOENCODING, timeout: 0.05).and_call_original
106
+ result = described_class.grep(handle: stored[:handle], regex: 'hit', limit: 999)
107
+ expect(result[:matches].length).to eq(50)
108
+ expect(result[:next_offset]).to eq(200)
109
+ expect { described_class.grep(handle: stored[:handle], regex: '[') }.to raise_error(RegexpError)
110
+ expect { described_class.grep(handle: stored[:handle], regex: 'hit', offset: -1) }.to raise_error(ArgumentError)
111
+ end
112
+
113
+ it 'interrupts expensive regexes on a giant unterminated line' do
114
+ stored = described_class.spill(bytes: "#{'a' * 2047}!", session_id: 'timeout')
115
+ expect { described_class.grep(handle: stored[:handle], regex: '(a+)\\1+$') }.to raise_error(Regexp::TimeoutError)
116
+ end
117
+
118
+ it 'rejects traversal, symlink directories, and short digest collisions' do
119
+ expect { described_class.spill(bytes: 'x', session_id: '../escape') }.to raise_error(ArgumentError)
120
+ stored = described_class.spill(bytes: 'x', session_id: 'safe')
121
+ File.binwrite(stored[:path], 'different')
122
+ expect { described_class.spill(bytes: 'x', session_id: 'safe') }.to raise_error(/collision/)
123
+ expect(File.binread(stored[:path])).to eq('different')
124
+ File.symlink(File.dirname(stored[:path]), File.join(described_class::ROOT, 'link'))
125
+ expect { described_class.spill(bytes: 'x', session_id: 'link') }.to raise_error(/symlink/)
126
+ end
127
+ end
128
+
15
129
  it 'lists artifacts for a session id' do
16
130
  Dir.mktmpdir do |dir|
17
131
  stub_const('PWN::Plugins::ArtifactRegistry::ROOT', dir)
@@ -28,7 +142,8 @@ describe PWN::Plugins::ArtifactRegistry do
28
142
  src = File.join(dir, 'dump.txt')
29
143
  File.write(src, "nop\ncall system\nret\n")
30
144
  hits = described_class.read_page(path: src, grep: 'call.*system')
31
- expect(hits[:matches].first[:text]).to include('call system')
145
+ expect(hits[:matches].first[:text]).to eq('call system')
146
+ expect(hits[:matches].first[:line]).to eq(2)
32
147
  end
33
148
  end
34
149
 
@@ -12,4 +12,16 @@ describe PWN::Plugins::Assembly do
12
12
  help_response = PWN::Plugins::Assembly
13
13
  expect(help_response).to respond_to :help
14
14
  end
15
+
16
+ it 'assemble and disassemble return structured engine output' do
17
+ asm = described_class.assemble(asm: "nop\nret", arch: 'x86_64')
18
+ expect(asm).to include(:bytes, :hex, :engine)
19
+ expect(asm[:bytes].bytesize).to be >= 2
20
+ expect(%w[keystone metasm]).to include(asm[:engine].to_s)
21
+ dis = described_class.disassemble(bytes: asm[:bytes], arch: 'x86_64')
22
+ expect(dis[:insns]).to be_an(Array)
23
+ blob = dis[:insns].map { |row| "#{row[:mnemonic]} #{row[:op_str]}" }.join(' ')
24
+ expect(blob).to match(/nop/i)
25
+ expect(%w[capstone metasm]).to include(dis[:engine].to_s)
26
+ end
15
27
  end
@@ -12,4 +12,19 @@ describe PWN::Plugins::BasicAuth do
12
12
  help_response = PWN::Plugins::BasicAuth
13
13
  expect(help_response).to respond_to :help
14
14
  end
15
+
16
+ it 'offers recon loot when a service prompts for auth' do
17
+ Dir.mktmpdir('pwn-basic-auth-') do |dir|
18
+ allow(Dir).to receive(:home).and_return(dir)
19
+ PWN::Plugins::Vault.ingest(
20
+ text: "username=admin\npassword=hunter2\n",
21
+ host: 'app.example.test',
22
+ source: 'recon',
23
+ where: 'banner:80',
24
+ engagement: 'lab'
25
+ )
26
+ encoded = described_class.encode(host: 'app.example.test', engagement: 'lab', service: 'http')
27
+ expect(described_class.decode(base64_str: encoded)).to eq('admin:hunter2')
28
+ end
29
+ end
15
30
  end
@@ -25,11 +25,68 @@ describe PWN::Plugins::BinaryParser do
25
25
  skip 'no ELF on PATH' unless path
26
26
  Dir.mktmpdir do |dir|
27
27
  allow(Dir).to receive(:home).and_return(dir)
28
- first = described_class.triage(path: path)
28
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
29
+ first = described_class.triage(path: path, session_id: 'fixture')
29
30
  expect(first).to include(:format, :arch, :protections, :sha256, :sections)
30
31
  expect(first[:cached]).to be false
31
- second = described_class.triage(path: path)
32
+ second = described_class.triage(path: path, session_id: 'fixture')
32
33
  expect(second[:cached]).to be true
33
34
  end
34
35
  end
36
+
37
+ it 'surfaces mitigations and attack surface for a stripped ELF in one JSON artifact' do
38
+ Dir.mktmpdir('pwn-triage-') do |dir|
39
+ allow(Dir).to receive(:home).and_return(dir)
40
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
41
+ source = File.join(dir, 'fixture.c')
42
+ path = File.join(dir, 'fixture')
43
+ File.write(source, <<~C)
44
+ #include <stdio.h>
45
+ int main(void) {
46
+ puts("https://example.test/api");
47
+ puts("/var/lib/pwn/keys");
48
+ puts("BEGIN RSA PRIVATE KEY");
49
+ printf("%s %n\\n", "fmt");
50
+ return 0;
51
+ }
52
+ C
53
+ out, status = Open3.capture2e('cc', '-O0', '-g', '-fstack-protector-all', '-o', path, source)
54
+ expect(status.success?).to eq(true), out
55
+ Open3.capture2e('strip', '--strip-all', path)
56
+ row = described_class.triage(path: path, session_id: 'fixture')
57
+ expect(row[:file_type] || row[:format]).to match(/elf/i)
58
+ expect(row[:arch].to_s).not_to be_empty
59
+ expect(row[:linking].to_s).to match(/dynamic|static/)
60
+ expect(row[:protections]).to include(:nx, :pie, :relro, :canary, :cfi)
61
+ expect(row[:protections].values).to all(satisfy { |value| [true, false].include?(value) || value.is_a?(String) })
62
+ expect(row[:stripped]).to eq(true)
63
+ expect(Array(row[:imports]).any? { |name| name.to_s.include?('puts') || name.to_s.include?('printf') || name.to_s.include?('libc') }).to eq(true)
64
+ strings = row[:interesting_strings]
65
+ blob = JSON.generate(strings)
66
+ expect(blob).to include('https://example.test/api')
67
+ expect(blob).to match(%r{/var/lib/pwn/keys})
68
+ expect(blob).to include('%n')
69
+ expect(row[:entropy]).to be_a(Hash)
70
+ expect(row[:entropy].keys).not_to be_empty
71
+ expect(row[:packer]).to include(:packed, :indicators)
72
+ expect(row[:handle].to_s).to match(%r{\Afixture/[0-9a-f]{8}\.bin\z})
73
+ stored = JSON.parse(File.binread(row[:artifact][:path] || File.join(dir, 'artifacts', row[:handle])), symbolize_names: true)
74
+ expect(stored[:sha256]).to eq(row[:sha256])
75
+ expect(stored[:protections]).to include(:canary)
76
+ end
77
+ end
78
+
79
+ it 'elf_resolve maps symbols, GOT, and PLT on a compiled ELF' do
80
+ Dir.mktmpdir('pwn-elf-resolve-') do |dir|
81
+ src = File.join(dir, 't.c')
82
+ path = File.join(dir, 't')
83
+ File.write(src, "#include <stdio.h>\n#include <stdlib.h>\nint main(void) { puts(\"hi\"); if (0) system(\"x\"); return 0; }\n")
84
+ out, status = Open3.capture2e('cc', '-O0', '-no-pie', '-o', path, src)
85
+ expect(status.success?).to eq(true), out
86
+ row = described_class.elf_resolve(path: path)
87
+ expect(row[:plt].keys.map(&:to_s)).to include('puts')
88
+ expect(row[:got].keys.map(&:to_s)).to include('puts')
89
+ expect(row[:symbols]['main'] || row[:symbols][:main]).to be_a(Integer)
90
+ end
91
+ end
35
92
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'open3'
4
6
 
5
7
  describe PWN::Plugins::ExploitDev do
6
8
  it 'should display information for authors' do
@@ -43,4 +45,46 @@ describe PWN::Plugins::ExploitDev do
43
45
  expect(row).to include(:path)
44
46
  expect(row[:symbols]).to be_a(Hash)
45
47
  end
48
+
49
+ it 'gadgets returns queryable records parsed from ropper/rp-style output' do
50
+ allow(PWN::Plugins::ROP).to receive(:gadgets).and_return(
51
+ backend: 'ropper',
52
+ gadgets: [
53
+ { address: 0x40101a, gadget: 'pop rdi; ret', regs_clobbered: %w[rdi rsp rip] },
54
+ { address: 0x40101c, gadget: 'ret', regs_clobbered: %w[rsp rip] }
55
+ ]
56
+ )
57
+ row = described_class.gadgets(path: '/bin/true', filter: 'pop rdi')
58
+ expect(row[:gadgets]).to be_an(Array)
59
+ expect(row[:gadgets]).to all(include(:address, :gadget))
60
+ expect(row[:gadgets].map { |g| g[:gadget] }).to include(a_string_matching(/pop rdi/i))
61
+ end
62
+
63
+ it 'ret2libc builds a packed chain from ELF resolve + pop rdi' do
64
+ Dir.mktmpdir('pwn-ret2libc-') do |dir|
65
+ src = File.join(dir, 'vuln.c')
66
+ bin = File.join(dir, 'vuln')
67
+ File.write(src, <<~C)
68
+ #include <stdio.h>
69
+ #include <stdlib.h>
70
+ #include <unistd.h>
71
+ char cmd[] = "echo RET2LIBC_OK";
72
+ void keep(void) { __asm__ volatile("pop %rdi; ret"); }
73
+ int main(int argc, char **argv) {
74
+ char buf[64];
75
+ (void)argv;
76
+ if (argc > 100) { system(cmd); keep(); }
77
+ read(0, buf, 256);
78
+ return 0;
79
+ }
80
+ C
81
+ out, status = Open3.capture2e('cc', '-O0', '-fno-stack-protector', '-no-pie', '-o', bin, src)
82
+ expect(status.success?).to eq(true), out
83
+ chain = described_class.ret2libc(path: bin, offset: 72, extra: 'cmd')
84
+ expect(chain[:payload].bytesize).to be > 72
85
+ expect(chain[:system]).to be_a(Integer)
86
+ expect(chain[:binsh] || chain[:arg]).to be_a(Integer)
87
+ expect(chain[:pop_rdi]).to be_a(Integer)
88
+ end
89
+ end
46
90
  end
@@ -8,6 +8,7 @@ RSpec.describe PWN::Plugins::Findings do
8
8
  @dir = Dir.mktmpdir
9
9
  allow(Dir).to receive(:home).and_return(@dir)
10
10
  stub_const('PWN::Plugins::Findings::FILE', File.join(@dir, 'findings.jsonl'))
11
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(@dir, 'artifacts'))
11
12
  @evidence = File.join(@dir, 'response.txt')
12
13
  File.write(@evidence, 'Local fixture returned the reproducible response.')
13
14
  end
@@ -30,6 +31,56 @@ RSpec.describe PWN::Plugins::Findings do
30
31
  expect(described_class.report(engagement_id: 'other')).to eq([])
31
32
  end
32
33
 
34
+ it 'persists directed enables links and replaces outgoing links durably' do
35
+ target = described_class.record_structured(finding)
36
+ source = described_class.record_structured(finding.merge(enables: [target[:id]]))
37
+ expect(target[:enables]).to eq([])
38
+ expect(described_class.report.find { |row| row[:id] == source[:id] }[:enables]).to eq([target[:id]])
39
+ expect(described_class.link(id: source[:id], enables: [])[:enables]).to eq([])
40
+ expect(described_class.report.find { |row| row[:id] == source[:id] }[:enables]).to eq([])
41
+ end
42
+
43
+ it 'rejects invalid outgoing links and cycles including legacy incoming links without mutation' do
44
+ a = described_class.record_structured(finding)
45
+ b = described_class.record_structured(finding.merge(attack_chain_refs: [a[:id]]))
46
+ other = described_class.record_structured(finding.merge(engagement_id: 'other'))
47
+ before = File.read(described_class::FILE)
48
+ [nil, 'bad', [123], ['missing'], [a[:id]], [b[:id], b[:id]], [other[:id]]].each do |enables|
49
+ expect { described_class.link(id: a[:id], enables: enables) }.to raise_error(ArgumentError)
50
+ end
51
+ expect { described_class.link(id: b[:id], enables: [a[:id]]) }.to raise_error(ArgumentError, /cycle/)
52
+ expect { described_class.record_structured(finding.merge(enables: [b[:id]], attack_chain_refs: [b[:id]])) }.to raise_error(ArgumentError, /cycle/)
53
+ expect { described_class.record_structured(finding.merge(enables: [other[:id]])) }.to raise_error(ArgumentError, /engagement/)
54
+ expect { described_class.record_structured(finding.merge(enables: [b[:id], b[:id]])) }.to raise_error(ArgumentError, /duplicate/)
55
+ expect(File.read(described_class::FILE)).to eq(before)
56
+ end
57
+
58
+ it 'treats empty and default engagement identifiers as the same graph scope' do
59
+ a = described_class.record_structured(finding.merge(engagement_id: ''))
60
+ b = described_class.record_structured(finding.merge(engagement_id: 'default', enables: [a[:id]]))
61
+ c = described_class.record_structured(finding.merge(engagement_id: 'default', attack_chain_refs: [a[:id]]))
62
+ expect(c[:attack_chain_refs]).to eq([a[:id]])
63
+ expect(b[:enables]).to eq([a[:id]])
64
+ expect { described_class.link(id: a[:id], enables: [b[:id]]) }.to raise_error(ArgumentError, /cycle/)
65
+ end
66
+
67
+ it 'links artifact handles to durable evidence hashes, reproduction steps and severity justification' do
68
+ stored = PWN::Plugins::ArtifactRegistry.spill(bytes: "crash\x00\xff".b, session_id: 'fixture')
69
+ args = finding.except(:evidence_paths).merge(
70
+ artifact_handles: [{ handle: stored[:handle], kind: 'crash', label: 'crash.bin' }],
71
+ severity_justification: 'The fixture leaks data without authentication; no integrity or availability impact demonstrated.',
72
+ reproduction_steps: ['Start the local fixture.', 'Run the supplied PoC and compare its output.']
73
+ )
74
+ row = described_class.record_structured(args)
75
+ expect(row[:severity_justification]).to eq(args[:severity_justification])
76
+ expect(row[:reproduction_steps]).to eq(args[:reproduction_steps])
77
+ evidence = row.fetch(:evidence_artifacts).first
78
+ expect(evidence).to include(handle: stored[:handle], path: stored[:path], sha256: stored[:sha256], kind: 'crash')
79
+ expect(File.binread(evidence[:stored])).to eq("crash\x00\xff".b)
80
+ expect(described_class.report.first[:evidence_artifacts]).to eq(row[:evidence_artifacts])
81
+ expect(row[:verification_status]).to eq('not_executed')
82
+ end
83
+
33
84
  it 'flags removed evidence and rejects malformed metrics with trailing tokens' do
34
85
  described_class.record_structured(finding)
35
86
  File.delete(@evidence)
@@ -38,6 +89,50 @@ RSpec.describe PWN::Plugins::Findings do
38
89
  expect { described_class.record_structured(finding.merge(cvss_vector: finding[:cvss_vector].sub('AV:N', 'AV:N:extra'))) }.to raise_error(ArgumentError, /cvss_vector/)
39
90
  end
40
91
 
92
+ it 'detects tampering of the durable copy even when the original evidence still matches' do
93
+ row = described_class.record_structured(finding)
94
+ stored = row[:evidence_artifacts].first[:stored]
95
+ File.binwrite(stored, 'corrupted stored bytes')
96
+ expect(described_class.evidence_verify(engagement_id: 'fixture')).to include(ok: false, mismatches: include(stored))
97
+ end
98
+
99
+ it 'refuses a symlink masquerading as a durable evidence copy' do
100
+ sha = Digest::SHA256.file(@evidence).hexdigest
101
+ stored = File.join(@dir, '.pwn', 'engagements', 'fixture', 'evidence', sha)
102
+ FileUtils.mkdir_p(File.dirname(stored))
103
+ File.symlink(@evidence, stored)
104
+ expect { described_class.record_structured(finding) }.to raise_error(ArgumentError, /symlink/)
105
+ expect(described_class.report).to eq([])
106
+ end
107
+
108
+ it 'rejects invalid, missing, symlinked or changed handles without recording a finding' do
109
+ artifact = PWN::Plugins::ArtifactRegistry.spill(bytes: 'original', session_id: 'fixture')
110
+ args = finding.except(:evidence_paths).merge(artifact_handles: [artifact[:handle]], severity_justification: 'Read-only exposure.')
111
+ ['../fixture/00000000.bin', 'fixture/00000000.bin', "#{artifact[:handle]}\n"].each do |handle|
112
+ expect { described_class.record_structured(args.merge(artifact_handles: [handle])) }.to raise_error(ArgumentError, /handle/)
113
+ end
114
+ File.unlink(artifact[:path])
115
+ File.symlink(@evidence, artifact[:path])
116
+ expect { described_class.record_structured(args) }.to raise_error(ArgumentError, /symlink/)
117
+ File.unlink(artifact[:path])
118
+ File.binwrite(artifact[:path], 'changed')
119
+ expect { described_class.record_structured(args) }.to raise_error(ArgumentError, /digest mismatch/)
120
+ expect(described_class.report).to eq([])
121
+ end
122
+
123
+ it 'accepts legacy full hash handles and preserves an expected digest supplied with a descriptor' do
124
+ artifact = PWN::Plugins::ArtifactRegistry.put(bytes: 'puts :fixture')
125
+ args = finding.except(:evidence_paths).merge(artifact_handles: [artifact[:sha256]], severity_justification: 'Read-only exposure.')
126
+ row = described_class.record_structured(args)
127
+ expect(row[:evidence_artifacts].first).to include(handle: artifact[:sha256], sha256: artifact[:sha256])
128
+ descriptor = { handle: artifact[:handle], sha256: '0' * 64 }
129
+ expect { described_class.record_structured(args.merge(artifact_handles: [descriptor])) }.to raise_error(ArgumentError, /sha256 mismatch/)
130
+ expect { described_class.record_structured(args.merge(severity_justification: ' ')) }.to raise_error(ArgumentError, /severity_justification/)
131
+ [[], [''], 'run a command', [123]].each do |steps|
132
+ expect { described_class.record_structured(args.merge(reproduction_steps: steps)) }.to raise_error(ArgumentError, /reproduction_steps/)
133
+ end
134
+ end
135
+
41
136
  it 'rejects a score inconsistent with the vector and unsafe engagement paths' do
42
137
  expect { described_class.record_structured(finding.merge(cvss_score: 9.8)) }.to raise_error(ArgumentError, /cvss_score/)
43
138
  expect { described_class.record_structured(finding.merge(engagement_id: '../escape')) }.to raise_error(ArgumentError, /engagement_id/)
@@ -78,11 +173,111 @@ RSpec.describe PWN::Plugins::Findings do
78
173
  File.write(response, "HTTP/1.1 200 OK\n\nuid=0(root) fixture")
79
174
  verified = described_class.verify(id: row[:id], kind: 'http', request_path: request, response_path: response, impact: 'uid=0')
80
175
  expect(verified[:verification_status]).to eq('reproduced')
176
+ expect(verified[:evidence_artifacts].map { |artifact| artifact[:path] }).to include(request, response)
177
+ expect(verified[:evidence_artifacts].all? { |artifact| Digest::SHA256.file(artifact[:stored]).hexdigest == artifact[:sha256] }).to be(true)
81
178
  expect(described_class.report(engagement_id: 'fixture').first[:verification_status]).to eq('reproduced')
82
179
  File.write(response, "HTTP/1.1 403 Forbidden\n\ndenied")
83
180
  retest = described_class.retest(id: row[:id], kind: 'http', request_path: request, response_path: response, impact: 'uid=0')
84
181
  expect(retest[:verification_status]).to eq('fixed')
85
182
  expect(retest[:status]).to eq('closed')
183
+ responses = retest[:evidence_artifacts].select { |artifact| artifact[:path] == response }
184
+ expect(responses.map { |artifact| artifact[:sha256] }.uniq.length).to eq(2)
185
+ end
186
+
187
+ it 'persists path-scoped impact assessments with actual evidence and only consecutive directed edges' do
188
+ a = described_class.record_structured(finding.merge(title: 'SSRF', cwe: 'CWE-918'))
189
+ b = described_class.record_structured(finding.merge(title: 'Internal admin'))
190
+ c = described_class.record_structured(finding.merge(title: 'Command execution'))
191
+ d = described_class.record_structured(finding.merge(title: 'Alternate entry'))
192
+ ids = [a[:id], b[:id], c[:id]]
193
+ path = File.join(@dir, 'chain.txt')
194
+ text = "Fixture replay #{ids.join(' -> ')}: SSRF reaches internal admin and executes the fixture command."
195
+ File.write(path, text)
196
+ result = described_class.chain_impact(ids: ids, combined_impact_path: path, escalate: true, combined_severity: 'critical')
197
+ expect(result[:rationale]).to eq(text)
198
+ rows = described_class.report.to_h { |row| [row[:id], row] }
199
+ expect(rows[a[:id]][:enables]).to eq([b[:id]])
200
+ expect(rows[b[:id]][:enables]).to eq([c[:id]])
201
+ expect(rows[c[:id]][:attack_chain_refs]).to eq([])
202
+ assessment = rows[c[:id]].fetch(:chain_assessments).first
203
+ expect(assessment).to include(finding_ids: ids, combined_severity: 'critical', rationale: text,
204
+ reproduction_steps: [a[:poc], b[:poc], c[:poc]])
205
+ artifact = assessment[:evidence_artifacts].first
206
+ expect(artifact).to include(path: path, sha256: Digest::SHA256.file(path).hexdigest, size: File.size(path))
207
+ expect(File.read(artifact[:stored])).to eq(text)
208
+ alternate = File.join(@dir, 'alternate.txt')
209
+ File.write(alternate, "Fixture replay #{d[:id]} -> #{c[:id]} demonstrates read-only exposure, not command execution.")
210
+ described_class.chain_impact(ids: [d[:id], c[:id]], combined_impact_path: alternate, escalate: true, combined_severity: 'high',
211
+ severity_justification: 'Read-only internal exposure.', reproduction_steps: ['Replay alternate fixture.'])
212
+ described_class.chain_impact(ids: ids, combined_impact_path: path, escalate: true, combined_severity: 'critical',
213
+ severity_justification: 'Command execution proven in fixture.')
214
+ assessments = described_class.report.find { |row| row[:id] == c[:id] }[:chain_assessments]
215
+ expect(assessments.length).to eq(2)
216
+ expect(assessments.find { |item| item[:finding_ids] == ids }[:rationale]).to eq('Command execution proven in fixture.')
217
+ expect(assessments.find { |item| item[:finding_ids] == [d[:id], c[:id]] }).to include(combined_severity: 'high', reproduction_steps: ['Replay alternate fixture.'])
218
+ expect(described_class.chain_score(ids: ids)[:combined_severity]).to eq('critical')
219
+ expect(described_class.chain_score(ids: [d[:id], c[:id]])[:combined_severity]).to eq('high')
220
+ expect(described_class.chain_score(ids: [b[:id], c[:id]])[:combined_severity]).to eq('medium')
221
+ expect(described_class.chain_score(ids: ids.reverse)[:combined_severity]).to eq('medium')
222
+ end
223
+
224
+ it 'scores exact requested paths through the canonical graph without unrelated assessment uplift' do
225
+ a = described_class.record_structured(finding.merge(title: 'SSRF', cwe: 'CWE-918'))
226
+ b = described_class.record_structured(finding.merge(title: 'Internal admin'))
227
+ other = described_class.record_structured(finding.merge(title: 'Other entry'))
228
+ path = File.join(@dir, 'impact.txt')
229
+ text = "Fixture replay #{a[:id]} -> #{b[:id]} proves SSRF reaches internal admin and executes a command."
230
+ File.write(path, text)
231
+ described_class.chain_impact(ids: [a[:id], b[:id]], combined_impact_path: path, escalate: true, combined_severity: 'critical')
232
+ described_class.link(id: other[:id], enables: [b[:id]])
233
+ allow(PWN::Reports).to receive(:attack_chains).and_call_original
234
+ expect(PWN::Reports).to receive(:attack_chains).with(findings: array_including(hash_including(id: a[:id]), hash_including(id: b[:id]))).at_least(:once).and_call_original
235
+ score = described_class.chain_score(ids: [a[:id], b[:id]])
236
+ expect(score).to include(score: 'critical', combined_severity: 'critical', n: 2, rationale: text)
237
+ expect(score[:chains].first).to include(finding_ids: [a[:id], b[:id]], assessment_status: 'evidence_backed')
238
+ expect(described_class.chain_score(chain_refs: [other[:id], b[:id]])).to include(score: 'medium', n: 2)
239
+ expect(described_class.chain_score(ids: [b[:id]])).to include(score: 'medium', n: 1)
240
+ expect(described_class.chain_score(ids: ['missing'])).to include(score: 'info', n: 0)
241
+ expect(described_class.chain_score[:combined_severity]).to eq('critical')
242
+ expect(described_class.report.map { |row| row[:severity] }.uniq).to eq(['medium'])
243
+ end
244
+
245
+ it 'rejects invalid impact paths before changing any finding or evidence ledger' do
246
+ a = described_class.record_structured(finding)
247
+ b = described_class.record_structured(finding.merge(enables: [a[:id]]))
248
+ other = described_class.record_structured(finding.merge(engagement_id: 'other'))
249
+ path = File.join(@dir, 'chain.txt')
250
+ File.write(path, "Fixture replay #{a[:id]} #{b[:id]} #{other[:id]} includes all IDs but does not waive graph validation.")
251
+ before = File.read(described_class::FILE)
252
+ ledger = File.join(@dir, '.pwn', 'engagements', 'fixture', 'evidence.jsonl')
253
+ evidence_before = File.read(ledger)
254
+ [[a[:id], a[:id]], [a[:id], other[:id]], [a[:id], b[:id]], ['missing', a[:id]]].each do |ids|
255
+ expect { described_class.chain_impact(ids: ids, combined_impact_path: path) }.to raise_error(ArgumentError)
256
+ end
257
+ expect(File.read(described_class::FILE)).to eq(before)
258
+ expect(File.read(ledger)).to eq(evidence_before)
259
+ end
260
+
261
+ it 'rejects reverse links against legacy chain_refs and validates evidence before persisting impact' do
262
+ a = described_class.record_structured(finding)
263
+ legacy = described_class.record(title: 'Legacy child', severity: 'medium', engagement_id: 'fixture',
264
+ poc_artifacts: [@evidence], chain_refs: [a[:id]],
265
+ evidence: 'This local fixture is retained as the legacy finding evidence.')
266
+ expect { described_class.link(id: legacy[:id], enables: [a[:id]]) }.to raise_error(ArgumentError, /cycle/)
267
+ expect { described_class.link(id: 'missing', enables: []) }.to raise_error(ArgumentError, /existing/)
268
+ path = File.join(@dir, 'impact-validation.txt')
269
+ args = { ids: [a[:id], legacy[:id]], combined_impact_path: path, escalate: true, combined_severity: 'critical' }
270
+ before = File.read(described_class::FILE)
271
+ expect { described_class.chain_impact(args) }.to raise_error(ArgumentError, /readable/)
272
+ ['', 'A long evidence file that still fails to mention either finding ID.'].each do |text|
273
+ File.write(path, text)
274
+ expect { described_class.chain_impact(args) }.to raise_error(ArgumentError, /name every/)
275
+ end
276
+ File.write(path, "Fixture #{a[:id]} -> #{legacy[:id]} demonstrates reproducible combined impact.")
277
+ expect { described_class.chain_impact(args.merge(severity_justification: ' ')) }.to raise_error(ArgumentError, /severity_justification/)
278
+ expect { described_class.chain_impact(args.merge(reproduction_steps: [])) }.to raise_error(ArgumentError, /reproduction_steps/)
279
+ expect { described_class.chain_impact(args.merge(combined_severity: 'unknown')) }.to raise_error(ArgumentError, /combined_severity/)
280
+ expect(File.read(described_class::FILE)).to eq(before)
86
281
  end
87
282
 
88
283
  it 'rejects signature-only verification and requires combined-impact evidence to escalate a chain' do
@@ -97,7 +292,7 @@ RSpec.describe PWN::Plugins::Findings do
97
292
  File.write(impact, "Chaining #{parent[:id]} with #{child[:id]} yields account takeover on the fixture.")
98
293
  chained = described_class.chain_impact(ids: [parent[:id], child[:id]], combined_impact_path: impact, escalate: true, combined_severity: 'high')
99
294
  expect(chained[:combined_severity]).to eq('high')
100
- expect(chained[:rationale]).to include('independently evidenced')
295
+ expect(chained[:rationale]).to eq(File.read(impact))
101
296
  gaps = described_class.issue_work_gaps(engagement_id: 'fixture')
102
297
  expect(gaps[:unverified]).to include(child[:id])
103
298
  expect(gaps[:unverified]).not_to include(parent[:id])