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.
- 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 +5 -2
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -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 +4 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -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/exploit_dev/SKILL.md +1 -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/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -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 +69 -1
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +105 -5
- data/lib/pwn/ai/agent/loop.rb +83 -25
- data/lib/pwn/ai/agent/metrics.rb +35 -1
- data/lib/pwn/ai/agent/registry.rb +28 -0
- data/lib/pwn/ai/agent/reward.rb +80 -15
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +93 -9
- 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 +84 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -2
- data/lib/pwn/plugins/artifact_registry.rb +32 -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/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_dev.rb +33 -0
- data/lib/pwn/plugins/findings.rb +56 -1
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/ghidra.rb +20 -2
- data/lib/pwn/plugins/packet.rb +26 -0
- data/lib/pwn/plugins/preflight_checker.rb +23 -0
- data/lib/pwn/plugins/process_tube.rb +31 -1
- 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/sessions.rb +4 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
- 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 +44 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -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/detonate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
- 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 +73 -0
- metadata +25 -1
data/lib/pwn/ai/agent/metrics.rb
CHANGED
|
@@ -99,9 +99,32 @@ module PWN
|
|
|
99
99
|
bump(bucket: e, success: success, duration: duration, error: error)
|
|
100
100
|
end
|
|
101
101
|
save(metrics: metrics)
|
|
102
|
+
append_jsonl(opts.merge(name: name, success: success, duration: duration, error: error, engine: engine))
|
|
102
103
|
t
|
|
103
104
|
end
|
|
104
105
|
|
|
106
|
+
public_class_method def self.append_jsonl(opts = {})
|
|
107
|
+
path = File.join(Dir.home, '.pwn', 'logs', 'tool_metrics.jsonl')
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
109
|
+
row = {
|
|
110
|
+
ts: Time.now.utc.iso8601,
|
|
111
|
+
session: Thread.current[:pwn_session_id],
|
|
112
|
+
tool: opts[:name],
|
|
113
|
+
latency_ms: (opts[:duration].to_f * 1000).round,
|
|
114
|
+
timeout_used: opts[:timeout],
|
|
115
|
+
outcome: opts[:success] ? 'ok' : 'err',
|
|
116
|
+
error_class: opts[:error].to_s.split(':').first,
|
|
117
|
+
bytes_out: opts[:bytes_out].to_i
|
|
118
|
+
}
|
|
119
|
+
File.open(path, 'a') do |f|
|
|
120
|
+
f.flock(File::LOCK_EX)
|
|
121
|
+
f.puts(JSON.generate(row))
|
|
122
|
+
end
|
|
123
|
+
row
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
105
128
|
# Supported Method Parameters::
|
|
106
129
|
# rows = PWN::AI::Agent::Metrics.summary(
|
|
107
130
|
# limit: 'optional - cap number of tools returned (default 25)',
|
|
@@ -664,11 +687,22 @@ module PWN
|
|
|
664
687
|
#{self}.record(
|
|
665
688
|
name: 'required - tool name that was dispatched',
|
|
666
689
|
success: 'required - Boolean, did the handler complete without error',
|
|
667
|
-
duration: 'optional - Float seconds the dispatch took',
|
|
690
|
+
duration: 'optional - Float seconds the dispatch took (wall time)',
|
|
668
691
|
error: 'optional - String error message when success is false',
|
|
669
692
|
engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)'
|
|
670
693
|
)
|
|
671
694
|
|
|
695
|
+
# Append one JSONL telemetry row under ~/.pwn/logs/tool_metrics.jsonl.
|
|
696
|
+
#{self}.append_jsonl(
|
|
697
|
+
name: 'required - tool name',
|
|
698
|
+
success: 'optional - Boolean outcome',
|
|
699
|
+
duration: 'optional - Float seconds the dispatch took (wall time)',
|
|
700
|
+
error: 'optional - error string',
|
|
701
|
+
engine: 'optional - engine name',
|
|
702
|
+
timeout: 'optional - timeout used',
|
|
703
|
+
bytes_out: 'optional - output byte count'
|
|
704
|
+
)
|
|
705
|
+
|
|
672
706
|
# Run summary and return its result
|
|
673
707
|
#{self}.summary(
|
|
674
708
|
limit: 'optional - cap number of tools returned (default 25)',
|
|
@@ -113,6 +113,7 @@ module PWN
|
|
|
113
113
|
# )
|
|
114
114
|
|
|
115
115
|
public_class_method def self.definitions(opts = {})
|
|
116
|
+
eager_load!
|
|
116
117
|
enabled = opts[:enabled]
|
|
117
118
|
enabled = enabled.map(&:to_s) if enabled
|
|
118
119
|
pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
|
|
@@ -296,6 +297,10 @@ module PWN
|
|
|
296
297
|
# )
|
|
297
298
|
|
|
298
299
|
public_class_method def self.discover(opts = {})
|
|
300
|
+
eager_load!(opts)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
public_class_method def self.eager_load!(opts = {})
|
|
299
304
|
force = opts[:force] ||= false
|
|
300
305
|
return @entries.keys if @discovered && !force
|
|
301
306
|
|
|
@@ -311,6 +316,19 @@ module PWN
|
|
|
311
316
|
@entries.keys
|
|
312
317
|
end
|
|
313
318
|
|
|
319
|
+
public_class_method def self.selftest(opts = {})
|
|
320
|
+
_dry = opts[:dry_run]
|
|
321
|
+
eager_load!
|
|
322
|
+
tools_dir = File.join(__dir__, 'tools')
|
|
323
|
+
files = Dir[File.join(tools_dir, '*.rb')]
|
|
324
|
+
rows = @entries.map do |name, e|
|
|
325
|
+
schema = e.schema.is_a?(Hash)
|
|
326
|
+
required = Array(e.schema&.dig(:parameters, :required) || e.schema&.dig('parameters', 'required'))
|
|
327
|
+
{ name: name, schema_valid: schema, handler: !e.handler.nil?, required: required }
|
|
328
|
+
end
|
|
329
|
+
{ tools: rows, files: files.length, registered: @entries.length, ok: rows.length >= files.length }
|
|
330
|
+
end
|
|
331
|
+
|
|
314
332
|
private_class_method def self.safe_check(opts = {})
|
|
315
333
|
entry = opts[:entry]
|
|
316
334
|
entry.check.call
|
|
@@ -428,6 +446,16 @@ module PWN
|
|
|
428
446
|
force: 'optional - re-require tool files even if already discovered (default false)'
|
|
429
447
|
)
|
|
430
448
|
|
|
449
|
+
# Eager-load every tools/*.rb so Registry.definitions works in a cold REPL.
|
|
450
|
+
#{self}.eager_load!(
|
|
451
|
+
force: 'optional - re-require tool files even if already discovered (default false)'
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Schema/handler contract check for every registered tool.
|
|
455
|
+
#{self}.selftest(
|
|
456
|
+
dry_run: 'optional - reserved dry-run flag'
|
|
457
|
+
)
|
|
458
|
+
|
|
431
459
|
# Print the AUTHOR(S) string for this module.
|
|
432
460
|
#{self}.authors
|
|
433
461
|
"
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -177,22 +177,37 @@ module PWN
|
|
|
177
177
|
v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
180
|
+
pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
|
|
181
|
+
v[:judge_score] = v[:score].to_f
|
|
182
|
+
vv = opts[:verifier_verdict]
|
|
183
|
+
vv = :pass if vv.nil? && (opts[:verifier_pass] == true || pass || (ground && ground[:verdict] == :confirmed))
|
|
184
|
+
vv = vv.to_s.to_sym if vv
|
|
185
|
+
v[:verifier_verdict] = vv
|
|
186
|
+
v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: vv, request: request, final: final))
|
|
187
|
+
v[:remediation_hint] = taxonomy_hint(verdict_class: v[:verdict_class])
|
|
188
|
+
prec = verifier_precedence?
|
|
189
|
+
if prec && vv == :pass
|
|
190
|
+
v[:success] = true
|
|
191
|
+
else
|
|
192
|
+
v[:success] = promote_to_success?(
|
|
193
|
+
orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
|
|
194
|
+
verify: if ground.nil?
|
|
195
|
+
nil
|
|
196
|
+
else
|
|
197
|
+
ground[:verdict] == :confirmed
|
|
198
|
+
end,
|
|
199
|
+
critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
|
|
200
|
+
)
|
|
201
|
+
end
|
|
189
202
|
v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
|
|
190
203
|
v[:engine] = eng
|
|
191
|
-
pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
|
|
192
204
|
v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
|
|
193
205
|
if pass
|
|
194
|
-
|
|
195
|
-
v[:score] = [
|
|
206
|
+
score = v[:score].to_f
|
|
207
|
+
v[:score] = [score, 0.6].max
|
|
208
|
+
v[:success] = true if prec
|
|
209
|
+
v[:verifier_verdict] ||= :pass
|
|
210
|
+
v[:verdict] = :solved
|
|
196
211
|
end
|
|
197
212
|
v[:score_components] ||= {
|
|
198
213
|
judge: v[:score].to_f,
|
|
@@ -201,7 +216,16 @@ module PWN
|
|
|
201
216
|
weights: { overlap: pass ? 0.0 : 0.15 }
|
|
202
217
|
}
|
|
203
218
|
v[:score_components][:weights][:overlap] = 0.0 if pass
|
|
204
|
-
|
|
219
|
+
if commit && defined?(Learning) && opts[:persist_components]
|
|
220
|
+
Learning.note_outcome(
|
|
221
|
+
task: request[0, 80],
|
|
222
|
+
success: v[:success],
|
|
223
|
+
score: v[:score],
|
|
224
|
+
details: v[:score_components].to_json,
|
|
225
|
+
verifier_verdict: v[:verifier_verdict],
|
|
226
|
+
verdict_class: v[:verdict_class]
|
|
227
|
+
)
|
|
228
|
+
end
|
|
205
229
|
# W3 — write Brier on every judged turn so overconfidence can
|
|
206
230
|
# throttle max_iters/critic even when plan_first never fired.
|
|
207
231
|
if commit
|
|
@@ -1076,7 +1100,7 @@ module PWN
|
|
|
1076
1100
|
CHEAP_ORM_TEMP = 0.1
|
|
1077
1101
|
CHEAP_ORM_TRACE_N = 12
|
|
1078
1102
|
ORM_SAMPLE_WEIGHT = 1.0
|
|
1079
|
-
HEURISTIC_SAMPLE_WEIGHT = 0.
|
|
1103
|
+
HEURISTIC_SAMPLE_WEIGHT = 0.45
|
|
1080
1104
|
ERROR_SAMPLE_WEIGHT = 0.15
|
|
1081
1105
|
ENGINE_CHAT_MODS = {
|
|
1082
1106
|
openai: 'PWN::AI::OpenAI',
|
|
@@ -1254,6 +1278,45 @@ module PWN
|
|
|
1254
1278
|
false
|
|
1255
1279
|
end
|
|
1256
1280
|
|
|
1281
|
+
private_class_method def self.verifier_precedence?(opts = {})
|
|
1282
|
+
return true unless opts.is_a?(Hash)
|
|
1283
|
+
return true unless defined?(PWN::Env)
|
|
1284
|
+
|
|
1285
|
+
v = PWN::Env.dig(:ai, :reward, :verifier_precedence)
|
|
1286
|
+
v != false
|
|
1287
|
+
rescue StandardError
|
|
1288
|
+
true
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
private_class_method def self.taxonomy_class(opts = {})
|
|
1292
|
+
given = (opts[:verdict_class] || opts['verdict_class']).to_s
|
|
1293
|
+
return given unless given.empty?
|
|
1294
|
+
|
|
1295
|
+
vv = opts[:verifier_verdict]
|
|
1296
|
+
score = opts[:score].to_f
|
|
1297
|
+
final = opts[:final].to_s
|
|
1298
|
+
request = opts[:request].to_s
|
|
1299
|
+
return 'unverified_claim' if vv == :pass && score < 0.6
|
|
1300
|
+
return 'missing_artifact' if request.match?(%r{/(?:tmp|home|opt|var)/}) && !final.match?(%r{/(?:tmp|home|opt|var)/})
|
|
1301
|
+
return 'wrong_path' if request.include?('/tmp/') && final.include?('/tmp/') && request.split.none? { |tok| tok.start_with?('/') && final.include?(tok) }
|
|
1302
|
+
return 'scope_miss' if final.match?(/out of scope|SCOPE_DENY/i)
|
|
1303
|
+
return 'partial_coverage' if score.between?(0.3, 0.59)
|
|
1304
|
+
return 'style_only' if score >= 0.6 && Array(opts[:trace]).empty?
|
|
1305
|
+
|
|
1306
|
+
'unverified_claim'
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
private_class_method def self.taxonomy_hint(opts = {})
|
|
1310
|
+
{
|
|
1311
|
+
'missing_artifact' => 'Write the requested path then read it back.',
|
|
1312
|
+
'wrong_path' => 'Use the absolute path named in the original request.',
|
|
1313
|
+
'unverified_claim' => 'Add a verifier PASS (file readback, exit 0, or hash match).',
|
|
1314
|
+
'scope_miss' => 'Stay inside the active engagement scope.',
|
|
1315
|
+
'partial_coverage' => 'Finish remaining request clauses before claiming done.',
|
|
1316
|
+
'style_only' => 'Produce a host-visible artifact, not prose restyling.'
|
|
1317
|
+
}[opts[:verdict_class].to_s] || 'Produce evidence that matches the original request.'
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1257
1320
|
private_class_method def self.cheap_orm_timeout
|
|
1258
1321
|
n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
|
|
1259
1322
|
n = CHEAP_ORM_TIMEOUT if n < 2
|
|
@@ -1770,7 +1833,9 @@ module PWN
|
|
|
1770
1833
|
critic_pass: 'optional - critic pass value consumed by #judge',
|
|
1771
1834
|
predicted: 'optional - predicted value consumed by #judge',
|
|
1772
1835
|
proxy_ok: 'optional - proxy ok value consumed by #judge',
|
|
1773
|
-
persist_components: 'optional - write score_components into the learning ledger'
|
|
1836
|
+
persist_components: 'optional - write score_components into the learning ledger',
|
|
1837
|
+
verifier_verdict: 'optional - :pass when a deterministic verifier already succeeded',
|
|
1838
|
+
verifier_pass: 'optional - true as a boolean alias for verifier_verdict :pass'
|
|
1774
1839
|
)
|
|
1775
1840
|
|
|
1776
1841
|
# Run promote to success and return its result
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -438,6 +438,34 @@ module PWN
|
|
|
438
438
|
"FACTS #{rows.map { |r| "#{r[:kind]}=#{r[:value]}" }.join(' ')}"
|
|
439
439
|
end
|
|
440
440
|
|
|
441
|
+
public_class_method def self.claim(opts = {})
|
|
442
|
+
eng = (opts[:engagement_id] || 'default').to_s
|
|
443
|
+
unit = opts[:unit].to_s
|
|
444
|
+
raise 'ERROR: unit is required' if unit.empty?
|
|
445
|
+
|
|
446
|
+
ttl = (opts[:ttl] || 300).to_i
|
|
447
|
+
agent = (opts[:agent_id] || 'anon').to_s
|
|
448
|
+
dir = File.join(Dir.home, '.pwn', 'swarm', eng)
|
|
449
|
+
FileUtils.mkdir_p(dir)
|
|
450
|
+
path = File.join(dir, "#{unit.gsub(/[^A-Za-z0-9._:-]/, '_')}.claim")
|
|
451
|
+
now = Time.now.to_i
|
|
452
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
453
|
+
f.flock(File::LOCK_EX)
|
|
454
|
+
existing = begin
|
|
455
|
+
JSON.parse(f.read, symbolize_names: true)
|
|
456
|
+
rescue StandardError
|
|
457
|
+
{}
|
|
458
|
+
end
|
|
459
|
+
return { ok: false, unit: unit, holder: existing[:agent_id] } if existing[:until].to_i > now && existing[:agent_id].to_s != agent
|
|
460
|
+
|
|
461
|
+
row = { unit: unit, agent_id: agent, until: now + ttl }
|
|
462
|
+
f.rewind
|
|
463
|
+
f.truncate(0)
|
|
464
|
+
f.write(JSON.generate(row))
|
|
465
|
+
{ ok: true, unit: unit, agent_id: agent, until: row[:until] }
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
441
469
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
442
470
|
|
|
443
471
|
public_class_method def self.authors
|
|
@@ -535,6 +563,14 @@ module PWN
|
|
|
535
563
|
engagement_id: 'optional - engagement id (defaults to default)'
|
|
536
564
|
)
|
|
537
565
|
|
|
566
|
+
# Atomically claim a work unit (INSERT-or-fail until TTL).
|
|
567
|
+
#{self}.claim(
|
|
568
|
+
unit: 'required - normalized target+phase key',
|
|
569
|
+
engagement_id: 'optional - engagement id (defaults to default)',
|
|
570
|
+
ttl: 'optional - seconds until the claim expires (defaults to 300)',
|
|
571
|
+
agent_id: 'optional - claimant id'
|
|
572
|
+
)
|
|
573
|
+
|
|
538
574
|
# Print the AUTHOR(S) string for this module.
|
|
539
575
|
#{self}.authors
|
|
540
576
|
"
|
|
@@ -5,6 +5,9 @@ require 'json'
|
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'securerandom'
|
|
7
7
|
require 'time'
|
|
8
|
+
require 'openssl'
|
|
9
|
+
require 'uri'
|
|
10
|
+
require 'base64'
|
|
8
11
|
|
|
9
12
|
module PWN
|
|
10
13
|
module AI
|
|
@@ -41,7 +44,12 @@ module PWN
|
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
public_class_method def self.placeholder?(opts = {})
|
|
44
|
-
|
|
47
|
+
s = opts[:text].to_s.dup
|
|
48
|
+
s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m, ' ')
|
|
49
|
+
s.gsub!(/<<[-~]?\s*(\w+).*?^\1\s*$/m, ' ')
|
|
50
|
+
s.gsub!(/'[^']*'/, "''")
|
|
51
|
+
s.gsub!(/"([^"\\]|\\.)*"/, '""')
|
|
52
|
+
PLACEHOLDER_RX.match?(s)
|
|
45
53
|
rescue StandardError
|
|
46
54
|
false
|
|
47
55
|
end
|
|
@@ -60,18 +68,36 @@ module PWN
|
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
public_class_method def self.mint_canary(opts = {})
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
_bytes = opts[:bytes]
|
|
72
|
+
turn = (opts[:turn] || Thread.current[:pwn_loop_iter] || 0).to_i
|
|
73
|
+
sid = (opts[:session_id] || Thread.current[:pwn_session_id] || 'sess').to_s
|
|
74
|
+
key = (Thread.current[:pwn_canary_key] ||= SecureRandom.hex(16))
|
|
75
|
+
tok = OpenSSL::HMAC.hexdigest('SHA256', key, "#{sid}:#{turn}")[0, 16]
|
|
76
|
+
hist = Thread.current[:pwn_canary_hist] ||= []
|
|
77
|
+
hist << tok
|
|
78
|
+
hist.shift while hist.length > 4
|
|
66
79
|
Thread.current[:pwn_canary] = tok
|
|
67
80
|
tok
|
|
68
81
|
end
|
|
69
82
|
|
|
70
83
|
public_class_method def self.canary_leak?(opts = {})
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
text = opts[:text].to_s
|
|
85
|
+
toks = Array(Thread.current[:pwn_canary_hist])
|
|
86
|
+
toks << Thread.current[:pwn_canary].to_s
|
|
87
|
+
toks = toks.reject(&:empty?).uniq
|
|
88
|
+
return false if toks.empty?
|
|
89
|
+
|
|
90
|
+
require 'base64'
|
|
91
|
+
toks.any? do |tok|
|
|
92
|
+
next true if text.include?(tok)
|
|
93
|
+
|
|
94
|
+
b64 = Base64.strict_encode64(tok)
|
|
95
|
+
b64u = Base64.urlsafe_encode64(tok)
|
|
96
|
+
hex = tok.each_byte.map { |b| format('%02x', b) }.join
|
|
97
|
+
enc = URI.encode_www_form_component(tok)
|
|
98
|
+
rot = tok.tr('A-Za-z', 'N-ZA-Mn-za-m')
|
|
99
|
+
text.include?(b64) || text.include?(b64u) || text.include?(hex) || text.include?(enc) || text.include?(rot)
|
|
100
|
+
end
|
|
75
101
|
end
|
|
76
102
|
|
|
77
103
|
public_class_method def self.injection_score(opts = {})
|
|
@@ -442,6 +468,49 @@ module PWN
|
|
|
442
468
|
nil
|
|
443
469
|
end
|
|
444
470
|
|
|
471
|
+
public_class_method def self.policy_decision(opts = {})
|
|
472
|
+
name = opts[:name].to_s
|
|
473
|
+
blob = opts[:args].inspect
|
|
474
|
+
path = File.join(Dir.home, '.pwn', 'toolguard.yaml')
|
|
475
|
+
return nil unless File.file?(path)
|
|
476
|
+
|
|
477
|
+
require 'yaml'
|
|
478
|
+
doc = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
479
|
+
Array(doc['rules'] || doc[:rules]).each do |rule|
|
|
480
|
+
next unless rule.is_a?(Hash)
|
|
481
|
+
|
|
482
|
+
tool = (rule['tool'] || rule[:tool]).to_s
|
|
483
|
+
next unless tool.empty? || tool == name
|
|
484
|
+
|
|
485
|
+
pat = (rule['arg_pattern'] || rule[:arg_pattern]).to_s
|
|
486
|
+
next if pat.empty? || blob.match?(Regexp.new(pat)) == false
|
|
487
|
+
|
|
488
|
+
action = (rule['action'] || rule[:action] || 'allow').to_s
|
|
489
|
+
next if action == 'allow'
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
action: action,
|
|
493
|
+
success: false,
|
|
494
|
+
error: (rule['reason'] || rule[:reason] || 'toolguard policy deny').to_s,
|
|
495
|
+
code: 'POLICY_DENY',
|
|
496
|
+
tool: name
|
|
497
|
+
}
|
|
498
|
+
end
|
|
499
|
+
nil
|
|
500
|
+
rescue StandardError
|
|
501
|
+
nil
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
public_class_method def self.scope_check!(opts = {})
|
|
505
|
+
return nil unless defined?(Engagement)
|
|
506
|
+
|
|
507
|
+
Engagement.deny_if_out_of_scope(
|
|
508
|
+
args: opts[:args],
|
|
509
|
+
command: opts[:command] || opts[:args].inspect,
|
|
510
|
+
text: opts[:text]
|
|
511
|
+
)
|
|
512
|
+
end
|
|
513
|
+
|
|
445
514
|
public_class_method def self.rfc1918?(opts = {})
|
|
446
515
|
oct = opts[:ip].to_s.split('.').map(&:to_i)
|
|
447
516
|
return false unless oct.length == 4
|
|
@@ -535,7 +604,9 @@ module PWN
|
|
|
535
604
|
|
|
536
605
|
# Mint a per-thread session canary token stored on Thread.current.
|
|
537
606
|
#{self}.mint_canary(
|
|
538
|
-
bytes: 'optional -
|
|
607
|
+
bytes: 'optional - unused reserved length (HMAC uses 16 hex chars)',
|
|
608
|
+
turn: 'optional - turn number mixed into the HMAC',
|
|
609
|
+
session_id: 'optional - session id mixed into the HMAC'
|
|
539
610
|
)
|
|
540
611
|
|
|
541
612
|
# True when text contains the current session canary.
|
|
@@ -690,6 +761,19 @@ module PWN
|
|
|
690
761
|
payload: 'optional - command string if class omitted'
|
|
691
762
|
)
|
|
692
763
|
|
|
764
|
+
# Evaluate ~/.pwn/toolguard.yaml first-match rules.
|
|
765
|
+
#{self}.policy_decision(
|
|
766
|
+
name: 'required - tool name',
|
|
767
|
+
args: 'required - Hash of tool arguments'
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Block network args outside the active engagement scope.
|
|
771
|
+
#{self}.scope_check!(
|
|
772
|
+
args: 'optional - Hash of tool arguments',
|
|
773
|
+
command: 'optional - command string to scan for hosts',
|
|
774
|
+
text: 'optional - free-form blob to scan'
|
|
775
|
+
)
|
|
776
|
+
|
|
693
777
|
# Print the AUTHOR(S) string for this module.
|
|
694
778
|
#{self}.authors
|
|
695
779
|
"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn/ai/agent/registry'
|
|
4
|
+
|
|
5
|
+
PWN::AI::Agent::Registry.register(
|
|
6
|
+
name: 'debug_session',
|
|
7
|
+
toolset: 'pwn',
|
|
8
|
+
schema: {
|
|
9
|
+
name: 'debug_session',
|
|
10
|
+
description: 'Run a binary under gdb to crash and return typed crash_info.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
binary: { type: 'string' },
|
|
15
|
+
args: { type: 'array', items: { type: 'string' } },
|
|
16
|
+
script: { type: 'array', items: { type: 'string' } }
|
|
17
|
+
},
|
|
18
|
+
required: %w[binary]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
handler: lambda { |args|
|
|
22
|
+
PWN::Plugins::GDB.debug_session(
|
|
23
|
+
binary: args[:binary] || args['binary'],
|
|
24
|
+
args: args[:args] || args['args'],
|
|
25
|
+
commands: args[:script] || args['script']
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
PWN::AI::Agent::Registry.register(
|
|
30
|
+
name: 'binary_diff',
|
|
31
|
+
toolset: 'pwn',
|
|
32
|
+
schema: {
|
|
33
|
+
name: 'binary_diff',
|
|
34
|
+
description: 'Diff two binaries (radiff2 -C) and rank changed functions.',
|
|
35
|
+
parameters: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: { a: { type: 'string' }, b: { type: 'string' } },
|
|
38
|
+
required: %w[a b]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
handler: lambda { |args|
|
|
42
|
+
PWN::Plugins::BinaryParser.diff(a: args[:a] || args['a'], b: args[:b] || args['b'])
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
PWN::AI::Agent::Registry.register(
|
|
46
|
+
name: 'crash_triage',
|
|
47
|
+
toolset: 'pwn',
|
|
48
|
+
schema: {
|
|
49
|
+
name: 'crash_triage',
|
|
50
|
+
description: 'Dedupe AFL crashes, minimize, debug, and emit findings with PoCs.',
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { campaign: { type: 'string' }, out_dir: { type: 'string' }, target: { type: 'string' } }
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
handler: lambda { |args|
|
|
57
|
+
dir = args[:out_dir] || args['out_dir'] || File.join(Dir.home, '.pwn', 'fuzz', (args[:campaign] || args['campaign'] || 'default').to_s)
|
|
58
|
+
PWN::Plugins::AFLplusplus.crash_triage(out_dir: dir)
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
PWN::AI::Agent::Registry.register(
|
|
62
|
+
name: 'capability_request',
|
|
63
|
+
toolset: 'sessions',
|
|
64
|
+
schema: {
|
|
65
|
+
name: 'capability_request',
|
|
66
|
+
description: 'Request a grantable capability (never auto-sudo).',
|
|
67
|
+
parameters: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: { cap: { type: 'string' }, reason: { type: 'string' }, operator_ack: { type: 'boolean' } },
|
|
70
|
+
required: %w[cap]
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
handler: lambda { |args|
|
|
74
|
+
PWN::Plugins::Capability.request(
|
|
75
|
+
cap: args[:cap] || args['cap'],
|
|
76
|
+
reason: args[:reason] || args['reason'],
|
|
77
|
+
operator_ack: args[:operator_ack] || args['operator_ack']
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
PWN::AI::Agent::Registry.register(
|
|
82
|
+
name: 'emulate',
|
|
83
|
+
toolset: 'pwn',
|
|
84
|
+
schema: {
|
|
85
|
+
name: 'emulate',
|
|
86
|
+
description: 'Emulate one function (Unicorn) and return registers/memory writes.',
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
binary: { type: 'string' },
|
|
91
|
+
addr: { type: 'string' },
|
|
92
|
+
args: { type: 'array' },
|
|
93
|
+
max_insns: { type: 'integer' }
|
|
94
|
+
},
|
|
95
|
+
required: %w[binary addr]
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
handler: lambda { |args|
|
|
99
|
+
PWN::Plugins::Emulator.emulate(
|
|
100
|
+
binary: args[:binary] || args['binary'],
|
|
101
|
+
addr: args[:addr] || args['addr'],
|
|
102
|
+
args: args[:args] || args['args'],
|
|
103
|
+
max_insns: args[:max_insns] || args['max_insns']
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
PWN::AI::Agent::Registry.register(
|
|
108
|
+
name: 'detonate',
|
|
109
|
+
toolset: 'pwn',
|
|
110
|
+
schema: {
|
|
111
|
+
name: 'detonate',
|
|
112
|
+
description: 'Run a sample in an isolated container. Refuses if isolation is missing.',
|
|
113
|
+
parameters: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: { path: { type: 'string' }, timeout: { type: 'integer' }, network: { type: 'string' } },
|
|
116
|
+
required: %w[path]
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
handler: lambda { |args|
|
|
120
|
+
PWN::Plugins::Detonate.detonate(
|
|
121
|
+
path: args[:path] || args['path'],
|
|
122
|
+
timeout: args[:timeout] || args['timeout'],
|
|
123
|
+
network: args[:network] || args['network']
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
PWN::AI::Agent::Registry.register(
|
|
128
|
+
name: 'budget_status',
|
|
129
|
+
toolset: 'sessions',
|
|
130
|
+
schema: {
|
|
131
|
+
name: 'budget_status',
|
|
132
|
+
description: 'Read-only remaining time/token/mutation budget for this loop.',
|
|
133
|
+
parameters: { type: 'object', properties: {} }
|
|
134
|
+
},
|
|
135
|
+
handler: ->(_args) { PWN::AI::Agent::Loop.budget_status }
|
|
136
|
+
)
|
|
137
|
+
PWN::AI::Agent::Registry.register(
|
|
138
|
+
name: 'pty_expect',
|
|
139
|
+
toolset: 'terminal',
|
|
140
|
+
schema: {
|
|
141
|
+
name: 'pty_expect',
|
|
142
|
+
description: 'Send optional data and wait for a regex on a PTY with timeout.',
|
|
143
|
+
parameters: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
session: { type: 'string' },
|
|
147
|
+
id: { type: 'string' },
|
|
148
|
+
send: { type: 'string' },
|
|
149
|
+
expect: { type: 'string' },
|
|
150
|
+
timeout: { type: 'integer' }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
handler: lambda { |args|
|
|
155
|
+
id = args[:session] || args[:id] || args['session'] || args['id']
|
|
156
|
+
line = args[:send] || args['send']
|
|
157
|
+
PWN::Plugins::ProcessTube.write_line(id: id, line: line) if line.to_s != ''
|
|
158
|
+
PWN::Plugins::ProcessTube.expect(id: id, until: args[:expect] || args['expect'], timeout: args[:timeout])
|
|
159
|
+
}
|
|
160
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn/ai/agent/registry'
|
|
4
|
+
|
|
5
|
+
PWN::AI::Agent::Registry.register(
|
|
6
|
+
name: 'engagement_open',
|
|
7
|
+
toolset: 'sessions',
|
|
8
|
+
schema: {
|
|
9
|
+
name: 'engagement_open',
|
|
10
|
+
description: 'Open or update an engagement scope document and mark it active.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
name: { type: 'string' },
|
|
15
|
+
scope_cidrs: { type: 'array', items: { type: 'string' } },
|
|
16
|
+
scope_domains: { type: 'array', items: { type: 'string' } }
|
|
17
|
+
},
|
|
18
|
+
required: %w[name]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
handler: lambda { |args|
|
|
22
|
+
PWN::AI::Agent::Engagement.open(
|
|
23
|
+
name: args[:name] || args['name'],
|
|
24
|
+
scope_cidrs: args[:scope_cidrs] || args['scope_cidrs'],
|
|
25
|
+
scope_domains: args[:scope_domains] || args['scope_domains']
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
PWN::AI::Agent::Registry.register(
|
|
30
|
+
name: 'engagement_close',
|
|
31
|
+
toolset: 'sessions',
|
|
32
|
+
schema: { name: 'engagement_close', description: 'Clear the active engagement.', parameters: { type: 'object', properties: {} } },
|
|
33
|
+
handler: ->(_args) { PWN::AI::Agent::Engagement.close }
|
|
34
|
+
)
|
|
35
|
+
PWN::AI::Agent::Registry.register(
|
|
36
|
+
name: 'engagement_status',
|
|
37
|
+
toolset: 'sessions',
|
|
38
|
+
schema: { name: 'engagement_status', description: 'Return the active engagement document.', parameters: { type: 'object', properties: { name: { type: 'string' } } } },
|
|
39
|
+
handler: ->(args) { PWN::AI::Agent::Engagement.status(name: args[:name] || args['name']) }
|
|
40
|
+
)
|