pwn 0.5.732 → 0.5.734

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +5 -0
  3. data/Gemfile +2 -2
  4. data/documentation/Agent-Tool-Registry.md +1 -1
  5. data/documentation/Configuration.md +26 -6
  6. data/documentation/General-PWN-Usage.md +1 -1
  7. data/documentation/Recon-Findings-API.md +4 -0
  8. data/documentation/pwn-REPL.md +21 -1
  9. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/findings/SKILL.md +4 -0
  11. data/etc/default_skills/pwn/plugins/github/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/repl/SKILL.md +27 -23
  13. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +47 -0
  14. data/etc/default_skills/pwn/plugins/repl/asm/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +47 -0
  16. data/etc/default_skills/pwn/plugins/repl/mesh/SKILL.md +47 -0
  17. data/etc/default_skills/pwn/plugins/repl/vault/SKILL.md +47 -0
  18. data/lib/pwn/ai/agent/loop.rb +8 -1
  19. data/lib/pwn/ai/agent/swarm.rb +64 -0
  20. data/lib/pwn/ai/agent/tools/finding_record.rb +19 -2
  21. data/lib/pwn/ai/agent/tools/swarm.rb +19 -0
  22. data/lib/pwn/config.rb +15 -2
  23. data/lib/pwn/plugins/findings.rb +171 -0
  24. data/lib/pwn/plugins/github.rb +44 -0
  25. data/lib/pwn/plugins/repl/ai.rb +1056 -0
  26. data/lib/pwn/plugins/repl/asm.rb +55 -0
  27. data/lib/pwn/plugins/repl/irc.rb +153 -0
  28. data/lib/pwn/plugins/repl/mesh.rb +1779 -0
  29. data/lib/pwn/plugins/repl/vault.rb +97 -0
  30. data/lib/pwn/plugins/repl.rb +94 -1419
  31. data/lib/pwn/version.rb +1 -1
  32. data/spec/lib/pwn/ai/agent/loop_issue_work_spec.rb +31 -0
  33. data/spec/lib/pwn/ai/agent/swarm_roster_spec.rb +29 -0
  34. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +5 -0
  35. data/spec/lib/pwn/config_spec.rb +14 -1
  36. data/spec/lib/pwn/plugins/findings_structured_spec.rb +33 -0
  37. data/spec/lib/pwn/plugins/github_spec.rb +21 -0
  38. data/spec/lib/pwn/plugins/repl/ai_spec.rb +21 -0
  39. data/spec/lib/pwn/plugins/repl/asm_spec.rb +21 -0
  40. data/spec/lib/pwn/plugins/repl/irc_spec.rb +21 -0
  41. data/spec/lib/pwn/plugins/repl/mesh_spec.rb +21 -0
  42. data/spec/lib/pwn/plugins/repl/vault_spec.rb +21 -0
  43. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +247 -0
  44. data/spec/lib/pwn/plugins/repl_mesh_console_spec.rb +130 -0
  45. data/spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb +113 -0
  46. data/spec/lib/pwn/plugins/repl_mesh_dm_spec.rb +72 -0
  47. data/spec/lib/pwn/plugins/repl_mesh_rx_paint_spec.rb +71 -0
  48. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -9
  49. data/spec/lib/pwn/plugins/repl_spec.rb +599 -3
  50. data/third_party/pwn_rdoc.jsonl +62 -27
  51. metadata +27 -5
@@ -239,3 +239,22 @@ PWN::AI::Agent::Registry.register(
239
239
  PWN::AI::Agent::Swarm.list
240
240
  }
241
241
  )
242
+ PWN::AI::Agent::Registry.register(
243
+ name: 'agent_roster',
244
+ toolset: 'swarm',
245
+ schema: {
246
+ name: 'agent_roster',
247
+ description: 'Create ephemeral recon, authz, injection, xss, and business_logic ' \
248
+ 'specialists for a swarm. Does not run those agents. Call agent_ask ' \
249
+ 'per specialist after view_graph shows the unit is free.',
250
+ parameters: {
251
+ type: 'object',
252
+ properties: { swarm_id: { type: 'string' } },
253
+ required: %w[swarm_id]
254
+ }
255
+ },
256
+ check: -> { defined?(PWN::AI::Agent::Swarm) },
257
+ handler: lambda { |args|
258
+ PWN::AI::Agent::Swarm.ensure_specialists(swarm_id: args[:swarm_id])
259
+ }
260
+ )
data/lib/pwn/config.rb CHANGED
@@ -258,19 +258,32 @@ module PWN
258
258
  },
259
259
  meshtastic: {
260
260
  admin_key: 'Public key authorized to send admin messages to nodes',
261
+ transport: 'auto',
262
+ dispatch_to_pwn_ai: false,
263
+ ai_whitelist: [],
261
264
  serial: {
262
- port: '/dev/ttyUSB0',
265
+ port: '/dev/ttyACM0',
263
266
  baud: 115_200,
264
267
  bits: 8,
265
268
  stop: 1,
266
269
  parity: :none
267
270
  },
271
+ bluetooth: {
272
+ address: 'AA:BB:CC:DD:EE:FF'
273
+ },
274
+ tcp: {
275
+ host: '127.0.0.1',
276
+ port: 4403
277
+ },
268
278
  mqtt: {
269
279
  host: 'mqtt.meshtastic.org',
270
280
  port: 1883,
271
281
  tls: false,
272
282
  user: 'meshdev',
273
- pass: 'large4cats'
283
+ pass: 'large4cats',
284
+ client_id: '',
285
+ keep_alive: 60,
286
+ ack_timeout: 5
274
287
  },
275
288
  channel: {
276
289
  active: 'LongFast',
@@ -210,6 +210,73 @@ module PWN
210
210
  rationale: 'Maximum recorded constituent severity. No automatic escalation; linking is not proof of combined exploitability.' }
211
211
  end
212
212
 
213
+ # Attest a working PoC from request/response or script output. File hashes are not execution.
214
+
215
+ public_class_method def self.verify(opts = {})
216
+ attest(opts.merge(mode: 'verify'))
217
+ end
218
+
219
+ # Re-run the same PoC path after a fix. Impact present => still_open; absent => fixed.
220
+
221
+ public_class_method def self.retest(opts = {})
222
+ attest(opts.merge(mode: 'retest'))
223
+ end
224
+
225
+ # Combine findings only when a combined-impact evidence file names every id.
226
+
227
+ public_class_method def self.chain_impact(opts = {})
228
+ opts = opts.transform_keys(&:to_sym)
229
+ ids = Array(opts[:ids]).map(&:to_s)
230
+ raise ArgumentError, 'ids must name at least two findings' if ids.length < 2
231
+
232
+ rows = report
233
+ chosen = ids.map { |id| rows.find { |row| row[:id].to_s == id } }
234
+ raise ArgumentError, 'ids must name existing findings' if chosen.any?(&:nil?)
235
+
236
+ path = opts[:combined_impact_path].to_s
237
+ raise ArgumentError, 'combined_impact_path must be an existing readable absolute file' unless path.start_with?('/') && File.file?(path) && File.readable?(path)
238
+
239
+ text = File.read(path)
240
+ raise ArgumentError, 'combined_impact_path must be at least 40 characters and name every finding id' unless text.length >= 40 && ids.all? { |id| text.include?(id) }
241
+
242
+ score = chain_score(ids: ids)
243
+ ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4 }
244
+ combined = score[:combined_severity]
245
+ if opts[:escalate]
246
+ wanted = opts[:combined_severity].to_s
247
+ raise ArgumentError, 'combined_severity is required when escalate is true' if wanted.empty? || !ranks.key?(wanted)
248
+
249
+ combined = wanted
250
+ end
251
+ patch = { combined_impact: path, combined_severity: combined, attack_chain_refs: (Array(chosen.last[:attack_chain_refs]) + ids[0..-2]).uniq }
252
+ updated = rewrite_row(id: ids.last, patch: patch)
253
+ evidence_anchor(row: updated, arts: [path])
254
+ {
255
+ finding_ids: ids,
256
+ combined_severity: combined,
257
+ combined_impact_path: path,
258
+ rationale: opts[:escalate] ? 'independently evidenced combined impact' : score[:rationale],
259
+ finding: updated
260
+ }
261
+ end
262
+
263
+ # Gaps that keep issue work unfinished: unreproduced rows, unchained pairs.
264
+
265
+ public_class_method def self.issue_work_gaps(opts = {})
266
+ opts = opts.transform_keys(&:to_sym)
267
+ rows = report
268
+ eng = opts[:engagement_id].to_s
269
+ sid = opts[:session_id].to_s
270
+ rows = rows.select { |row| row[:engagement_id].to_s == eng } unless eng.empty?
271
+ rows = rows.select { |row| row[:session_id].to_s == sid } unless sid.empty?
272
+ structured = rows.select { |row| row[:cwe].to_s.start_with?('CWE-') || row.key?(:verification_status) }
273
+ reproduced = structured.select { |row| row[:verification_status].to_s == 'reproduced' }
274
+ unverified = structured.select { |row| row[:verification_status].to_s == 'not_executed' }
275
+ unchained = reproduced.length >= 2 && reproduced.none? { |row| Array(row[:attack_chain_refs]).any? || row[:combined_impact].to_s.strip != '' }
276
+ { recorded: structured.map { |row| row[:id] }, unverified: unverified.map { |row| row[:id] },
277
+ reproduced: reproduced.map { |row| row[:id] }, unchained: unchained }
278
+ end
279
+
213
280
  public_class_method def self.render(opts = {})
214
281
  dir = opts[:dir_path].to_s
215
282
  dir = File.join(Dir.home, '.pwn', 'exports') if dir.empty?
@@ -224,6 +291,76 @@ module PWN
224
291
  }
225
292
  end
226
293
 
294
+ private_class_method def self.attest(opts = {})
295
+ opts = opts.transform_keys(&:to_sym)
296
+ row = report.find { |item| item[:id].to_s == opts[:id].to_s }
297
+ raise ArgumentError, 'id must name an existing finding' unless row
298
+
299
+ kind = opts[:kind].to_s
300
+ raise ArgumentError, 'kind must be http or script' unless %w[http script].include?(kind)
301
+
302
+ impact = opts[:impact].to_s
303
+ raise ArgumentError, 'impact must be a non-empty string' if impact.strip.empty?
304
+
305
+ arts, body = execution_blob(opts.merge(kind: kind))
306
+ hit = body.include?(impact)
307
+ if opts[:mode].to_s == 'retest'
308
+ status = hit ? 'still_open' : 'fixed'
309
+ finding_status = hit ? 'open' : 'closed'
310
+ else
311
+ status = hit ? 'reproduced' : 'failed'
312
+ finding_status = row[:status]
313
+ end
314
+ updated = rewrite_row(
315
+ id: row[:id],
316
+ patch: {
317
+ verification_status: status,
318
+ verification_kind: kind,
319
+ impact: impact,
320
+ poc_artifacts: arts,
321
+ status: finding_status,
322
+ verified_at: Time.now.utc.iso8601
323
+ }
324
+ )
325
+ evidence_anchor(row: updated, arts: arts)
326
+ updated
327
+ end
328
+
329
+ private_class_method def self.execution_blob(opts = {})
330
+ kind = opts[:kind].to_s
331
+ if kind == 'http'
332
+ req = opts[:request_path].to_s
333
+ res = opts[:response_path].to_s
334
+ [req, res].each do |path|
335
+ raise ArgumentError, 'request_path and response_path must be existing readable absolute files' unless path.start_with?('/') && File.file?(path) && File.readable?(path)
336
+ end
337
+ [[req, res], "#{File.read(req)}\n#{File.read(res)}"]
338
+ else
339
+ log = opts[:execution_log].to_s
340
+ raise ArgumentError, 'execution_log must be an existing readable absolute file' unless log.start_with?('/') && File.file?(log) && File.readable?(log)
341
+
342
+ [[log], File.read(log)]
343
+ end
344
+ end
345
+
346
+ private_class_method def self.rewrite_row(opts = {})
347
+ id = opts[:id].to_s
348
+ patch = opts[:patch] || {}
349
+ rows = report
350
+ idx = rows.index { |row| row[:id].to_s == id }
351
+ raise ArgumentError, 'id must name an existing finding' unless idx
352
+
353
+ rows[idx] = rows[idx].merge(patch)
354
+ FileUtils.mkdir_p(File.dirname(FILE))
355
+ File.open(FILE, File::RDWR | File::CREAT, 0o644) do |file|
356
+ file.flock(File::LOCK_EX)
357
+ file.rewind
358
+ file.truncate(0)
359
+ rows.each { |row| file.puts(JSON.generate(row)) }
360
+ end
361
+ rows[idx]
362
+ end
363
+
227
364
  private_class_method def self.evidence_anchor(opts = {})
228
365
  row = opts[:row]
229
366
  arts = Array(opts[:arts])
@@ -327,6 +464,40 @@ module PWN
327
464
  chain_refs: 'optional - alias for ids'
328
465
  )
329
466
 
467
+ # Attest a working PoC from HTTP or script evidence; hashes are not execution.
468
+ #{self}.verify(
469
+ id: 'required - finding id',
470
+ kind: 'required - http or script',
471
+ impact: 'required - marker that must appear in the evidence',
472
+ request_path: 'optional - absolute HTTP request file when kind is http',
473
+ response_path: 'optional - absolute HTTP response file when kind is http',
474
+ execution_log: 'optional - absolute script output file when kind is script'
475
+ )
476
+
477
+ # Re-run the same PoC path after a fix (still_open or fixed).
478
+ #{self}.retest(
479
+ id: 'required - finding id',
480
+ kind: 'required - http or script',
481
+ impact: 'required - marker that must appear in the evidence',
482
+ request_path: 'optional - absolute HTTP request file when kind is http',
483
+ response_path: 'optional - absolute HTTP response file when kind is http',
484
+ execution_log: 'optional - absolute script output file when kind is script'
485
+ )
486
+
487
+ # Escalate combined severity only with an evidence file that names every id.
488
+ #{self}.chain_impact(
489
+ ids: 'required - Array of at least two finding ids',
490
+ combined_impact_path: 'required - absolute evidence file',
491
+ escalate: 'optional - true to set combined_severity from evidence',
492
+ combined_severity: 'optional - info|low|medium|high|critical when escalate is true'
493
+ )
494
+
495
+ # List unreproduced findings and whether two-plus reproduced rows lack a chain.
496
+ #{self}.issue_work_gaps(
497
+ engagement_id: 'optional - engagement identifier',
498
+ session_id: 'optional - pwn-ai session id'
499
+ )
500
+
330
501
  # Print the AUTHOR(S) string for this module.
331
502
  #{self}.authors
332
503
  "
@@ -196,6 +196,38 @@ module PWN
196
196
  raise e
197
197
  end
198
198
 
199
+ # Open a GitHub pull request that carries finding remediations and optional SARIF.
200
+
201
+ public_class_method def self.open_fix_pr(opts = {})
202
+ owner = resolve_username(opts)
203
+ repo = opts[:repo].to_s
204
+ title = opts[:title].to_s
205
+ head = opts[:head].to_s
206
+ raise ArgumentError, 'repo is required' if repo.empty?
207
+ raise ArgumentError, 'title is required' if title.empty?
208
+ raise ArgumentError, 'head is required' if head.empty?
209
+
210
+ body = opts[:body].to_s
211
+ sarif = opts[:sarif_path].to_s
212
+ body = "#{body}\n\nSARIF: #{sarif}" unless sarif.empty?
213
+ payload = api(
214
+ path: "repos/#{owner}/#{repo}/pulls",
215
+ method: :post,
216
+ token: opts[:token],
217
+ body: {
218
+ title: title,
219
+ body: body,
220
+ head: head,
221
+ base: (opts[:base] || 'main').to_s
222
+ }
223
+ )
224
+ {
225
+ number: payload['number'] || payload[:number],
226
+ html_url: payload['html_url'] || payload[:html_url],
227
+ sarif_path: sarif.empty? ? nil : sarif
228
+ }
229
+ end
230
+
199
231
  # Supported Method Parameters::
200
232
  # response = PWN::Plugins::Github.api(
201
233
  # path: 'required - REST path relative to https://api.github.com (e.g. "repos/0dayinc/pwn/releases/latest")',
@@ -292,6 +324,18 @@ module PWN
292
324
  token: 'optional - token value consumed by #job_log'
293
325
  )
294
326
 
327
+ # Open a pull request for finding remediations and optional SARIF.
328
+ #{self}.open_fix_pr(
329
+ owner: 'optional - repo owner (default PWN::Env[:plugins][:github][:username])',
330
+ repo: 'required - repo name',
331
+ title: 'required - pull request title',
332
+ head: 'required - branch containing the fix',
333
+ base: 'optional - base branch (defaults to main)',
334
+ body: 'optional - pull request body',
335
+ sarif_path: 'optional - absolute SARIF path mentioned in the body',
336
+ token: 'optional - PAT (default PWN::Env[:plugins][:github][:personal_access_token])'
337
+ )
338
+
295
339
  # Run api and return its result
296
340
  #{self}.api(
297
341
  path: 'required - REST path relative to https://api.github.com (e.g. repos/0dayinc/pwn/releases/latest)',