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,8 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'json'
6
+ require 'open3'
4
7
 
5
8
  describe PWN::Plugins::GDBMI do
9
+ def with_fake_gdb
10
+ Dir.mktmpdir('pwn-gdbmi-') do |dir|
11
+ fake = File.join(dir, 'gdb')
12
+ File.write(fake, <<~RUBY)
13
+ #!/usr/bin/env ruby
14
+ $stdout.sync = true
15
+ $stdin.binmode
16
+ $stdout.puts '=thread-group-added,id="i1"'
17
+ $stdout.puts '(gdb) '
18
+ loop do
19
+ line = $stdin.gets
20
+ break if line.nil?
21
+
22
+ cmd = line.to_s.strip
23
+ break if cmd == '-gdb-exit' || cmd == 'quit'
24
+
25
+ case cmd
26
+ when /^-break-insert /
27
+ $stdout.puts '^done,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x401000",func="main"}'
28
+ when /^-exec-run/, /^-exec-continue/, /^-interpreter-exec console/
29
+ $stdout.puts '^running'
30
+ $stdout.puts '*stopped,reason="signal-received",signal-name="SIGSEGV",frame={addr="0x41414141",func="vuln",offset="0"}'
31
+ when /^-data-list-register-values/
32
+ $stdout.puts '^done,register-values=[{number="16",name="rip",value="0x41414141"},{number="7",name="rsp",value="0x7fffffffe000"}]'
33
+ when /^-data-read-memory-bytes/
34
+ $stdout.puts '^done,memory=[{begin="0x41414141",offset="0",end="0x41414145",contents="41414141"}]'
35
+ when /^-stack-list-frames/
36
+ $stdout.puts '^done,stack=[frame={level="0",addr="0x41414141",func="vuln"},frame={level="1",addr="0x401200",func="main"}]'
37
+ when /si_addr/
38
+ $stdout.puts '^done,value="0x41414141"'
39
+ else
40
+ $stdout.puts '^done'
41
+ end
42
+ $stdout.puts '(gdb) '
43
+ end
44
+ RUBY
45
+ File.chmod(0o755, fake)
46
+ path = File.join(dir, 'fixture.bin')
47
+ File.binwrite(path, 'ELF')
48
+ original = ENV.fetch('PATH', '')
49
+ ENV['PATH'] = "#{dir}:#{original}"
50
+ begin
51
+ yield path
52
+ ensure
53
+ ENV['PATH'] = original
54
+ end
55
+ end
56
+ end
57
+
6
58
  it 'should display information for authors' do
7
59
  expect(described_class).to respond_to :authors
8
60
  end
@@ -12,6 +64,52 @@ describe PWN::Plugins::GDBMI do
12
64
  end
13
65
 
14
66
  it 'break requires a location' do
15
- expect { described_class.break(location: '') }.to raise_error(/location/)
67
+ expect { described_class.break(location: '') }.to raise_error(/location|addr/)
68
+ end
69
+
70
+ it 'is available as GDBMi and speaks mi3' do
71
+ expect(PWN::Plugins::GDBMi).to eq(described_class)
72
+ with_fake_gdb do |path|
73
+ sid = described_class.open(binary: path)
74
+ expect(described_class.session(session: sid)[:interpreter]).to eq('mi3')
75
+ described_class.close(session: sid)
76
+ end
77
+ end
78
+
79
+ it 'rejects injected breakpoint locations before spawning gdb' do
80
+ expect(Open3).not_to receive(:popen2)
81
+ expect { described_class.break(session: 'missing', location: 'main;!id') }.to raise_error(ArgumentError, /location|addr/)
82
+ end
83
+
84
+ it 'returns a structured crash report from run_to_crash' do
85
+ with_fake_gdb do |path|
86
+ crash = described_class.run_to_crash(binary: path, stdin: 'AAAA')
87
+ expect(crash).to include(signal: 'SIGSEGV', pc: '0x41414141', fault_addr: '0x41414141')
88
+ expect(crash[:exploitability]).to eq('pc_control')
89
+ expect(crash[:backtrace]).to be_an(Array)
90
+ expect(crash[:registers]).to include('rip' => '0x41414141')
91
+ end
92
+ end
93
+
94
+ it 'feeds ExploitDev a cyclic offset from the crash PC' do
95
+ payload = PWN::Plugins::ExploitDev.cyclic(length: 64)
96
+ crash = { signal: 'SIGSEGV', pc: '0x41414141', fault_addr: '0x41414141', exploitability: 'pc_control' }
97
+ row = PWN::Plugins::ExploitDev.from_crash(crash: crash, payload: payload)
98
+ expect(row[:offset]).to be_a(Integer)
99
+ expect(payload[row[:offset], 4]).to eq('AAAA')
100
+ end
101
+
102
+ it 'triages a compiled crashing binary to a structured report', :gdb_integration do
103
+ Dir.mktmpdir('pwn-gdb-real-') do |dir|
104
+ source = File.join(dir, 'crash.c')
105
+ path = File.join(dir, 'crash')
106
+ File.write(source, "int main(void) { *(volatile int *)0 = 1; return 0; }\n")
107
+ out, status = Open3.capture2e('cc', '-O0', '-g', '-fno-stack-protector', '-o', path, source)
108
+ expect(status.success?).to eq(true), out
109
+ crash = described_class.run_to_crash(binary: path)
110
+ expect(crash[:signal].to_s).to match(/SEGV/)
111
+ expect(crash[:pc].to_s).to match(/\A0x[0-9a-f]+\z/)
112
+ expect(crash).to include(:fault_addr, :exploitability)
113
+ end
16
114
  end
17
115
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ describe PWN::Plugins::Httpx do
8
+ it 'should display information for authors' do
9
+ expect(described_class).to respond_to :authors
10
+ end
11
+
12
+ it 'should display information for existing help method' do
13
+ expect(described_class).to respond_to :help
14
+ end
15
+
16
+ it 'parses httpx JSONL tech stack without a live probe' do
17
+ jsonl = "#{JSON.generate('url' => 'https://wp.example.test', 'status_code' => 200, 'title' => 'WordPress', 'tech' => %w[nginx PHP WordPress], 'webserver' => 'nginx')}\n"
18
+ row = described_class.probe(jsonl: jsonl)
19
+ expect(row[:hosts].first['url']).to include('wp.example.test')
20
+ expect(row[:techs].join).to match(/wordpress/i)
21
+ expect(row[:techs].join).to match(/nginx/i)
22
+ end
23
+ end
@@ -3,6 +3,147 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Plugins::Jobs do
6
+ before do
7
+ @root = Dir.mktmpdir('pwn-jobs-')
8
+ stub_const('PWN::Plugins::Jobs::JOBS_DIR', @root)
9
+ end
10
+
11
+ after do
12
+ FileUtils.remove_entry(@root)
13
+ end
14
+
15
+ def fresh(code)
16
+ setup = "require 'pwn'; PWN::Plugins::Jobs.send(:remove_const, :JOBS_DIR); PWN::Plugins::Jobs.const_set(:JOBS_DIR, #{described_class::JOBS_DIR.inspect}); "
17
+ out, err, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../../../../lib', __dir__), '-e', setup + code)
18
+ raise err unless status.success?
19
+
20
+ JSON.parse(out, symbolize_names: true)
21
+ end
22
+
23
+ def finished(id)
24
+ Timeout.timeout(8) do
25
+ loop do
26
+ row = described_class.status(id: id)
27
+ return row unless row[:status] == 'RUNNING'
28
+
29
+ sleep 0.05
30
+ end
31
+ end
32
+ end
33
+
34
+ it 'survives an exited creator and records completion in a fresh session' do
35
+ row = fresh("puts JSON.generate(PWN::Plugins::Jobs.start(command: 'sleep 2; printf durable', max_runtime: 21600, session_id: 'old'))")
36
+ expect(row[:status]).to eq('RUNNING')
37
+ expect(row[:worker_pid]).to be_a(Integer)
38
+ expect(row[:worker_identity]).not_to be_nil
39
+ worker_session = Process.getsid(row[:worker_pid])
40
+ expect(fresh("puts JSON.generate(PWN::Plugins::Jobs.status(id: '#{row[:id]}'))")[:status]).to eq('RUNNING')
41
+ done = finished(row[:id])
42
+ expect(worker_session).to eq(row[:worker_pid])
43
+ expect(done).to include(status: 'COMPLETED', exit_code: 0, max_runtime: 21_600, session_id: 'old')
44
+ expect(done[:finished_at]).not_to be_nil
45
+ expect(fresh("puts JSON.generate(PWN::Plugins::Jobs.result(id: '#{row[:id]}'))")[:tail]).to eq('durable')
46
+ end
47
+
48
+ it 'enforces timeout without polling and kills a TERM-resistant process tree' do
49
+ marker = File.join(@root, 'escaped')
50
+ row = described_class.start(command: "trap '' TERM; (sleep 2; touch #{marker}) & wait", max_runtime: 0.2)
51
+ sleep 2.5
52
+ persisted = JSON.parse(File.read(File.join(@root, "#{row[:id]}.json")), symbolize_names: true)
53
+ expect(persisted).to include(status: 'TIMEOUT', signal: 9)
54
+ expect(File.exist?(marker)).to be(false)
55
+ end
56
+
57
+ it 'cancels durably without directly signaling a persisted PID' do
58
+ row = described_class.start(command: "trap '' TERM; sleep 10", max_runtime: 10)
59
+ expect(described_class.stop(handle: row[:id])).to be(true)
60
+ expect(File.exist?(File.join(@root, "#{row[:id]}.cancel"))).to be(true)
61
+ expect(finished(row[:id])).to include(status: 'STOPPED', signal: 9)
62
+ expect(described_class.stop(id: row[:id])).to be(false)
63
+ end
64
+
65
+ it 'pages binary logs with byte offsets and distinguishes EOF from completion' do
66
+ row = described_class.start(command: "printf 'abc\\377def'; sleep 1", max_runtime: 5)
67
+ first = described_class.job_tail(handle: row[:id], offset: 0, length: 4)
68
+ expect(first).to include(bytes: 4, next_offset: 4, encoding: 'base64', eof: false, status: 'RUNNING')
69
+ expect(Base64.strict_decode64(first[:data])).to eq("abc\xFF".b)
70
+ last = described_class.tail(id: row[:id], offset: 4, length: 100)
71
+ expect(last).to include(body: 'def', data: 'def', bytes: 3, next_offset: 7, eof: true, status: 'RUNNING')
72
+ finished(row[:id])
73
+ expect(described_class.tail(id: row[:id], offset: 7)).to include(bytes: 0, eof: true, status: 'COMPLETED')
74
+ expect(described_class.tail(id: row[:id])).to be_a(String)
75
+ end
76
+
77
+ it 'filters and limits durable jobs across sessions' do
78
+ first = described_class.start(command: 'true', session_id: 'one')
79
+ finished(first[:id])
80
+ second = described_class.start(command: 'true', session_id: 'two')
81
+ finished(second[:id])
82
+ expect(described_class.list(session_id: 'one', limit: 1).map { |row| row[:id] }).to eq([first[:id]])
83
+ expect(described_class.list(limit: 1).size).to eq(1)
84
+ end
85
+
86
+ it 'records real nonzero exits, signals and spawn errors' do
87
+ row = described_class.start(command: 'exit 7')
88
+ expect(finished(row[:id])).to include(status: 'FAILED', exit_code: 7, signal: nil)
89
+ row = described_class.start(command: 'kill -TERM $$')
90
+ expect(finished(row[:id])).to include(status: 'FAILED', exit_code: nil, signal: 15)
91
+ row = described_class.start(command: 'true', cwd: File.join(@root, 'absent'))
92
+ expect(finished(row[:id])).to include(status: 'FAILED')
93
+ expect(finished(row[:id])[:error]).to include('ENOENT')
94
+ end
95
+
96
+ it 'reuses an idempotency key across concurrent fresh creators' do
97
+ command = "printf once >> #{@root}/runs"
98
+ code = "puts JSON.generate(PWN::Plugins::Jobs.start(command: #{command.inspect}, idempotency_key: 'retry'))"
99
+ rows = 2.times.map { Thread.new { fresh(code) } }.map(&:value)
100
+ expect(rows.map { |row| row[:id] }.uniq.size).to eq(1)
101
+ finished(rows.first[:id])
102
+ expect(File.read(File.join(@root, 'runs'))).to eq('once')
103
+ end
104
+
105
+ it 'marks a dead supervisor LOST and never signals a reused metadata PID' do
106
+ row = described_class.start(command: 'sleep 1', max_runtime: 5)
107
+ Process.kill('KILL', row[:worker_pid])
108
+ sleep 0.1
109
+ path = File.join(@root, "#{row[:id]}.json")
110
+ meta = JSON.parse(File.read(path))
111
+ meta['pid'] = Process.pid
112
+ File.write(path, JSON.generate(meta))
113
+ expect(described_class.status(id: row[:id])).to include(status: 'LOST')
114
+ expect(described_class.stop(id: row[:id])).to be(false)
115
+ expect(JSON.parse(File.read(path))['status']).to eq('LOST')
116
+ sleep 1
117
+ end
118
+
119
+ it 'honors cwd and string environment maps with private files and nil defaults' do
120
+ row = described_class.start(cmd: 'printf "$JOB_TEST:$PWD"', cwd: @root, env: { 'JOB_TEST' => 'value' }, max_runtime: nil, session_id: nil, idempotency_key: nil)
121
+ expect(finished(row[:id])).to include(max_runtime: 0)
122
+ expect(described_class.result(id: row[:id])[:tail]).to eq("value:#{@root}")
123
+ expect(File.stat(@root).mode & 0o777).to eq(0o700)
124
+ expect(File.stat(row[:log]).mode & 0o777).to eq(0o600)
125
+ expect(File.stat(File.join(@root, "#{row[:id]}.json")).mode & 0o777).to eq(0o600)
126
+ expect { described_class.status(handle: '../invalid') }.to raise_error(ArgumentError)
127
+ expect { described_class.start(command: 'true', env: { nope: 1 }) }.to raise_error(ArgumentError)
128
+ end
129
+
130
+ it 'preserves synchronous graph dependencies and drains skipped descendants' do
131
+ graph = described_class.graph(artifact_dir: @root, jobs: [
132
+ { id: 'a', command: 'exit 1' },
133
+ { id: 'b', command: 'true', needs: ['a'] },
134
+ { id: 'c', command: 'true', needs: ['b'] }
135
+ ])
136
+ expect(graph[:ok]).to be(false)
137
+ expect(graph[:jobs]['c'][:skipped]).to be(true)
138
+ end
139
+
140
+ it 'treats pre-supervisor metadata as unknown rather than inventing completion' do
141
+ id = '0123456789ab'
142
+ File.write(File.join(@root, "#{id}.json"), JSON.generate(id: id, pid: Process.pid, started_at: Time.now.utc.iso8601))
143
+ expect(described_class.status(id: id)).to include(status: 'LOST', alive: false)
144
+ expect(described_class.stop(id: id)).to be(false)
145
+ end
146
+
6
147
  it 'should display information for authors' do
7
148
  expect(described_class).to respond_to :authors
8
149
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+
6
+ describe PWN::Plugins::MethodCatalog do
7
+ it 'should display information for authors' do
8
+ expect(described_class).to respond_to :authors
9
+ end
10
+
11
+ it 'should display information for existing help method' do
12
+ expect(described_class).to respond_to :help
13
+ end
14
+
15
+ it 'builds a JSON schema with side-effect class from YARD-style plugin docs' do
16
+ row = described_class.schema(mod: 'PWN::Plugins::BasicAuth', method: 'encode')
17
+ expect(row[:parameters][:properties].keys.map(&:to_s)).to include('username', 'password')
18
+ expect(row[:side_effect].to_s).to match(/read_only|active_scan|exploit|destructive/)
19
+ expect(row[:parameters][:additionalProperties]).to eq(false)
20
+ end
21
+
22
+ it 'rejects an unknown keyword arg pre-execution and echoes the schema' do
23
+ out = described_class.guard_eval(code: "PWN::Plugins::BasicAuth.encode(not_a_key: 1, username: 'a')")
24
+ expect(out[:error].to_s).to match(/unknown keyword/i)
25
+ expect(out[:error].to_s).to include('not_a_key')
26
+ expect(out[:schema]).to be_a(Hash)
27
+ expect(out[:schema][:parameters] || out[:schema]['parameters']).not_to be_nil
28
+ end
29
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'fileutils'
4
6
 
5
7
  describe PWN::Plugins::NmapIt do
6
8
  it 'should display information for authors' do
@@ -23,4 +25,51 @@ describe PWN::Plugins::NmapIt do
23
25
  it 'to_findings returns [] for a missing xml file' do
24
26
  expect(described_class.to_findings(xml_file: '/tmp/no-such-nmap.xml')).to eq([])
25
27
  end
28
+
29
+ def write_nmap_xml(path, hosts)
30
+ body = +'<?xml version="1.0"?><nmaprun>'
31
+ hosts.each do |host|
32
+ body << %(<host><status state="up"/><address addr="#{host[:ip]}" addrtype="ipv4"/>)
33
+ body << '<ports>'
34
+ Array(host[:ports]).each do |port|
35
+ body << %(<port protocol="#{port[:proto] || 'tcp'}" portid="#{port[:id]}"><state state="#{port[:state] || 'open'}"/><service name="#{port[:service]}" version="#{port[:version]}"/>)
36
+ Array(port[:scripts]).each do |name, output|
37
+ body << %(<script id="#{name}" output="#{output}"/>)
38
+ end
39
+ body << '</port>'
40
+ end
41
+ body << '</ports>'
42
+ if host[:scripts]
43
+ body << '<hostscript>'
44
+ host[:scripts].each { |name, output| body << %(<script id="#{name}" output="#{output}"/>) }
45
+ body << '</hostscript>'
46
+ end
47
+ body << '</host>'
48
+ end
49
+ body << '</nmaprun>'
50
+ File.write(path, body)
51
+ end
52
+
53
+ it 'ingests XML into the engagement store and answers what changed since yesterday in one query' do
54
+ Dir.mktmpdir('pwn-nmap-store-') do |dir|
55
+ stub_const('PWN::AI::Agent::Engagement::ROOT', dir)
56
+ stub_const('PWN::AI::Agent::Engagement::ACTIVE_FILE', File.join(dir, 'active'))
57
+ PWN::Engagement.open(name: 'lab', scope_cidrs: ['10.0.0.0/8'])
58
+ yesterday = File.join(dir, 'yesterday.xml')
59
+ today = File.join(dir, 'today.xml')
60
+ write_nmap_xml(yesterday, [{ ip: '10.0.0.1', ports: [{ id: 22, service: 'ssh', version: 'OpenSSH' }], scripts: { 'smb-os-discovery' => 'Windows' } }])
61
+ write_nmap_xml(today, [{ ip: '10.0.0.1', ports: [{ id: 22, service: 'ssh', version: 'OpenSSH' }, { id: 80, service: 'http', scripts: { 'http-title' => 'Admin' } }] }])
62
+ first = described_class.scan(xml: yesterday, engagement: 'lab', override: true, at: Time.now.utc - (26 * 3600))
63
+ expect(first[:hosts].first[:host]).to eq('10.0.0.1')
64
+ expect(first[:hosts].first[:scripts].to_s).to include('smb-os-discovery')
65
+ stored = PWN::Engagement.hosts(name: 'lab')
66
+ expect(stored.values.first[:services]).to include('ssh')
67
+ second = described_class.scan(xml: today, engagement: 'lab', override: true, at: Time.now.utc)
68
+ expect(second[:diff][:added_ports].map { |row| row[:port] }).to include(80)
69
+ delta = described_class.changes(since: 'yesterday', engagement: 'lab')
70
+ expect(delta[:added_ports].map { |row| row[:port] }).to include(80)
71
+ expect(delta[:added_ports].map { |row| row[:port] }).not_to include(22)
72
+ expect(delta[:changed_scripts].any? { |row| row[:name].to_s.include?('http-title') || row[:after].to_s.include?('Admin') }).to eq(true)
73
+ end
74
+ end
26
75
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'json'
4
6
 
5
7
  describe PWN::Plugins::Nuclei do
6
8
  it 'should display information for authors' do
@@ -12,8 +14,38 @@ describe PWN::Plugins::Nuclei do
12
14
  end
13
15
 
14
16
  it 'to_findings maps JSONL rows into report-shaped hashes' do
15
- rows = [{ 'info' => { 'name' => 'xss', 'severity' => 'high' }, 'matched-at' => 'https://x/' }]
17
+ rows = [{ 'info' => { 'name' => 'xss', 'severity' => 'high' }, 'matched-at' => 'https://x/', 'template-id' => 'xss-generic' }]
16
18
  out = described_class.to_findings(rows: rows)
17
19
  expect(out.first).to include(:title, :severity, :url)
20
+ expect(out.first[:template_id] || out.first[:template]).to eq('xss-generic')
21
+ end
22
+
23
+ it 'ingests nuclei JSONL into the findings store with matched-at, template id, and severity' do
24
+ Dir.mktmpdir('pwn-nuclei-') do |dir|
25
+ stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
26
+ jsonl = File.join(dir, 'hits.jsonl')
27
+ File.write(jsonl, "#{JSON.generate(
28
+ 'template-id' => 'cve-2024-wordpress-rce',
29
+ 'matched-at' => 'https://wp.example.test/xmlrpc.php',
30
+ 'host' => 'https://wp.example.test',
31
+ 'info' => { 'name' => 'WordPress XML-RPC RCE', 'severity' => 'high', 'description' => 'RCE via xmlrpc' }
32
+ )}\n")
33
+ row = described_class.scan(jsonl: jsonl, record: true)
34
+ finding = Array(row[:findings]).first
35
+ expect(finding[:url] || finding[:matched_at]).to include('https://wp.example.test/xmlrpc.php')
36
+ expect(finding[:template_id] || finding[:template]).to eq('cve-2024-wordpress-rce')
37
+ expect(finding[:severity].to_s).to match(/high/i)
38
+ stored = PWN::Plugins::Findings.report
39
+ expect(stored.map { |item| item[:title] }.join).to include('WordPress')
40
+ expect(stored.first[:matched_at] || stored.first[:url]).to include('xmlrpc.php')
41
+ expect(stored.first[:template_id]).to eq('cve-2024-wordpress-rce')
42
+ end
43
+ end
44
+
45
+ it 'selects nuclei tags from httpx-detected tech stack' do
46
+ tags = described_class.select_templates(techs: %w[nginx WordPress PHP])
47
+ blob = Array(tags[:tags] || tags).join(',')
48
+ expect(blob).to match(/wordpress/i)
49
+ expect(blob).to match(/nginx/i)
18
50
  end
19
51
  end
@@ -1,8 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'json'
6
+ require 'open3'
4
7
 
5
8
  describe PWN::Plugins::Radare2 do
9
+ def with_fake_r2
10
+ Dir.mktmpdir('pwn-r2pipe-') do |dir|
11
+ fake = File.join(dir, 'r2')
12
+ File.write(fake, <<~'RUBY')
13
+ #!/usr/bin/env ruby
14
+ $stdout.binmode
15
+ $stdin.binmode
16
+ $stdout.write("\x00")
17
+ $stdout.flush
18
+ loop do
19
+ line = $stdin.gets
20
+ break if line.nil?
21
+
22
+ cmd = line.chomp
23
+ break if cmd == 'q'
24
+
25
+ out = case cmd
26
+ when 'aaa' then ''
27
+ when 'aflj' then '[{"name":"main","offset":4198400,"size":32}]'
28
+ when /^pdfj/ then '{"name":"main","addr":4198400,"ops":[{"offset":4198400,"opcode":"push rbp","type":"rpush"}]}'
29
+ when /^pdj/ then '[{"offset":4198400,"opcode":"push rbp"}]'
30
+ when /^axtj/ then '[{"from":4198432,"type":"CALL","name":"sym.main"}]'
31
+ when /^wx/, /^s / then ''
32
+ when 's' then '0x401000'
33
+ when /^pxj/ then '[144,144]'
34
+ when 'pd 2 @ main' then "0x00401000 push rbp\n"
35
+ else '{}'
36
+ end
37
+ $stdout.write("#{out}\x00")
38
+ $stdout.flush
39
+ end
40
+ RUBY
41
+ File.chmod(0o755, fake)
42
+ path = File.join(dir, 'fixture.bin')
43
+ File.binwrite(path, 'MZ')
44
+ original = ENV.fetch('PATH', nil)
45
+ ENV['PATH'] = "#{dir}:#{original}"
46
+ begin
47
+ yield path
48
+ ensure
49
+ ENV['PATH'] = original
50
+ end
51
+ end
52
+ end
53
+
6
54
  it 'should display information for authors' do
7
55
  expect(described_class).to respond_to :authors
8
56
  end
@@ -10,4 +58,71 @@ describe PWN::Plugins::Radare2 do
10
58
  it 'should display information for existing help method' do
11
59
  expect(described_class).to respond_to :help
12
60
  end
61
+
62
+ it 'rejects injected addresses before any r2pipe command is written' do
63
+ expect(Open3).not_to receive(:popen2)
64
+ %i[disasm xrefs_to seek patch_bytes].each do |name|
65
+ expect { described_class.public_send(name, session: 'missing', addr: 'main;!id', n: 8, hex: '90') }.to raise_error(ArgumentError, /addr|address|hex/)
66
+ end
67
+ end
68
+
69
+ it 'enumerates functions and returns structured main disassembly from two JSON helpers' do
70
+ with_fake_r2 do |path|
71
+ sid = described_class.open(bin: path)
72
+ functions = described_class.functions(session: sid)
73
+ disasm = described_class.disasm(session: sid, addr: 'main')
74
+ described_class.close(session: sid)
75
+ expect(functions).to be_an(Array)
76
+ expect(functions.first).to include('name' => 'main')
77
+ expect(disasm).to be_a(Hash)
78
+ expect(disasm.fetch('ops')).not_to be_empty
79
+ end
80
+ end
81
+
82
+ it 'keeps cmd raw, cmdj parsed, seek JSON, and write-gated patch_bytes' do
83
+ with_fake_r2 do |path|
84
+ sid = described_class.open(bin: path)
85
+ expect(described_class.cmd(session: sid, cmd: 'pd 2 @ main')).to include('push rbp')
86
+ expect(described_class.cmdj(session: sid, cmd: 'afl')).to be_an(Array)
87
+ expect(described_class.xrefs_to(session: sid, addr: 'main')).to be_an(Array)
88
+ expect(described_class.seek(session: sid, addr: 'main')).to include(addr: '0x401000', offset: 0x401000)
89
+ expect { described_class.patch_bytes(session: sid, addr: 'main', hex: '9090') }.to raise_error(ArgumentError, /write/)
90
+ described_class.close(session: sid)
91
+ write = described_class.open(bin: path, write: true)
92
+ expect(described_class.patch_bytes(session: write, addr: 'main', hex: '9090')).to eq([144, 144])
93
+ described_class.close(session: write)
94
+ end
95
+ end
96
+
97
+ it 'lets the agent enumerate functions and disassemble main in two structured tool calls' do
98
+ PWN::AI::Agent::Registry.discover(force: true)
99
+ load '/opt/pwn/lib/pwn/ai/agent/tools/radare2.rb'
100
+ with_fake_r2 do |path|
101
+ functions = PWN::AI::Agent::Registry.lookup(name: 'r2_functions').handler.call('bin' => path)
102
+ disasm = PWN::AI::Agent::Registry.lookup(name: 'r2_disasm').handler.call('session' => functions[:session], 'addr' => 'main')
103
+ expect(functions[:command]).to eq('aflj')
104
+ expect(functions[:functions].first).to include('name' => 'main')
105
+ expect(disasm[:command]).to eq('pdfj')
106
+ expect(disasm[:disasm].fetch('ops')).not_to be_empty
107
+ end
108
+ end
109
+
110
+ it 'enumerates functions and pulls main disassembly from a compiled binary', :radare2_integration do
111
+ Dir.mktmpdir('pwn-r2-real-') do |dir|
112
+ source = File.join(dir, 'fixture.c')
113
+ path = File.join(dir, 'fixture')
114
+ File.write(source, "int main(void) { return 0; }\n")
115
+ out, status = Open3.capture2e('cc', '-O0', '-g', '-o', path, source)
116
+ expect(status.success?).to eq(true), out
117
+ sid = described_class.open(bin: path)
118
+ functions = described_class.functions(session: sid)
119
+ disasm = described_class.disasm(session: sid, addr: 'main')
120
+ described_class.close(session: sid)
121
+ expect(functions).to be_an(Array)
122
+ expect(functions.any? { |row| row['name'].to_s.include?('main') }).to eq(true)
123
+ expect(disasm).to be_a(Hash)
124
+ expect(disasm['ops']).to be_an(Array)
125
+ expect(disasm['ops']).not_to be_empty
126
+ end
127
+ end
13
128
  end
@@ -23,4 +23,19 @@ describe PWN::Plugins::Recon do
23
23
  names = described_class.crt_sh(domain: '0dayinc.com')
24
24
  expect(names).to include('www.0dayinc.com', 'vpn.0dayinc.com')
25
25
  end
26
+
27
+ it 'harvests recon banners into the loot store' do
28
+ Dir.mktmpdir('pwn-recon-loot-') do |dir|
29
+ allow(Dir).to receive(:home).and_return(dir)
30
+ described_class.harvest_loot(
31
+ engagement_id: 'lab',
32
+ assets: [{
33
+ address: 'app.example.test',
34
+ observations: [{ banner: "SSH-2.0\nusername=admin\npassword=hunter2\n" }]
35
+ }]
36
+ )
37
+ offered = PWN::Plugins::Vault.offer(host: 'app.example.test', engagement: 'lab')
38
+ expect(offered.first[:secret]).to eq('hunter2')
39
+ end
40
+ end
26
41
  end