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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +5 -0
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +1 -1
- data/documentation/Configuration.md +26 -6
- data/documentation/General-PWN-Usage.md +1 -1
- data/documentation/Recon-Findings-API.md +4 -0
- data/documentation/pwn-REPL.md +21 -1
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/github/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +27 -23
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/asm/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/mesh/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/vault/SKILL.md +47 -0
- data/lib/pwn/ai/agent/loop.rb +8 -1
- data/lib/pwn/ai/agent/swarm.rb +64 -0
- data/lib/pwn/ai/agent/tools/finding_record.rb +19 -2
- data/lib/pwn/ai/agent/tools/swarm.rb +19 -0
- data/lib/pwn/config.rb +15 -2
- data/lib/pwn/plugins/findings.rb +171 -0
- data/lib/pwn/plugins/github.rb +44 -0
- data/lib/pwn/plugins/repl/ai.rb +1056 -0
- data/lib/pwn/plugins/repl/asm.rb +55 -0
- data/lib/pwn/plugins/repl/irc.rb +153 -0
- data/lib/pwn/plugins/repl/mesh.rb +1779 -0
- data/lib/pwn/plugins/repl/vault.rb +97 -0
- data/lib/pwn/plugins/repl.rb +94 -1419
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_issue_work_spec.rb +31 -0
- data/spec/lib/pwn/ai/agent/swarm_roster_spec.rb +29 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +5 -0
- data/spec/lib/pwn/config_spec.rb +14 -1
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +33 -0
- data/spec/lib/pwn/plugins/github_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl/asm_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl/irc_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl/mesh_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl/vault_spec.rb +21 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +247 -0
- data/spec/lib/pwn/plugins/repl_mesh_console_spec.rb +130 -0
- data/spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb +113 -0
- data/spec/lib/pwn/plugins/repl_mesh_dm_spec.rb +72 -0
- data/spec/lib/pwn/plugins/repl_mesh_rx_paint_spec.rb +71 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -9
- data/spec/lib/pwn/plugins/repl_spec.rb +599 -3
- data/third_party/pwn_rdoc.jsonl +62 -27
- 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/
|
|
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',
|
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -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
|
"
|
data/lib/pwn/plugins/github.rb
CHANGED
|
@@ -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)',
|