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
|
@@ -25,14 +25,16 @@ PWN::AI::Agent::Registry.register(
|
|
|
25
25
|
'Pass timeout as a conservative integer seconds estimate given ' \
|
|
26
26
|
'HOST LOAD (loadavg, ncpu, mem). Omit for a host-derived default. ' \
|
|
27
27
|
'Explicit timeout is honored up to 10800s (3 hours) for any payload. On ' \
|
|
28
|
-
'timeout, keep the same payload and retry with timeout += 180 ' \
|
|
29
|
-
'until the 3-hour budget is gone; then rewrite (max 10 mutations/task).',
|
|
28
|
+
'timeout for short foreground work, keep the same payload and retry with timeout += 180 ' \
|
|
29
|
+
'until the 3-hour budget is gone; then rewrite (max 10 mutations/task). For fuzz campaigns, full scans, or Ghidra use job_run, or background:true here; poll the returned job id instead of timeout retries.',
|
|
30
30
|
parameters: {
|
|
31
31
|
type: 'object',
|
|
32
32
|
properties: {
|
|
33
33
|
command: { type: 'string', description: 'The exact shell command to run.' },
|
|
34
34
|
encoding: { type: 'string', description: 'Set to base64 when data holds the command bytes.' },
|
|
35
35
|
data: { type: 'string', description: 'Base64 command blob when encoding is base64.' },
|
|
36
|
+
background: { type: 'boolean', description: 'Run detached under the durable job supervisor, bypassing foreground timeout/retry budgets.' },
|
|
37
|
+
max_runtime: { type: 'integer', minimum: 0, description: 'Detached supervisor runtime in seconds; 0 unlimited. Use job_run for cwd/env/idempotency options.' },
|
|
36
38
|
timeout: {
|
|
37
39
|
type: 'integer',
|
|
38
40
|
description: 'Conservative seconds this command should take given HOST LOAD. Omit for a host-derived default. Explicit values honored 1..10800 (3 hours). On timeout keep the same payload and timeout += 180; rewrite only after the 3-hour budget (max 10 mutations/task).'
|
|
@@ -47,20 +49,25 @@ PWN::AI::Agent::Registry.register(
|
|
|
47
49
|
# Payload bytes are opaque; only the shell interprets command syntax.
|
|
48
50
|
args = PWN::AI::Agent::ToolGuard.unwrap_payload(args: args, key: :command)
|
|
49
51
|
args = PWN::AI::Agent::ToolGuard.coerce_args(args: args, required: %w[command])
|
|
50
|
-
return PWN::AI::Agent::ToolGuard.invalid_payload(hint: args[:__schema_hint]) if args[:__schema_error]
|
|
52
|
+
return PWN::AI::Agent::ToolGuard.invalid_payload(rule_id: 'payload.required', hint: args[:__schema_hint]) if args[:__schema_error]
|
|
51
53
|
|
|
52
54
|
cmd = args[:command].to_s
|
|
53
|
-
|
|
55
|
+
placeholder = PWN::AI::Agent::ToolGuard.placeholder_match(text: cmd, placeholder_ok: args[:placeholder_ok] || args['placeholder_ok'])
|
|
56
|
+
if placeholder
|
|
54
57
|
return PWN::AI::Agent::ToolGuard.invalid_payload(
|
|
58
|
+
**placeholder,
|
|
55
59
|
hint: 'command is required (string). Do not send ..., {...}, {…}.',
|
|
56
60
|
text: cmd,
|
|
57
|
-
|
|
61
|
+
remedy: 'Replace the placeholder with complete content or set placeholder_ok: true for intentional literal text. For quoting/transport use encoding: "base64" and data: Base64.strict_encode64(command); base64 does not bypass validation, so retain placeholder_ok: true when needed.'
|
|
58
62
|
)
|
|
59
63
|
end
|
|
60
|
-
|
|
64
|
+
bashism = PWN::AI::Agent::ToolGuard.bashism_match(text: cmd)
|
|
65
|
+
if bashism && !PWN::AI::Agent::ToolGuard.shell_bash?
|
|
61
66
|
return PWN::AI::Agent::ToolGuard.invalid_payload(
|
|
67
|
+
**bashism,
|
|
62
68
|
hint: 'bash-only construct (PIPESTATUS/RANDOM/[[) is not POSIX. Rewrite for /bin/sh or set ai.agent.shell_bash.',
|
|
63
|
-
text: cmd
|
|
69
|
+
text: cmd,
|
|
70
|
+
remedy: 'Rewrite the matched construct for /bin/sh or enable ai.agent.shell_bash. Neither base64 nor placeholder_ok bypasses shell-syntax validation.'
|
|
64
71
|
)
|
|
65
72
|
end
|
|
66
73
|
timeout = PWN::AI::Agent::ToolGuard.deadline_s(timeout: args[:timeout], kind: :shell, payload: cmd)
|
|
@@ -69,15 +76,20 @@ PWN::AI::Agent::Registry.register(
|
|
|
69
76
|
hint: "command exceeds max payload size #{PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES} bytes",
|
|
70
77
|
text: cmd,
|
|
71
78
|
byte_range: [PWN::AI::Agent::ToolGuard::MAX_PAYLOAD_BYTES, cmd.bytesize],
|
|
72
|
-
code: 'PAYLOAD_TOO_LARGE'
|
|
79
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
80
|
+
rule_id: 'payload.max_bytes',
|
|
81
|
+
remedy: 'Split the command into smaller complete calls within max_payload_bytes. Base64 and placeholder_ok do not increase the decoded size limit.'
|
|
73
82
|
)
|
|
74
83
|
end
|
|
75
|
-
return PWN::AI::Agent::ToolGuard.invalid_payload(hint: 'command must be a nonempty string') unless args[:command].is_a?(String) && !cmd.empty?
|
|
84
|
+
return PWN::AI::Agent::ToolGuard.invalid_payload(rule_id: 'payload.type', hint: 'command must be a nonempty string') unless args[:command].is_a?(String) && !cmd.empty?
|
|
76
85
|
|
|
77
86
|
scoped = PWN::AI::Agent::ToolGuard.scope_refusal(command: cmd)
|
|
78
87
|
return scoped if scoped
|
|
79
88
|
|
|
80
|
-
|
|
89
|
+
sandboxed = PWN::Plugins::AISandbox.wrap_shell(command: cmd, timeout: timeout)
|
|
90
|
+
return sandboxed unless sandboxed.nil?
|
|
91
|
+
|
|
92
|
+
return PWN::Plugins::Jobs.start(command: cmd, max_runtime: args[:max_runtime], session_id: Thread.current[:pwn_session_id]).merge(routed: 'job_run') if args[:background] == true || (args[:timeout].to_i <= 0 && PWN::AI::Agent::ToolGuard.auto_job?(payload: cmd))
|
|
81
93
|
|
|
82
94
|
stdout = +''
|
|
83
95
|
stderr = +''
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'digest'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
module PWN
|
|
7
8
|
module AI
|
|
@@ -178,6 +179,27 @@ module PWN
|
|
|
178
179
|
MUTEX.synchronize { THREADS.count(&:alive?) }
|
|
179
180
|
end
|
|
180
181
|
|
|
182
|
+
# Freeze operator-owned destinations once, before tools can change cwd.
|
|
183
|
+
public_class_method def self.commit_artifacts!(opts = {})
|
|
184
|
+
return Thread.current[:pwn_artifact_contract] if opts[:inherit] && Thread.current[:pwn_artifact_contract]
|
|
185
|
+
|
|
186
|
+
request = opts[:request].to_s.dup.freeze
|
|
187
|
+
paths = output_paths(request: request, cwd: opts[:cwd]).map(&:freeze).freeze
|
|
188
|
+
Thread.current[:pwn_artifact_contract] = {
|
|
189
|
+
id: SecureRandom.hex(16).freeze,
|
|
190
|
+
request: request,
|
|
191
|
+
required_artifacts: paths,
|
|
192
|
+
started_at: Time.now.freeze
|
|
193
|
+
}.freeze
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
public_class_method def self.required_artifacts(opts = {})
|
|
197
|
+
contract = Thread.current[:pwn_artifact_contract]
|
|
198
|
+
return contract[:required_artifacts] if contract && (contract[:request] == opts[:request].to_s || Thread.current[:pwn_loop_nested])
|
|
199
|
+
|
|
200
|
+
output_paths(request: opts[:request], cwd: opts[:cwd])
|
|
201
|
+
end
|
|
202
|
+
|
|
181
203
|
# Literal destinations only; source paths are never delivery obligations.
|
|
182
204
|
public_class_method def self.output_paths(opts = {})
|
|
183
205
|
request = opts[:request].to_s
|
|
@@ -211,9 +233,13 @@ module PWN
|
|
|
211
233
|
return {} unless opts[:success] == true && opts[:effect].to_s == 'write'
|
|
212
234
|
|
|
213
235
|
before = opts[:before] || {}
|
|
214
|
-
artifact_snapshot(paths: opts[:paths]).select do |path, row|
|
|
236
|
+
observed = artifact_snapshot(paths: opts[:paths]).select do |path, row|
|
|
215
237
|
row && before.key?(path) && before[path] != row
|
|
216
238
|
end
|
|
239
|
+
observed.transform_values do |row|
|
|
240
|
+
contract = Thread.current[:pwn_artifact_contract]
|
|
241
|
+
contract ? row.merge(artifact_turn_id: contract[:id]) : row
|
|
242
|
+
end
|
|
217
243
|
end
|
|
218
244
|
|
|
219
245
|
private_class_method def self.artifact_readback(opts = {})
|
|
@@ -242,9 +268,9 @@ module PWN
|
|
|
242
268
|
public_class_method def self.arbitrate(opts = {})
|
|
243
269
|
request = opts[:request].to_s
|
|
244
270
|
messages = Array(opts[:messages])
|
|
245
|
-
t0 = opts[:session_t0] || Thread.current[:pwn_loop_t0]
|
|
271
|
+
t0 = opts[:session_t0] || Thread.current[:pwn_artifact_contract]&.dig(:started_at) || Thread.current[:pwn_loop_t0]
|
|
246
272
|
paths = Array(opts[:paths]).map { |path| File.expand_path(path.to_s) }
|
|
247
|
-
paths +=
|
|
273
|
+
paths += required_artifacts(request: request)
|
|
248
274
|
paths.uniq!
|
|
249
275
|
ledger = evidence_ledger(messages: messages)
|
|
250
276
|
unmet = []
|
|
@@ -257,7 +283,10 @@ module PWN
|
|
|
257
283
|
unmet << { criterion: 'empty_artifact', detail: path } if File.size(path) < 2
|
|
258
284
|
unmet << { criterion: 'write_missing', detail: path } unless row && row[:write]
|
|
259
285
|
unmet << { criterion: 'readback_missing', detail: path } unless row && row[:read]
|
|
260
|
-
|
|
286
|
+
# Filesystem timestamps can lag Time.now by a clock tick. A bound
|
|
287
|
+
# current-turn write delta plus matching readback is stronger proof.
|
|
288
|
+
current_turn = row && row.dig(:evidence, :artifact_turn_id) == Thread.current[:pwn_artifact_contract]&.dig(:id) && Thread.current[:pwn_artifact_contract]
|
|
289
|
+
unmet << { criterion: 'artifact_mtime_before_session', detail: path } if t0 && File.mtime(path) < t0 && !current_turn
|
|
261
290
|
end
|
|
262
291
|
{
|
|
263
292
|
complete: unmet.empty? && (!paths.empty? || ledger.any?),
|
|
@@ -274,8 +303,14 @@ module PWN
|
|
|
274
303
|
next unless msg[:artifact_observations].is_a?(Hash)
|
|
275
304
|
|
|
276
305
|
msg[:artifact_observations].each do |path, observed|
|
|
306
|
+
next unless observed.is_a?(Hash)
|
|
307
|
+
|
|
308
|
+
contract = Thread.current[:pwn_artifact_contract]
|
|
309
|
+
next if contract && observed[:artifact_turn_id] != contract[:id]
|
|
310
|
+
|
|
277
311
|
current = artifact_readback(path: path)
|
|
278
|
-
|
|
312
|
+
readback = observed.except(:artifact_turn_id)
|
|
313
|
+
next unless current && current == readback
|
|
279
314
|
|
|
280
315
|
ledger[path] = { write: true, read: true, evidence: observed }
|
|
281
316
|
end
|
|
@@ -338,6 +373,12 @@ module PWN
|
|
|
338
373
|
# Extract explicit output literals, excluding source/read paths.
|
|
339
374
|
#{self}.output_paths(request: 'required - original user request', cwd: 'optional - relative path base')
|
|
340
375
|
|
|
376
|
+
# Precommit immutable required_artifacts for this turn before tools run.
|
|
377
|
+
#{self}.commit_artifacts!(request: 'required - original user request', cwd: 'optional - initial relative path base', inherit: 'optional - reuse the owning turn contract for a nested call')
|
|
378
|
+
|
|
379
|
+
# Return precommitted paths, or parse literals outside an active turn.
|
|
380
|
+
#{self}.required_artifacts(request: 'required - original user request', cwd: 'optional - relative path base outside a turn')
|
|
381
|
+
|
|
341
382
|
# Snapshot named destinations immediately before a tool runs.
|
|
342
383
|
#{self}.artifact_snapshot(paths: 'required - destination paths')
|
|
343
384
|
|
data/lib/pwn/ai/agent.rb
CHANGED
|
@@ -23,7 +23,8 @@ module PWN
|
|
|
23
23
|
autoload :RequestRuntime, 'pwn/ai/agent/request_runtime'
|
|
24
24
|
autoload :Profiles, 'pwn/ai/agent/profiles'
|
|
25
25
|
autoload :EngagementMemory, 'pwn/ai/agent/engagement_memory'
|
|
26
|
-
autoload :Dispatch,
|
|
26
|
+
autoload :Dispatch, 'pwn/ai/agent/dispatch'
|
|
27
|
+
autoload :Confirmation, 'pwn/ai/agent/confirmation'
|
|
27
28
|
autoload :Result, 'pwn/ai/agent/result'
|
|
28
29
|
autoload :PromptBuilder, 'pwn/ai/agent/prompt_builder'
|
|
29
30
|
autoload :Loop, 'pwn/ai/agent/loop'
|
|
@@ -38,6 +39,7 @@ module PWN
|
|
|
38
39
|
autoload :Verification, 'pwn/ai/agent/verification'
|
|
39
40
|
autoload :Curriculum, 'pwn/ai/agent/curriculum'
|
|
40
41
|
autoload :TaskSummarizer, 'pwn/ai/agent/task_summarizer'
|
|
42
|
+
autoload :TaskDAG, 'pwn/ai/agent/task_dag'
|
|
41
43
|
autoload :Policy, 'pwn/ai/agent/policy'
|
|
42
44
|
autoload :PolicyEvaluation, 'pwn/ai/agent/policy_evaluation'
|
|
43
45
|
autoload :ToolGuard, 'pwn/ai/agent/tool_guard'
|
data/lib/pwn/ai/cli.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'optparse'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'json'
|
|
4
6
|
require 'pwn'
|
|
5
7
|
|
|
6
8
|
module PWN
|
|
@@ -10,10 +12,13 @@ module PWN
|
|
|
10
12
|
public_class_method def self.parse(opts = {})
|
|
11
13
|
result = {}
|
|
12
14
|
parser = OptionParser.new do |options|
|
|
13
|
-
options.banner = 'Usage: pwn-ai [--analyze PATH | --replay ID | --rerun ID] [--ai PROMPT]'
|
|
15
|
+
options.banner = 'Usage: pwn-ai [--analyze PATH | --replay ID | --rerun ID | --plan-only | --execute PATH | --resume RUN_ID] [--ai PROMPT]'
|
|
14
16
|
options.on('--analyze PATH', 'Ingest evidence before starting the AI session') { |v| result[:analyze] = v }
|
|
15
17
|
options.on('--replay ID', 'Render saved trace without executing tools') { |v| result[:replay] = v }
|
|
16
18
|
options.on('--rerun ID', 'Re-execute saved tools in a fresh context (side effects possible)') { |v| result[:rerun] = v }
|
|
19
|
+
options.on('--plan-only', 'Emit a YAML task DAG with zero side effects') { result[:plan_only] = true }
|
|
20
|
+
options.on('--execute PATH', 'Run an approved YAML task DAG') { |v| result[:execute] = v }
|
|
21
|
+
options.on('--resume RUN_ID', 'Resume a checkpointed DAG run, skipping completed steps') { |v| result[:resume] = v }
|
|
17
22
|
options.on('--ai PROMPT', 'One-shot request; - reads standard input') { |v| result[:ai] = v }
|
|
18
23
|
options.on('--pwn-env PATH', 'Use the specified encrypted configuration') { |v| result[:pwn_env_path] = v }
|
|
19
24
|
options.on('--pwn-dec PATH', 'Use the specified decryptor') { |v| result[:pwn_dec_path] = v }
|
|
@@ -21,8 +26,9 @@ module PWN
|
|
|
21
26
|
end
|
|
22
27
|
remaining = parser.parse!(Array(opts[:argv]).dup)
|
|
23
28
|
raise OptionParser::InvalidArgument, "unexpected arguments: #{remaining.join(' ')}" unless remaining.empty?
|
|
24
|
-
raise OptionParser::InvalidArgument, 'choose only one of --analyze, --replay, --rerun' if %i[analyze replay rerun].count { |key| result.key?(key) } > 1
|
|
29
|
+
raise OptionParser::InvalidArgument, 'choose only one of --analyze, --replay, --rerun, --plan-only, --execute, --resume' if %i[analyze replay rerun plan_only execute resume].count { |key| result.key?(key) } > 1
|
|
25
30
|
raise OptionParser::InvalidArgument, '--ai cannot accompany --replay or --rerun' if result[:ai] && (result[:replay] || result[:rerun])
|
|
31
|
+
raise OptionParser::InvalidArgument, '--plan-only requires --ai' if result[:plan_only] && !result[:ai]
|
|
26
32
|
|
|
27
33
|
result[:help_text] = parser.to_s
|
|
28
34
|
result
|
|
@@ -37,6 +43,24 @@ module PWN
|
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
require 'pwn'
|
|
46
|
+
if parsed[:plan_only]
|
|
47
|
+
prompt = parsed[:ai] == '-' ? (opts[:input] || $stdin).read : parsed[:ai]
|
|
48
|
+
raise ArgumentError, '--ai requires a non-empty prompt' if prompt.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
dag = PWN::AI::Agent::TaskDAG.plan(request: prompt)
|
|
51
|
+
output.puts(YAML.dump(JSON.parse(JSON.generate(dag))))
|
|
52
|
+
return 0
|
|
53
|
+
end
|
|
54
|
+
if parsed[:resume] || parsed[:execute]
|
|
55
|
+
PWN::Config.refresh_env(**parsed.slice(:pwn_env_path, :pwn_dec_path))
|
|
56
|
+
report = if parsed[:resume]
|
|
57
|
+
PWN::AI::Agent::TaskDAG.resume(run_id: parsed[:resume])
|
|
58
|
+
else
|
|
59
|
+
PWN::AI::Agent::TaskDAG.execute(path: parsed[:execute])
|
|
60
|
+
end
|
|
61
|
+
output.puts(JSON.generate(report))
|
|
62
|
+
return report[:ok] == false ? 1 : 0
|
|
63
|
+
end
|
|
40
64
|
if parsed[:replay] || parsed[:rerun]
|
|
41
65
|
require 'pwn/session_trace'
|
|
42
66
|
if parsed[:replay]
|
data/lib/pwn/ai/context.rb
CHANGED
|
@@ -49,7 +49,8 @@ module PWN
|
|
|
49
49
|
text = if defined?(PWN::Plugins::Radare2) && File.file?(path)
|
|
50
50
|
sid = PWN::Plugins::Radare2.open(path: path)
|
|
51
51
|
addr = fn.empty? ? 'main' : fn
|
|
52
|
-
PWN::Plugins::Radare2.disasm(session: sid, addr: addr, n: 64)
|
|
52
|
+
result = PWN::Plugins::Radare2.disasm(session: sid, addr: addr, n: 64)
|
|
53
|
+
result.is_a?(String) ? result : JSON.pretty_generate(result)
|
|
53
54
|
else
|
|
54
55
|
File.binread(path).to_s[0, CHUNK]
|
|
55
56
|
end
|
|
@@ -136,8 +137,9 @@ module PWN
|
|
|
136
137
|
File.binwrite(dest, bytes)
|
|
137
138
|
{ sha256: sha, path: dest, size: bytes.bytesize }
|
|
138
139
|
end
|
|
139
|
-
chunks = []
|
|
140
140
|
body = summary.to_s
|
|
141
|
+
body = PWN::AI::Router.summarize(text: body) if bytes.bytesize > WINDOW && defined?(PWN::AI::Router)
|
|
142
|
+
chunks = []
|
|
141
143
|
i = 0
|
|
142
144
|
while i < body.bytesize
|
|
143
145
|
chunks << body.byteslice(i, CHUNK)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module AI
|
|
7
|
+
# Task-class model routing from pwn.yaml ai_router / model_routes.
|
|
8
|
+
module Router
|
|
9
|
+
TASKS = %i[summarize plan exploit_dev triage].freeze
|
|
10
|
+
FRONTIER = %w[openai anthropic grok gemini].freeze
|
|
11
|
+
LOCAL = %w[ollama openwebui].freeze
|
|
12
|
+
ENGINE_MOD = {
|
|
13
|
+
'ollama' => 'PWN::AI::Ollama',
|
|
14
|
+
'openwebui' => 'PWN::AI::OpenWebUI',
|
|
15
|
+
'openai' => 'PWN::AI::OpenAI',
|
|
16
|
+
'anthropic' => 'PWN::AI::Anthropic',
|
|
17
|
+
'grok' => 'PWN::AI::Grok',
|
|
18
|
+
'gemini' => 'PWN::AI::Gemini'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
public_class_method def self.required_bins
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve engine/model fallbacks for a task class.
|
|
26
|
+
public_class_method def self.resolve(opts = {})
|
|
27
|
+
task = (opts[:task] || opts[:class] || :summarize).to_s.to_sym
|
|
28
|
+
entry = config_entry(task: task)
|
|
29
|
+
endpoints = expand_endpoints(entry: entry)
|
|
30
|
+
allow_frontier = !%i[summarize plan].include?(task)
|
|
31
|
+
allow_frontier &&= entry[:allow_frontier] != false && entry['allow_frontier'] != false
|
|
32
|
+
{ task: task, endpoints: endpoints, allow_frontier: allow_frontier }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Summarize text on the cheap local chain; never calls frontier models.
|
|
36
|
+
public_class_method def self.summarize(opts = {})
|
|
37
|
+
text = (opts[:text] || opts[:content]).to_s
|
|
38
|
+
reset_task = opts[:reset_usage]
|
|
39
|
+
reset_usage if reset_task
|
|
40
|
+
note_usage(task: :summarize, frontier_tokens: 0)
|
|
41
|
+
if opts[:llm]
|
|
42
|
+
resolve(task: :summarize)[:endpoints].each do |endpoint|
|
|
43
|
+
next if frontier?(engine: endpoint[:engine])
|
|
44
|
+
|
|
45
|
+
out = try_chat(endpoint: endpoint, request: "Summarize this artifact for paging. Be terse.\n#{text.byteslice(0, 8_192)}")
|
|
46
|
+
return out if out.to_s.strip != ''
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
extractive(text: text)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
extractive(text: text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return per-task token accounting (frontier_tokens is always 0 for summarize).
|
|
55
|
+
public_class_method def self.usage(opts = {})
|
|
56
|
+
_n = opts[:n]
|
|
57
|
+
@usage ||= {}
|
|
58
|
+
@usage
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Clear the in-process usage ledger.
|
|
62
|
+
public_class_method def self.reset_usage(opts = {})
|
|
63
|
+
_n = opts[:n]
|
|
64
|
+
@usage = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
public_class_method def self.authors
|
|
68
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
public_class_method def self.help
|
|
72
|
+
puts "USAGE:
|
|
73
|
+
# List host binaries this module expects to be installed.
|
|
74
|
+
#{self}.required_bins
|
|
75
|
+
|
|
76
|
+
# Resolve engine/model fallbacks for a task class.
|
|
77
|
+
#{self}.resolve(
|
|
78
|
+
task: 'optional - summarize, plan, exploit_dev, or triage',
|
|
79
|
+
class: 'optional - alias for task'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Summarize text on the cheap local chain; never calls frontier models.
|
|
83
|
+
#{self}.summarize(
|
|
84
|
+
text: 'optional - artifact or page text to summarize',
|
|
85
|
+
content: 'optional - alias for text',
|
|
86
|
+
reset_usage: 'optional - true clears the usage ledger first',
|
|
87
|
+
llm: 'optional - true tries local-engine chat after skipping frontier endpoints'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Return per-task token accounting (frontier_tokens is always 0 for summarize).
|
|
91
|
+
#{self}.usage(
|
|
92
|
+
n: 'optional - unused placeholder so the method reads opts'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Clear the in-process usage ledger.
|
|
96
|
+
#{self}.reset_usage(
|
|
97
|
+
n: 'optional - unused placeholder so the method reads opts'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Print the AUTHOR(S) string for this module.
|
|
101
|
+
#{self}.authors
|
|
102
|
+
"
|
|
103
|
+
constants.sort
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private_class_method def self.config_entry(opts = {})
|
|
107
|
+
task = opts[:task]
|
|
108
|
+
router = env_hash(key: :ai_router)
|
|
109
|
+
routes = env_dig(keys: %i[ai agent model_routes])
|
|
110
|
+
raw = (router[task] || router[task.to_s] || routes[task] || routes[task.to_s]).dup
|
|
111
|
+
raw = default_entry(task: task) if raw.nil? || raw == {}
|
|
112
|
+
raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : { engine: raw.to_s }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method def self.default_entry(opts = {})
|
|
116
|
+
task = opts[:task]
|
|
117
|
+
case task
|
|
118
|
+
when :exploit_dev
|
|
119
|
+
{ engine: 'ollama', model: 'qwen2.5-coder', fallbacks: [{ engine: 'openai' }] }
|
|
120
|
+
when :summarize, :plan, :triage
|
|
121
|
+
{ engine: 'ollama', fallbacks: [{ engine: 'openwebui' }], allow_frontier: false }
|
|
122
|
+
else
|
|
123
|
+
{ engine: env_dig(keys: %i[ai active]).to_s }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private_class_method def self.expand_endpoints(opts = {})
|
|
128
|
+
entry = opts[:entry]
|
|
129
|
+
head = { engine: (entry[:engine] || entry[:endpoint] || 'ollama').to_s, model: entry[:model] }
|
|
130
|
+
rest = Array(entry[:fallbacks] || entry[:fallback]).map do |item|
|
|
131
|
+
item.is_a?(Hash) ? item.transform_keys(&:to_sym) : { engine: item.to_s }
|
|
132
|
+
end
|
|
133
|
+
([head] + rest).map { |row| { engine: row[:engine].to_s.downcase, model: row[:model] } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private_class_method def self.frontier?(opts = {})
|
|
137
|
+
FRONTIER.include?(opts[:engine].to_s.downcase)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private_class_method def self.try_chat(opts = {})
|
|
141
|
+
endpoint = opts[:endpoint]
|
|
142
|
+
engine = endpoint[:engine].to_s
|
|
143
|
+
const = ENGINE_MOD[engine]
|
|
144
|
+
return nil unless const
|
|
145
|
+
|
|
146
|
+
mod = Object.const_get(const)
|
|
147
|
+
return nil unless mod.respond_to?(:chat)
|
|
148
|
+
|
|
149
|
+
resp = mod.chat(request: opts[:request], model: endpoint[:model], spinner: false, quiet: true, timeout: 8)
|
|
150
|
+
text = resp.is_a?(Hash) ? (resp[:content] || resp['content'] || resp.dig(:choices, 0, :content)).to_s : resp.to_s
|
|
151
|
+
text.strip.empty? ? nil : text
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private_class_method def self.extractive(opts = {})
|
|
157
|
+
text = opts[:text].to_s
|
|
158
|
+
text.byteslice(0, 2_048).to_s
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private_class_method def self.note_usage(opts = {})
|
|
162
|
+
task = opts[:task].to_sym
|
|
163
|
+
@usage ||= {}
|
|
164
|
+
@usage[task] ||= { frontier_tokens: 0, local_tokens: 0 }
|
|
165
|
+
@usage[task][:frontier_tokens] = opts[:frontier_tokens].to_i if opts.key?(:frontier_tokens)
|
|
166
|
+
@usage[task][:local_tokens] += opts[:local_tokens].to_i if opts[:local_tokens]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private_class_method def self.env_hash(opts = {})
|
|
170
|
+
return {} unless defined?(PWN::Env)
|
|
171
|
+
|
|
172
|
+
val = PWN::Env[opts[:key]]
|
|
173
|
+
if val.is_a?(Hash)
|
|
174
|
+
val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
175
|
+
else
|
|
176
|
+
{}
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError
|
|
179
|
+
{}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private_class_method def self.env_dig(opts = {})
|
|
183
|
+
return {} unless defined?(PWN::Env) && PWN::Env.respond_to?(:dig)
|
|
184
|
+
|
|
185
|
+
val = PWN::Env.dig(*opts[:keys])
|
|
186
|
+
if val.is_a?(Hash)
|
|
187
|
+
val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
188
|
+
else
|
|
189
|
+
val
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError
|
|
192
|
+
{}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
data/lib/pwn/ai/tools/README.md
CHANGED
data/lib/pwn/ai.rb
CHANGED
data/lib/pwn/config.rb
CHANGED
|
@@ -17,6 +17,13 @@ module PWN
|
|
|
17
17
|
# user values on `pwn setup --migrate --fix`
|
|
18
18
|
public_class_method def self.env_template
|
|
19
19
|
{
|
|
20
|
+
ai_sandbox: 'off',
|
|
21
|
+
ai_router: {
|
|
22
|
+
summarize: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }], allow_frontier: false },
|
|
23
|
+
plan: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }], allow_frontier: false },
|
|
24
|
+
exploit_dev: { engine: 'ollama', model: 'qwen2.5-coder', fallbacks: [{ engine: 'openai' }] },
|
|
25
|
+
triage: { engine: 'ollama', model: nil, fallbacks: [{ engine: 'openwebui' }] }
|
|
26
|
+
},
|
|
20
27
|
ai: {
|
|
21
28
|
active: 'grok',
|
|
22
29
|
module_reflection: false,
|
|
@@ -204,6 +211,9 @@ module PWN
|
|
|
204
211
|
max_total_mb: 512,
|
|
205
212
|
model_routes: {
|
|
206
213
|
summarize: nil,
|
|
214
|
+
plan: nil,
|
|
215
|
+
exploit_dev: nil,
|
|
216
|
+
triage: nil,
|
|
207
217
|
judge: nil,
|
|
208
218
|
act: nil,
|
|
209
219
|
sensitive: nil
|
data/lib/pwn/engagement.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'ipaddr'
|
|
6
|
+
require 'time'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
# Engagement scope, host state, and vault refs under ~/.pwn/engagements.
|
|
@@ -85,6 +86,31 @@ module PWN
|
|
|
85
86
|
hosts(name: opts[:engagement] || opts[:name])
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
public_class_method def self.record_scan(opts = {})
|
|
90
|
+
name = (opts[:engagement] || opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
|
|
91
|
+
dir = File.join(PWN::AI::Agent::Engagement::ROOT, name, 'scans')
|
|
92
|
+
FileUtils.mkdir_p(dir)
|
|
93
|
+
at = opts[:at] || Time.now.utc
|
|
94
|
+
at = Time.parse(at.to_s).utc unless at.is_a?(Time)
|
|
95
|
+
path = File.join(dir, "nmap-#{at.utc.strftime('%Y%m%dT%H%M%SZ')}.json")
|
|
96
|
+
body = {
|
|
97
|
+
at: at.utc.iso8601,
|
|
98
|
+
kind: opts[:kind] || 'nmap',
|
|
99
|
+
xml: opts[:xml],
|
|
100
|
+
hosts: opts[:hosts],
|
|
101
|
+
ports: opts[:ports]
|
|
102
|
+
}
|
|
103
|
+
File.write(path, JSON.pretty_generate(body))
|
|
104
|
+
body.merge(path: path)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
public_class_method def self.scans(opts = {})
|
|
108
|
+
name = (opts[:name] || opts[:engagement] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
|
|
109
|
+
Dir[File.join(PWN::AI::Agent::Engagement::ROOT, name, 'scans', 'nmap-*.json')].map do |path|
|
|
110
|
+
JSON.parse(File.read(path), symbolize_names: true).merge(path: path)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
88
114
|
public_class_method def self.authors
|
|
89
115
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
90
116
|
end
|
|
@@ -147,7 +173,25 @@ module PWN
|
|
|
147
173
|
#{self}.merge_scan(
|
|
148
174
|
hosts: 'optional - Array of {host, port, service} hashes',
|
|
149
175
|
results: 'optional - alias for hosts',
|
|
150
|
-
override: 'optional - true records out-of-scope hosts'
|
|
176
|
+
override: 'optional - true records out-of-scope hosts',
|
|
177
|
+
engagement: 'optional - engagement identifier (defaults to active)'
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Persist a timestamped nmap inventory snapshot under the engagement scans/ dir.
|
|
181
|
+
#{self}.record_scan(
|
|
182
|
+
hosts: 'required - inventory host array from NmapIt',
|
|
183
|
+
ports: 'optional - flattened port rows',
|
|
184
|
+
xml: 'optional - source nmap XML path',
|
|
185
|
+
at: 'optional - Time or ISO8601 timestamp of the scan',
|
|
186
|
+
kind: 'optional - scanner kind (defaults to nmap)',
|
|
187
|
+
engagement: 'optional - engagement identifier',
|
|
188
|
+
name: 'optional - alias for engagement'
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# List persisted nmap snapshots oldest-first.
|
|
192
|
+
#{self}.scans(
|
|
193
|
+
name: 'optional - engagement identifier',
|
|
194
|
+
engagement: 'optional - alias for name'
|
|
151
195
|
)
|
|
152
196
|
|
|
153
197
|
# Print the AUTHOR(S) string for this module.
|