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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
  3. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
  4. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +5 -2
  5. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
  7. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -0
  9. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  10. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  11. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  14. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  15. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  17. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  18. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  21. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  24. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  26. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  27. data/lib/pwn/ai/agent/dispatch.rb +69 -1
  28. data/lib/pwn/ai/agent/engagement.rb +167 -0
  29. data/lib/pwn/ai/agent/learning.rb +105 -5
  30. data/lib/pwn/ai/agent/loop.rb +83 -25
  31. data/lib/pwn/ai/agent/metrics.rb +35 -1
  32. data/lib/pwn/ai/agent/registry.rb +28 -0
  33. data/lib/pwn/ai/agent/reward.rb +80 -15
  34. data/lib/pwn/ai/agent/swarm.rb +36 -0
  35. data/lib/pwn/ai/agent/tool_guard.rb +93 -9
  36. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  37. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  38. data/lib/pwn/ai/agent/turn_finalizer.rb +84 -0
  39. data/lib/pwn/ai/agent.rb +1 -0
  40. data/lib/pwn/config.rb +18 -2
  41. data/lib/pwn/plugins/artifact_registry.rb +32 -2
  42. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  43. data/lib/pwn/plugins/binary_parser.rb +27 -0
  44. data/lib/pwn/plugins/capability.rb +68 -0
  45. data/lib/pwn/plugins/detonate.rb +62 -0
  46. data/lib/pwn/plugins/emulator.rb +64 -0
  47. data/lib/pwn/plugins/exploit_dev.rb +33 -0
  48. data/lib/pwn/plugins/findings.rb +56 -1
  49. data/lib/pwn/plugins/gdb.rb +42 -0
  50. data/lib/pwn/plugins/ghidra.rb +20 -2
  51. data/lib/pwn/plugins/packet.rb +26 -0
  52. data/lib/pwn/plugins/preflight_checker.rb +23 -0
  53. data/lib/pwn/plugins/process_tube.rb +31 -1
  54. data/lib/pwn/plugins/repl.rb +25 -2
  55. data/lib/pwn/plugins/vault.rb +116 -0
  56. data/lib/pwn/plugins.rb +4 -0
  57. data/lib/pwn/reports/engagement.rb +63 -0
  58. data/lib/pwn/reports.rb +1 -0
  59. data/lib/pwn/sessions.rb +4 -0
  60. data/lib/pwn/version.rb +1 -1
  61. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
  62. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  63. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  64. data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
  65. data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
  66. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  67. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
  68. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  69. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  70. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  71. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  72. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  73. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  74. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  75. data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
  76. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  77. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  78. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  79. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  80. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  81. data/third_party/pwn_rdoc.jsonl +73 -0
  82. metadata +25 -1
@@ -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
  "
@@ -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
- analyze(opts)
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.
@@ -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
  "
@@ -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'
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.719'
4
+ VERSION = '0.5.721'
5
5
  end
@@ -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')