pwn 0.5.712 → 0.5.714

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +3 -0
  3. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +1 -0
  4. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  5. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +16 -0
  6. data/etc/default_skills/pwn/ai/agent/tools/artifacts/SKILL.md +45 -0
  7. data/etc/default_skills/pwn/ai/agent/tools/binary_triage/SKILL.md +45 -0
  8. data/etc/default_skills/pwn/ai/agent/tools/decompile/SKILL.md +45 -0
  9. data/etc/default_skills/pwn/ai/agent/tools/exploitdev/SKILL.md +45 -0
  10. data/etc/default_skills/pwn/ai/agent/tools/finding_record/SKILL.md +45 -0
  11. data/etc/default_skills/pwn/ai/agent/tools/fuzz_campaign/SKILL.md +45 -0
  12. data/etc/default_skills/pwn/ai/agent/tools/intel_lookup/SKILL.md +45 -0
  13. data/etc/default_skills/pwn/ai/agent/tools/job_run/SKILL.md +45 -0
  14. data/etc/default_skills/pwn/ai/agent/tools/pty_session/SKILL.md +45 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/session_export/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/swarm_map/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/memory_index/SKILL.md +1 -0
  18. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +2 -0
  20. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  21. data/etc/default_skills/pwn/plugins/detect_os/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/findings/SKILL.md +50 -0
  23. data/etc/default_skills/pwn/plugins/ghidra/SKILL.md +49 -0
  24. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/log/SKILL.md +1 -0
  26. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
  27. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +2 -0
  28. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/sessions/SKILL.md +3 -0
  30. data/etc/default_skills/pwn/setup/references/urls.md +1 -0
  31. data/etc/intel/files_exploits.csv +2 -0
  32. data/lib/pwn/ai/agent/dispatch.rb +5 -0
  33. data/lib/pwn/ai/agent/learning.rb +72 -0
  34. data/lib/pwn/ai/agent/loop.rb +62 -13
  35. data/lib/pwn/ai/agent/mistakes.rb +21 -1
  36. data/lib/pwn/ai/agent/prompt_builder.rb +16 -13
  37. data/lib/pwn/ai/agent/registry.rb +21 -1
  38. data/lib/pwn/ai/agent/reward.rb +4 -2
  39. data/lib/pwn/ai/agent/swarm.rb +26 -0
  40. data/lib/pwn/ai/agent/tool_guard.rb +201 -1
  41. data/lib/pwn/ai/agent/tools/artifacts.rb +61 -0
  42. data/lib/pwn/ai/agent/tools/binary_triage.rb +20 -0
  43. data/lib/pwn/ai/agent/tools/decompile.rb +27 -0
  44. data/lib/pwn/ai/agent/tools/exploitdev.rb +45 -0
  45. data/lib/pwn/ai/agent/tools/finding_record.rb +48 -0
  46. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -0
  47. data/lib/pwn/ai/agent/tools/intel_lookup.rb +24 -0
  48. data/lib/pwn/ai/agent/tools/job_run.rb +56 -0
  49. data/lib/pwn/ai/agent/tools/pty_session.rb +73 -0
  50. data/lib/pwn/ai/agent/tools/session_export.rb +20 -0
  51. data/lib/pwn/ai/agent/tools/shell.rb +5 -0
  52. data/lib/pwn/ai/agent/tools/swarm_map.rb +26 -0
  53. data/lib/pwn/config.rb +10 -1
  54. data/lib/pwn/memory_index.rb +50 -0
  55. data/lib/pwn/plugins/aflplusplus.rb +30 -1
  56. data/lib/pwn/plugins/artifact_registry.rb +43 -0
  57. data/lib/pwn/plugins/binary_parser.rb +31 -0
  58. data/lib/pwn/plugins/detect_os.rb +109 -2
  59. data/lib/pwn/plugins/exploit_db.rb +38 -1
  60. data/lib/pwn/plugins/findings.rb +105 -0
  61. data/lib/pwn/plugins/ghidra.rb +75 -0
  62. data/lib/pwn/plugins/jobs.rb +12 -1
  63. data/lib/pwn/plugins/log.rb +18 -3
  64. data/lib/pwn/plugins/nmap_it.rb +29 -0
  65. data/lib/pwn/plugins/preflight_checker.rb +36 -0
  66. data/lib/pwn/plugins/transparent_browser.rb +29 -0
  67. data/lib/pwn/plugins.rb +2 -0
  68. data/lib/pwn/sessions.rb +92 -1
  69. data/lib/pwn/setup.rb +41 -15
  70. data/lib/pwn/version.rb +1 -1
  71. data/spec/lib/pwn/ai/agent/fixtures/error_class_docker.json +7 -0
  72. data/spec/lib/pwn/ai/agent/learning_spec.rb +15 -0
  73. data/spec/lib/pwn/ai/agent/loop_spec.rb +57 -2
  74. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +21 -0
  75. data/spec/lib/pwn/ai/agent/registry_spec.rb +11 -0
  76. data/spec/lib/pwn/ai/agent/reward_spec.rb +14 -0
  77. data/spec/lib/pwn/ai/agent/swarm_spec.rb +6 -0
  78. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  79. data/spec/lib/pwn/ai/agent/tools/artifacts_spec.rb +16 -0
  80. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +14 -0
  81. data/spec/lib/pwn/ai/agent/tools/decompile_spec.rb +14 -0
  82. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +14 -0
  83. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +15 -0
  84. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +14 -0
  85. data/spec/lib/pwn/ai/agent/tools/intel_lookup_spec.rb +14 -0
  86. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +16 -0
  87. data/spec/lib/pwn/ai/agent/tools/pty_session_spec.rb +17 -0
  88. data/spec/lib/pwn/ai/agent/tools/session_export_spec.rb +14 -0
  89. data/spec/lib/pwn/ai/agent/tools/swarm_map_spec.rb +14 -0
  90. data/spec/lib/pwn/cron_spec.rb +2 -2
  91. data/spec/lib/pwn/memory_index_spec.rb +9 -0
  92. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +9 -0
  93. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +12 -0
  94. data/spec/lib/pwn/plugins/detect_os_spec.rb +40 -0
  95. data/spec/lib/pwn/plugins/exploit_db_spec.rb +12 -0
  96. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +23 -0
  97. data/spec/lib/pwn/plugins/findings_spec.rb +30 -0
  98. data/spec/lib/pwn/plugins/frida_spec.rb +5 -0
  99. data/spec/lib/pwn/plugins/fuzz_spec.rb +20 -0
  100. data/spec/lib/pwn/plugins/gdb_spec.rb +15 -0
  101. data/spec/lib/pwn/plugins/ghidra_spec.rb +14 -0
  102. data/spec/lib/pwn/plugins/k8s_spec.rb +5 -0
  103. data/spec/lib/pwn/plugins/log_spec.rb +10 -5
  104. data/spec/lib/pwn/plugins/nmap_it_spec.rb +4 -0
  105. data/spec/lib/pwn/plugins/nuclei_spec.rb +6 -0
  106. data/spec/lib/pwn/plugins/preflight_checker_spec.rb +24 -0
  107. data/spec/lib/pwn/plugins/process_tube_spec.rb +23 -0
  108. data/spec/lib/pwn/plugins/semgrep_spec.rb +4 -0
  109. data/spec/lib/pwn/plugins/sock_spec.rb +7 -0
  110. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +15 -0
  111. data/spec/lib/pwn/sessions_spec.rb +53 -0
  112. data/spec/lib/pwn/setup_spec.rb +32 -0
  113. data/third_party/pwn_rdoc.jsonl +64 -2
  114. metadata +42 -3
  115. data/spec/lib/pwn/req_queue_spec.rb +0 -168
  116. data/tmp_pwn_critic_skills_flaw.txt +0 -9
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'swarm_map',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'swarm_map',
10
+ description: 'Fan-out TCP/port map of a CIDR or host list. Sequential when swarm is off.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ targets: { type: 'string' },
15
+ ports: { type: 'string' }
16
+ },
17
+ required: %w[targets]
18
+ }
19
+ },
20
+ handler: lambda { |args|
21
+ PWN::AI::Agent::Swarm.map_targets(
22
+ targets: args[:targets] || args['targets'],
23
+ ports: args[:ports] || args['ports'] || '80,443'
24
+ )
25
+ }
26
+ )
data/lib/pwn/config.rb CHANGED
@@ -193,7 +193,16 @@ module PWN
193
193
  # Advisory only: never replaces TaskSummarizer / plan_first.
194
194
  policy: true,
195
195
  toolsets: nil,
196
- operator_account: nil
196
+ operator_account: nil,
197
+ sessions_keep_days: 90,
198
+ artifacts_keep_days: 90,
199
+ max_total_mb: 512,
200
+ model_routes: {
201
+ summarize: nil,
202
+ judge: nil,
203
+ act: nil,
204
+ sensitive: nil
205
+ }
197
206
  # multi-agent personas : ~/.pwn/agents.yml (see PWN::AI::Agent::Swarm.help)
198
207
  # swarm bus : ~/.pwn/swarm/<swarm_id>/bus.jsonl
199
208
  }
@@ -272,6 +272,50 @@ module PWN
272
272
  []
273
273
  end
274
274
 
275
+ public_class_method def self.bm25(opts = {})
276
+ query = opts[:query].to_s.downcase
277
+ docs = Array(opts[:docs]).map(&:to_s)
278
+ qtok = expand_query(query: query)
279
+ return docs.map { |d| { doc: d, score: 0.0 } } if qtok.empty?
280
+
281
+ df = Hash.new(0)
282
+ tokenized = docs.map do |d|
283
+ toks = d.downcase.scan(/[a-z0-9]{3,}/)
284
+ toks.uniq.each { |t| df[t] += 1 }
285
+ toks
286
+ end
287
+ n = docs.length.to_f
288
+ avgdl = tokenized.sum(&:length) / [n, 1].max
289
+ k1 = 1.2
290
+ b = 0.75
291
+ ranked = docs.each_with_index.map do |doc, i|
292
+ toks = tokenized[i]
293
+ tf = Hash.new(0)
294
+ toks.each { |t| tf[t] += 1 }
295
+ dl = toks.length.to_f
296
+ score = qtok.sum do |t|
297
+ f = tf[t]
298
+ next 0.0 if f.zero?
299
+
300
+ idf = Math.log(((n - df[t] + 0.5) / (df[t] + 0.5)) + 1)
301
+ idf * ((f * (k1 + 1)) / (f + (k1 * (1 - b + (b * dl / avgdl)))))
302
+ end
303
+ { doc: doc, score: score }
304
+ end
305
+ ranked.sort_by { |h| -h[:score] }
306
+ end
307
+
308
+ private_class_method def self.expand_query(opts = {})
309
+ toks = opts[:query].to_s.downcase.scan(/[a-z0-9]{3,}/)
310
+ table = {
311
+ 'bypass' => %w[evasion evade],
312
+ 'rate' => %w[throttle],
313
+ 'limit' => %w[throttle],
314
+ 'login' => %w[auth]
315
+ }
316
+ toks.flat_map { |t| [t] + Array(table[t]) }.uniq
317
+ end
318
+
275
319
  # Author(s):: 0day Inc. <support@0dayinc.com>
276
320
 
277
321
  public_class_method def self.authors
@@ -308,6 +352,12 @@ module PWN
308
352
  texts: 'required - Array texts value consumed by #embed'
309
353
  )
310
354
 
355
+ # Rank documents by BM25 (pure Ruby; no embedder required).
356
+ #{self}.bm25(
357
+ query: 'required - search text',
358
+ docs: 'required - Array of document strings'
359
+ )
360
+
311
361
  # Run reset and return its result
312
362
  #{self}.reset
313
363
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'digest'
5
+ require 'json'
5
6
 
6
7
  module PWN
7
8
  module Plugins
@@ -24,16 +25,38 @@ module PWN
24
25
  { stdout: stdout, stderr: stderr, exit: status.exitstatus }
25
26
  end
26
27
 
28
+ public_class_method def self.parse_stats(opts = {})
29
+ out_dir = (opts[:out_dir] || opts[:output]).to_s
30
+ raise 'ERROR: out_dir is required' if out_dir.empty?
31
+
32
+ path = File.join(out_dir, 'fuzzer_stats')
33
+ path = File.join(out_dir, 'default', 'fuzzer_stats') unless File.file?(path)
34
+ return {} unless File.file?(path)
35
+
36
+ File.read(path).each_line.with_object({}) do |line, acc|
37
+ key, val = line.split(':', 2)
38
+ next if key.to_s.strip.empty?
39
+
40
+ acc[key.strip.to_sym] = val.to_s.strip
41
+ end
42
+ end
43
+
27
44
  public_class_method def self.crash_triage(opts = {})
28
45
  out_dir = (opts[:out_dir] || opts[:output]).to_s
29
46
  raise 'ERROR: out_dir is required' if out_dir.empty?
30
47
 
31
48
  crashes = Dir[File.join(out_dir, '**/crashes/id:*')]
32
49
  hashes = crashes.map { |p| [p, Digest::SHA256.file(p).hexdigest] }
50
+ rows = hashes.map do |path, sha|
51
+ art = File.join(out_dir, "crash-#{sha[0, 12]}.json")
52
+ File.write(art, JSON.generate(path: path, sha256: sha))
53
+ { path: path, sha256: sha, artifact: art }
54
+ end
33
55
  {
34
56
  crashes: crashes,
35
57
  unique: hashes.map(&:last).uniq,
36
- asan: crashes.any? { |p| File.binread(p).include?('AddressSanitizer') }
58
+ asan: crashes.any? { |p| File.binread(p).include?('AddressSanitizer') },
59
+ artifacts: rows
37
60
  }
38
61
  end
39
62
 
@@ -56,6 +79,12 @@ module PWN
56
79
  args: 'optional - Array args value consumed by #fuzz'
57
80
  )
58
81
 
82
+ # Parse AFL fuzzer_stats (execs_per_sec, paths, crashes) from out_dir.
83
+ #{self}.parse_stats(
84
+ out_dir: 'required - AFL output directory containing fuzzer_stats',
85
+ output: 'optional - alias for out_dir'
86
+ )
87
+
59
88
  # Dedup AFL++ crash files by sha256 and note AddressSanitizer hits.
60
89
  #{self}.crash_triage(
61
90
  out_dir: 'required - AFL output directory containing crashes/',
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'digest'
5
6
 
6
7
  module PWN
7
8
  module Plugins
@@ -40,6 +41,35 @@ module PWN
40
41
  end
41
42
  end
42
43
 
44
+ public_class_method def self.get(opts = {})
45
+ path = opts[:path].to_s
46
+ raise 'ERROR: path is required' if path.empty?
47
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
48
+
49
+ { path: path, sha256: Digest::SHA256.file(path).hexdigest, bytes: File.size(path), body: File.binread(path)[0, 65_536] }
50
+ end
51
+
52
+ public_class_method def self.read_page(opts = {})
53
+ path = opts[:path].to_s
54
+ raise 'ERROR: path is required' if path.empty?
55
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
56
+
57
+ offset = opts[:offset].to_i
58
+ length = (opts[:length] || 4_096).to_i
59
+ length = 4_096 if length <= 0
60
+ mode = (opts[:mode] || 'text').to_s
61
+ data = File.binread(path, length, offset).to_s
62
+ case mode
63
+ when 'hex'
64
+ { path: path, offset: offset, bytes: data.bytesize, hex: data.unpack1('H*') }
65
+ when 'base64'
66
+ require 'base64'
67
+ { path: path, offset: offset, bytes: data.bytesize, body: Base64.strict_encode64(data) }
68
+ else
69
+ { path: path, offset: offset, bytes: data.bytesize, body: data.force_encoding('UTF-8').scrub }
70
+ end
71
+ end
72
+
43
73
  public_class_method def self.authors
44
74
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
45
75
  end
@@ -61,6 +91,19 @@ module PWN
61
91
  session_id: 'optional - session id value consumed by #list'
62
92
  )
63
93
 
94
+ # Read an artifact file and return sha256 plus a body cap.
95
+ #{self}.get(
96
+ path: 'required - filesystem path of a registered artifact'
97
+ )
98
+
99
+ # Page an artifact as text, hex, or base64 from an offset.
100
+ #{self}.read_page(
101
+ path: 'required - filesystem path of the artifact',
102
+ offset: 'optional - byte offset (defaults to 0)',
103
+ length: 'optional - byte count (defaults to 4096)',
104
+ mode: 'optional - text, hex, or base64 (defaults to text)'
105
+ )
106
+
64
107
  # Print the AUTHOR(S) string for this module.
65
108
  #{self}.authors
66
109
  "
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'metasm'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'fileutils'
4
7
 
5
8
  module PWN
6
9
  module Plugins
@@ -78,6 +81,29 @@ module PWN
78
81
  []
79
82
  end
80
83
 
84
+ public_class_method def self.triage(opts = {})
85
+ path = opts[:path].to_s
86
+ inf = info(opts)
87
+ secs = sections(opts)
88
+ imps = Array(imports(opts)).first(40)
89
+ exps = Array(exports(opts)).first(40)
90
+ summary = {
91
+ format: inf[:format],
92
+ arch: inf[:cpu],
93
+ bits: inf[:cpu].to_s[/\d+/],
94
+ imports: imps,
95
+ exports: exps,
96
+ function_count: Array(symbols(opts)).length,
97
+ section_entropy_map: secs.to_h { |s| [s[:name], s[:size]] }
98
+ }
99
+ sha = Digest::SHA256.file(path).hexdigest
100
+ dir = File.join(Dir.home, '.pwn', 'artifacts', 'triage')
101
+ FileUtils.mkdir_p(dir)
102
+ art = File.join(dir, "#{sha[0, 16]}.json")
103
+ File.write(art, JSON.pretty_generate(summary.merge(path: path, sha256: sha)))
104
+ summary.merge(artifact: art)
105
+ end
106
+
81
107
  public_class_method def self.authors
82
108
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
83
109
  end
@@ -114,6 +140,11 @@ module PWN
114
140
  limit: 'optional - limit value consumed by #relocations'
115
141
  )
116
142
 
143
+ # Structured binary triage JSON plus an artifact path under ~/.pwn/artifacts/triage.
144
+ #{self}.triage(
145
+ path: 'required - filesystem path of the binary to triage'
146
+ )
147
+
117
148
  # Print the AUTHOR(S) string for this module.
118
149
  #{self}.authors
119
150
  "
@@ -31,10 +31,17 @@ module PWN
31
31
  os = :cygwin if OS.cygwin?
32
32
  os = :freebsd if OS.freebsd?
33
33
  os = :linux if OS.linux?
34
- os = :netbsd if OS.host_os.include?('netbsd')
35
- os = :openbsd if OS.host_os.include?('openbsd')
34
+ os = :netbsd if OS.host_os.to_s.include?('netbsd')
35
+ os = :openbsd if OS.host_os.to_s.include?('openbsd')
36
36
  os = :osx if OS.osx?
37
37
  os = :windows if OS.windows?
38
+ if os.nil?
39
+ uname = `uname -s 2>/dev/null`.to_s.downcase
40
+ os = :openbsd if uname.include?('openbsd')
41
+ os = :freebsd if uname.include?('freebsd')
42
+ os = :netbsd if uname.include?('netbsd')
43
+ os = :linux if uname.include?('linux')
44
+ end
38
45
 
39
46
  os
40
47
  rescue StandardError => e
@@ -151,6 +158,56 @@ module PWN
151
158
  osr['VERSION'].to_s
152
159
  end
153
160
 
161
+ # Supported Method Parameters::
162
+ # PWN::Plugins::DetectOS.living_off_the_land(
163
+ # os_release: 'optional - /etc/os-release body (defaults to reading the file)',
164
+ # which: 'optional - Hash of binary name => present Boolean (skips live PATH)',
165
+ # bins: 'optional - extra binary names to probe besides plugin required_bins'
166
+ # )
167
+
168
+ public_class_method def self.living_off_the_land(opts = {})
169
+ os_type = opts[:type] || type
170
+ flavor = distro(opts)
171
+ ver = version(opts)
172
+ cpu = opts[:arch] || arch
173
+ endn = opts[:endian] || endian
174
+ names = inventory_names(bins: opts[:bins])
175
+ which = opts[:which]
176
+ present = []
177
+ missing = []
178
+ names.each do |name|
179
+ ok = if which.is_a?(Hash)
180
+ which[name] || which[name.to_sym]
181
+ else
182
+ bin_present?(name: name)
183
+ end
184
+ (ok ? present : missing) << name
185
+ end
186
+ cap = if opts.key?(:cap_net_raw)
187
+ opts[:cap_net_raw]
188
+ else
189
+ cap_net_raw_live
190
+ end
191
+ docker = if opts.key?(:docker)
192
+ opts[:docker]
193
+ else
194
+ docker_live
195
+ end
196
+ summary = "#{flavor} #{ver} #{cpu} present=#{present.first(24).join(',')} missing=#{missing.first(12).join(',')}"
197
+ {
198
+ type: os_type,
199
+ distro: flavor,
200
+ version: ver,
201
+ arch: cpu.to_s,
202
+ endian: endn,
203
+ present: present,
204
+ missing: missing,
205
+ cap_net_raw: cap,
206
+ docker: docker,
207
+ summary: summary
208
+ }
209
+ end
210
+
154
211
  public_class_method def self.authors
155
212
  "AUTHOR(S):
156
213
  0day Inc. <support@0dayinc.com>
@@ -193,12 +250,62 @@ module PWN
193
250
  version: 'optional - force a version string instead of detecting'
194
251
  )
195
252
 
253
+ # Profile this host: distro, arch, and which plugin bins are on PATH.
254
+ #{self}.living_off_the_land(
255
+ os_release: 'optional - /etc/os-release body (defaults to reading the file)',
256
+ lsb_release: 'optional - /etc/lsb-release body',
257
+ build_prop: 'optional - Android /system/build.prop body',
258
+ type: 'optional - OS family from #type',
259
+ which: 'optional - Hash of binary name => true/false (skips live PATH when set)',
260
+ bins: 'optional - extra binary names to probe besides plugin required_bins',
261
+ arch: 'optional - CPU arch override',
262
+ endian: 'optional - :little or :big override',
263
+ cap_net_raw: 'optional - Boolean CAP_NET_RAW override',
264
+ docker: 'optional - Boolean docker.sock override'
265
+ )
266
+
196
267
  # Print the AUTHOR(S) string for this module.
197
268
  #{self}.authors
198
269
  "
199
270
  constants.sort
200
271
  end
201
272
 
273
+ private_class_method def self.inventory_names(opts = {})
274
+ extra = Array(opts[:bins]).map(&:to_s).reject(&:empty?)
275
+ deps = if defined?(PWN::Plugins::PreflightChecker::PLUGIN_DEPS)
276
+ PWN::Plugins::PreflightChecker::PLUGIN_DEPS.values.flat_map { |row| Array(row[:bins]) }
277
+ else
278
+ []
279
+ end
280
+ (deps + extra).map(&:to_s).reject(&:empty?).uniq
281
+ end
282
+
283
+ private_class_method def self.bin_present?(opts = {})
284
+ name = opts[:name].to_s
285
+ return false if name.empty?
286
+ return PWN::Plugins::PreflightChecker.bin?(name: name) if defined?(PWN::Plugins::PreflightChecker)
287
+
288
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
289
+ rescue StandardError
290
+ false
291
+ end
292
+
293
+ private_class_method def self.cap_net_raw_live
294
+ return unless defined?(PWN::Plugins::PreflightChecker)
295
+
296
+ PWN::Plugins::PreflightChecker.cap_net_raw?
297
+ rescue StandardError
298
+ nil
299
+ end
300
+
301
+ private_class_method def self.docker_live
302
+ return unless defined?(PWN::Plugins::PreflightChecker)
303
+
304
+ PWN::Plugins::PreflightChecker.service?(name: 'docker', path: '/var/run/docker.sock')
305
+ rescue StandardError
306
+ nil
307
+ end
308
+
202
309
  private_class_method def self.normalize_id(opts = {})
203
310
  raw = opts[:id].to_s.downcase.strip.gsub(/["']/, '')
204
311
  return :unknown if raw.empty?
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'open3'
5
+ require 'csv'
6
+ require 'fileutils'
5
7
 
6
8
  module PWN
7
9
  module Plugins
@@ -15,7 +17,9 @@ module PWN
15
17
  q = (opts[:query] || opts[:q]).to_s
16
18
  raise 'ERROR: query is required' if q.empty?
17
19
 
18
- return { error: 'searchsploit missing', query: q } unless PWN::Plugins::PreflightChecker.bin?(name: 'searchsploit')
20
+ local = local_hits(query: q)
21
+ return { query: q, results: local, source: 'local' } unless local.empty?
22
+ return { error: 'searchsploit missing', query: q, results: [] } unless PWN::Plugins::PreflightChecker.bin?(name: 'searchsploit')
19
23
 
20
24
  stdout, stderr, status = Open3.capture3('searchsploit', '--json', q)
21
25
  parsed = begin
@@ -37,6 +41,39 @@ module PWN
37
41
  search(query: q).merge(cpe: cpe, product: product, version: version)
38
42
  end
39
43
 
44
+ private_class_method def self.local_hits(opts = {})
45
+ q = opts[:query].to_s.downcase
46
+ path = intel_csv
47
+ return [] unless File.file?(path)
48
+
49
+ CSV.read(path, headers: true).filter_map do |row|
50
+ blob = row.to_h.values.join(' ').downcase
51
+ next unless blob.include?(q) || q.split.any? { |tok| tok.length > 2 && blob.include?(tok) }
52
+
53
+ {
54
+ cve: row['cve'],
55
+ edb_id: row['id'],
56
+ description: row['description'],
57
+ local_path: row['file'],
58
+ cvss: row['cvss']
59
+ }
60
+ end
61
+ rescue StandardError
62
+ []
63
+ end
64
+
65
+ private_class_method def self.intel_csv
66
+ dest = File.join(Dir.home, '.pwn', 'intel', 'files_exploits.csv')
67
+ return dest if File.file?(dest)
68
+
69
+ bundled = File.expand_path('../../../etc/intel/files_exploits.csv', __dir__)
70
+ if File.file?(bundled)
71
+ FileUtils.mkdir_p(File.dirname(dest))
72
+ FileUtils.cp(bundled, dest)
73
+ end
74
+ dest
75
+ end
76
+
40
77
  public_class_method def self.authors
41
78
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
42
79
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+
7
+ module PWN
8
+ module Plugins
9
+ # Persistent finding rows for pwn-ai (title, severity, host, evidence, PoC).
10
+ module Findings
11
+ FILE = File.join(Dir.home, '.pwn', 'findings.jsonl')
12
+
13
+ public_class_method def self.required_bins
14
+ []
15
+ end
16
+
17
+ public_class_method def self.record(opts = {})
18
+ title = opts[:title].to_s
19
+ raise 'ERROR: title is required' if title.empty?
20
+
21
+ arts = Array(opts[:poc_artifacts]).map(&:to_s).reject(&:empty?)
22
+ arts << opts[:poc].to_s unless opts[:poc].to_s.empty?
23
+ raise 'ERROR: poc_artifacts are required' if arts.empty?
24
+
25
+ row = {
26
+ id: SecureRandom.hex(6),
27
+ title: title,
28
+ severity: (opts[:severity] || 'info').to_s,
29
+ host: opts[:host].to_s,
30
+ evidence: opts[:evidence].to_s,
31
+ poc: opts[:poc].to_s,
32
+ poc_artifacts: arts,
33
+ session_id: opts[:session_id].to_s,
34
+ at: Time.now.utc.iso8601
35
+ }
36
+ FileUtils.mkdir_p(File.dirname(FILE))
37
+ File.open(FILE, 'a') { |f| f.puts(JSON.generate(row)) }
38
+ row
39
+ end
40
+
41
+ public_class_method def self.report(opts = {})
42
+ return [] unless File.file?(FILE)
43
+
44
+ rows = File.readlines(FILE).filter_map do |ln|
45
+ JSON.parse(ln, symbolize_names: true)
46
+ rescue JSON::ParserError
47
+ nil
48
+ end
49
+ host = opts[:host].to_s
50
+ rows = rows.select { |r| r[:host].to_s == host } unless host.empty?
51
+ rows
52
+ end
53
+
54
+ public_class_method def self.render(opts = {})
55
+ dir = opts[:dir_path].to_s
56
+ dir = File.join(Dir.home, '.pwn', 'exports') if dir.empty?
57
+ name = opts[:report_name].to_s
58
+ name = 'findings' if name.empty?
59
+ payload = { title: 'Findings', findings: report }
60
+ {
61
+ markdown: PWN::Reports::Markdown.generate(results_hash: payload, dir_path: dir, report_name: name),
62
+ html: PWN::Reports::HTML.generate(results_hash: payload, dir_path: dir, report_name: name),
63
+ json: PWN::Reports::JSON.generate(results_hash: payload, dir_path: dir, report_name: name)
64
+ }
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
+ # Append a finding row to ~/.pwn/findings.jsonl.
77
+ #{self}.record(
78
+ title: 'required - short finding title',
79
+ severity: 'optional - info|low|medium|high|critical (defaults to info)',
80
+ host: 'optional - affected host or URL',
81
+ evidence: 'optional - proof text or path',
82
+ poc: 'optional - filesystem path of a PoC',
83
+ poc_artifacts: 'required - Array of artifact paths proving the issue',
84
+ session_id: 'optional - pwn-ai session id'
85
+ )
86
+
87
+ # List recorded findings, optionally filtered by host.
88
+ #{self}.report(
89
+ host: 'optional - only rows whose host matches this string'
90
+ )
91
+
92
+ # Render findings as markdown, html, and json reports.
93
+ #{self}.render(
94
+ dir_path: 'optional - output directory (defaults to ~/.pwn/exports)',
95
+ report_name: 'optional - basename without extension (defaults to findings)'
96
+ )
97
+
98
+ # Print the AUTHOR(S) string for this module.
99
+ #{self}.authors
100
+ "
101
+ constants.sort
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'json'
6
+
7
+ module PWN
8
+ module Plugins
9
+ # Headless Ghidra analyzeHeadless wrapper with r2 fallback.
10
+ module Ghidra
11
+ public_class_method def self.required_bins
12
+ %w[analyzeHeadless]
13
+ end
14
+
15
+ public_class_method def self.analyze(opts = {})
16
+ path = (opts[:bin] || opts[:path]).to_s
17
+ raise 'ERROR: bin is required' if path.empty?
18
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
19
+
20
+ if PWN::Plugins::PreflightChecker.bin?(name: 'analyzeHeadless')
21
+ proj = opts[:project_dir].to_s
22
+ proj = File.join(Dir.home, '.pwn', 'ghidra') if proj.empty?
23
+ FileUtils.mkdir_p(proj)
24
+ stdout, stderr, status = Open3.capture3('analyzeHeadless', proj, 'pwn', '-import', path, '-deleteProject')
25
+ return { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'ghidra' }
26
+ end
27
+
28
+ return r2_fallback(path: path) if PWN::Plugins::PreflightChecker.bin?(name: 'r2')
29
+
30
+ { error: 'analyzeHeadless missing', hint: 'install Ghidra or r2; pwn setup --profile re' }
31
+ end
32
+
33
+ public_class_method def self.decompile(opts = {})
34
+ analyze(opts)
35
+ end
36
+
37
+ private_class_method def self.r2_fallback(opts = {})
38
+ path = opts[:path].to_s
39
+ stdout, stderr, status = Open3.capture3('r2', '-q', '-c', 'aaa;s main;pdg', path)
40
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'r2' }
41
+ rescue StandardError => e
42
+ { error: e.message, engine: 'r2' }
43
+ end
44
+
45
+ public_class_method def self.authors
46
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
47
+ end
48
+
49
+ public_class_method def self.help
50
+ puts "USAGE:
51
+ # List host binaries this module expects to be installed.
52
+ #{self}.required_bins
53
+
54
+ # Run analyzeHeadless (or r2 pdg) against a binary.
55
+ #{self}.analyze(
56
+ bin: 'required - filesystem path of the binary',
57
+ path: 'optional - alias for bin',
58
+ project_dir: 'optional - Ghidra project directory under ~/.pwn/ghidra'
59
+ )
60
+
61
+ # Decompile a binary; same as #analyze when Ghidra/r2 is the engine.
62
+ #{self}.decompile(
63
+ bin: 'required - filesystem path of the binary',
64
+ path: 'optional - alias for bin',
65
+ project_dir: 'optional - Ghidra project directory'
66
+ )
67
+
68
+ # Print the AUTHOR(S) string for this module.
69
+ #{self}.authors
70
+ "
71
+ constants.sort
72
+ end
73
+ end
74
+ end
75
+ end