pwn 0.5.739 → 0.5.741
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/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +163 -6
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +6 -2
- data/documentation/Recon-Findings-API.md +33 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
- data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
- data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
- data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
- data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
- data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -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/gdbmi/SKILL.md +8 -2
- data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/SKILL.md +2 -0
- data/lib/pwn/ai/agent/confirmation.rb +163 -0
- data/lib/pwn/ai/agent/dispatch.rb +43 -5
- data/lib/pwn/ai/agent/loop.rb +45 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
- data/lib/pwn/ai/agent/registry.rb +2 -1
- data/lib/pwn/ai/agent/result.rb +76 -6
- data/lib/pwn/ai/agent/task_dag.rb +220 -0
- data/lib/pwn/ai/agent/tool_guard.rb +166 -24
- data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
- data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
- data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
- data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
- data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
- data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
- data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
- data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
- data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
- data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
- data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
- data/lib/pwn/ai/agent/tools/shell.rb +22 -10
- data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
- data/lib/pwn/ai/agent.rb +3 -1
- data/lib/pwn/ai/cli.rb +26 -2
- data/lib/pwn/ai/context.rb +4 -2
- data/lib/pwn/ai/router.rb +196 -0
- data/lib/pwn/ai/tools/README.md +5 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +10 -0
- data/lib/pwn/engagement.rb +45 -1
- data/lib/pwn/ffi/capstone.rb +120 -0
- data/lib/pwn/ffi/keystone.rb +107 -0
- data/lib/pwn/ffi.rb +2 -0
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/aflplusplus.rb +238 -13
- data/lib/pwn/plugins/ai_sandbox.rb +240 -0
- data/lib/pwn/plugins/artifact_registry.rb +188 -22
- data/lib/pwn/plugins/assembly.rb +73 -2
- data/lib/pwn/plugins/basic_auth.rb +17 -4
- data/lib/pwn/plugins/binary_parser.rb +439 -38
- data/lib/pwn/plugins/exploit_dev.rb +83 -14
- data/lib/pwn/plugins/findings.rb +208 -42
- data/lib/pwn/plugins/gdbmi.rb +265 -38
- data/lib/pwn/plugins/httpx.rb +93 -0
- data/lib/pwn/plugins/jobs.rb +260 -102
- data/lib/pwn/plugins/method_catalog.rb +328 -0
- data/lib/pwn/plugins/nmap_it.rb +157 -26
- data/lib/pwn/plugins/nuclei.rb +122 -29
- data/lib/pwn/plugins/preflight_checker.rb +3 -2
- data/lib/pwn/plugins/process_tube.rb +51 -2
- data/lib/pwn/plugins/radare2.rb +120 -26
- data/lib/pwn/plugins/recon.rb +33 -0
- data/lib/pwn/plugins/repl/mesh.rb +203 -13
- data/lib/pwn/plugins/rop.rb +121 -31
- data/lib/pwn/plugins/transparent_browser.rb +212 -14
- data/lib/pwn/plugins/vault.rb +182 -24
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +8 -2
- data/lib/pwn/reports/html.rb +28 -9
- data/lib/pwn/reports/json.rb +2 -1
- data/lib/pwn/reports/markdown.rb +49 -12
- data/lib/pwn/reports/sarif.rb +17 -3
- data/lib/pwn/reports.rb +184 -34
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reports_spec.rb +93 -0
- data/spec/integration/result_condition_spec.rb +11 -8
- data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
- data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
- data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
- data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
- data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
- data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
- data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
- data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
- data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
- data/spec/lib/pwn/ai/cli_spec.rb +12 -1
- data/spec/lib/pwn/ai/router_spec.rb +57 -0
- data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
- data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
- data/spec/lib/pwn/migrate_spec.rb +30 -2
- data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
- data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
- data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
- data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
- data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
- data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
- data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
- data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
- data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
- data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
- data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
- data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
- data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
- data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
- data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
- data/spec/lib/pwn/reports_spec.rb +196 -0
- data/spec/spec_helper.rb +3 -0
- data/third_party/pwn_rdoc.jsonl +230 -4
- metadata +47 -5
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
|
|
9
|
+
module PWN
|
|
10
|
+
module AI
|
|
11
|
+
module Agent
|
|
12
|
+
# Planner/executor split: YAML task DAGs with per-step checkpoints.
|
|
13
|
+
module TaskDAG
|
|
14
|
+
ROOT = File.join(Dir.home, '.pwn', 'runs')
|
|
15
|
+
|
|
16
|
+
public_class_method def self.required_bins
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Emit a YAML-serializable DAG with zero tool side effects.
|
|
21
|
+
public_class_method def self.plan(opts = {})
|
|
22
|
+
request = opts[:request].to_s
|
|
23
|
+
raise 'ERROR: request is required' if request.strip.empty?
|
|
24
|
+
|
|
25
|
+
steps = Array(opts[:steps])
|
|
26
|
+
steps = infer_steps(request: request) if steps.empty?
|
|
27
|
+
{
|
|
28
|
+
version: 1,
|
|
29
|
+
request: request,
|
|
30
|
+
steps: steps.map { |step| normalize_step(step: step) }
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Run an approved DAG; completed checkpoints are skipped on resume.
|
|
35
|
+
public_class_method def self.execute(opts = {})
|
|
36
|
+
root = opts[:root] || ROOT
|
|
37
|
+
run_id = (opts[:run_id] || SecureRandom.hex(8)).to_s
|
|
38
|
+
dir = File.join(root, run_id)
|
|
39
|
+
FileUtils.mkdir_p(dir)
|
|
40
|
+
dag = load_dag(opts.merge(dir: dir))
|
|
41
|
+
File.write(File.join(dir, 'dag.yaml'), YAML.dump(JSON.parse(JSON.generate(dag)))) unless File.file?(File.join(dir, 'dag.yaml'))
|
|
42
|
+
File.write(File.join(dir, 'run_id'), run_id)
|
|
43
|
+
done = load_done(dir: dir)
|
|
44
|
+
steps = Array(dag['steps'] || dag[:steps]).map { |step| normalize_step(step: step) }
|
|
45
|
+
results = []
|
|
46
|
+
remaining = steps.reject { |step| done[step['id']] }
|
|
47
|
+
until remaining.empty?
|
|
48
|
+
ready = remaining.select { |step| Array(step['dependencies']).all? { |need| done[need] } }
|
|
49
|
+
raise ArgumentError, 'task DAG deadlock or missing dependencies' if ready.empty?
|
|
50
|
+
|
|
51
|
+
ready.each do |step|
|
|
52
|
+
remaining.delete(step)
|
|
53
|
+
row = run_step(step: step, dir: dir)
|
|
54
|
+
done[step['id']] = true
|
|
55
|
+
results << row
|
|
56
|
+
persist_checkpoint(dir: dir, step: step, row: row)
|
|
57
|
+
return { run_id: run_id, dir: dir, results: results, halted: step['id'] } if opts[:halt_after].to_s == step['id']
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
{ run_id: run_id, dir: dir, results: results, ok: results.all? { |row| row[:ok] != false } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resume a checkpointed run, skipping completed steps.
|
|
64
|
+
public_class_method def self.resume(opts = {})
|
|
65
|
+
execute(opts)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
public_class_method def self.authors
|
|
69
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
public_class_method def self.help
|
|
73
|
+
puts "USAGE:
|
|
74
|
+
# List host binaries this module expects to be installed.
|
|
75
|
+
#{self}.required_bins
|
|
76
|
+
|
|
77
|
+
# Emit a YAML-serializable DAG with zero tool side effects.
|
|
78
|
+
#{self}.plan(
|
|
79
|
+
request: 'required - operator request to decompose',
|
|
80
|
+
steps: 'optional - Array of step hashes; inferred from the request when omitted'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Run an approved DAG; completed checkpoints are skipped on resume.
|
|
84
|
+
#{self}.execute(
|
|
85
|
+
dag: 'optional - Hash DAG; required unless path or run_id is set',
|
|
86
|
+
path: 'optional - YAML DAG path',
|
|
87
|
+
run_id: 'optional - existing run to resume; generated when omitted',
|
|
88
|
+
root: 'optional - runs directory; defaults to ~/.pwn/runs',
|
|
89
|
+
halt_after: 'optional - step id used by tests to stop after a checkpoint'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Resume a checkpointed run, skipping completed steps.
|
|
93
|
+
#{self}.resume(
|
|
94
|
+
run_id: 'required - run id previously returned by execute',
|
|
95
|
+
root: 'optional - runs directory; defaults to ~/.pwn/runs'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Print the AUTHOR(S) string for this module.
|
|
99
|
+
#{self}.authors
|
|
100
|
+
"
|
|
101
|
+
constants.sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private_class_method def self.infer_steps(opts = {})
|
|
105
|
+
parts = opts[:request].to_s.split(/\s+then\s+|;\s*|\n+/i).map(&:strip).reject(&:empty?)
|
|
106
|
+
parts = [opts[:request].to_s.strip] if parts.empty?
|
|
107
|
+
prev = nil
|
|
108
|
+
parts.each_with_index.map do |clause, idx|
|
|
109
|
+
id = "s#{idx + 1}"
|
|
110
|
+
tool, args = map_clause(clause: clause)
|
|
111
|
+
step = {
|
|
112
|
+
'id' => id,
|
|
113
|
+
'tool' => tool,
|
|
114
|
+
'args' => args,
|
|
115
|
+
'side_effect' => side_effect_tier(tool: tool, args: args),
|
|
116
|
+
'dependencies' => prev ? [prev] : []
|
|
117
|
+
}
|
|
118
|
+
prev = id
|
|
119
|
+
step
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_class_method def self.map_clause(opts = {})
|
|
124
|
+
clause = opts[:clause].to_s
|
|
125
|
+
case clause
|
|
126
|
+
when /\bnmap\b|\bnmap_scan\b/i
|
|
127
|
+
['nmap_scan', { 'xml' => clause }]
|
|
128
|
+
when /\bnuclei\b/i
|
|
129
|
+
['nuclei_scan', { 'target' => clause }]
|
|
130
|
+
when /\b(pwn_eval|ruby)\b/i
|
|
131
|
+
['pwn_eval', { 'code' => clause }]
|
|
132
|
+
else
|
|
133
|
+
['shell', { 'command' => clause }]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private_class_method def self.side_effect_tier(opts = {})
|
|
138
|
+
tool = opts[:tool].to_s
|
|
139
|
+
args = opts[:args] || {}
|
|
140
|
+
payload = args['command'] || args[:command] || args['code'] || args[:code] || JSON.generate(args)
|
|
141
|
+
kind = PWN::Plugins::AISandbox.classify(payload: payload, side_effect: opts[:side_effect])
|
|
142
|
+
case kind.to_s
|
|
143
|
+
when 'write' then 'destructive'
|
|
144
|
+
when 'network' then 'active_scan'
|
|
145
|
+
else
|
|
146
|
+
tool.match?(/scan|nuclei|nmap|fuzz/) ? 'active_scan' : 'read_only'
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method def self.normalize_step(opts = {})
|
|
151
|
+
step = opts[:step]
|
|
152
|
+
step = step.transform_keys(&:to_s) if step.is_a?(Hash)
|
|
153
|
+
raise ArgumentError, 'each step needs id, tool, and args' unless step.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
args = step['args'] || {}
|
|
156
|
+
args = args.transform_keys(&:to_s) if args.is_a?(Hash)
|
|
157
|
+
{
|
|
158
|
+
'id' => step['id'].to_s,
|
|
159
|
+
'tool' => step['tool'].to_s,
|
|
160
|
+
'args' => args,
|
|
161
|
+
'side_effect' => (step['side_effect'] || side_effect_tier(tool: step['tool'], args: args)).to_s,
|
|
162
|
+
'dependencies' => Array(step['dependencies']).map(&:to_s)
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private_class_method def self.load_dag(opts = {})
|
|
167
|
+
if opts[:dag]
|
|
168
|
+
dag = opts[:dag]
|
|
169
|
+
return dag.transform_keys(&:to_s) if dag.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
return dag
|
|
172
|
+
end
|
|
173
|
+
path = opts[:path].to_s
|
|
174
|
+
path = File.join(opts[:dir], 'dag.yaml') if path.empty?
|
|
175
|
+
raise 'ERROR: dag, path, or an existing run is required' unless File.file?(path)
|
|
176
|
+
|
|
177
|
+
YAML.safe_load_file(path)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private_class_method def self.load_done(opts = {})
|
|
181
|
+
path = File.join(opts[:dir], 'checkpoints.jsonl')
|
|
182
|
+
return {} unless File.file?(path)
|
|
183
|
+
|
|
184
|
+
File.readlines(path).each_with_object({}) do |line, acc|
|
|
185
|
+
row = JSON.parse(line)
|
|
186
|
+
acc[row['id']] = true if row['status'] == 'completed'
|
|
187
|
+
rescue JSON::ParserError
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private_class_method def self.persist_checkpoint(opts = {})
|
|
193
|
+
path = File.join(opts[:dir], 'checkpoints.jsonl')
|
|
194
|
+
rec = { id: opts[:step]['id'], status: 'completed', at: Time.now.utc.iso8601, result: opts[:row] }
|
|
195
|
+
File.open(path, 'a') do |file|
|
|
196
|
+
file.flock(File::LOCK_EX)
|
|
197
|
+
file.puts(JSON.generate(rec))
|
|
198
|
+
file.flush
|
|
199
|
+
file.fsync
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private_class_method def self.run_step(opts = {})
|
|
204
|
+
step = opts[:step]
|
|
205
|
+
PWN::AI::Agent::Registry.discover
|
|
206
|
+
raw = PWN::AI::Agent::Dispatch.call(
|
|
207
|
+
tool_call: { function: { name: step['tool'], arguments: JSON.generate(step['args']) } },
|
|
208
|
+
scope_path: File.join(opts[:dir], 'absent.yaml')
|
|
209
|
+
)
|
|
210
|
+
body = JSON.parse(raw, symbolize_names: true)
|
|
211
|
+
result = body[:result] || body
|
|
212
|
+
ok = !result.is_a?(Hash) || result[:error].nil?
|
|
213
|
+
{ id: step['id'], ok: ok, result: result }
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
{ id: step['id'], ok: false, error: "#{e.class}: #{e.message}" }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -8,6 +8,8 @@ require 'time'
|
|
|
8
8
|
require 'openssl'
|
|
9
9
|
require 'uri'
|
|
10
10
|
require 'base64'
|
|
11
|
+
require 'ripper'
|
|
12
|
+
require 'strscan'
|
|
11
13
|
|
|
12
14
|
module PWN
|
|
13
15
|
module AI
|
|
@@ -59,36 +61,155 @@ module PWN
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
public_class_method def self.placeholder?(opts = {})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
s
|
|
72
|
-
s
|
|
73
|
-
|
|
64
|
+
!placeholder_match(text: opts[:text], language: opts[:language], placeholder_ok: opts[:placeholder_ok]).nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
public_class_method def self.placeholder_match(opts = {})
|
|
68
|
+
return nil if opts[:placeholder_ok] == true
|
|
69
|
+
|
|
70
|
+
raw = opts[:text].to_s.dup.force_encoding(Encoding::UTF_8)
|
|
71
|
+
return ruby_placeholder_match(text: raw) if opts[:language].to_s == 'ruby'
|
|
72
|
+
|
|
73
|
+
s = shell_code_surface(text: raw)
|
|
74
|
+
found = /(?:\A|[;\n|&()])\s*(?<token>\.{3}|…)\s*(?=\z|[;\n|&()])/.match(s) || /\A\s*\[\s*(?<token>\.{3}|…)\s*\]\s*\z/.match(s)
|
|
75
|
+
if found
|
|
76
|
+
index = found.begin(:token)
|
|
77
|
+
token = found[:token]
|
|
78
|
+
else
|
|
79
|
+
found = PLACEHOLDER_RX.match(s)
|
|
80
|
+
return nil unless found
|
|
81
|
+
|
|
82
|
+
token = found[0].strip
|
|
83
|
+
index = found.begin(0) + found[0].index(token)
|
|
84
|
+
end
|
|
85
|
+
offset = s[0...index].bytesize
|
|
86
|
+
{ rule_id: 'payload.placeholder', match: raw.byteslice(offset, token.bytesize), offset: offset }
|
|
74
87
|
rescue StandardError
|
|
75
|
-
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private_class_method def self.ruby_placeholder_match(opts = {})
|
|
92
|
+
raw = opts[:text]
|
|
93
|
+
# Parsed ranges and argument forwarding are real Ruby, not elision.
|
|
94
|
+
return nil if Ripper.sexp(raw)
|
|
95
|
+
|
|
96
|
+
lines = raw.lines
|
|
97
|
+
tokens = Ripper.lex(raw).reject { |_, type, _, _| %i[on_sp on_nl on_ignored_nl on_comment].include?(type) }
|
|
98
|
+
tokens.each_with_index do |(position, type, text, _), index|
|
|
99
|
+
next unless %i[on_op on_ident].include?(type)
|
|
100
|
+
|
|
101
|
+
marker = text if %w[... …].include?(text)
|
|
102
|
+
following = tokens[index, 4]
|
|
103
|
+
marker = '<3dots>' if following.map { |row| row[2] } == ['<', '3', 'dots', '>']
|
|
104
|
+
next unless marker
|
|
105
|
+
|
|
106
|
+
offset = lines.first(position[0] - 1).sum(&:bytesize) + position[1]
|
|
107
|
+
return { rule_id: 'payload.placeholder', match: marker, offset: offset }
|
|
108
|
+
end
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Shlex-style word scanning with quote removal and queued heredocs.
|
|
113
|
+
# Mask literal bytes, retaining newlines and original byte positions.
|
|
114
|
+
private_class_method def self.shell_code_surface(opts = {})
|
|
115
|
+
raw = opts[:text].to_s
|
|
116
|
+
scanner = StringScanner.new(raw)
|
|
117
|
+
surface = raw.b.dup
|
|
118
|
+
pending = []
|
|
119
|
+
mask = lambda do |start, finish|
|
|
120
|
+
surface[start...finish] = raw.byteslice(start...finish).b.gsub(/[^\n]/n, '_')
|
|
121
|
+
end
|
|
122
|
+
until scanner.eos?
|
|
123
|
+
next if scanner.scan(/[ \t\r]+/)
|
|
124
|
+
|
|
125
|
+
if scanner.scan("\n")
|
|
126
|
+
pending.each do |delimiter, tabs|
|
|
127
|
+
until scanner.eos?
|
|
128
|
+
start = scanner.pos
|
|
129
|
+
line = scanner.scan(/[^\n]*(?:\n|\z)/)
|
|
130
|
+
mask.call(start, scanner.pos)
|
|
131
|
+
value = line.delete_suffix("\n")
|
|
132
|
+
value = value.sub(/\A\t+/, '') if tabs
|
|
133
|
+
break if value == delimiter
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
pending.clear
|
|
137
|
+
elsif scanner.peek(1) == '#'
|
|
138
|
+
start = scanner.pos
|
|
139
|
+
scanner.scan(/[^\n]*/)
|
|
140
|
+
mask.call(start, scanner.pos)
|
|
141
|
+
elsif scanner.peek(3) == '<<<'
|
|
142
|
+
start = scanner.pos
|
|
143
|
+
scanner.scan('<<<')
|
|
144
|
+
scanner.scan(/[ \t]*/)
|
|
145
|
+
shell_word(scanner: scanner)
|
|
146
|
+
mask.call(start, scanner.pos)
|
|
147
|
+
elsif scanner.peek(2) == '<<'
|
|
148
|
+
start = scanner.pos
|
|
149
|
+
operator = scanner.scan(/<<-?/)
|
|
150
|
+
scanner.scan(/[ \t]*/)
|
|
151
|
+
word = shell_word(scanner: scanner)
|
|
152
|
+
pending << [word[:text], operator == '<<-'] unless word[:text].empty?
|
|
153
|
+
mask.call(start, scanner.pos)
|
|
154
|
+
elsif scanner.scan(/[;|&()<>]/)
|
|
155
|
+
next
|
|
156
|
+
else
|
|
157
|
+
start = scanner.pos
|
|
158
|
+
word = shell_word(scanner: scanner)
|
|
159
|
+
mask.call(start, scanner.pos) if word[:quoted]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
surface.force_encoding(raw.encoding)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private_class_method def self.shell_word(opts = {})
|
|
166
|
+
scanner = opts[:scanner]
|
|
167
|
+
text = +''
|
|
168
|
+
quoted = false
|
|
169
|
+
until scanner.eos? || scanner.peek(1).b.match?(/[\s;|&()<>]/n)
|
|
170
|
+
char = scanner.getch
|
|
171
|
+
if ["'", '"'].include?(char)
|
|
172
|
+
quoted = true
|
|
173
|
+
quote = char
|
|
174
|
+
until scanner.eos?
|
|
175
|
+
char = scanner.getch
|
|
176
|
+
break if char == quote
|
|
177
|
+
|
|
178
|
+
char = scanner.getch if char == '\\' && quote == '"' && !scanner.eos?
|
|
179
|
+
text << char
|
|
180
|
+
end
|
|
181
|
+
elsif char == '\\' && !scanner.eos?
|
|
182
|
+
quoted = true
|
|
183
|
+
text << scanner.getch
|
|
184
|
+
else
|
|
185
|
+
text << char
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
{ text: text, quoted: quoted }
|
|
76
189
|
end
|
|
77
190
|
|
|
78
191
|
public_class_method def self.bashism?(opts = {})
|
|
192
|
+
!bashism_match(text: opts[:text]).nil?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
public_class_method def self.bashism_match(opts = {})
|
|
79
196
|
surface = shell_syntax_surface(text: opts[:text])
|
|
80
|
-
return
|
|
197
|
+
return nil if surface.to_s.empty?
|
|
81
198
|
|
|
82
|
-
surface = surface.gsub(/\$\{?RANDOM\}?\b
|
|
83
|
-
BASHISM_RX.match
|
|
199
|
+
surface = surface.gsub(/\$\{?RANDOM\}?\b/) { |token| ' ' * token.length } if surface.match?(/\bRANDOM=/)
|
|
200
|
+
found = BASHISM_RX.match(surface)
|
|
201
|
+
return nil unless found
|
|
202
|
+
|
|
203
|
+
raw = opts[:text].to_s
|
|
204
|
+
{ rule_id: 'payload.shell_syntax', match: raw[found.begin(0), found[0].length], offset: raw[0...found.begin(0)].bytesize }
|
|
84
205
|
rescue StandardError
|
|
85
|
-
|
|
206
|
+
nil
|
|
86
207
|
end
|
|
87
208
|
|
|
88
209
|
public_class_method def self.shell_syntax_surface(opts = {})
|
|
89
210
|
s = opts[:text].to_s.dup
|
|
90
|
-
s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m
|
|
91
|
-
s.gsub!(/'[^']*'
|
|
211
|
+
s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m) { |body| ' ' * body.length }
|
|
212
|
+
s.gsub!(/'[^']*'/) { |body| "'#{' ' * (body.length - 2)}'" }
|
|
92
213
|
s
|
|
93
214
|
end
|
|
94
215
|
|
|
@@ -217,13 +338,15 @@ module PWN
|
|
|
217
338
|
|
|
218
339
|
public_class_method def self.invalid_payload(opts = {})
|
|
219
340
|
hint = opts[:hint].to_s
|
|
220
|
-
tok = opts[:offending_token].to_s
|
|
341
|
+
tok = (opts[:match] || opts[:offending_token]).to_s
|
|
221
342
|
text = opts[:text].to_s
|
|
222
343
|
range = opts[:byte_range]
|
|
223
344
|
if range.nil? && !tok.empty? && !text.empty?
|
|
224
|
-
idx = text.index(tok)
|
|
345
|
+
idx = opts[:offset] || text.b.index(tok.b)
|
|
225
346
|
range = [idx, idx + tok.bytesize] if idx
|
|
226
347
|
end
|
|
348
|
+
remedy = (opts[:remedy] || opts[:suggestion]).to_s
|
|
349
|
+
remedy = "Correct the payload before retrying. #{hint}" if remedy.empty?
|
|
227
350
|
{
|
|
228
351
|
stdout: '',
|
|
229
352
|
stderr: hint,
|
|
@@ -231,10 +354,13 @@ module PWN
|
|
|
231
354
|
error: 'invalid_payload',
|
|
232
355
|
code: (opts[:code] || 'SYNTAX_DENY').to_s,
|
|
233
356
|
rule_id: (opts[:rule_id] || 'payload').to_s,
|
|
357
|
+
match: tok.empty? ? nil : tok,
|
|
358
|
+
offset: range&.first,
|
|
359
|
+
remedy: remedy,
|
|
234
360
|
offending_token: tok,
|
|
235
361
|
byte_range: range,
|
|
236
362
|
max_payload_bytes: MAX_PAYLOAD_BYTES,
|
|
237
|
-
suggestion:
|
|
363
|
+
suggestion: remedy,
|
|
238
364
|
hint: hint,
|
|
239
365
|
shell: opts[:shell] || shell_name
|
|
240
366
|
}
|
|
@@ -631,7 +757,15 @@ module PWN
|
|
|
631
757
|
# Detect isolated ellipsis placeholders, not inline ranges or prose.
|
|
632
758
|
#{self}.placeholder?(
|
|
633
759
|
text: 'optional - command or payload text to inspect',
|
|
634
|
-
placeholder_ok: 'optional - true skips denial when the operator opts in'
|
|
760
|
+
placeholder_ok: 'optional - true skips denial when the operator opts in',
|
|
761
|
+
language: 'optional - ruby uses Ripper; shell (default) scans quotes and heredocs'
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Locate a rejected placeholder in the original decoded payload.
|
|
765
|
+
#{self}.placeholder_match(
|
|
766
|
+
text: 'optional - command or payload text to inspect',
|
|
767
|
+
placeholder_ok: 'optional - true returns no match for intentional literal content',
|
|
768
|
+
language: 'optional - ruby uses Ripper; shell (default) scans quotes and heredocs'
|
|
635
769
|
)
|
|
636
770
|
|
|
637
771
|
# Run bashism and return its result
|
|
@@ -639,6 +773,11 @@ module PWN
|
|
|
639
773
|
text: 'optional - text value consumed by #bashism?'
|
|
640
774
|
)
|
|
641
775
|
|
|
776
|
+
# Locate unsupported shell syntax in the original decoded payload.
|
|
777
|
+
#{self}.bashism_match(
|
|
778
|
+
text: 'optional - shell command to inspect for bash-only constructs'
|
|
779
|
+
)
|
|
780
|
+
|
|
642
781
|
# Strip quoted heredocs and single-quoted strings before bash-syntax lint.
|
|
643
782
|
#{self}.shell_syntax_surface(
|
|
644
783
|
text: 'required - command string to lint'
|
|
@@ -693,7 +832,10 @@ module PWN
|
|
|
693
832
|
offending_token: 'optional - exact rejected token span',
|
|
694
833
|
suggestion: 'optional - how to rewrite the payload',
|
|
695
834
|
text: 'optional - original payload used to compute byte_range',
|
|
696
|
-
byte_range: 'optional - [start, stop] byte offsets of the offending span'
|
|
835
|
+
byte_range: 'optional - [start, stop] byte offsets of the offending span',
|
|
836
|
+
match: 'optional - exact matched substring; alias for offending_token',
|
|
837
|
+
offset: 'optional - zero-based byte offset in the original decoded payload',
|
|
838
|
+
remedy: 'optional - sanctioned correction; alias for suggestion'
|
|
697
839
|
)
|
|
698
840
|
|
|
699
841
|
# Build a machine-readable guard denial (SCOPE_DENY, CANARY_DENY, ...).
|
|
@@ -38,32 +38,62 @@ PWN::AI::Agent::Registry.register(
|
|
|
38
38
|
toolset: 'sessions',
|
|
39
39
|
schema: {
|
|
40
40
|
name: 'artifact_read',
|
|
41
|
-
description: '
|
|
41
|
+
description: 'Read a bounded byte page. Use handle from a spilled tool result, offset and length; follow next_offset until eof. Handle pages default to lossless base64.',
|
|
42
42
|
parameters: {
|
|
43
43
|
type: 'object',
|
|
44
44
|
properties: {
|
|
45
|
+
handle: { type: 'string' },
|
|
45
46
|
path: { type: 'string' },
|
|
46
|
-
offset: { type: 'integer' },
|
|
47
|
-
length: { type: 'integer' },
|
|
47
|
+
offset: { type: 'integer', minimum: 0 },
|
|
48
|
+
length: { type: 'integer', minimum: 1 },
|
|
48
49
|
mode: { type: 'string', description: 'text|hex|base64' },
|
|
49
50
|
grep: { type: 'string' },
|
|
50
51
|
ref: { type: 'string' },
|
|
51
52
|
sha256: { type: 'string' }
|
|
52
53
|
},
|
|
53
|
-
required: %w[path]
|
|
54
|
+
anyOf: [{ required: %w[handle] }, { required: %w[path] }, { required: %w[ref] }]
|
|
54
55
|
}
|
|
55
56
|
},
|
|
56
57
|
handler: lambda { |args|
|
|
57
58
|
PWN::Plugins::ArtifactRegistry.read_page(
|
|
59
|
+
handle: args[:handle] || args['handle'],
|
|
58
60
|
path: args[:path] || args['path'] || args[:ref] || args['ref'],
|
|
59
61
|
offset: args[:offset] || args['offset'],
|
|
60
62
|
length: args[:length] || args['length'],
|
|
61
|
-
|
|
63
|
+
max_length: PWN::AI::Agent::Result.page_length,
|
|
64
|
+
max_bytes: PWN::AI::Agent::Result.page_length,
|
|
65
|
+
mode: args[:mode] || args['mode'] || (args[:handle] || args['handle'] ? 'base64' : 'text'),
|
|
62
66
|
grep: args[:grep] || args['grep'],
|
|
63
67
|
sha256: args[:sha256] || args['sha256']
|
|
64
68
|
)
|
|
65
69
|
}
|
|
66
70
|
)
|
|
71
|
+
PWN::AI::Agent::Registry.register(
|
|
72
|
+
name: 'artifact_grep',
|
|
73
|
+
toolset: 'sessions',
|
|
74
|
+
schema: {
|
|
75
|
+
name: 'artifact_grep',
|
|
76
|
+
description: 'Search saved tool output by complete binary lines (first match per line, maximum line 64 MiB). Returns bounded previews and byte offsets; resume next_offset until eof, even after empty matches. Use artifact_read for exact bytes.',
|
|
77
|
+
parameters: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
handle: { type: 'string' },
|
|
81
|
+
regex: { type: 'string' },
|
|
82
|
+
offset: { type: 'integer', minimum: 0 }
|
|
83
|
+
},
|
|
84
|
+
required: %w[handle regex]
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
handler: lambda { |args|
|
|
88
|
+
PWN::Plugins::ArtifactRegistry.grep(
|
|
89
|
+
handle: args[:handle] || args['handle'],
|
|
90
|
+
regex: args[:regex] || args['regex'],
|
|
91
|
+
offset: args[:offset] || args['offset'],
|
|
92
|
+
limit: 1,
|
|
93
|
+
max_bytes: PWN::AI::Agent::Result.page_length
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
)
|
|
67
97
|
PWN::AI::Agent::Registry.register(
|
|
68
98
|
name: 'artifact_put',
|
|
69
99
|
toolset: 'sessions',
|
|
@@ -7,14 +7,18 @@ PWN::AI::Agent::Registry.register(
|
|
|
7
7
|
toolset: 'pwn',
|
|
8
8
|
schema: {
|
|
9
9
|
name: 'binary_triage',
|
|
10
|
-
description: 'Triage an ELF/PE/Mach-O binary
|
|
10
|
+
description: 'Triage an ELF/PE/Mach-O binary into one JSON object: type, arch, linking, NX/PIE/RELRO/canary/CFI, imports/exports, interesting strings, section entropy, packer heuristics. Persists to the artifact store.',
|
|
11
11
|
parameters: {
|
|
12
12
|
type: 'object',
|
|
13
|
-
properties: {
|
|
13
|
+
properties: {
|
|
14
|
+
path: { type: 'string', description: 'Filesystem path of the binary.' },
|
|
15
|
+
session_id: { type: 'string', description: 'Artifact session id (defaults to triage).' }
|
|
16
|
+
},
|
|
14
17
|
required: %w[path]
|
|
15
18
|
}
|
|
16
19
|
},
|
|
17
20
|
handler: lambda { |args|
|
|
18
|
-
|
|
21
|
+
args = args.transform_keys(&:to_sym)
|
|
22
|
+
PWN::Plugins::BinaryParser.triage(path: args[:path], session_id: args[:session_id] || args[:session] || 'triage')
|
|
19
23
|
}
|
|
20
24
|
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pwn/ai/agent/registry'
|
|
4
|
+
|
|
5
|
+
PWN::AI::Agent::Registry.register(
|
|
6
|
+
name: 'browser_goto',
|
|
7
|
+
toolset: 'pwn',
|
|
8
|
+
schema: {
|
|
9
|
+
name: 'browser_goto',
|
|
10
|
+
description: 'Navigate a URL and optionally record screenshot, DOM snapshot, and HAR into the artifact store. When title is set, records a finding with pixel and HAR proof in the same call.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
url: { type: 'string', description: 'HTTP(S) URL to open.' },
|
|
15
|
+
uri: { type: 'string' },
|
|
16
|
+
capture: { type: 'boolean', description: 'Spill screenshot, DOM, and HAR (defaults true when title is set).' },
|
|
17
|
+
title: { type: 'string', description: 'Finding title; records pixel and HAR proof without a second call.' },
|
|
18
|
+
severity: { type: 'string' },
|
|
19
|
+
session_id: { type: 'string' },
|
|
20
|
+
engagement_id: { type: 'string' },
|
|
21
|
+
html: { type: 'string', description: 'DOM HTML fixture when no live browser is open.' },
|
|
22
|
+
har: { type: 'string', description: 'HAR JSON fixture when no live capture proxy is attached.' },
|
|
23
|
+
screenshot: { type: 'string', description: 'PNG path used when no live screenshot is available.' },
|
|
24
|
+
label: { type: 'string' }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
handler: lambda { |args|
|
|
29
|
+
args = args.transform_keys(&:to_sym)
|
|
30
|
+
PWN::Plugins::TransparentBrowser.goto(args)
|
|
31
|
+
}
|
|
32
|
+
)
|
|
@@ -26,6 +26,29 @@ PWN::AI::Agent::Registry.register(
|
|
|
26
26
|
)
|
|
27
27
|
}
|
|
28
28
|
)
|
|
29
|
+
PWN::AI::Agent::Registry.register(
|
|
30
|
+
name: 'gdb_run_to_crash',
|
|
31
|
+
toolset: 'pwn',
|
|
32
|
+
schema: {
|
|
33
|
+
name: 'gdb_run_to_crash',
|
|
34
|
+
description: 'Run a binary under gdb MI3 until crash and return {signal, pc, fault_addr, exploitability} plus ExploitDev offset from stdin/payload.',
|
|
35
|
+
parameters: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
binary: { type: 'string' },
|
|
39
|
+
bin: { type: 'string' },
|
|
40
|
+
stdin: { type: 'string', description: 'Crashing input bytes.' },
|
|
41
|
+
payload: { type: 'string' },
|
|
42
|
+
pwndbg: { type: 'boolean' }
|
|
43
|
+
},
|
|
44
|
+
anyOf: [{ required: %w[binary] }, { required: %w[bin] }]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
handler: lambda { |args|
|
|
48
|
+
args = args.transform_keys(&:to_sym)
|
|
49
|
+
PWN::Plugins::GDBMI.run_to_crash(binary: args[:binary] || args[:bin], stdin: args[:stdin] || args[:payload], pwndbg: args[:pwndbg])
|
|
50
|
+
}
|
|
51
|
+
)
|
|
29
52
|
PWN::AI::Agent::Registry.register(
|
|
30
53
|
name: 'binary_diff',
|
|
31
54
|
toolset: 'pwn',
|