pwn 0.5.739 → 0.5.741
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +163 -6
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +6 -2
- data/documentation/Recon-Findings-API.md +33 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
- data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
- data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
- data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
- data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
- data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
- data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/SKILL.md +2 -0
- data/lib/pwn/ai/agent/confirmation.rb +163 -0
- data/lib/pwn/ai/agent/dispatch.rb +43 -5
- data/lib/pwn/ai/agent/loop.rb +45 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/result.rb +76 -6
- data/lib/pwn/ai/agent/task_dag.rb +220 -0
- data/lib/pwn/ai/agent/tool_guard.rb +166 -24
- data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
- data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
- data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
- data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
- data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
- data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
- data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
- data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
- data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
- data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
- data/lib/pwn/ai/agent/tools/shell.rb +22 -10
- data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
- data/lib/pwn/ai/agent.rb +3 -1
- data/lib/pwn/ai/cli.rb +26 -2
- data/lib/pwn/ai/context.rb +4 -2
- data/lib/pwn/ai/router.rb +196 -0
- data/lib/pwn/ai/tools/README.md +5 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +10 -0
- data/lib/pwn/engagement.rb +45 -1
- data/lib/pwn/ffi/capstone.rb +120 -0
- data/lib/pwn/ffi/keystone.rb +107 -0
- data/lib/pwn/ffi.rb +2 -0
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/aflplusplus.rb +238 -13
- data/lib/pwn/plugins/ai_sandbox.rb +240 -0
- data/lib/pwn/plugins/artifact_registry.rb +188 -22
- data/lib/pwn/plugins/assembly.rb +73 -2
- data/lib/pwn/plugins/basic_auth.rb +17 -4
- data/lib/pwn/plugins/binary_parser.rb +439 -38
- data/lib/pwn/plugins/exploit_dev.rb +83 -14
- data/lib/pwn/plugins/findings.rb +208 -42
- data/lib/pwn/plugins/gdbmi.rb +265 -38
- data/lib/pwn/plugins/httpx.rb +93 -0
- data/lib/pwn/plugins/jobs.rb +260 -102
- data/lib/pwn/plugins/method_catalog.rb +328 -0
- data/lib/pwn/plugins/nmap_it.rb +157 -26
- data/lib/pwn/plugins/nuclei.rb +122 -29
- data/lib/pwn/plugins/preflight_checker.rb +3 -2
- data/lib/pwn/plugins/process_tube.rb +51 -2
- data/lib/pwn/plugins/radare2.rb +120 -26
- data/lib/pwn/plugins/recon.rb +33 -0
- data/lib/pwn/plugins/repl/mesh.rb +203 -13
- data/lib/pwn/plugins/rop.rb +121 -31
- data/lib/pwn/plugins/transparent_browser.rb +212 -14
- data/lib/pwn/plugins/vault.rb +182 -24
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +8 -2
- data/lib/pwn/reports/html.rb +28 -9
- data/lib/pwn/reports/json.rb +2 -1
- data/lib/pwn/reports/markdown.rb +49 -12
- data/lib/pwn/reports/sarif.rb +17 -3
- data/lib/pwn/reports.rb +184 -34
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reports_spec.rb +93 -0
- data/spec/integration/result_condition_spec.rb +11 -8
- data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
- data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
- data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
- data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
- data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
- data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
- data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
- data/spec/lib/pwn/ai/cli_spec.rb +12 -1
- data/spec/lib/pwn/ai/router_spec.rb +57 -0
- data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
- data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
- data/spec/lib/pwn/migrate_spec.rb +30 -2
- data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
- data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
- data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
- data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
- data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
- data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
- data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
- data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
- data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
- data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
- data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
- data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
- data/spec/lib/pwn/reports_spec.rb +196 -0
- data/spec/spec_helper.rb +3 -0
- data/third_party/pwn_rdoc.jsonl +230 -4
- metadata +47 -5
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'meshtastic'
|
|
5
5
|
|
|
6
|
-
|
|
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')
|