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.
Files changed (70) 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 +1 -0
  5. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  7. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  8. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  9. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  11. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  13. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  14. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  16. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  17. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  18. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  20. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  21. data/lib/pwn/ai/agent/dispatch.rb +67 -0
  22. data/lib/pwn/ai/agent/engagement.rb +167 -0
  23. data/lib/pwn/ai/agent/learning.rb +88 -4
  24. data/lib/pwn/ai/agent/loop.rb +20 -2
  25. data/lib/pwn/ai/agent/reward.rb +76 -12
  26. data/lib/pwn/ai/agent/swarm.rb +36 -0
  27. data/lib/pwn/ai/agent/tool_guard.rb +39 -0
  28. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  29. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  30. data/lib/pwn/ai/agent/turn_finalizer.rb +65 -0
  31. data/lib/pwn/ai/agent.rb +1 -0
  32. data/lib/pwn/config.rb +18 -2
  33. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  34. data/lib/pwn/plugins/binary_parser.rb +27 -0
  35. data/lib/pwn/plugins/capability.rb +68 -0
  36. data/lib/pwn/plugins/detect_os.rb +1 -2
  37. data/lib/pwn/plugins/detonate.rb +62 -0
  38. data/lib/pwn/plugins/emulator.rb +64 -0
  39. data/lib/pwn/plugins/exploit_db.rb +33 -9
  40. data/lib/pwn/plugins/findings.rb +47 -0
  41. data/lib/pwn/plugins/gdb.rb +42 -0
  42. data/lib/pwn/plugins/packet.rb +26 -0
  43. data/lib/pwn/plugins/repl.rb +25 -2
  44. data/lib/pwn/plugins/vault.rb +116 -0
  45. data/lib/pwn/plugins.rb +4 -0
  46. data/lib/pwn/reports/engagement.rb +63 -0
  47. data/lib/pwn/reports.rb +1 -0
  48. data/lib/pwn/version.rb +1 -1
  49. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  50. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  51. data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
  52. data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
  53. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  55. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  56. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  57. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  58. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  59. data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -14
  60. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  61. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  62. data/spec/lib/pwn/plugins/exploit_db_spec.rb +38 -3
  63. data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
  64. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  65. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  66. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  67. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  68. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  69. data/third_party/pwn_rdoc.jsonl +63 -0
  70. 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
- body = opts[:body].to_s
211
- stripped = body.lstrip
212
- return body if stripped.start_with?('{', '[')
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(body)) do |gz|
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
- name = File.basename(entry.full_name, '.md')
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.scan(/^##\s+(.+)$/).flatten.map(&:strip)
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
- rescue StandardError
230
- body.start_with?('{') ? body : JSON.generate({})
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
@@ -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
  "
@@ -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
  "
@@ -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
  "
@@ -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/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.718'
4
+ VERSION = '0.5.720'
5
5
  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
@@ -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/pentest_report.json"},"effect":"write"}'
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