pwn 0.5.719 → 0.5.721
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/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +5 -2
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -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/gdb/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
- data/lib/pwn/ai/agent/dispatch.rb +69 -1
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +105 -5
- data/lib/pwn/ai/agent/loop.rb +83 -25
- data/lib/pwn/ai/agent/metrics.rb +35 -1
- data/lib/pwn/ai/agent/registry.rb +28 -0
- data/lib/pwn/ai/agent/reward.rb +80 -15
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +93 -9
- data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
- data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +84 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -2
- data/lib/pwn/plugins/artifact_registry.rb +32 -2
- data/lib/pwn/plugins/bin_workspace.rb +81 -0
- data/lib/pwn/plugins/binary_parser.rb +27 -0
- data/lib/pwn/plugins/capability.rb +68 -0
- data/lib/pwn/plugins/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_dev.rb +33 -0
- data/lib/pwn/plugins/findings.rb +56 -1
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/ghidra.rb +20 -2
- data/lib/pwn/plugins/packet.rb +26 -0
- data/lib/pwn/plugins/preflight_checker.rb +23 -0
- data/lib/pwn/plugins/process_tube.rb +31 -1
- data/lib/pwn/plugins/repl.rb +25 -2
- data/lib/pwn/plugins/vault.rb +116 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +63 -0
- data/lib/pwn/reports.rb +1 -0
- data/lib/pwn/sessions.rb +4 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
- data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
- data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
- data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
- data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
- data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
- data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
- data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
- data/third_party/pwn_rdoc.jsonl +73 -0
- metadata +25 -1
data/lib/pwn/plugins/gdb.rb
CHANGED
|
@@ -62,6 +62,34 @@ module PWN
|
|
|
62
62
|
batch(opts.merge(commands: cmds))
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
public_class_method def self.crash_info(opts = {})
|
|
66
|
+
binary = opts[:binary].to_s
|
|
67
|
+
row = run_to_crash(opts.merge(binary: binary))
|
|
68
|
+
out = "#{row[:stdout]}#{row[:stderr]}"
|
|
69
|
+
sig = out[/Program received signal ([A-Z0-9]+)/, 1] || out[/SIG[A-Z]+/]
|
|
70
|
+
pc = out[/\bpc\s+0x([0-9a-f]+)/i, 1] || out[/rip\s+0x([0-9a-f]+)/i, 1]
|
|
71
|
+
frames = out.scan(/#\d+\s+0x[0-9a-f]+.+/).first(8)
|
|
72
|
+
exploitability = if sig.to_s.include?('SEGV') && pc
|
|
73
|
+
'likely-exploitable'
|
|
74
|
+
elsif sig
|
|
75
|
+
'unknown'
|
|
76
|
+
end
|
|
77
|
+
{
|
|
78
|
+
signal: sig,
|
|
79
|
+
pc: pc,
|
|
80
|
+
faulting_access: out[/Access at address 0x([0-9a-f]+)/, 1],
|
|
81
|
+
backtrace: frames,
|
|
82
|
+
regs: out,
|
|
83
|
+
exploitability_heuristic: exploitability,
|
|
84
|
+
raw: row
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
public_class_method def self.debug_session(opts = {})
|
|
89
|
+
script = Array(opts[:script] || opts[:commands])
|
|
90
|
+
crash_info(opts.merge(commands: script))
|
|
91
|
+
end
|
|
92
|
+
|
|
65
93
|
public_class_method def self.authors
|
|
66
94
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
67
95
|
end
|
|
@@ -105,6 +133,20 @@ module PWN
|
|
|
105
133
|
args: 'optional - Array of inferior argv'
|
|
106
134
|
)
|
|
107
135
|
|
|
136
|
+
# Parse a run-to-crash gdb session into typed crash_info.
|
|
137
|
+
#{self}.crash_info(
|
|
138
|
+
binary: 'required - crashing binary path',
|
|
139
|
+
args: 'optional - Array of inferior argv',
|
|
140
|
+
commands: 'optional - extra gdb commands'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Alias of crash_info for the debug_session agent tool.
|
|
144
|
+
#{self}.debug_session(
|
|
145
|
+
binary: 'required - crashing binary path',
|
|
146
|
+
args: 'optional - Array of inferior argv',
|
|
147
|
+
script: 'optional - Array of gdb/MI commands'
|
|
148
|
+
)
|
|
149
|
+
|
|
108
150
|
# Print the AUTHOR(S) string for this module.
|
|
109
151
|
#{self}.authors
|
|
110
152
|
"
|
data/lib/pwn/plugins/ghidra.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'digest'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module Plugins
|
|
@@ -31,12 +32,28 @@ module PWN
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
public_class_method def self.decompile(opts = {})
|
|
34
|
-
|
|
35
|
+
path = (opts[:bin] || opts[:path]).to_s
|
|
36
|
+
raise 'ERROR: bin is required' if path.empty?
|
|
37
|
+
|
|
38
|
+
sha = Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
39
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{sha}.json") if sha
|
|
40
|
+
return JSON.parse(File.read(cache), symbolize_names: true).merge(cached: true) if cache && File.file?(cache)
|
|
41
|
+
|
|
42
|
+
row = analyze(opts)
|
|
43
|
+
fn = opts[:function].to_s
|
|
44
|
+
row[:function] = fn unless fn.empty?
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(cache)) if cache
|
|
46
|
+
File.write(cache, JSON.generate(row)) if cache
|
|
47
|
+
row
|
|
35
48
|
end
|
|
36
49
|
|
|
37
50
|
private_class_method def self.r2_fallback(opts = {})
|
|
38
51
|
path = opts[:path].to_s
|
|
39
52
|
stdout, stderr, status = Open3.capture3('r2', '-q', '-c', 'aaa;s main;pdg', path)
|
|
53
|
+
if stdout.to_s.strip.empty?
|
|
54
|
+
stdout, stderr, status = Open3.capture3('r2', '-q', '-c', 'aaa;s main;pdc', path)
|
|
55
|
+
return { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'r2-pdc' }
|
|
56
|
+
end
|
|
40
57
|
{ stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'r2' }
|
|
41
58
|
rescue StandardError => e
|
|
42
59
|
{ error: e.message, engine: 'r2' }
|
|
@@ -62,7 +79,8 @@ module PWN
|
|
|
62
79
|
#{self}.decompile(
|
|
63
80
|
bin: 'required - filesystem path of the binary',
|
|
64
81
|
path: 'optional - alias for bin',
|
|
65
|
-
project_dir: 'optional - Ghidra project directory'
|
|
82
|
+
project_dir: 'optional - Ghidra project directory',
|
|
83
|
+
function: 'optional - function name to decompile instead of the whole program'
|
|
66
84
|
)
|
|
67
85
|
|
|
68
86
|
# Print the AUTHOR(S) string for this module.
|
data/lib/pwn/plugins/packet.rb
CHANGED
|
@@ -1194,6 +1194,24 @@ module PWN
|
|
|
1194
1194
|
raise e
|
|
1195
1195
|
end
|
|
1196
1196
|
|
|
1197
|
+
public_class_method def self.tcp_connect_scan(opts = {})
|
|
1198
|
+
hosts = Array(opts[:hosts] || opts[:targets])
|
|
1199
|
+
ports = Array(opts[:ports]).map(&:to_i).reject(&:zero?)
|
|
1200
|
+
ports = [80, 443] if ports.empty?
|
|
1201
|
+
timeout = (opts[:timeout] || 2).to_f
|
|
1202
|
+
rows = hosts.product(ports).map do |host, port|
|
|
1203
|
+
open = begin
|
|
1204
|
+
sock = Socket.tcp(host, port, connect_timeout: timeout)
|
|
1205
|
+
sock.close
|
|
1206
|
+
true
|
|
1207
|
+
rescue StandardError
|
|
1208
|
+
false
|
|
1209
|
+
end
|
|
1210
|
+
{ host: host, port: port, open: open }
|
|
1211
|
+
end
|
|
1212
|
+
{ results: rows, degraded: true, fidelity: 'connect-scan (no raw sockets / custom IP flags)' }
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1197
1215
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
1198
1216
|
|
|
1199
1217
|
public_class_method def self.authors
|
|
@@ -1424,6 +1442,14 @@ module PWN
|
|
|
1424
1442
|
iface: 'optional - interface to send packet (defaults to eth0)'
|
|
1425
1443
|
)
|
|
1426
1444
|
|
|
1445
|
+
# Unprivileged TCP connect sweep (fallback when CAP_NET_RAW is missing).
|
|
1446
|
+
#{self}.tcp_connect_scan(
|
|
1447
|
+
hosts: 'required - Array of hostnames or IPs',
|
|
1448
|
+
targets: 'optional - alias for hosts',
|
|
1449
|
+
ports: 'optional - Array of ports (defaults to 80,443)',
|
|
1450
|
+
timeout: 'optional - connect timeout seconds (defaults to 2)'
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1427
1453
|
# Print the AUTHOR(S) string for this module.
|
|
1428
1454
|
#{self}.authors
|
|
1429
1455
|
"
|
|
@@ -67,6 +67,23 @@ module PWN
|
|
|
67
67
|
{ ok: false, name: name, degraded: true, hint: "missing #{name}; install via pwn setup --deps" }
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
CAPABILITIES = {
|
|
71
|
+
web_scan: %w[nuclei burpsuite zaproxy],
|
|
72
|
+
raw_packet: %w[],
|
|
73
|
+
decompile: %w[analyzeHeadless r2],
|
|
74
|
+
debug: %w[gdb]
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
public_class_method def self.capability_coverage(opts = {})
|
|
78
|
+
want = opts[:capability] || opts[:cap]
|
|
79
|
+
if want
|
|
80
|
+
bins = Array(CAPABILITIES[want.to_sym])
|
|
81
|
+
covered = bins.any? { |b| bin?(name: b) }
|
|
82
|
+
return { capability: want.to_s, covered: covered, bins: bins }
|
|
83
|
+
end
|
|
84
|
+
CAPABILITIES.keys.map { |k| capability_coverage(capability: k) }
|
|
85
|
+
end
|
|
86
|
+
|
|
70
87
|
TASK_BINS = {
|
|
71
88
|
'proxy' => %w[burpsuite zaproxy],
|
|
72
89
|
'vuln-scan' => %w[nuclei openvas],
|
|
@@ -176,6 +193,12 @@ module PWN
|
|
|
176
193
|
name: 'required - binary name to probe'
|
|
177
194
|
)
|
|
178
195
|
|
|
196
|
+
# Report whether a named capability is covered by any healthy plugin binary.
|
|
197
|
+
#{self}.capability_coverage(
|
|
198
|
+
capability: 'optional - capability name such as web_scan',
|
|
199
|
+
cap: 'optional - alias for capability'
|
|
200
|
+
)
|
|
201
|
+
|
|
179
202
|
# First healthy binary for a task (proxy, vuln-scan, re).
|
|
180
203
|
#{self}.pick(
|
|
181
204
|
task: 'required - capability name such as proxy or vuln-scan'
|
|
@@ -21,7 +21,7 @@ module PWN
|
|
|
21
21
|
argv = cmd.is_a?(Array) ? cmd.map(&:to_s) : ['bash', '-lc', cmd.to_s]
|
|
22
22
|
r, w, pid = PTY.spawn(*argv)
|
|
23
23
|
id = "tube_#{pid}"
|
|
24
|
-
@tubes[id] = { r: r, w: w, pid: pid, buf: +'' }
|
|
24
|
+
@tubes[id] = { r: r, w: w, pid: pid, buf: +'', started_at: Time.now, last_io: Time.now }
|
|
25
25
|
{ id: id, pid: pid }
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -105,6 +105,26 @@ module PWN
|
|
|
105
105
|
n
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
public_class_method def self.list(opts = {})
|
|
109
|
+
_idle = opts[:idle]
|
|
110
|
+
now = Time.now
|
|
111
|
+
@tubes.map do |id, t|
|
|
112
|
+
{ id: id, pid: t[:pid], started_at: t[:started_at], last_io: t[:last_io], age_s: (now - (t[:started_at] || now)).to_i }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
public_class_method def self.kill(opts = {})
|
|
117
|
+
id = opts[:id].to_s
|
|
118
|
+
t = @tubes[id]
|
|
119
|
+
return { ok: false, id: id } unless t
|
|
120
|
+
|
|
121
|
+
Process.kill('TERM', t[:pid]) if t[:pid]
|
|
122
|
+
close(id: id)
|
|
123
|
+
{ ok: true, id: id }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
{ ok: false, id: id, error: e.message }
|
|
126
|
+
end
|
|
127
|
+
|
|
108
128
|
public_class_method def self.authors
|
|
109
129
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
110
130
|
end
|
|
@@ -165,6 +185,16 @@ module PWN
|
|
|
165
185
|
session_id: 'optional - agent session id for transcript grouping'
|
|
166
186
|
)
|
|
167
187
|
|
|
188
|
+
# List live tubes with age.
|
|
189
|
+
#{self}.list(
|
|
190
|
+
idle: 'optional - unused reserved idle-seconds filter'
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# SIGTERM then close a tube by id.
|
|
194
|
+
#{self}.kill(
|
|
195
|
+
id: 'required - tube id from spawn or connect'
|
|
196
|
+
)
|
|
197
|
+
|
|
168
198
|
# Print the AUTHOR(S) string for this module.
|
|
169
199
|
#{self}.authors
|
|
170
200
|
"
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -1583,7 +1583,7 @@ module PWN
|
|
|
1583
1583
|
end
|
|
1584
1584
|
|
|
1585
1585
|
PWN_AI_SLASH_COMMANDS = %w[
|
|
1586
|
-
/back /cron /debug /delegate /help /memory /model /sessions /skills /trace
|
|
1586
|
+
/back /cron /debug /delegate /help /learning /memory /model /sessions /skills /trace
|
|
1587
1587
|
].freeze
|
|
1588
1588
|
|
|
1589
1589
|
PWN_AI_SLASH_SUBCOMMANDS = {
|
|
@@ -1595,7 +1595,8 @@ module PWN
|
|
|
1595
1595
|
'/memory' => %w[list recall remember forget clear],
|
|
1596
1596
|
'/model' => %w[list],
|
|
1597
1597
|
'/sessions' => %w[list resume delete stats],
|
|
1598
|
-
'/skills' => %w[list recall]
|
|
1598
|
+
'/skills' => %w[list recall],
|
|
1599
|
+
'/learning' => %w[list requeue]
|
|
1599
1600
|
}.freeze
|
|
1600
1601
|
|
|
1601
1602
|
# Supported Method Parameters::
|
|
@@ -1785,6 +1786,8 @@ module PWN
|
|
|
1785
1786
|
puts ' Use agent_list / agent_debate from pwn-ai, or pwn-ai-delegate in the pwn REPL.'
|
|
1786
1787
|
when '/model'
|
|
1787
1788
|
pwn_ai_run_model(args: args)
|
|
1789
|
+
when '/learning'
|
|
1790
|
+
pwn_ai_run_learning(args: args)
|
|
1788
1791
|
end
|
|
1789
1792
|
true
|
|
1790
1793
|
rescue StandardError => e
|
|
@@ -2016,6 +2019,21 @@ module PWN
|
|
|
2016
2019
|
end
|
|
2017
2020
|
end
|
|
2018
2021
|
|
|
2022
|
+
public_class_method def self.pwn_ai_run_learning(opts = {})
|
|
2023
|
+
args = Array(opts[:args])
|
|
2024
|
+
sub = args[0] || 'list'
|
|
2025
|
+
case sub
|
|
2026
|
+
when 'list'
|
|
2027
|
+
flag = args.include?('--conflicted') || args.include?('conflicted')
|
|
2028
|
+
rows = flag ? PWN::AI::Agent::Learning.list_conflicted : PWN::AI::Agent::Learning.outcomes(limit: 20)
|
|
2029
|
+
puts rows.inspect
|
|
2030
|
+
when 'requeue'
|
|
2031
|
+
puts PWN::AI::Agent::Learning.requeue_conflicted.inspect
|
|
2032
|
+
else
|
|
2033
|
+
puts 'Usage: /learning [list [--conflicted]|requeue]'
|
|
2034
|
+
end
|
|
2035
|
+
end
|
|
2036
|
+
|
|
2019
2037
|
public_class_method def self.pwn_ai_run_skills(opts = {})
|
|
2020
2038
|
args = Array(opts[:args])
|
|
2021
2039
|
sub = args[0] || 'list'
|
|
@@ -2246,6 +2264,11 @@ module PWN
|
|
|
2246
2264
|
args: 'optional - Array args value consumed by #pwn_ai_run_memory'
|
|
2247
2265
|
)
|
|
2248
2266
|
|
|
2267
|
+
# List or requeue conflicted learning outcomes.
|
|
2268
|
+
#{self}.pwn_ai_run_learning(
|
|
2269
|
+
args: 'optional - list [--conflicted] or requeue'
|
|
2270
|
+
)
|
|
2271
|
+
|
|
2249
2272
|
# Run pwn ai run skills and return its result
|
|
2250
2273
|
#{self}.pwn_ai_run_skills(
|
|
2251
2274
|
args: 'optional - Array args value consumed by #pwn_ai_run_skills'
|
data/lib/pwn/plugins/vault.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'openssl'
|
|
5
5
|
require 'yaml'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'fileutils'
|
|
6
8
|
|
|
7
9
|
module PWN
|
|
8
10
|
module Plugins
|
|
@@ -244,6 +246,99 @@ module PWN
|
|
|
244
246
|
raise e
|
|
245
247
|
end
|
|
246
248
|
|
|
249
|
+
public_class_method def self.store(opts = {})
|
|
250
|
+
label = opts[:label].to_s
|
|
251
|
+
secret = opts[:secret].to_s
|
|
252
|
+
raise 'ERROR: label and secret are required' if label.empty? || secret.empty?
|
|
253
|
+
|
|
254
|
+
box = load_box
|
|
255
|
+
box[label] = encrypt_secret(secret: secret)
|
|
256
|
+
save_box(box: box)
|
|
257
|
+
{ label: label, stored: true }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
public_class_method def self.fetch(opts = {})
|
|
261
|
+
label = opts[:label].to_s
|
|
262
|
+
raise 'ERROR: label is required' if label.empty?
|
|
263
|
+
|
|
264
|
+
row = load_box[label]
|
|
265
|
+
return nil unless row
|
|
266
|
+
|
|
267
|
+
decrypt_secret(row: row)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
public_class_method def self.expand(opts = {})
|
|
271
|
+
text = opts[:text].to_s
|
|
272
|
+
text.gsub(/\{\{vault:([^}]+)\}\}/) { fetch(label: Regexp.last_match(1).to_s.strip).to_s }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
public_class_method def self.redact(opts = {})
|
|
276
|
+
text = opts[:text].to_s
|
|
277
|
+
load_box.each do |label, row|
|
|
278
|
+
val = decrypt_secret(row: row).to_s
|
|
279
|
+
next if val.empty?
|
|
280
|
+
|
|
281
|
+
text = text.gsub(val, "{{vault:#{label}}}")
|
|
282
|
+
end
|
|
283
|
+
text
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private_class_method def self.key_path
|
|
287
|
+
File.join(Dir.home, '.pwn-vault.key')
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private_class_method def self.box_path
|
|
291
|
+
File.join(Dir.home, '.pwn', 'vault-secrets.json')
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private_class_method def self.master_key(opts = {})
|
|
295
|
+
_n = opts[:n]
|
|
296
|
+
path = key_path
|
|
297
|
+
unless File.file?(path)
|
|
298
|
+
File.binwrite(path, OpenSSL::Random.random_bytes(32))
|
|
299
|
+
File.chmod(0o600, path)
|
|
300
|
+
end
|
|
301
|
+
File.binread(path)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private_class_method def self.encrypt_secret(opts = {})
|
|
305
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
306
|
+
cipher.encrypt
|
|
307
|
+
cipher.key = master_key
|
|
308
|
+
iv = cipher.random_iv
|
|
309
|
+
cipher.auth_data = 'pwn-vault'
|
|
310
|
+
ct = cipher.update(opts[:secret].to_s) + cipher.final
|
|
311
|
+
{ iv: Base64.strict_encode64(iv), ct: Base64.strict_encode64(ct), tag: Base64.strict_encode64(cipher.auth_tag) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private_class_method def self.decrypt_secret(opts = {})
|
|
315
|
+
row = opts[:row]
|
|
316
|
+
return '' unless row.is_a?(Hash)
|
|
317
|
+
|
|
318
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
319
|
+
cipher.decrypt
|
|
320
|
+
cipher.key = master_key
|
|
321
|
+
cipher.iv = Base64.strict_decode64(row[:iv] || row['iv'].to_s)
|
|
322
|
+
cipher.auth_tag = Base64.strict_decode64(row[:tag] || row['tag'].to_s)
|
|
323
|
+
cipher.auth_data = 'pwn-vault'
|
|
324
|
+
cipher.update(Base64.strict_decode64(row[:ct] || row['ct'].to_s)) + cipher.final
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private_class_method def self.load_box(opts = {})
|
|
328
|
+
return {} unless opts.is_a?(Hash)
|
|
329
|
+
return {} unless File.file?(box_path)
|
|
330
|
+
|
|
331
|
+
JSON.parse(File.read(box_path))
|
|
332
|
+
rescue StandardError
|
|
333
|
+
{}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
private_class_method def self.save_box(opts = {})
|
|
337
|
+
FileUtils.mkdir_p(File.dirname(box_path))
|
|
338
|
+
File.write(box_path, JSON.pretty_generate(opts[:box] || {}))
|
|
339
|
+
File.chmod(0o600, box_path)
|
|
340
|
+
end
|
|
341
|
+
|
|
247
342
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
248
343
|
|
|
249
344
|
public_class_method def self.authors
|
|
@@ -304,6 +399,27 @@ module PWN
|
|
|
304
399
|
file: 'required - file to check if encrypted'
|
|
305
400
|
)
|
|
306
401
|
|
|
402
|
+
# Store a secret outside the transcript (AES-GCM; key in ~/.pwn-vault.key).
|
|
403
|
+
#{self}.store(
|
|
404
|
+
label: 'required - vault label',
|
|
405
|
+
secret: 'required - secret value'
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Fetch a stored secret by label.
|
|
409
|
+
#{self}.fetch(
|
|
410
|
+
label: 'required - vault label'
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Replace {{vault:label}} tokens with stored secrets.
|
|
414
|
+
#{self}.expand(
|
|
415
|
+
text: 'required - string possibly containing {{vault:label}} tokens'
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Replace stored secret values with {{vault:label}} placeholders.
|
|
419
|
+
#{self}.redact(
|
|
420
|
+
text: 'required - string that may contain stored secrets'
|
|
421
|
+
)
|
|
422
|
+
|
|
307
423
|
# Print the AUTHOR(S) string for this module.
|
|
308
424
|
#{self}.authors
|
|
309
425
|
"
|
data/lib/pwn/plugins.rb
CHANGED
|
@@ -10,6 +10,8 @@ module PWN
|
|
|
10
10
|
autoload :ArtifactRegistry, 'pwn/plugins/artifact_registry'
|
|
11
11
|
autoload :Assembly, 'pwn/plugins/assembly'
|
|
12
12
|
autoload :BinaryParser, 'pwn/plugins/binary_parser'
|
|
13
|
+
autoload :BinWorkspace, 'pwn/plugins/bin_workspace'
|
|
14
|
+
autoload :Capability, 'pwn/plugins/capability'
|
|
13
15
|
autoload :AuthenticationHelper, 'pwn/plugins/authentication_helper'
|
|
14
16
|
autoload :BareSIP, 'pwn/plugins/baresip'
|
|
15
17
|
autoload :BasicAuth, 'pwn/plugins/basic_auth'
|
|
@@ -28,7 +30,9 @@ module PWN
|
|
|
28
30
|
autoload :DAOSQLite3, 'pwn/plugins/dao_sqlite3'
|
|
29
31
|
autoload :DefectDojo, 'pwn/plugins/defect_dojo'
|
|
30
32
|
autoload :DetectOS, 'pwn/plugins/detect_os'
|
|
33
|
+
autoload :Detonate, 'pwn/plugins/detonate'
|
|
31
34
|
autoload :EIN, 'pwn/plugins/ein'
|
|
35
|
+
autoload :Emulator, 'pwn/plugins/emulator'
|
|
32
36
|
autoload :ExploitDB, 'pwn/plugins/exploit_db'
|
|
33
37
|
autoload :FileFu, 'pwn/plugins/file_fu'
|
|
34
38
|
autoload :Findings, 'pwn/plugins/findings'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module Reports
|
|
7
|
+
# Compile engagement findings into a client-ready HTML/Markdown report.
|
|
8
|
+
module Engagement
|
|
9
|
+
public_class_method def self.generate(opts = {})
|
|
10
|
+
name = (opts[:engagement] || opts[:name] || 'default').to_s
|
|
11
|
+
rows = if opts[:findings]
|
|
12
|
+
Array(opts[:findings])
|
|
13
|
+
elsif defined?(PWN::Plugins::Findings)
|
|
14
|
+
PWN::Plugins::Findings.report
|
|
15
|
+
else
|
|
16
|
+
[]
|
|
17
|
+
end
|
|
18
|
+
rows = rows.sort_by { |r| -severity_rank(row: r) }
|
|
19
|
+
payload = {
|
|
20
|
+
title: "Engagement #{name}",
|
|
21
|
+
findings: rows,
|
|
22
|
+
summary: { count: rows.length, critical: rows.count { |r| r[:severity].to_s == 'critical' || r['severity'].to_s == 'critical' } }
|
|
23
|
+
}
|
|
24
|
+
dir = opts[:dir_path].to_s
|
|
25
|
+
dir = File.join(Dir.home, '.pwn', 'exports') if dir.empty?
|
|
26
|
+
report_name = (opts[:report_name] || "engagement-#{name}").to_s
|
|
27
|
+
{
|
|
28
|
+
markdown: PWN::Reports::Markdown.generate(results_hash: payload, dir_path: dir, report_name: report_name),
|
|
29
|
+
html: PWN::Reports::HTML.generate(results_hash: payload, dir_path: dir, report_name: report_name),
|
|
30
|
+
json: PWN::Reports::JSON.generate(results_hash: payload, dir_path: dir, report_name: report_name)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method def self.severity_rank(opts = {})
|
|
35
|
+
row = opts[:row]
|
|
36
|
+
row = {} unless row.is_a?(Hash)
|
|
37
|
+
sev = (row[:severity] || row['severity']).to_s
|
|
38
|
+
{ 'critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1 }[sev] || 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
public_class_method def self.authors
|
|
42
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
public_class_method def self.help
|
|
46
|
+
puts "USAGE:
|
|
47
|
+
# Compile findings into markdown, html, and json.
|
|
48
|
+
#{self}.generate(
|
|
49
|
+
engagement: 'optional - engagement name (defaults to default)',
|
|
50
|
+
name: 'optional - alias for engagement',
|
|
51
|
+
findings: 'optional - Array of finding hashes (defaults to Findings.report)',
|
|
52
|
+
dir_path: 'optional - output directory',
|
|
53
|
+
report_name: 'optional - basename without extension'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Print the AUTHOR(S) string for this module.
|
|
57
|
+
#{self}.authors
|
|
58
|
+
"
|
|
59
|
+
constants.sort
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/pwn/reports.rb
CHANGED
|
@@ -9,6 +9,7 @@ module PWN
|
|
|
9
9
|
module Reports
|
|
10
10
|
autoload :AIRedTeam, 'pwn/reports/ai_red_team'
|
|
11
11
|
autoload :CSV, 'pwn/reports/csv'
|
|
12
|
+
autoload :Engagement, 'pwn/reports/engagement'
|
|
12
13
|
autoload :Fuzz, 'pwn/reports/fuzz'
|
|
13
14
|
autoload :HTML, 'pwn/reports/html'
|
|
14
15
|
autoload :HTMLFooter, 'pwn/reports/html_footer'
|
data/lib/pwn/sessions.rb
CHANGED
|
@@ -172,6 +172,10 @@ module PWN
|
|
|
172
172
|
next unless %w[user assistant].include?(row[:role].to_s)
|
|
173
173
|
|
|
174
174
|
body = row[:content].to_s
|
|
175
|
+
if body.include?('[compacted') && body =~ /ref=(\S+)/
|
|
176
|
+
ref = Regexp.last_match(1)
|
|
177
|
+
body = File.read(ref) if File.file?(ref)
|
|
178
|
+
end
|
|
175
179
|
next if body.strip.empty?
|
|
176
180
|
next if !target.empty? && !body.downcase.include?(target)
|
|
177
181
|
|
data/lib/pwn/version.rb
CHANGED
|
@@ -28,6 +28,15 @@ describe PWN::AI::Agent::Dispatch do
|
|
|
28
28
|
expect(described_class.effect(name: 'memory_recall', args: { query: 'docs' })).to eq :recall
|
|
29
29
|
expect(described_class.effect(name: 'memory_remember', args: { key: 'sop' })).to eq :store
|
|
30
30
|
expect(described_class.effect(name: 'learning_note_outcome', args: { task: 'x' })).to eq :store
|
|
31
|
+
expect(
|
|
32
|
+
described_class.effect(
|
|
33
|
+
name: 'shell',
|
|
34
|
+
args: {
|
|
35
|
+
command: "cat >> ~/.pwn/logs/pwn-ai-DEBUG-2026-09-03.md <<'EOF'\n" \
|
|
36
|
+
"ToolGuard, TransparentBrowser, and devtools are strengths.\nEOF"
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
).to eq :write
|
|
31
40
|
src = File.read(described_class.method(:effect).source_location.first)
|
|
32
41
|
expect(src).to match(/def self\.effect/)
|
|
33
42
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
describe PWN::AI::Agent::Engagement 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 'blocks out-of-scope IPs when a CIDR engagement is active' do
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
19
|
+
stub_const('PWN::AI::Agent::Engagement::ROOT', File.join(dir, '.pwn', 'engagements'))
|
|
20
|
+
stub_const('PWN::AI::Agent::Engagement::ACTIVE_FILE', File.join(dir, '.pwn', 'engagements', 'active'))
|
|
21
|
+
described_class.open(name: 'lab', scope_cidrs: ['10.0.0.0/24'])
|
|
22
|
+
deny = described_class.deny_if_out_of_scope(command: 'scan 8.8.8.8')
|
|
23
|
+
expect(deny[:error]).to include('8.8.8.8')
|
|
24
|
+
expect(described_class.in_scope?(ip: '10.0.0.5')).to eq(true)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -355,3 +355,26 @@ describe PWN::AI::Agent::Learning do
|
|
|
355
355
|
end
|
|
356
356
|
end
|
|
357
357
|
end
|
|
358
|
+
|
|
359
|
+
describe 'PWN::AI::Agent::Learning conflicted outcomes' do
|
|
360
|
+
it 'tags verifier/judge conflicts and keeps them out of RECENT FAILURES' do
|
|
361
|
+
Dir.mktmpdir do |dir|
|
|
362
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
|
|
363
|
+
PWN::AI::Agent::Learning.note_outcome(task: 'pass-low-judge', success: false, score: 0.36, verifier_verdict: :pass, details: 'overlap=0.01 ratio=1.0')
|
|
364
|
+
expect(PWN::AI::Agent::Learning.list_conflicted.first[:status]).to eq('conflicted')
|
|
365
|
+
ctx = PWN::AI::Agent::Learning.to_context(limit: 10)
|
|
366
|
+
expect(ctx).not_to include('overlap=')
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'prunes bak files down to max_baks' do
|
|
371
|
+
Dir.mktmpdir do |dir|
|
|
372
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(dir, 'learning.jsonl'))
|
|
373
|
+
File.write(File.join(dir, 'learning.jsonl'), "{}\n")
|
|
374
|
+
7.times { |i| File.write(File.join(dir, "learning.jsonl.bak-#{i}"), 'x') }
|
|
375
|
+
out = PWN::AI::Agent::Learning.compact!(max_baks: 5)
|
|
376
|
+
expect(Dir[File.join(dir, '*.bak*')].length).to eq(5)
|
|
377
|
+
expect(out[:pruned]).to eq(2)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -985,6 +985,50 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
985
985
|
Thread.current[:pwn_loop_deliverables] = nil
|
|
986
986
|
end
|
|
987
987
|
|
|
988
|
+
it 'does not bounce with empty unmet after a redirected write whose body mentions browsers' do
|
|
989
|
+
Dir.mktmpdir do |dir|
|
|
990
|
+
path = File.join(dir, 'pwn-ai-DEBUG-2026-09-03.md')
|
|
991
|
+
File.write(path, "# assessment\n" * 40)
|
|
992
|
+
req = "analyze how well pwn-ai enables pentest. Store this list in #{path}."
|
|
993
|
+
write_cmd = "cat >> #{path} <<'EOF'\nTransparentBrowser and devtools are strengths.\nEOF"
|
|
994
|
+
msgs = [
|
|
995
|
+
{ role: 'user', content: req },
|
|
996
|
+
{
|
|
997
|
+
role: 'assistant',
|
|
998
|
+
tool_calls: [{ function: { name: 'shell', arguments: { command: write_cmd }.to_json } }]
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
role: 'tool',
|
|
1002
|
+
name: 'shell',
|
|
1003
|
+
content: {
|
|
1004
|
+
success: true,
|
|
1005
|
+
result: { stdout: 'APPENDED', exit: 0 },
|
|
1006
|
+
effect: PWN::AI::Agent::Dispatch.effect(name: 'shell', args: { command: write_cmd })
|
|
1007
|
+
}.to_json
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
role: 'assistant',
|
|
1011
|
+
tool_calls: [{ function: { name: 'shell', arguments: { command: "cat #{path}" }.to_json } }]
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
role: 'tool',
|
|
1015
|
+
name: 'shell',
|
|
1016
|
+
content: { success: true, result: { stdout: File.read(path), exit: 0 }, effect: 'read' }.to_json
|
|
1017
|
+
}
|
|
1018
|
+
]
|
|
1019
|
+
expect(described_class.send(:completion_unmet, request: req, messages: msgs)).to eq([])
|
|
1020
|
+
expect(described_class.send(:request_unsatisfied?, request: req, messages: msgs)).to eq(false)
|
|
1021
|
+
expect(
|
|
1022
|
+
described_class.send(
|
|
1023
|
+
:may_finalize?,
|
|
1024
|
+
request: req,
|
|
1025
|
+
messages: msgs,
|
|
1026
|
+
text: "Done. The assessment is at #{path}."
|
|
1027
|
+
)
|
|
1028
|
+
).to eq(true)
|
|
1029
|
+
end
|
|
1030
|
+
end
|
|
1031
|
+
|
|
988
1032
|
it 'keeps issue_work unsatisfied until proofs exist, without Crit/High regex' do
|
|
989
1033
|
src = File.read(described_class.method(:run).source_location.first)
|
|
990
1034
|
expect(src).not_to include('Crit/High')
|