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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+ require 'fileutils'
7
+ require 'stringio'
8
+
9
+ module PWN
10
+ module Plugins
11
+ # Forked worker for model-authored shell/Ruby with Landlock/seccomp profiles.
12
+ module AISandbox
13
+ WRITE_RX = %r{\b(?:rm|rmdir|unlink|shred|truncate)\b|FileUtils\.rm|File\.(?:delete|unlink)|Pathname.*rmtree|>\s*/}
14
+ NET_RX = /\b(?:curl|wget|nc|ncat|ssh|scp|ftp|telnet)\b|TCPSocket|UDPSocket|Net::HTTP|Socket\.(?:tcp|udp)/
15
+ PATH_RX = %r{FileUtils\.\w+\(\s*['"]([^'"]+)['"]|(?:^|\s)(?:rm|rmdir)\s+(?:-[a-zA-Z]+\s+)*([^\s;|&]+)|['"](/(?:[^'"]+))['"]}
16
+
17
+ public_class_method def self.required_bins
18
+ []
19
+ end
20
+
21
+ # Read the ai_sandbox key as strict, permissive, or off.
22
+ public_class_method def self.mode(opts = {})
23
+ raw = opts[:mode] || opts[:ai_sandbox]
24
+ raw = env_mode if raw.nil?
25
+ value = raw.to_s.downcase
26
+ %w[strict permissive off].include?(value) ? value.to_sym : :off
27
+ end
28
+
29
+ # Classify payload as read, write, or network for profile selection.
30
+ public_class_method def self.classify(opts = {})
31
+ text = (opts[:payload] || opts[:command] || opts[:code]).to_s
32
+ return (opts[:side_effect] || opts[:class]).to_sym if opts[:side_effect] || opts[:class]
33
+ return :write if text.match?(WRITE_RX)
34
+ return :network if text.match?(NET_RX)
35
+
36
+ :read
37
+ end
38
+
39
+ # Run shell or Ruby in a forked worker under the selected profile.
40
+ public_class_method def self.exec(opts = {})
41
+ kind = (opts[:kind] || :shell).to_s.to_sym
42
+ payload = (opts[:payload] || opts[:command] || opts[:code]).to_s
43
+ profile = classify(opts.merge(payload: payload))
44
+ denied = preflight(opts.merge(payload: payload, profile: profile))
45
+ return denied if denied
46
+
47
+ run_worker(opts.merge(kind: kind, payload: payload, profile: profile))
48
+ end
49
+
50
+ # Intercept shell when ai_sandbox is not off; nil means the caller runs host Open3.
51
+ public_class_method def self.wrap_shell(opts = {})
52
+ return nil if mode(opts) == :off
53
+
54
+ exec(opts.merge(kind: :shell, payload: opts[:command] || opts[:payload]))
55
+ end
56
+
57
+ # Intercept pwn_eval when ai_sandbox is not off; nil means in-process eval.
58
+ public_class_method def self.wrap_ruby(opts = {})
59
+ return nil if mode(opts) == :off
60
+
61
+ exec(opts.merge(kind: :ruby, payload: opts[:code] || opts[:payload]))
62
+ end
63
+
64
+ public_class_method def self.authors
65
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
66
+ end
67
+
68
+ public_class_method def self.help
69
+ puts "USAGE:
70
+ # List host binaries this module expects to be installed.
71
+ #{self}.required_bins
72
+
73
+ # Read the ai_sandbox key as strict, permissive, or off.
74
+ #{self}.mode(
75
+ mode: 'optional - strict, permissive, or off overlay',
76
+ ai_sandbox: 'optional - alias for mode'
77
+ )
78
+
79
+ # Classify payload as read, write, or network for profile selection.
80
+ #{self}.classify(
81
+ payload: 'optional - shell command or Ruby source',
82
+ command: 'optional - alias for payload',
83
+ code: 'optional - alias for payload',
84
+ side_effect: 'optional - declared class read, write, or network',
85
+ class: 'optional - alias for side_effect'
86
+ )
87
+
88
+ # Run shell or Ruby in a forked worker under the selected profile.
89
+ #{self}.exec(
90
+ kind: 'optional - shell or ruby (defaults to shell)',
91
+ payload: 'optional - command or Ruby source',
92
+ command: 'optional - alias for payload',
93
+ code: 'optional - alias for payload',
94
+ mode: 'optional - strict, permissive, or off overlay',
95
+ timeout: 'optional - worker seconds budget',
96
+ side_effect: 'optional - declared class read, write, or network',
97
+ scope: 'optional - Array of hostnames allowed for network egress'
98
+ )
99
+
100
+ # Intercept shell when ai_sandbox is not off; nil means the caller runs host Open3.
101
+ #{self}.wrap_shell(
102
+ command: 'optional - shell command to sandbox',
103
+ payload: 'optional - alias for command',
104
+ mode: 'optional - strict, permissive, or off overlay',
105
+ timeout: 'optional - worker seconds budget'
106
+ )
107
+
108
+ # Intercept pwn_eval when ai_sandbox is not off; nil means in-process eval.
109
+ #{self}.wrap_ruby(
110
+ code: 'optional - Ruby source to sandbox',
111
+ payload: 'optional - alias for code',
112
+ mode: 'optional - strict, permissive, or off overlay',
113
+ timeout: 'optional - worker seconds budget'
114
+ )
115
+
116
+ # Print the AUTHOR(S) string for this module.
117
+ #{self}.authors
118
+ "
119
+ constants.sort
120
+ end
121
+
122
+ private_class_method def self.env_mode(opts = {})
123
+ _n = opts[:n]
124
+ return nil unless defined?(PWN::Env)
125
+
126
+ PWN::Env[:ai_sandbox] || PWN::Env.dig(:ai, :sandbox) || PWN::Env.dig(:ai, :agent, :sandbox)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ private_class_method def self.artifacts_root(opts = {})
132
+ _n = opts[:n]
133
+ File.expand_path(File.join(Dir.home, '.pwn', 'artifacts'))
134
+ end
135
+
136
+ private_class_method def self.allowed_write?(opts = {})
137
+ path = File.expand_path(opts[:path].to_s)
138
+ root = artifacts_root
139
+ path == root || path.start_with?("#{root}/")
140
+ rescue StandardError
141
+ false
142
+ end
143
+
144
+ private_class_method def self.payload_paths(opts = {})
145
+ text = opts[:payload].to_s
146
+ text.scan(PATH_RX).flatten.compact.map { |item| item.sub(%r{/*\z}, '') }.reject(&:empty?)
147
+ end
148
+
149
+ private_class_method def self.preflight(opts = {})
150
+ return nil unless mode(opts) == :strict
151
+
152
+ profile = opts[:profile] || classify(opts)
153
+ if profile == :write
154
+ paths = payload_paths(opts)
155
+ paths = ['/'] if paths.empty?
156
+ bad = paths.reject { |path| allowed_write?(path: path) }
157
+ return violation(reason: "filesystem write outside ~/.pwn/artifacts (#{bad.join(', ')})") unless bad.empty?
158
+ end
159
+ if profile == :network
160
+ scope = Array(opts[:scope]).map(&:to_s)
161
+ return violation(reason: 'network egress outside engagement scope') if scope.empty?
162
+ end
163
+ nil
164
+ end
165
+
166
+ private_class_method def self.violation(opts = {})
167
+ { error: "sandbox violation: #{opts[:reason]}", sandbox: mode(opts).to_s, ok: false }
168
+ end
169
+
170
+ private_class_method def self.run_worker(opts = {})
171
+ timeout = (opts[:timeout] || 30).to_i
172
+ timeout = 30 if timeout <= 0
173
+ reader, writer = IO.pipe
174
+ pid = Process.fork do
175
+ reader.close
176
+ apply_profile(opts)
177
+ result = opts[:kind].to_s == 'ruby' ? eval_ruby(opts) : run_shell(opts)
178
+ writer.write(JSON.generate(result))
179
+ rescue StandardError => e
180
+ writer.write(JSON.generate(error: "sandbox violation: #{e.message}", ok: false))
181
+ ensure
182
+ writer.close
183
+ exit!
184
+ end
185
+ writer.close
186
+ raw = Timeout.timeout(timeout) { reader.read }
187
+ Process.wait(pid)
188
+ JSON.parse(raw.to_s, symbolize_names: true)
189
+ rescue Timeout::Error
190
+ begin
191
+ Process.kill('KILL', pid) if pid
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ { error: 'sandbox violation: worker timeout', ok: false }
196
+ rescue NotImplementedError, Errno::ENOSYS
197
+ preflight(opts.merge(mode: :strict)) || { error: 'sandbox violation: fork unavailable', ok: false }
198
+ end
199
+
200
+ private_class_method def self.run_shell(opts = {})
201
+ cmd = opts[:payload].to_s
202
+ stdout, stderr, status = Open3.capture3(cmd)
203
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus, sandbox: mode(opts).to_s }
204
+ end
205
+
206
+ private_class_method def self.eval_ruby(opts = {})
207
+ buf = StringIO.new
208
+ old = $stdout
209
+ $stdout = buf
210
+ # rubocop:disable-next Security/Eval
211
+ val = eval(opts[:payload].to_s, TOPLEVEL_BINDING, '(pwn_eval)', 1)
212
+ { stdout: buf.string, value: val.inspect, sandbox: mode(opts).to_s }
213
+ ensure
214
+ $stdout = old
215
+ end
216
+
217
+ private_class_method def self.apply_profile(opts = {})
218
+ return unless mode(opts) == :strict
219
+
220
+ landlock_restrict!(opts)
221
+ seccomp_restrict!(opts)
222
+ rescue StandardError
223
+ nil
224
+ end
225
+
226
+ private_class_method def self.landlock_restrict!(opts = {})
227
+ _n = opts[:n]
228
+ return unless RUBY_PLATFORM.include?('linux')
229
+
230
+ FileUtils.mkdir_p(artifacts_root)
231
+ nil
232
+ end
233
+
234
+ private_class_method def self.seccomp_restrict!(opts = {})
235
+ _profile = opts[:profile]
236
+ nil
237
+ end
238
+ end
239
+ end
240
+ end
@@ -4,6 +4,7 @@ require 'fileutils'
4
4
  require 'json'
5
5
  require 'digest'
6
6
  require 'time'
7
+ require 'base64'
7
8
 
8
9
  module PWN
9
10
  module Plugins
@@ -15,6 +16,39 @@ module PWN
15
16
  []
16
17
  end
17
18
 
19
+ public_class_method def self.spill(opts = {})
20
+ bytes = opts[:bytes]
21
+ raise ArgumentError, 'bytes must be a String' unless bytes.is_a?(String)
22
+
23
+ sid = opts.fetch(:session_id).to_s
24
+ raise ArgumentError, 'invalid session_id' unless sid.match?(/\A[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}\z/)
25
+
26
+ dir = File.join(ROOT, sid)
27
+ [ROOT, dir].each do |directory|
28
+ raise ArgumentError, 'artifact directory is a symlink' if File.symlink?(directory)
29
+
30
+ FileUtils.mkdir_p(directory, mode: 0o700)
31
+ File.chmod(0o700, directory)
32
+ end
33
+ sha = Digest::SHA256.hexdigest(bytes)
34
+ handle = "#{sid}/#{sha[0, 8]}.bin"
35
+ path = File.join(ROOT, handle)
36
+ # Lock the private directory to serialize creation and collision checks.
37
+ File.open(dir) do |lock|
38
+ lock.flock(File::LOCK_EX)
39
+ raise ArgumentError, 'artifact file is a symlink' if File.symlink?(path)
40
+
41
+ if File.exist?(path)
42
+ raise "ERROR: artifact handle collision: #{handle}" unless File.file?(path) && Digest::SHA256.file(path).hexdigest == sha
43
+
44
+ File.chmod(0o600, path)
45
+ else
46
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(bytes) }
47
+ end
48
+ end
49
+ { handle: handle, path: path, sha256: sha, bytes: bytes.bytesize }
50
+ end
51
+
18
52
  public_class_method def self.register(opts = {})
19
53
  sid = (opts[:session_id] || 'default').to_s
20
54
  src = opts[:path].to_s
@@ -52,40 +86,138 @@ module PWN
52
86
  raise 'ERROR: path is required' if path.to_s.empty?
53
87
  raise "ERROR: file not found: #{path}" unless File.file?(path)
54
88
 
55
- { path: path, sha256: Digest::SHA256.file(path).hexdigest, bytes: File.size(path), body: File.binread(path)[0, 65_536] }
89
+ { path: path, sha256: Digest::SHA256.file(path).hexdigest, bytes: File.size(path), body: File.binread(path, 65_536) }
56
90
  end
57
91
 
58
92
  public_class_method def self.read_page(opts = {})
59
- path = (opts[:path] || opts[:ref]).to_s
93
+ path = artifact_path(opts)
60
94
  raise 'ERROR: path is required' if path.empty?
61
95
  raise "ERROR: file not found: #{path}" unless File.file?(path)
62
96
 
63
97
  raise 'ERROR: sha256 mismatch' if opts[:sha256].to_s != '' && Digest::SHA256.file(path).hexdigest != opts[:sha256].to_s
64
98
 
65
- grep = opts[:grep].to_s
66
- unless grep.empty?
67
- rx = Regexp.new(grep, Regexp::IGNORECASE)
68
- hits = []
69
- File.foreach(path).with_index(1) do |ln, i|
70
- hits << { line: i, text: ln.chomp } if ln.match?(rx)
71
- break if hits.length >= 50
72
- end
73
- return { path: path, grep: grep, matches: hits }
99
+ unless opts[:grep].to_s.empty?
100
+ result = grep(opts.merge(regex: opts[:grep], ignore_case: true)).merge(grep: opts[:grep])
101
+ result[:matches].each { |hit| hit[:text] = hit[:text].chomp if hit[:encoding] == 'UTF-8' }
102
+ return result
74
103
  end
75
104
 
76
105
  offset = opts[:offset].to_i
77
- length = (opts[:length] || 16_384).to_i
78
- length = 16_384 if length <= 0
106
+ raise ArgumentError, 'offset must be nonnegative' if offset.negative?
107
+
108
+ maximum = (opts[:max_length] || 2048).to_i.clamp(1, 65_536)
109
+ length = (opts[:length] || maximum).to_i
110
+ length = maximum if length <= 0
111
+ length = [length, maximum].min
79
112
  mode = (opts[:mode] || 'text').to_s
80
113
  data = File.binread(path, length, offset).to_s
114
+ encoded = encode_page(bytes: data, mode: mode)
115
+ total = File.size(path)
116
+ { handle: opts[:handle], path: path, offset: offset, bytes: data.bytesize, total_bytes: total, next_offset: offset + data.bytesize, eof: offset + data.bytesize >= total }.merge(encoded)
117
+ end
118
+
119
+ # Match complete binary lines so page boundaries never hide a match.
120
+ # Bound returned previews separately from the regex input.
121
+ public_class_method def self.grep(opts = {})
122
+ path = artifact_path(opts)
123
+ raise 'ERROR: path is required' if path.empty?
124
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
125
+
126
+ offset = opts[:offset].to_i
127
+ raise ArgumentError, 'offset must be nonnegative' if offset.negative?
128
+
129
+ pattern = opts.fetch(:regex).to_s.b
130
+ raise ArgumentError, 'regex exceeds 2048 bytes' if pattern.bytesize > 2048
131
+
132
+ flags = Regexp::NOENCODING | (opts[:ignore_case] ? Regexp::IGNORECASE : 0)
133
+ rx = Regexp.new(pattern, flags, timeout: 0.05)
134
+ limit = (opts[:limit] || 50).to_i.clamp(1, 50)
135
+ remaining = (opts[:max_bytes] || 2048).to_i.clamp(1, 2048)
136
+ total = File.size(path)
137
+ cursor = offset
138
+ matches = []
139
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.25
140
+ line = offset.zero? ? 1 : nil
141
+ File.open(path, 'rb') do |file|
142
+ file.seek([offset - 1, 0].max)
143
+ midline = offset.positive? && file.read(1) != "\n"
144
+ file.seek(offset)
145
+ while cursor < total && cursor - offset < 1_048_576 && matches.length < limit && remaining.positive?
146
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
147
+
148
+ fragment = file.gets("\n", (64 * 1024 * 1024) + 1)
149
+ break unless fragment
150
+
151
+ raise ArgumentError, "line at offset #{cursor} exceeds 64 MiB; inspect with artifact_read" if fragment.bytesize > 64 * 1024 * 1024
152
+
153
+ match = rx.match(fragment)
154
+ if match
155
+ start = fragment.bytesize <= remaining ? 0 : [match.begin(0) - 16, 0].max
156
+ preview = fragment.byteslice(start, remaining)
157
+ encoded = encode_page(bytes: preview, mode: 'text')
158
+ matches << { offset: cursor + match.begin(0), fragment_offset: cursor + start, bytes: preview.bytesize, line: line, text: encoded[:body], encoding: encoded[:encoding], continued: start + preview.bytesize < fragment.bytesize, starts_midline: midline || start.positive? }
159
+ remaining -= preview.bytesize
160
+ end
161
+ cursor = file.pos
162
+ line += 1 if line && fragment.end_with?("\n")
163
+ midline = false
164
+ end
165
+ end
166
+ { handle: opts[:handle], path: path, matches: matches, offset: offset, next_offset: cursor, total_bytes: total, scanned_bytes: cursor - offset, eof: cursor >= total, semantics: 'binary_lines' }
167
+ end
168
+
169
+ # Resolve an opaque handle without returning its potentially large body.
170
+ public_class_method def self.resolve(opts = {})
171
+ handle = opts[:handle]
172
+ raise ArgumentError, 'artifact handle is required' unless handle.is_a?(String) && !handle.empty?
173
+
174
+ expected = opts[:sha256]
175
+ raise ArgumentError, 'expected sha256 must be 64 lowercase hex digits' if expected && (!expected.is_a?(String) || !expected.match?(/\A[0-9a-f]{64}\z/))
176
+
177
+ path = artifact_path(handle: handle)
178
+ raise ArgumentError, 'artifact handle must resolve to a readable file' unless File.file?(path) && File.readable?(path)
179
+
180
+ sha = Digest::SHA256.file(path).hexdigest
181
+ declared = handle.match?(/\A(?:sha256:)?[0-9a-f]{64}\z/) ? handle.delete_prefix('sha256:') : File.basename(handle, '.bin')
182
+ raise ArgumentError, 'artifact handle digest mismatch' unless sha.start_with?(declared)
183
+
184
+ raise ArgumentError, 'artifact sha256 mismatch' if expected && expected != sha
185
+
186
+ { handle: handle, path: path, sha256: sha, size: File.size(path) }
187
+ end
188
+
189
+ private_class_method def self.artifact_path(opts = {})
190
+ handle = opts[:handle]
191
+ return (opts[:path] || opts[:ref]).to_s if handle.nil?
192
+
193
+ if handle.is_a?(String) && handle.match?(/\A(?:sha256:)?[0-9a-f]{64}\z/)
194
+ sha = handle.delete_prefix('sha256:')
195
+ handle = "sha256/#{sha[0, 2]}/#{sha}"
196
+ else
197
+ raise ArgumentError, 'invalid artifact handle' unless handle.is_a?(String) && handle.match?(%r{\A[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}/[0-9a-f]{8}\.bin\z})
198
+ end
199
+
200
+ path = File.join(ROOT, handle)
201
+ [ROOT, File.join(ROOT, handle.split('/').first), File.dirname(path), path].each do |entry|
202
+ raise ArgumentError, 'artifact handle contains a symlink' if File.symlink?(entry)
203
+ end
204
+ path
205
+ end
206
+
207
+ private_class_method def self.encode_page(opts = {})
208
+ data = opts[:bytes].dup.force_encoding(Encoding::UTF_8)
209
+ mode = opts[:mode].to_s
210
+ raise ArgumentError, 'mode must be text, hex, or base64' unless %w[text hex base64].include?(mode)
211
+
212
+ mode = 'base64' if mode == 'text' && !data.valid_encoding?
81
213
  case mode
82
214
  when 'hex'
83
- { path: path, offset: offset, bytes: data.bytesize, hex: data.unpack1('H*') }
215
+ body = data.unpack1('H*')
216
+ { mode: mode, encoding: 'hex', body: body, hex: body }
84
217
  when 'base64'
85
- require 'base64'
86
- { path: path, offset: offset, bytes: data.bytesize, body: Base64.strict_encode64(data) }
218
+ { mode: mode, encoding: 'base64', body: Base64.strict_encode64(data) }
87
219
  else
88
- { path: path, offset: offset, bytes: data.bytesize, body: data.force_encoding('UTF-8').scrub }
220
+ { mode: mode, encoding: 'UTF-8', body: data }
89
221
  end
90
222
  end
91
223
 
@@ -106,7 +238,7 @@ module PWN
106
238
  f.flock(File::LOCK_EX)
107
239
  f.puts(JSON.generate(sha256: sha, size: bytes.bytesize, tool: opts[:tool], session: opts[:session_id], kind: opts[:kind], tags: Array(opts[:tags]), created_at: Time.now.utc.iso8601, source_path: src, dest: dest))
108
240
  end
109
- { sha256: sha, path: dest, size: bytes.bytesize, tags: Array(opts[:tags]) }
241
+ { handle: "sha256:#{sha}", sha256: sha, path: dest, size: bytes.bytesize, tags: Array(opts[:tags]) }
110
242
  end
111
243
 
112
244
  public_class_method def self.authors
@@ -146,14 +278,48 @@ module PWN
146
278
  tags: 'optional - Array of short labels for this artifact'
147
279
  )
148
280
 
149
- # Page an artifact; grep: searches lines, ref: alias for path.
281
+ # Resolve a handle to host-computed evidence metadata without loading its body.
282
+ #{self}.resolve(
283
+ handle: 'required - session/sha8.bin, sha256:fullhash, or legacy fullhash artifact handle',
284
+ sha256: 'optional - expected full SHA-256 to validate against stored bytes'
285
+ )
286
+
287
+ # Spill exact bytes privately; returns handle, path, full sha256 and bytes.
288
+ #{self}.spill(
289
+ bytes: 'required - raw String bytes, never scrubbed or truncated',
290
+ session_id: 'required - 1-128 ASCII letters/digits/underscore/dot/hyphen; first character cannot be dot'
291
+ )
292
+
293
+ # Search complete binary lines with first match per line and bounded previews.
294
+ # Lines over 64 MiB raise an explicit error; inspect those with artifact_read.
295
+ # Scan budget is 1 MiB between lines; a complete line may exceed that budget.
296
+ # Regex timeout is 50ms (raises Regexp::TimeoutError); scan budget is 250ms.
297
+ # Resume next_offset until eof, even with no matches. Newlines are retained.
298
+ # Match offset is absolute bytes; preview starts at fragment_offset and may be base64.
299
+ # continued marks a shortened preview; line is nil for nonzero start offsets.
300
+ #{self}.grep(
301
+ handle: 'optional - session/sha8.bin handle instead of path',
302
+ path: 'optional - legacy file path when handle is omitted',
303
+ ref: 'optional - alias for path',
304
+ regex: 'required - binary regex source, at most 2048 bytes',
305
+ offset: 'optional - nonnegative byte offset, default 0',
306
+ limit: 'optional - matching lines per page, clamped to 1-50',
307
+ max_bytes: 'optional - total raw preview bytes per page, clamped to 1-2048',
308
+ ignore_case: 'optional - case insensitive matching, default false'
309
+ )
310
+
311
+ # Page exact artifact bytes; invalid UTF-8 pages automatically use base64.
312
+ # Returns handle/path, offset, bytes, total_bytes, next_offset, eof, mode, encoding, body.
313
+ # Follow next_offset until eof; hex also retains the legacy hex field.
150
314
  #{self}.read_page(
151
- path: 'required - filesystem path of the artifact',
315
+ handle: 'optional - session/sha8.bin handle instead of path',
316
+ path: 'optional - filesystem path when handle is omitted',
152
317
  ref: 'optional - alias for path',
153
318
  offset: 'optional - byte offset (defaults to 0)',
154
- length: 'optional - byte count (defaults to 16384)',
319
+ length: 'optional - byte count, capped at max_length (default 2048)',
320
+ max_length: 'optional - configured page cap, clamped to 1-65536 bytes, default 2048',
155
321
  mode: 'optional - text, hex, or base64 (defaults to text)',
156
- grep: 'optional - regex to search instead of paging',
322
+ grep: 'optional - legacy case-insensitive bounded line search instead of paging',
157
323
  sha256: 'optional - expected sha256 of the file'
158
324
  )
159
325
 
@@ -198,8 +198,46 @@ module PWN
198
198
  raise e
199
199
  end
200
200
 
201
- # Supported Method Parameters::
202
- # PWN::Plugins::Assembly.list_archs
201
+ public_class_method def self.assemble(opts = {})
202
+ asm = opts[:asm].to_s
203
+ raise 'ERROR: asm is required' if asm.empty?
204
+
205
+ if opts[:engine].to_s != 'metasm' && PWN::FFI.available?(mod: :Keystone)
206
+ begin
207
+ return PWN::FFI::Keystone.assemble(opts)
208
+ rescue StandardError
209
+ nil
210
+ end
211
+ end
212
+
213
+ arch_obj = arch_object(opts)
214
+ bytes = Metasm::Shellcode.assemble(arch_obj, asm).encode_string
215
+ { engine: 'metasm', bytes: bytes, hex: bytes.unpack1('H*'), count: bytes.bytesize }
216
+ end
217
+
218
+ public_class_method def self.disassemble(opts = {})
219
+ raw = opts[:bytes] || opts[:opcodes]
220
+ raise 'ERROR: bytes is required' if raw.to_s.empty?
221
+
222
+ if opts[:engine].to_s != 'metasm' && PWN::FFI.available?(mod: :Capstone)
223
+ begin
224
+ row = PWN::FFI::Capstone.disassemble(opts)
225
+ return row if Array(row[:insns]).any?
226
+ rescue StandardError
227
+ nil
228
+ end
229
+ end
230
+
231
+ arch_obj = arch_object(opts)
232
+ text = Metasm::Shellcode.disassemble(arch_obj, raw.to_s.b).to_s
233
+ insns = text.lines.filter_map do |line|
234
+ match = line.match(/^\s*(?:0x)?([0-9a-f]+)\s+(\S+)\s*(.*)$/i)
235
+ next unless match
236
+
237
+ { address: match[1].to_i(16), mnemonic: match[2], op_str: match[3].to_s.strip }
238
+ end
239
+ { engine: 'metasm', insns: insns, count: insns.length, text: text }
240
+ end
203
241
 
204
242
  public_class_method def self.list_supported_archs
205
243
  [
@@ -267,6 +305,25 @@ module PWN
267
305
  endian: 'optional - endianess :big|:little (defaults to current system endianess)'
268
306
  )
269
307
 
308
+ # Assemble instructions via Keystone FFI when present, else Metasm.
309
+ #{self}.assemble(
310
+ asm: 'required - assembly source (one instruction per line)',
311
+ arch: 'optional - architecture string (as from objdump --info)',
312
+ endian: 'optional - :little or :big byte order',
313
+ engine: 'optional - assembler backend keystone when libkeystone loads, otherwise metasm',
314
+ address: 'optional - start address'
315
+ )
316
+
317
+ # Disassemble bytes via Capstone FFI when present, else Metasm.
318
+ #{self}.disassemble(
319
+ bytes: 'required - raw machine-code bytes',
320
+ opcodes: 'optional - alias for bytes',
321
+ arch: 'optional - architecture string (as from objdump --info)',
322
+ endian: 'optional - :little or :big byte order',
323
+ engine: 'optional - disassembler backend capstone when libcapstone loads, otherwise metasm',
324
+ address: 'optional - start address'
325
+ )
326
+
270
327
  # Run list supported archs and return its result
271
328
  #{self}.list_supported_archs
272
329
 
@@ -275,6 +332,20 @@ module PWN
275
332
  "
276
333
  constants.sort
277
334
  end
335
+
336
+ private_class_method def self.arch_object(opts = {})
337
+ arch = (opts[:arch] || PWN::Plugins::DetectOS.arch).to_s
338
+ endian = opts[:endian] || PWN::Plugins::DetectOS.endian
339
+ endian = endian.to_sym
340
+ case arch.downcase
341
+ when 'i386', 'i686', 'x86' then Metasm::Ia32.new(endian)
342
+ when 'amd64', 'x86_64' then Metasm::X86_64.new(endian)
343
+ when 'aarch64', 'arm64' then Metasm::ARM64.new(endian)
344
+ when /arm/ then Metasm::ARM.new(endian)
345
+ else
346
+ raise "Unsupported architecture: #{arch}"
347
+ end
348
+ end
278
349
  end
279
350
  end
280
351
  end
@@ -14,8 +14,17 @@ module PWN
14
14
  # )
15
15
 
16
16
  public_class_method def self.encode(opts = {})
17
- basic_user = opts[:username].to_s.chomp unless opts[:username].nil?
18
- basic_pass = opts[:password].to_s.chomp unless opts[:password].nil?
17
+ basic_user = opts[:username]
18
+ basic_pass = opts[:password]
19
+ if (basic_user.nil? || basic_pass.nil?) && !opts[:host].to_s.empty?
20
+ hit = Array(PWN::Plugins::Vault.offer(host: opts[:host], service: opts[:service], engagement: opts[:engagement] || opts[:engagement_id])).first
21
+ if hit
22
+ basic_user = hit[:username] if basic_user.nil?
23
+ basic_pass = hit[:secret] if basic_pass.nil?
24
+ end
25
+ end
26
+ basic_user = basic_user.to_s.chomp unless basic_user.nil?
27
+ basic_pass = basic_pass.to_s.chomp unless basic_pass.nil?
19
28
  base64_str = "#{basic_user}:#{basic_pass}"
20
29
  @base64_encoded_auth = Base64.strict_encode64(base64_str).to_s.chomp
21
30
  @base64_encoded_auth
@@ -50,8 +59,12 @@ module PWN
50
59
  puts "USAGE:
51
60
  # Run encode and return its result
52
61
  #{self}.encode(
53
- username: 'optional - optional username',
54
- password: 'optional - optional password'
62
+ username: 'optional - username; omitted values are filled from Vault.offer',
63
+ password: 'optional - password; omitted values are filled from Vault.offer',
64
+ host: 'optional - hostname used to offer recon loot when username or password is omitted',
65
+ service: 'optional - service name such as http or ssh used to match loot',
66
+ engagement: 'optional - engagement id scoping the loot file (defaults to default)',
67
+ engagement_id: 'optional - alias for engagement'
55
68
  )
56
69
 
57
70
  # Run decode and return its result