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
@@ -3,7 +3,7 @@
3
3
  require 'spec_helper'
4
4
  require 'meshtastic'
5
5
 
6
- describe PWN::Plugins::REPL, 'mesh packet channel routing' do
6
+ RSpec.shared_context 'mesh packet channel fixture' do
7
7
  let(:mesh_env) do
8
8
  {
9
9
  transport: 'serial',
@@ -42,6 +42,112 @@ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
42
42
  PWN.send(:remove_const, c) if PWN.const_defined?(c)
43
43
  end
44
44
  end
45
+ end
46
+
47
+ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
48
+ include_context 'mesh packet channel fixture'
49
+
50
+ [false, true].each do |local|
51
+ it "paints absolute source and destination paths for #{local ? 'TX' : 'RX'}" do
52
+ mesh_env[:mqtt] = { region: 'US/CA' }
53
+ win = double('rx', maxx: 200)
54
+ %i[attron attroff addstr refresh].each { |method| allow(win).to receive(method) }
55
+ allow(Curses).to receive(:color_pair) { |color| color }
56
+ PWN.const_set(:MeshRxBodyWin, win)
57
+ PWN.const_set(:MeshMutex, Mutex.new)
58
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
59
+ described_class.send(:mesh_handle_rx, local: local, msg: { packet: {
60
+ channel: 1, from: 0xb0b, to: 0xffffffff,
61
+ decoded: { portnum: 1, payload: 'Absolute paths' }
62
+ } })
63
+ expect(win).to have_received(:addstr).with(a_string_including('US/CA/2/e/LongFast/!00000b0b (ME)', 'US/CA/2/e/LongFast/#'))
64
+ expect(win).to have_received(:attron).with((local ? 23 : 21) | Curses::A_BOLD)
65
+ end
66
+ it "includes the original sender and message in a #{local ? 'sent' : 'received'} reaction without changing header colors" do
67
+ win = double('rx', maxx: 200)
68
+ %i[attron attroff addstr refresh].each { |method| allow(win).to receive(method) }
69
+ allow(Curses).to receive(:color_pair) { |color| color }
70
+ PWN.const_set(:MeshRxBodyWin, win)
71
+ PWN.const_set(:MeshMutex, Mutex.new)
72
+ PWN.const_set(:MeshRxState, {})
73
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
74
+ original = Meshtastic::MeshPacket.new(id: 123, from: 0xaabbccdd, to: 0xffffffff, channel: 1,
75
+ decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'Meet at noon' }).to_h
76
+ described_class.send(:mesh_handle_rx, msg: { packet: original })
77
+ reaction = Meshtastic::MeshPacket.new(id: 124, from: 0xb0b, to: 0xffffffff, channel: 1,
78
+ decoded: { portnum: :TEXT_MESSAGE_APP, payload: '👍'.b, emoji: 1, reply_id: 123 }).to_h
79
+ described_class.send(:mesh_handle_rx, local: local, msg: { packet: reaction })
80
+ expect(win).to have_received(:addstr).with(" Reacted to: \"!aabbccdd >> Meet at noon\" with: 👍.\n")
81
+ expect(win).to have_received(:attron).with((local ? 23 : 21) | Curses::A_BOLD).at_least(:once)
82
+ expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).once
83
+ reply = Meshtastic::MeshPacket.new(id: 125, from: 0xb0b, to: 0xffffffff, channel: 1,
84
+ decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'See you there!', reply_id: 123 }).to_h
85
+ described_class.send(:mesh_handle_rx, local: local, msg: { packet: reply })
86
+ expect(win).to have_received(:addstr).with(" Replied to: \"!aabbccdd >> Meet at noon\" with: See you there!\n")
87
+ end
88
+ end
89
+
90
+ it 'uses the received topic region for both endpoints of a DM' do
91
+ mesh_env[:mqtt] = { region: 'US' }
92
+ win = double('rx', maxx: 200)
93
+ %i[attron attroff addstr refresh].each { |method| allow(win).to receive(method) }
94
+ allow(Curses).to receive(:color_pair).and_return(0)
95
+ PWN.const_set(:MeshRxBodyWin, win)
96
+ PWN.const_set(:MeshMutex, Mutex.new)
97
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
98
+ described_class.send(:mesh_handle_rx, msg: {
99
+ topic: 'msh/US/CA/2/e/LongFast/!aabbccdd',
100
+ packet: { channel: 1, from: 0xaabbccdd, to: 0xb0b, decoded: { portnum: 1, payload: 'DM' } }
101
+ })
102
+ expect(win).to have_received(:addstr).with(a_string_including('US/CA/2/e/LongFast/!aabbccdd', 'US/CA/2/e/LongFast/!00000b0b (ME)'))
103
+ end
104
+
105
+ it 'preserves a configured channel topic without a wildcard suffix' do
106
+ mesh_env[:channel][:LongFast][:topic] = '2/e/LongFast'
107
+ expect(described_class.send(:mesh_conversation_path, env: mesh_env, channel_name: 'LongFast')).to eq('US/2/e/LongFast')
108
+ end
109
+
110
+ it 'remembers the actual transmitted packet ID for reactions to locally sent messages' do
111
+ sent = nil
112
+ allow(Meshtastic::Serial).to receive(:send_to_radio) do |args|
113
+ sent = Meshtastic::ToRadio.decode(args[:to_radio]).packet
114
+ end
115
+ allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
116
+ described_class.send(:mesh_send_text, env: mesh_env, obj: radio, text: 'Local message', channel_name: 'LongFast')
117
+ expect(sent.decoded.payload).to eq('Local message')
118
+ packet = { decoded: { emoji: 1, reply_id: sent.id } }
119
+ text = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 1)
120
+ expect(text).to eq('Reacted to: "!00000b0b >> Local message" with: 👍.')
121
+ end
122
+
123
+ it 'recovers a reaction target from transport history when the display cache is empty' do
124
+ radio[:proto_data] << Meshtastic::FromRadio.new(packet: {
125
+ id: 123, from: 0xaabbccdd, to: 0xffffffff, channel: 1,
126
+ decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'Earlier message' }
127
+ }).to_h
128
+ packet = { channel: 1, decoded: { emoji: 1, reply_id: 123 } }
129
+ text = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 1)
130
+ expect(text).to eq('Reacted to: "!aabbccdd >> Earlier message" with: 👍.')
131
+ end
132
+
133
+ it 'resolves the same radio slot after its channel name becomes known' do
134
+ described_class.send(:mesh_reaction_text, packet: { id: 123, decoded: {} }, text: 'Before channel listing', from: '!aabbccdd', channel: '', index: 1)
135
+ packet = { decoded: { emoji: 1, reply_id: 123 } }
136
+ text = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 1)
137
+ expect(text).to eq('Reacted to: "!aabbccdd >> Before channel listing" with: 👍.')
138
+ other = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 2)
139
+ expect(other).to include('original message unavailable')
140
+ end
141
+
142
+ it 'labels unavailable reaction targets rather than inventing their sender or text' do
143
+ packet = { decoded: { emoji: 1, reply_id: 999 } }
144
+ text = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 1)
145
+ expect(text).to eq('Reacted to: "original message unavailable (packet 999)" with: 👍.')
146
+ packet[:decoded][:emoji] = 0
147
+ expect(described_class.send(:mesh_reaction_text, packet: packet, text: 'Reply')).to eq('Replied to: "original message unavailable (packet 999)" with: Reply')
148
+ packet[:decoded].delete(:reply_id)
149
+ expect(described_class.send(:mesh_reaction_text, packet: packet, text: '👍')).to eq('👍')
150
+ end
45
151
 
46
152
  it 'does not route LongFast to disabled protobuf slots with omitted roles' do
47
153
  mesh_env[:channel][:LongFast].delete(:radio_index)
@@ -1,8 +1,130 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'meshtastic'
4
5
 
5
6
  describe PWN::Plugins::REPL, 'mesh compose history' do
7
+ it 'does not warn when the real subscriber sees an empty protobuf Data message' do
8
+ stub_const('PWN::MeshTransport', :serial)
9
+ queue = Queue.new
10
+ encoded = Meshtastic::FromRadio.new(packet: { decoded: Meshtastic::Data.new }).to_proto
11
+ queue << Meshtastic::FromRadio.decode(encoded)
12
+ queue << Meshtastic::FromRadio.new(queueStatus: { free: 1 })
13
+ queue.close
14
+ received = []
15
+ output = StringIO.new
16
+ old = $stdout
17
+ $stdout = output
18
+ described_class.send(:mesh_subscribe, obj: { from_radio_queue: queue }, psks: {}, on_message: proc { |message| received << message })
19
+ expect(received.size).to eq(2)
20
+ expect(output.string).not_to include("Can't decode")
21
+ Meshtastic::MeshInterface.new.decode_payload(payload: 'unsupported', msg_type: :PRIVATE_APP)
22
+ expect(output.string).to include("Can't decode", 'unsupported')
23
+ ensure
24
+ $stdout = old
25
+ end
26
+
27
+ it 'queues warnings for acknowledgement instead of writing through the TUI' do
28
+ events = Queue.new
29
+ stub_const('PWN::MeshEvents', events)
30
+ expect(described_class).not_to receive(:puts)
31
+ described_class.send(:mesh_ui_puts, text: 'WARNING: fixture warning')
32
+ expect(events.pop(true)).to eq(notice: 'WARNING: fixture warning')
33
+ end
34
+
35
+ it 'displays queued diagnostics on the UI thread' do
36
+ events = Queue.new
37
+ stub_const('PWN::MeshEvents', events)
38
+ events << { notice: 'RX failed: fixture' }
39
+ expect(described_class).to receive(:mesh_notice).with(text: 'RX failed: fixture')
40
+ described_class.send(:mesh_drain_events)
41
+ end
42
+
43
+ it 'captures dependency stdout and stderr and restores both streams on failure' do
44
+ events = Queue.new
45
+ stub_const('PWN::MeshEvents', events)
46
+ original = [$stdout, $stderr]
47
+ expect do
48
+ described_class.send(:mesh_capture_output, run: proc {
49
+ puts 'WARNING: decoder fixture'
50
+ warn 'ERROR: transport fixture'
51
+ raise IOError, 'fixture'
52
+ })
53
+ end.to raise_error(IOError, 'fixture')
54
+ expect([$stdout, $stderr]).to eq(original)
55
+ output = []
56
+ output << events.pop(true)[:notice] until events.empty?
57
+ expect(output.join).to include('WARNING: decoder fixture', 'ERROR: transport fixture')
58
+ end
59
+
60
+ it 'centers a reverse-colored Ok button and repaints the underlying panes' do
61
+ win = double('notice', maxx: 78, maxy: 6)
62
+ %i[keypad erase setpos addstr refresh close attron attroff].each { |method| allow(win).to receive(method) }
63
+ allow(Curses).to receive(:color_pair).with(20).and_return(5120)
64
+ allow(Curses).to receive(:cols).and_return(80)
65
+ allow(Curses).to receive(:lines).and_return(24)
66
+ allow(Curses::Window).to receive(:new).and_return(win)
67
+ pane = double('pane', touch: nil, refresh: nil)
68
+ stub_const('PWN::MeshTxWin', pane)
69
+ keys = ["\e", 'q', "\n"]
70
+ described_class.send(:mesh_notice, text: 'Decoder warning', getch: proc { keys.shift })
71
+ expect(keys).to be_empty
72
+ expect(win).to have_received(:addstr).with('Ok').exactly(3).times
73
+ expect(win).not_to have_received(:addstr).with(a_string_matching(/Acknowledged|Enter/))
74
+ expect(win).to have_received(:setpos).with(4, 38).exactly(3).times
75
+ expect(win).to have_received(:attron).with(5120 | Curses::A_BOLD | Curses::A_REVERSE).exactly(3).times
76
+ expect(win).to have_received(:attroff).with(5120 | Curses::A_BOLD | Curses::A_REVERSE).exactly(3).times
77
+ expect(win).to have_received(:close)
78
+ expect(pane).to have_received(:touch)
79
+ end
80
+
81
+ it 'keeps an idle PhoneAPI session alive and stops its heartbeat with the subscriber' do
82
+ events = Queue.new
83
+ beats = Queue.new
84
+ release = Queue.new
85
+ obj = {}
86
+ stub_const('PWN::MeshEvents', events)
87
+ stub_const('PWN::MeshObj', obj)
88
+ stub_const('PWN::MeshTransport', :serial)
89
+ allow(Meshtastic::Serial).to receive(:send_to_radio) do |args|
90
+ beats << [Thread.current, Meshtastic::ToRadio.decode(args[:to_radio])]
91
+ end
92
+ allow(described_class).to receive(:mesh_subscribe) do |args|
93
+ release.pop
94
+ args[:on_message].call(packet: { decoded: { portnum: 1, payload: 'after idle' } })
95
+ end
96
+ thread = described_class.send(:mesh_start_rx!, env: { channel: {} }, obj: obj, heartbeat_interval: 0.01)
97
+ first = beats.pop(timeout: 1)
98
+ second = beats.pop(timeout: 1)
99
+ expect(first).not_to be_nil
100
+ expect(second).not_to be_nil
101
+ expect(second.last.payload_variant).to eq(:heartbeat)
102
+ expect(second.last.heartbeat.nonce).to eq(0)
103
+ release << true
104
+ expect(thread.join(1)).to eq(thread)
105
+ expect(first.first).not_to be_alive
106
+ expect(events.pop[:msg].dig(:packet, :decoded, :payload)).to eq('after idle')
107
+ ensure
108
+ thread&.kill
109
+ thread&.join(1)
110
+ end
111
+
112
+ it 'drains incoming messages during idle input polls without a keypress' do
113
+ pi = double('pry', config: double('config', pwn_mesh: true))
114
+ events = Queue.new
115
+ stub_const('PWN::MeshEvents', events)
116
+ message = { packet: { decoded: { portnum: 1, payload: 'idle RX' } } }
117
+ polls = 0
118
+ allow(described_class).to receive(:mesh_draw_input)
119
+ expect(described_class).to receive(:mesh_handle_rx).with(msg: message)
120
+ reader = proc do
121
+ polls += 1
122
+ events << { msg: message } if polls == 20
123
+ polls > 20 ? "\u0004" : nil
124
+ end
125
+ described_class.send(:mesh_console_loop, pry: pi, getch: reader)
126
+ end
127
+
6
128
  it 'recalls submissions with Up/Down and restores the unfinished draft and cursor' do
7
129
  config = double('config', pwn_mesh: true)
8
130
  pry = double('pry', config: config)
@@ -11,4 +11,23 @@ describe 'ROP normalized adapter' do
11
11
  expect(result[:gadgets]).to all(include(:gadget, :address, :regs_clobbered))
12
12
  expect(result[:gadgets].all? { |row| row[:gadget].split(';').length <= 2 }).to eq(true)
13
13
  end
14
+
15
+ it 'parses ropper and rp listings into queryable gadget records' do
16
+ ropper = <<~TXT
17
+ [INFO] Load gadgets from cache
18
+ 0x000000000040101a: pop rdi; ret
19
+ 0x000000000040101b: pop rsi; pop r15; ret
20
+ TXT
21
+ rp = <<~TXT
22
+ 0x0000000000401020: pop rdx ; ret
23
+ 0x401021: ret
24
+ TXT
25
+ a = PWN::Plugins::ROP.parse(text: ropper, backend: 'ropper')
26
+ b = PWN::Plugins::ROP.parse(text: rp, backend: 'rp')
27
+ expect(a.map { |row| row[:gadget] }).to include('pop rdi; ret')
28
+ expect(a.first).to include(:address, :gadget)
29
+ expect(b.map { |row| format('%#x', row[:address]) }).to include('0x401020')
30
+ hits = PWN::Plugins::ROP.filter(gadgets: a + b, constraints: { contains: 'pop rdx' })
31
+ expect(hits.length).to eq(1)
32
+ end
14
33
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'tmpdir'
5
+ require 'json'
5
6
 
6
7
  describe PWN::Plugins::TransparentBrowser do
7
8
  it 'should display information for authors' do
@@ -53,6 +54,7 @@ describe PWN::Plugins::TransparentBrowser do
53
54
  it 'evidence! writes screenshot, dom, and har files' do
54
55
  Dir.mktmpdir do |dir|
55
56
  allow(Dir).to receive(:home).and_return(dir)
57
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
56
58
  browser = Object.new
57
59
  def browser.html
58
60
  '<html/>'
@@ -63,4 +65,73 @@ describe PWN::Plugins::TransparentBrowser do
63
65
  expect(File.file?(out[:har])).to be true
64
66
  end
65
67
  end
68
+
69
+ it 'goto optionally spills screenshot, DOM, and HAR into the artifact store' do
70
+ Dir.mktmpdir('pwn-tb-cap-') do |dir|
71
+ allow(Dir).to receive(:home).and_return(dir)
72
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
73
+ browser = Object.new
74
+ def browser.html
75
+ '<html><body>admin</body></html>'
76
+ end
77
+
78
+ def browser.goto(url)
79
+ @url = url
80
+ end
81
+
82
+ def browser.url
83
+ @url.to_s
84
+ end
85
+ out = described_class.goto(
86
+ browser_obj: { browser: browser },
87
+ url: 'https://app.example.test/admin',
88
+ capture: true,
89
+ session_id: 'web'
90
+ )
91
+ expect(out[:url]).to include('/admin')
92
+ expect(File.binread(out[:evidence][:screenshot]).b.start_with?("\x89PNG".b)).to be true
93
+ har = JSON.parse(File.read(out[:evidence][:har]))
94
+ expect(har.dig('log', 'entries').to_s).to include('/admin')
95
+ expect(out[:evidence][:screenshot_handle].to_s).not_to be_empty
96
+ expect(out[:evidence][:har_handle].to_s).not_to be_empty
97
+ end
98
+ end
99
+
100
+ it 'records an auth-bypass finding with pixel and HAR proof in one goto' do
101
+ Dir.mktmpdir('pwn-tb-find-') do |dir|
102
+ allow(Dir).to receive(:home).and_return(dir)
103
+ stub_const('PWN::Plugins::ArtifactRegistry::ROOT', File.join(dir, 'artifacts'))
104
+ stub_const('PWN::Plugins::Findings::FILE', File.join(dir, 'findings.jsonl'))
105
+ browser = Object.new
106
+ def browser.html
107
+ '<html><h1>Dashboard</h1></html>'
108
+ end
109
+
110
+ def browser.goto(url)
111
+ @url = url
112
+ end
113
+
114
+ def browser.url
115
+ @url.to_s
116
+ end
117
+ out = described_class.goto(
118
+ browser_obj: { browser: browser },
119
+ url: 'https://app.example.test/admin',
120
+ title: 'Auth bypass',
121
+ severity: 'high',
122
+ session_id: 'web'
123
+ )
124
+ row = out[:finding]
125
+ expect(row[:title]).to match(/auth bypass/i)
126
+ arts = Array(row[:poc_artifacts]) + Array(row[:evidence_artifacts]).flat_map { |item| [item[:path], item[:stored]] }
127
+ shot = arts.find { |path| path.to_s.end_with?('.png') || (File.file?(path.to_s) && File.binread(path).b.start_with?("\x89PNG".b)) }
128
+ har = arts.find { |path| path.to_s.include?('har') || (File.file?(path.to_s) && File.read(path).include?('"log"')) }
129
+ expect(shot).not_to be_nil
130
+ expect(har).not_to be_nil
131
+ expect(File.binread(shot).b.start_with?("\x89PNG".b)).to be true
132
+ expect(JSON.parse(File.read(har)).dig('log', 'entries').to_s).to include('/admin')
133
+ stored = PWN::Plugins::Findings.report
134
+ expect(stored.first[:title]).to match(/auth bypass/i)
135
+ end
136
+ end
66
137
  end
@@ -23,4 +23,45 @@ describe PWN::Plugins::Vault do
23
23
  expect(described_class.redact(text: 'tok=s3cret-value')).to include('{{vault:api}}')
24
24
  end
25
25
  end
26
+
27
+ it 'stores engagement-scoped creds with provenance and offers them for auth' do
28
+ Dir.mktmpdir('pwn-loot-') do |dir|
29
+ allow(Dir).to receive(:home).and_return(dir)
30
+ row = described_class.store(
31
+ secret: 'hunter2',
32
+ username: 'admin',
33
+ host: 'app.example.test',
34
+ service: 'http',
35
+ kind: 'password',
36
+ source: 'recon',
37
+ where: 'https://app.example.test/.env',
38
+ finding_id: 'f00bar',
39
+ engagement: 'lab'
40
+ )
41
+ expect(row[:engagement]).to eq('lab')
42
+ expect(row[:finding_id]).to eq('f00bar')
43
+ expect(row[:where]).to include('.env')
44
+ hits = described_class.query(host: 'app.example.test', engagement: 'lab')
45
+ expect(hits.first[:username]).to eq('admin')
46
+ expect(hits.first[:finding_id]).to eq('f00bar')
47
+ offered = described_class.offer(host: 'app.example.test', service: 'http', engagement: 'lab')
48
+ expect(offered.first[:username]).to eq('admin')
49
+ expect(offered.first[:secret]).to eq('hunter2')
50
+ end
51
+ end
52
+
53
+ it 'ingests recon banner creds into the loot store' do
54
+ Dir.mktmpdir('pwn-loot-ingest-') do |dir|
55
+ allow(Dir).to receive(:home).and_return(dir)
56
+ described_class.ingest(
57
+ text: "username=admin\npassword=hunter2\n",
58
+ host: 'app.example.test',
59
+ source: 'recon',
60
+ where: 'banner:22',
61
+ engagement: 'lab'
62
+ )
63
+ offered = described_class.offer(host: 'app.example.test', engagement: 'lab')
64
+ expect(offered.first[:secret]).to eq('hunter2')
65
+ end
66
+ end
26
67
  end
@@ -12,6 +12,57 @@ describe PWN::Reports::Engagement do
12
12
  expect(described_class).to respond_to :help
13
13
  end
14
14
 
15
+ it 'isolates default findings from named engagements with symbol and string keys' do
16
+ rows = [{ title: 'legacy' }, { 'title' => 'default row', 'engagement_id' => 'default' },
17
+ { title: 'private alpha', engagement_id: 'alpha' }, { 'title' => 'private beta', 'engagement_id' => 'beta' }]
18
+ allow(PWN::Plugins::Findings).to receive(:report).and_return(rows)
19
+ Dir.mktmpdir do |dir|
20
+ out = described_class.generate(dir_path: dir)
21
+ data = JSON.parse(File.read(out[:json]))
22
+ expect(data['findings'].map { |row| row['title'] }).to eq(['legacy', 'default row'])
23
+ out = described_class.generate(engagement: 'beta', dir_path: dir)
24
+ expect(JSON.parse(File.read(out[:json]))['findings'].map { |row| row['title'] }).to eq(['private beta'])
25
+ end
26
+ end
27
+
28
+ it 'excludes explicitly supplied findings belonging to other engagements' do
29
+ Dir.mktmpdir do |dir|
30
+ out = described_class.generate(engagement: 'alpha', dir_path: dir, findings: [
31
+ { title: 'implicit' }, { 'title' => 'selected', 'engagement_id' => 'alpha' },
32
+ { title: 'other', engagement_id: 'beta' }
33
+ ])
34
+ expect(JSON.parse(File.read(out[:json]))['findings'].map { |row| row['title'] }).to eq(%w[implicit selected])
35
+ end
36
+ end
37
+
38
+ it 'keeps finding evidence portable after moving the generated report bundle' do
39
+ Dir.mktmpdir do |dir|
40
+ stored = File.join(dir, 'stored')
41
+ bytes = "raise 'Do not execute this fixture'"
42
+ File.write(stored, bytes)
43
+ evidence = { kind: 'poc', handle: 'artifact:fixture', label: '../fixture.rb', stored: stored,
44
+ sha256: Digest::SHA256.hexdigest(bytes), size: bytes.bytesize, finding_id: 'f1', ts: 'fixture-time' }
45
+ bundle = File.join(dir, 'bundle')
46
+ out = described_class.generate(engagement: 'lab', dir_path: bundle, findings: [
47
+ { id: 'f1', engagement_id: 'lab', title: 'fixture', poc: bytes,
48
+ reproduction_steps: ['Run fixture in lab', 'Observe output'], severity_justification: 'Fixture only',
49
+ evidence_artifacts: [evidence], legacy_field: 'preserved' }
50
+ ])
51
+ moved = File.join(dir, 'moved')
52
+ FileUtils.mv(bundle, moved)
53
+ File.unlink(stored)
54
+ row = JSON.parse(File.read(File.join(moved, File.basename(out[:json]))))['findings'].first
55
+ exported = row['evidence_artifacts'].first
56
+ expect(exported).to include('handle' => 'artifact:fixture', 'finding_id' => 'f1', 'ts' => 'fixture-time')
57
+ expect(row['legacy_field']).to eq('preserved')
58
+ expect(File.binread(File.join(moved, exported['attachment']))).to eq(bytes)
59
+ %i[html markdown].each do |format|
60
+ text = File.read(File.join(moved, File.basename(out[format])))
61
+ expect(text).to include(exported['attachment'], evidence[:sha256], 'Fixture only', 'Observe output')
62
+ end
63
+ end
64
+ end
65
+
15
66
  it 'writes html for two findings with poc paths' do
16
67
  Dir.mktmpdir do |dir|
17
68
  poc = File.join(dir, 'poc.rb')