pwn 0.5.718 → 0.5.720
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 +1 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -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 +2 -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/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/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 +67 -0
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +88 -4
- data/lib/pwn/ai/agent/loop.rb +20 -2
- data/lib/pwn/ai/agent/reward.rb +76 -12
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +39 -0
- 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 +65 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -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/detect_os.rb +1 -2
- data/lib/pwn/plugins/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_db.rb +33 -9
- data/lib/pwn/plugins/findings.rb +47 -0
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/packet.rb +26 -0
- 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/version.rb +1 -1
- 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 +1 -1
- data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -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/detect_os_spec.rb +0 -14
- 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/exploit_db_spec.rb +38 -3
- data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
- 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 +63 -0
- metadata +25 -1
|
@@ -207,27 +207,51 @@ module PWN
|
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
private_class_method def self.gtfobins_json(opts = {})
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
raw = opts[:body]
|
|
211
|
+
raw = raw.to_s.b unless raw.nil?
|
|
212
|
+
stripped = raw.to_s.lstrip
|
|
213
|
+
if stripped.start_with?('{', '[')
|
|
214
|
+
parsed = JSON.parse(stripped)
|
|
215
|
+
raise 'ERROR: GTFOBins JSON was empty' if parsed.nil? || parsed.empty?
|
|
216
|
+
|
|
217
|
+
return stripped
|
|
218
|
+
end
|
|
213
219
|
|
|
214
220
|
bins = {}
|
|
215
|
-
Zlib::GzipReader.wrap(StringIO.new(
|
|
221
|
+
Zlib::GzipReader.wrap(StringIO.new(raw)) do |gz|
|
|
216
222
|
Gem::Package::TarReader.new(gz) do |tar|
|
|
217
223
|
tar.each do |entry|
|
|
218
224
|
next unless entry.file?
|
|
219
|
-
next unless entry.full_name.include?('_gtfobins/') && entry.full_name.end_with?('.md')
|
|
220
225
|
|
|
221
|
-
|
|
226
|
+
path = entry.full_name.to_s
|
|
227
|
+
next unless path.include?('/_gtfobins/') || path.start_with?('_gtfobins/')
|
|
228
|
+
|
|
229
|
+
base = File.basename(path)
|
|
230
|
+
next if base.empty? || base.start_with?('.')
|
|
231
|
+
|
|
232
|
+
name = base.sub(/\.md\z/i, '')
|
|
222
233
|
text = entry.read.to_s
|
|
223
|
-
funcs = text
|
|
234
|
+
funcs = gtfobins_functions(text: text)
|
|
224
235
|
bins[name] = { 'binary' => name, 'functions' => funcs }
|
|
225
236
|
end
|
|
226
237
|
end
|
|
227
238
|
end
|
|
239
|
+
raise 'ERROR: GTFOBins archive contained no _gtfobins entries' if bins.empty?
|
|
240
|
+
|
|
228
241
|
JSON.generate(bins)
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private_class_method def self.gtfobins_functions(opts = {})
|
|
245
|
+
text = opts[:text].to_s
|
|
246
|
+
headed = text.scan(/^##\s+(.+)$/).flatten.map(&:strip)
|
|
247
|
+
return headed unless headed.empty?
|
|
248
|
+
|
|
249
|
+
if text =~ /^functions:\s*$/
|
|
250
|
+
keys = text.scan(/^(?: |\t)([A-Za-z0-9._-]+):\s*$/).flatten
|
|
251
|
+
return keys unless keys.empty?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
[]
|
|
231
255
|
end
|
|
232
256
|
end
|
|
233
257
|
end
|
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'securerandom'
|
|
6
|
+
require 'digest'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module Plugins
|
|
@@ -39,9 +40,28 @@ module PWN
|
|
|
39
40
|
}
|
|
40
41
|
FileUtils.mkdir_p(File.dirname(FILE))
|
|
41
42
|
File.open(FILE, 'a') { |f| f.puts(JSON.generate(row)) }
|
|
43
|
+
evidence_anchor(row: row, arts: arts)
|
|
42
44
|
row
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
public_class_method def self.evidence_verify(opts = {})
|
|
48
|
+
eng = (opts[:engagement_id] || opts[:name] || 'default').to_s
|
|
49
|
+
path = File.join(Dir.home, '.pwn', 'engagements', eng, 'evidence.jsonl')
|
|
50
|
+
return { ok: true, rows: 0 } unless File.file?(path)
|
|
51
|
+
|
|
52
|
+
mismatches = []
|
|
53
|
+
n = 0
|
|
54
|
+
File.readlines(path).each do |ln|
|
|
55
|
+
row = JSON.parse(ln, symbolize_names: true)
|
|
56
|
+
n += 1
|
|
57
|
+
next unless File.file?(row[:path].to_s)
|
|
58
|
+
|
|
59
|
+
sha = Digest::SHA256.file(row[:path]).hexdigest
|
|
60
|
+
mismatches << row[:path] unless sha == row[:sha256].to_s
|
|
61
|
+
end
|
|
62
|
+
{ ok: mismatches.empty?, rows: n, mismatches: mismatches }
|
|
63
|
+
end
|
|
64
|
+
|
|
45
65
|
public_class_method def self.report(opts = {})
|
|
46
66
|
return [] unless File.file?(FILE)
|
|
47
67
|
|
|
@@ -84,6 +104,27 @@ module PWN
|
|
|
84
104
|
}
|
|
85
105
|
end
|
|
86
106
|
|
|
107
|
+
private_class_method def self.evidence_anchor(opts = {})
|
|
108
|
+
row = opts[:row]
|
|
109
|
+
arts = Array(opts[:arts])
|
|
110
|
+
eng = (row[:engagement_id] || 'default').to_s
|
|
111
|
+
dir = File.join(Dir.home, '.pwn', 'engagements', eng)
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
path = File.join(dir, 'evidence.jsonl')
|
|
114
|
+
arts.each do |art|
|
|
115
|
+
next unless File.file?(art.to_s)
|
|
116
|
+
|
|
117
|
+
rec = {
|
|
118
|
+
ts: Time.now.utc.iso8601,
|
|
119
|
+
finding_id: row[:id],
|
|
120
|
+
path: art,
|
|
121
|
+
sha256: Digest::SHA256.file(art).hexdigest,
|
|
122
|
+
size: File.size(art)
|
|
123
|
+
}
|
|
124
|
+
File.open(path, 'a') { |f| f.puts(JSON.generate(rec)) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
87
128
|
public_class_method def self.authors
|
|
88
129
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
89
130
|
end
|
|
@@ -134,6 +175,12 @@ module PWN
|
|
|
134
175
|
report_name: 'optional - basename without extension (defaults to findings)'
|
|
135
176
|
)
|
|
136
177
|
|
|
178
|
+
# Re-hash evidence files and report tampering.
|
|
179
|
+
#{self}.evidence_verify(
|
|
180
|
+
engagement_id: 'optional - engagement name (defaults to default)',
|
|
181
|
+
name: 'optional - alias for engagement_id'
|
|
182
|
+
)
|
|
183
|
+
|
|
137
184
|
# Print the AUTHOR(S) string for this module.
|
|
138
185
|
#{self}.authors
|
|
139
186
|
"
|
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/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
|
"
|
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/version.rb
CHANGED
|
@@ -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
|
|
@@ -931,7 +931,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
931
931
|
{
|
|
932
932
|
role: 'tool',
|
|
933
933
|
name: 'pwn_eval',
|
|
934
|
-
content: '{"success":true,"result":{"stdout":"Report saved to /tmp/
|
|
934
|
+
content: '{"success":true,"result":{"stdout":"Report saved to /tmp/pwn-spec-report.json"},"effect":"write"}'
|
|
935
935
|
},
|
|
936
936
|
{
|
|
937
937
|
role: 'assistant',
|
|
@@ -422,4 +422,20 @@ describe 'PWN::AI::Agent::Reward vs TUI plan' do
|
|
|
422
422
|
m = PWN::AI::Agent::Reward.send(:judge_model)
|
|
423
423
|
expect(m).to eq('local-judge')
|
|
424
424
|
end
|
|
425
|
+
|
|
426
|
+
it 'records verifier PASS as success even when the judge scores 0.0' do
|
|
427
|
+
allow(PWN::AI::Agent::Reward).to receive(:llm_judge).and_return(
|
|
428
|
+
{ score: 0.0, source: 'heuristic', verdict: :wrong, rationale: 'overlap=0.01', confidence: 0.3 }
|
|
429
|
+
)
|
|
430
|
+
v = PWN::AI::Agent::Reward.judge(
|
|
431
|
+
request: 'write /tmp/x',
|
|
432
|
+
final: 'PASS file exists',
|
|
433
|
+
trace: ['{"success":true}'],
|
|
434
|
+
commit: false,
|
|
435
|
+
verifier_verdict: :pass
|
|
436
|
+
)
|
|
437
|
+
expect(v[:success]).to eq(true)
|
|
438
|
+
expect(v[:verifier_verdict]).to eq(:pass)
|
|
439
|
+
expect(v[:judge_score]).to be < 0.6
|
|
440
|
+
end
|
|
425
441
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
4
5
|
|
|
5
6
|
describe PWN::AI::Agent::Swarm do
|
|
6
7
|
it 'should display information for authors' do
|
|
@@ -43,4 +44,14 @@ describe PWN::AI::Agent::Swarm do
|
|
|
43
44
|
expect(rows.length).to eq(4)
|
|
44
45
|
expect(rows.map { |r| r[:port] }.uniq.length).to eq(4)
|
|
45
46
|
end
|
|
47
|
+
|
|
48
|
+
it 'allows only one live claim per unit' do
|
|
49
|
+
Dir.mktmpdir do |dir|
|
|
50
|
+
allow(Dir).to receive(:home).and_return(dir)
|
|
51
|
+
a = described_class.claim(unit: '10.0.0.5:recon', agent_id: 'a', ttl: 60, engagement_id: 'lab')
|
|
52
|
+
b = described_class.claim(unit: '10.0.0.5:recon', agent_id: 'b', ttl: 60, engagement_id: 'lab')
|
|
53
|
+
expect(a[:ok]).to eq(true)
|
|
54
|
+
expect(b[:ok]).to eq(false)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
46
57
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools debug_lane' do
|
|
6
|
+
before(:all) do
|
|
7
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
8
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/debug_lane.rb'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'registers debug_session binary_diff crash_triage emulate detonate budget_status pty_expect' do
|
|
12
|
+
%w[debug_session binary_diff crash_triage capability_request emulate detonate budget_status pty_expect].each do |name|
|
|
13
|
+
expect(PWN::AI::Agent::Registry.lookup(name: name)).not_to be_nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'PWN::AI::Agent::Tools engagement' do
|
|
6
|
+
before(:all) do
|
|
7
|
+
PWN::AI::Agent::Registry.discover(force: true)
|
|
8
|
+
load '/opt/pwn/lib/pwn/ai/agent/tools/engagement.rb'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'registers engagement_open close and status' do
|
|
12
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'engagement_open')).not_to be_nil
|
|
13
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'engagement_close')).not_to be_nil
|
|
14
|
+
expect(PWN::AI::Agent::Registry.lookup(name: 'engagement_status')).not_to be_nil
|
|
15
|
+
end
|
|
16
|
+
end
|