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,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'ripper'
|
|
5
|
+
require 'yard'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
module Plugins
|
|
9
|
+
# JSON-schema descriptors for public PWN::Plugins methods, generated from
|
|
10
|
+
# YARD / Supported Method Parameters docs. pwn_eval validates kwargs first.
|
|
11
|
+
module MethodCatalog
|
|
12
|
+
SIDE_EFFECTS = %w[read_only active_scan exploit destructive].freeze
|
|
13
|
+
|
|
14
|
+
public_class_method def self.required_bins
|
|
15
|
+
[]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Build a JSON-schema descriptor for one public plugin method.
|
|
19
|
+
public_class_method def self.schema(opts = {})
|
|
20
|
+
mod = plugin_const(opts)
|
|
21
|
+
name = (opts[:method] || opts[:name]).to_s
|
|
22
|
+
raise 'ERROR: method is required' if name.empty?
|
|
23
|
+
|
|
24
|
+
cache_key = "#{mod}##{name}"
|
|
25
|
+
@schema_cache ||= {}
|
|
26
|
+
return @schema_cache[cache_key] if @schema_cache[cache_key]
|
|
27
|
+
|
|
28
|
+
keys = allowed_keys(mod: mod, method: name)
|
|
29
|
+
props = keys.to_h { |key, hint| [key, property_schema(key: key, hint: hint)] }
|
|
30
|
+
@schema_cache[cache_key] = {
|
|
31
|
+
name: "#{mod}.#{name}",
|
|
32
|
+
description: purpose_line(mod: mod, method: name),
|
|
33
|
+
side_effect: classify_side_effect(mod: mod, method: name),
|
|
34
|
+
parameters: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: props,
|
|
37
|
+
additionalProperties: false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Validate literal kwargs on PWN::Plugins calls; nil means the payload may eval.
|
|
43
|
+
public_class_method def self.guard_eval(opts = {})
|
|
44
|
+
code = opts[:code].to_s
|
|
45
|
+
extract_calls(code: code).each do |call|
|
|
46
|
+
descriptor = schema(mod: call[:mod], method: call[:method])
|
|
47
|
+
allowed = descriptor[:parameters][:properties].keys.map(&:to_s)
|
|
48
|
+
unknown = call[:keys].map(&:to_s) - allowed
|
|
49
|
+
next if unknown.empty?
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
error: "unknown keyword: #{unknown.join(', ')}",
|
|
53
|
+
schema: descriptor,
|
|
54
|
+
method: descriptor[:name]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
nil
|
|
58
|
+
rescue SyntaxError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List descriptors for every public PWN::Plugins class method (lazy per module).
|
|
63
|
+
public_class_method def self.descriptors(opts = {})
|
|
64
|
+
mods = Array(opts[:modules] || plugin_modules)
|
|
65
|
+
mods.flat_map do |mod|
|
|
66
|
+
public_methods_for(mod: mod).map { |name| schema(mod: mod, method: name) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
public_class_method def self.authors
|
|
71
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
public_class_method def self.help
|
|
75
|
+
puts "USAGE:
|
|
76
|
+
# List host binaries this module expects to be installed.
|
|
77
|
+
#{self}.required_bins
|
|
78
|
+
|
|
79
|
+
# Build a JSON-schema descriptor for one public plugin method.
|
|
80
|
+
#{self}.schema(
|
|
81
|
+
mod: 'optional - PWN::Plugins::Name or short Name',
|
|
82
|
+
module: 'optional - alias for mod',
|
|
83
|
+
method: 'required - public class method name',
|
|
84
|
+
name: 'optional - alias for method'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Validate literal kwargs on PWN::Plugins calls; nil means the payload may eval.
|
|
88
|
+
#{self}.guard_eval(
|
|
89
|
+
code: 'required - Ruby source that may call PWN::Plugins methods'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# List descriptors for every public PWN::Plugins class method (lazy per module).
|
|
93
|
+
#{self}.descriptors(
|
|
94
|
+
modules: 'optional - Array of PWN::Plugins constants to catalog'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Print the AUTHOR(S) string for this module.
|
|
98
|
+
#{self}.authors
|
|
99
|
+
"
|
|
100
|
+
constants.sort
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private_class_method def self.plugin_const(opts = {})
|
|
104
|
+
raw = (opts[:mod] || opts[:module] || opts[:const]).to_s
|
|
105
|
+
raw = "PWN::Plugins::#{raw}" unless raw.include?('::')
|
|
106
|
+
raise 'ERROR: module is required' if raw.empty? || raw == 'PWN::Plugins::'
|
|
107
|
+
raise 'ERROR: only PWN::Plugins methods are catalogued' unless raw.start_with?('PWN::Plugins::')
|
|
108
|
+
|
|
109
|
+
raw
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method def self.plugin_modules(opts = {})
|
|
113
|
+
_n = opts[:n]
|
|
114
|
+
PWN::Plugins.constants.sort.map { |name| "PWN::Plugins::#{name}" }.select do |mod|
|
|
115
|
+
Object.const_get(mod).is_a?(Module)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private_class_method def self.public_methods_for(opts = {})
|
|
122
|
+
klass = Object.const_get(opts[:mod].to_s)
|
|
123
|
+
klass.singleton_methods(false).map(&:to_s).reject { |name| %w[allocate new].include?(name) }.sort
|
|
124
|
+
rescue StandardError
|
|
125
|
+
[]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method def self.classify_side_effect(opts = {})
|
|
129
|
+
blob = "#{opts[:mod]} #{opts[:method]} #{purpose_line(opts)}".downcase
|
|
130
|
+
return 'destructive' if blob.match?(/rm_rf|unlink|delete|kill|wipe|drop|destroy|format|truncate/)
|
|
131
|
+
return 'exploit' if blob.match?(/exploit|overflow|shellcode|ret2|rce|payload/)
|
|
132
|
+
return 'active_scan' if blob.match?(/scan|fuzz|brute|attack|nuclei|nmap|crawl|inject/)
|
|
133
|
+
|
|
134
|
+
'read_only'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private_class_method def self.property_schema(opts = {})
|
|
138
|
+
hint = opts[:hint].to_s.downcase
|
|
139
|
+
type = case hint
|
|
140
|
+
when /bool/
|
|
141
|
+
'boolean'
|
|
142
|
+
when /array/
|
|
143
|
+
'array'
|
|
144
|
+
when /int|number|float/
|
|
145
|
+
'number'
|
|
146
|
+
else
|
|
147
|
+
'string'
|
|
148
|
+
end
|
|
149
|
+
{ type: type, description: opts[:hint].to_s }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private_class_method def self.allowed_keys(opts = {})
|
|
153
|
+
mod = opts[:mod].to_s
|
|
154
|
+
name = opts[:method].to_s
|
|
155
|
+
hints = {}
|
|
156
|
+
yard_options(text: comment_block(mod: mod, method: name)).each { |key, hint| hints[key] = hint }
|
|
157
|
+
help_options(mod: mod, method: name).each { |key, hint| hints[key] = hint }
|
|
158
|
+
body_keys(mod: mod, method: name).each { |key| hints[key] ||= key }
|
|
159
|
+
hints
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private_class_method def self.yard_options(opts = {})
|
|
163
|
+
require 'yard'
|
|
164
|
+
doc = YARD::Docstring.new(opts[:text].to_s)
|
|
165
|
+
found = {}
|
|
166
|
+
(doc.tags('option') + doc.tags('param')).each do |tag|
|
|
167
|
+
key = (tag.pair&.name || tag.name).to_s.delete_prefix(':')
|
|
168
|
+
next if key.empty? || key == 'opts'
|
|
169
|
+
|
|
170
|
+
found[key] = [tag.types&.join(','), tag.text].compact.join(' ').strip
|
|
171
|
+
end
|
|
172
|
+
opts[:text].to_s.scan(/(\w+):\s*['"]((?:required|optional)[^'"]*)['"]/i) { |key, hint| found[key] = hint }
|
|
173
|
+
found
|
|
174
|
+
rescue StandardError
|
|
175
|
+
{}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private_class_method def self.comment_block(opts = {})
|
|
179
|
+
klass = Object.const_get(opts[:mod].to_s)
|
|
180
|
+
path, line = klass.method(opts[:method].to_sym).source_location
|
|
181
|
+
return '' unless path && line
|
|
182
|
+
|
|
183
|
+
lines = File.readlines(path)
|
|
184
|
+
buf = []
|
|
185
|
+
(line - 2).downto(0) do |idx|
|
|
186
|
+
text = lines[idx]
|
|
187
|
+
break unless text&.match?(/^\s*#/)
|
|
188
|
+
|
|
189
|
+
buf.unshift(text.sub(/^\s*#\s?/, ''))
|
|
190
|
+
end
|
|
191
|
+
buf.join
|
|
192
|
+
rescue StandardError
|
|
193
|
+
''
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private_class_method def self.help_options(opts = {})
|
|
197
|
+
klass = Object.const_get(opts[:mod].to_s)
|
|
198
|
+
path, = klass.method(:help).source_location
|
|
199
|
+
return {} unless path && File.file?(path)
|
|
200
|
+
|
|
201
|
+
text = File.read(path)
|
|
202
|
+
name = Regexp.escape(opts[:method].to_s)
|
|
203
|
+
chunk = text[/#\{self\}\.#{name}\((.*?)\)/m, 1]
|
|
204
|
+
return {} unless chunk
|
|
205
|
+
|
|
206
|
+
chunk.scan(/(\w+):\s*['"]((?:required|optional)[^'"]*)['"]/i).to_h
|
|
207
|
+
rescue StandardError
|
|
208
|
+
{}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private_class_method def self.body_keys(opts = {})
|
|
212
|
+
klass = Object.const_get(opts[:mod].to_s)
|
|
213
|
+
path, line = klass.method(opts[:method].to_sym).source_location
|
|
214
|
+
return [] unless path && line && File.file?(path)
|
|
215
|
+
|
|
216
|
+
lines = File.readlines(path)
|
|
217
|
+
indent = lines[line - 1][/^\s*/]
|
|
218
|
+
body = []
|
|
219
|
+
((line - 1)...lines.length).each do |idx|
|
|
220
|
+
body << lines[idx]
|
|
221
|
+
break if idx > (line - 1) && lines[idx].match?(/^#{Regexp.escape(indent)}end\b/)
|
|
222
|
+
end
|
|
223
|
+
body.join.scan(/opts\[\s*:([A-Za-z_]\w*)\s*\]/).flatten.uniq
|
|
224
|
+
rescue StandardError
|
|
225
|
+
[]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private_class_method def self.purpose_line(opts = {})
|
|
229
|
+
comment_block(opts).to_s.lines.map(&:strip).reject(&:empty?).first.to_s
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private_class_method def self.extract_calls(opts = {})
|
|
233
|
+
sexp = Ripper.sexp(opts[:code].to_s)
|
|
234
|
+
return [] unless sexp
|
|
235
|
+
|
|
236
|
+
found = []
|
|
237
|
+
walk_sexp(node: sexp, found: found)
|
|
238
|
+
found.select { |row| row[:mod].to_s.start_with?('PWN::Plugins::') && !row[:keys].nil? }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private_class_method def self.walk_sexp(opts = {})
|
|
242
|
+
node = opts[:node]
|
|
243
|
+
found = opts[:found]
|
|
244
|
+
return unless node.is_a?(Array)
|
|
245
|
+
|
|
246
|
+
if node[0] == :method_add_arg && node[1].is_a?(Array) && node[1][0] == :call
|
|
247
|
+
rec = call_record(node: node[1], args: node[2])
|
|
248
|
+
found << rec if rec
|
|
249
|
+
elsif %i[call command_call].include?(node[0])
|
|
250
|
+
rec = call_record(node: node)
|
|
251
|
+
found << rec if rec
|
|
252
|
+
end
|
|
253
|
+
node.each { |child| walk_sexp(node: child, found: found) if child.is_a?(Array) }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private_class_method def self.call_record(opts = {})
|
|
257
|
+
node = opts[:node]
|
|
258
|
+
recv = node[1]
|
|
259
|
+
name_node = node[3]
|
|
260
|
+
args_node = opts[:args] || node[4]
|
|
261
|
+
ident = name_node.is_a?(Array) && name_node[0] == :@ident ? name_node[1] : nil
|
|
262
|
+
mod = const_path(node: recv)
|
|
263
|
+
return nil if ident.nil? || mod.nil?
|
|
264
|
+
|
|
265
|
+
keys = kwarg_keys(node: args_node)
|
|
266
|
+
return nil if keys.nil?
|
|
267
|
+
|
|
268
|
+
{ mod: mod, method: ident, keys: keys }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private_class_method def self.const_path(opts = {})
|
|
272
|
+
node = opts[:node]
|
|
273
|
+
return nil unless node.is_a?(Array)
|
|
274
|
+
|
|
275
|
+
case node[0]
|
|
276
|
+
when :@const
|
|
277
|
+
node[1]
|
|
278
|
+
when :var_ref, :top_const_ref
|
|
279
|
+
const_path(node: node[1])
|
|
280
|
+
when :const_path_ref, :const_path_field
|
|
281
|
+
left = const_path(node: node[1])
|
|
282
|
+
right = const_path(node: node[2])
|
|
283
|
+
[left, right].compact.join('::')
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private_class_method def self.kwarg_keys(opts = {})
|
|
288
|
+
node = opts[:node]
|
|
289
|
+
return [] if node.nil?
|
|
290
|
+
return nil unless node.is_a?(Array)
|
|
291
|
+
|
|
292
|
+
return kwarg_keys(node: node[1]) if node[0] == :arg_paren
|
|
293
|
+
|
|
294
|
+
hash = find_assoc_hash(node: node)
|
|
295
|
+
return nil if hash.nil? && ident_arg?(node: node)
|
|
296
|
+
return [] if hash.nil?
|
|
297
|
+
|
|
298
|
+
hash.each_with_object([]) do |assoc, keys|
|
|
299
|
+
next unless assoc.is_a?(Array) && assoc[0] == :assoc_new
|
|
300
|
+
|
|
301
|
+
label = assoc[1]
|
|
302
|
+
keys << label[1].to_s.delete_suffix(':') if label.is_a?(Array) && label[0] == :@label
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private_class_method def self.find_assoc_hash(opts = {})
|
|
307
|
+
node = opts[:node]
|
|
308
|
+
return nil unless node.is_a?(Array)
|
|
309
|
+
return node[1] if node[0] == :bare_assoc_hash
|
|
310
|
+
|
|
311
|
+
node.each do |child|
|
|
312
|
+
next unless child.is_a?(Array)
|
|
313
|
+
|
|
314
|
+
found = find_assoc_hash(node: child)
|
|
315
|
+
return found if found
|
|
316
|
+
end
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private_class_method def self.ident_arg?(opts = {})
|
|
321
|
+
node = opts[:node]
|
|
322
|
+
return false unless node.is_a?(Array)
|
|
323
|
+
|
|
324
|
+
node.flatten.include?(:@ident) && !node.flatten.include?(:@label)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
data/lib/pwn/plugins/nmap_it.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'nmap/xml'
|
|
|
5
5
|
require 'open3'
|
|
6
6
|
require 'securerandom'
|
|
7
7
|
require 'tmpdir'
|
|
8
|
+
require 'rexml/document'
|
|
9
|
+
require 'time'
|
|
8
10
|
|
|
9
11
|
module PWN
|
|
10
12
|
module Plugins
|
|
@@ -118,30 +120,46 @@ module PWN
|
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
public_class_method def self.to_findings(opts = {})
|
|
121
|
-
|
|
123
|
+
inventory(opts)[:ports]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
public_class_method def self.inventory(opts = {})
|
|
127
|
+
xml_file = (opts[:xml_file] || opts[:xml]).to_s
|
|
122
128
|
raise 'ERROR: xml_file is required' if xml_file.empty?
|
|
123
|
-
return [] unless File.file?(xml_file)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
129
|
+
return { hosts: [], ports: [] } unless File.file?(xml_file)
|
|
130
|
+
|
|
131
|
+
doc = REXML::Document.new(File.read(xml_file))
|
|
132
|
+
hosts = []
|
|
133
|
+
ports = []
|
|
134
|
+
doc.elements.each('nmaprun/host') do |node|
|
|
135
|
+
ip = node.elements['address']&.attributes&.[]('addr').to_s
|
|
136
|
+
next if ip.empty?
|
|
137
|
+
|
|
138
|
+
host_scripts = script_map(node: node.elements['hostscript'])
|
|
139
|
+
host_ports = []
|
|
140
|
+
node.elements.each('ports/port') do |port_node|
|
|
141
|
+
number = port_node.attributes['portid'].to_i
|
|
142
|
+
proto = port_node.attributes['protocol'].to_s
|
|
143
|
+
state = port_node.elements['state']&.attributes&.[]('state').to_s
|
|
144
|
+
svc = port_node.elements['service']
|
|
145
|
+
scripts = script_map(node: port_node)
|
|
146
|
+
row = {
|
|
147
|
+
host: ip,
|
|
148
|
+
port: number,
|
|
149
|
+
proto: proto,
|
|
150
|
+
state: state,
|
|
151
|
+
service: svc&.attributes&.[]('name'),
|
|
152
|
+
version: svc&.attributes&.[]('version') || svc&.attributes&.[]('product'),
|
|
153
|
+
scripts: scripts,
|
|
154
|
+
template_id: nil,
|
|
155
|
+
severity: 'info'
|
|
156
|
+
}
|
|
157
|
+
host_ports << row
|
|
158
|
+
ports << row
|
|
142
159
|
end
|
|
160
|
+
hosts << { host: ip, ports: host_ports, services: host_ports.map { |row| row[:service] }.compact.uniq, scripts: host_scripts }
|
|
143
161
|
end
|
|
144
|
-
|
|
162
|
+
{ hosts: hosts, ports: ports, xml: xml_file }
|
|
145
163
|
end
|
|
146
164
|
|
|
147
165
|
public_class_method def self.scan(opts = {})
|
|
@@ -154,10 +172,41 @@ module PWN
|
|
|
154
172
|
xml = File.join(Dir.tmpdir, "pwn-nmap-#{Process.pid}-#{SecureRandom.hex(4)}.xml") if xml.empty?
|
|
155
173
|
port_scan(opts.merge(xml: xml, targets: targets))
|
|
156
174
|
end
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
inv = inventory(xml_file: xml)
|
|
176
|
+
rows = inv[:ports]
|
|
177
|
+
eng = opts[:engagement] || opts[:name]
|
|
178
|
+
if defined?(PWN::Engagement) && opts[:engagement] != false
|
|
179
|
+
PWN::Engagement.merge_scan(results: rows, override: opts[:override], engagement: eng)
|
|
180
|
+
PWN::Engagement.record_scan(hosts: inv[:hosts], ports: rows, xml: xml, at: opts[:at], engagement: eng, kind: 'nmap')
|
|
181
|
+
end
|
|
182
|
+
grouped = inv[:hosts]
|
|
183
|
+
{
|
|
184
|
+
hosts: grouped,
|
|
185
|
+
ports: rows,
|
|
186
|
+
xml: xml,
|
|
187
|
+
diff: changes(opts.merge(engagement: eng, latest: inv[:hosts]))
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
public_class_method def self.changes(opts = {})
|
|
192
|
+
eng = opts[:engagement] || opts[:name]
|
|
193
|
+
snaps = defined?(PWN::Engagement) ? PWN::Engagement.scans(engagement: eng, name: eng) : []
|
|
194
|
+
latest_hosts = opts[:latest]
|
|
195
|
+
if latest_hosts.nil?
|
|
196
|
+
return { since: opts[:since], added_hosts: [], removed_hosts: [], added_ports: [], removed_ports: [], changed_scripts: [], first: true } if snaps.empty?
|
|
197
|
+
|
|
198
|
+
latest_hosts = Array(snaps.last[:hosts])
|
|
199
|
+
end
|
|
200
|
+
cutoff = parse_since(since: opts[:since])
|
|
201
|
+
pool = Array(snaps[0..-2])
|
|
202
|
+
previous = pool.reverse.find do |snap|
|
|
203
|
+
at = Time.parse(snap[:at].to_s).utc
|
|
204
|
+
cutoff.nil? || at <= cutoff
|
|
205
|
+
end
|
|
206
|
+
previous ||= snaps[-2]
|
|
207
|
+
return { since: opts[:since], previous_at: nil, added_hosts: [], removed_hosts: [], added_ports: [], removed_ports: [], changed_scripts: [], first: true } unless previous
|
|
208
|
+
|
|
209
|
+
diff_inventories(previous: Array(previous[:hosts]), current: latest_hosts).merge(since: opts[:since], previous_at: previous[:at], current_at: snaps.last && snaps.last[:at])
|
|
161
210
|
end
|
|
162
211
|
|
|
163
212
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
@@ -207,7 +256,22 @@ module PWN
|
|
|
207
256
|
engagement: 'optional - false skips host-state merge',
|
|
208
257
|
override: 'optional - true records out-of-scope hosts',
|
|
209
258
|
target: 'optional - alias for targets',
|
|
210
|
-
name: 'optional - engagement name for host-state merge'
|
|
259
|
+
name: 'optional - engagement name for host-state merge',
|
|
260
|
+
at: 'optional - Time or ISO8601 timestamp stored on the scan snapshot'
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Parse nmap XML into hosts, ports, and script output hashes.
|
|
264
|
+
#{self}.inventory(
|
|
265
|
+
xml_file: 'required - path to nmap XML output unless xml is set',
|
|
266
|
+
xml: 'optional - alias for xml_file'
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Diff the latest engagement snapshot against yesterday or a prior scan.
|
|
270
|
+
#{self}.changes(
|
|
271
|
+
since: 'optional - yesterday, last-scan, ISO8601, or Time (defaults to previous snapshot)',
|
|
272
|
+
engagement: 'optional - engagement identifier',
|
|
273
|
+
name: 'optional - alias for engagement',
|
|
274
|
+
latest: 'optional - inventory host array to treat as current'
|
|
211
275
|
)
|
|
212
276
|
|
|
213
277
|
# Print the AUTHOR(S) string for this module.
|
|
@@ -215,6 +279,73 @@ module PWN
|
|
|
215
279
|
"
|
|
216
280
|
constants.sort
|
|
217
281
|
end
|
|
282
|
+
|
|
283
|
+
private_class_method def self.script_map(opts = {})
|
|
284
|
+
node = opts[:node]
|
|
285
|
+
return {} unless node
|
|
286
|
+
|
|
287
|
+
map = {}
|
|
288
|
+
node.elements.each('script') do |script|
|
|
289
|
+
map[script.attributes['id'].to_s] = script.attributes['output'].to_s
|
|
290
|
+
end
|
|
291
|
+
map
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private_class_method def self.parse_since(opts = {})
|
|
295
|
+
token = opts[:since]
|
|
296
|
+
return nil if token.nil? || token.to_s.empty? || token.to_s.match?(/last.?scan/i)
|
|
297
|
+
return token.getutc if token.is_a?(Time)
|
|
298
|
+
|
|
299
|
+
return Time.now.utc - 86_400 if token.to_s.match?(/yesterday/i)
|
|
300
|
+
|
|
301
|
+
Time.parse(token.to_s).utc
|
|
302
|
+
rescue ArgumentError
|
|
303
|
+
Time.now.utc - 86_400
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private_class_method def self.diff_inventories(opts = {})
|
|
307
|
+
prev = index_inventory(hosts: opts[:previous])
|
|
308
|
+
curr = index_inventory(hosts: opts[:current])
|
|
309
|
+
added_ports = (curr[:ports].keys - prev[:ports].keys).map { |key| curr[:ports][key] }
|
|
310
|
+
removed_ports = (prev[:ports].keys - curr[:ports].keys).map { |key| prev[:ports][key] }
|
|
311
|
+
changed_scripts = []
|
|
312
|
+
curr[:scripts].each do |key, now|
|
|
313
|
+
was = prev[:scripts][key]
|
|
314
|
+
next if was == now
|
|
315
|
+
|
|
316
|
+
changed_scripts << { host: key[0], port: key[1], name: key[2], before: was, after: now }
|
|
317
|
+
end
|
|
318
|
+
{
|
|
319
|
+
added_hosts: (curr[:hosts] - prev[:hosts]).to_a,
|
|
320
|
+
removed_hosts: (prev[:hosts] - curr[:hosts]).to_a,
|
|
321
|
+
added_ports: added_ports,
|
|
322
|
+
removed_ports: removed_ports,
|
|
323
|
+
changed_scripts: changed_scripts
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private_class_method def self.index_inventory(opts = {})
|
|
328
|
+
hosts = []
|
|
329
|
+
ports = {}
|
|
330
|
+
scripts = {}
|
|
331
|
+
Array(opts[:hosts]).each do |host|
|
|
332
|
+
host = host.transform_keys(&:to_sym) if host.respond_to?(:transform_keys)
|
|
333
|
+
ip = host[:host].to_s
|
|
334
|
+
hosts << ip
|
|
335
|
+
Array(host[:scripts]).each do |name, output|
|
|
336
|
+
scripts[[ip, 0, name.to_s]] = output.to_s
|
|
337
|
+
end
|
|
338
|
+
Array(host[:ports]).each do |port|
|
|
339
|
+
port = port.transform_keys(&:to_sym) if port.respond_to?(:transform_keys)
|
|
340
|
+
key = [ip, port[:port], port[:proto].to_s]
|
|
341
|
+
ports[key] = { host: ip, port: port[:port], proto: port[:proto], service: port[:service], version: port[:version] }
|
|
342
|
+
Array(port[:scripts]).each do |name, output|
|
|
343
|
+
scripts[[ip, port[:port], name.to_s]] = output.to_s
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
{ hosts: hosts, ports: ports, scripts: scripts }
|
|
348
|
+
end
|
|
218
349
|
end
|
|
219
350
|
end
|
|
220
351
|
end
|