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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. 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
@@ -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
- xml_file = opts[:xml_file].to_s
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
- rows = []
126
- parse_xml_results(xml_file: xml_file) do |xml|
127
- xml.each_host do |host|
128
- host.each_port do |port|
129
- scripts = {}
130
- port.scripts.each { |name, output| scripts[name.to_s] = output.to_s } if port.respond_to?(:scripts) && port.scripts
131
- rows << {
132
- host: host.ip.to_s,
133
- port: port.number,
134
- proto: port.protocol.to_s,
135
- service: (port.service.name if port.respond_to?(:service) && port.service),
136
- version: (port.service.version if port.respond_to?(:service) && port.service.respond_to?(:version)),
137
- scripts: scripts,
138
- template_id: nil,
139
- severity: 'info'
140
- }
141
- end
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
- rows
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
- rows = to_findings(xml_file: xml)
158
- PWN::Engagement.merge_scan(results: rows, override: opts[:override], engagement: opts[:engagement] || opts[:name]) if defined?(PWN::Engagement) && opts[:engagement] != false
159
- grouped = rows.group_by { |row| row[:host] }.map { |host, ports| { host: host, ports: ports, services: ports.map { |p| p[:service] }.compact } }
160
- { hosts: grouped, ports: rows, xml: xml }
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