pwn 0.5.562 → 0.5.573
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +4 -3
- data/.ruby-version +1 -1
- data/Gemfile +17 -17
- data/README.md +5 -5
- data/build_gem.sh +14 -10
- data/documentation/lifecycle_authz_replay.example.yaml +27 -0
- data/lib/pwn/ai/anthropic.rb +281 -0
- data/lib/pwn/ai/grok.rb +8 -3
- data/lib/pwn/ai/introspection.rb +9 -0
- data/lib/pwn/ai/open_ai.rb +6 -2
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/bounty/lifecycle_authz_replay.rb +505 -0
- data/lib/pwn/bounty.rb +16 -0
- data/lib/pwn/config.rb +145 -7
- data/lib/pwn/cron.rb +210 -0
- data/lib/pwn/driver.rb +9 -0
- data/lib/pwn/memory.rb +136 -0
- data/lib/pwn/plugins/repl.rb +395 -50
- data/lib/pwn/plugins/xxd.rb +24 -0
- data/lib/pwn/sessions.rb +160 -0
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +4 -0
- data/spec/lib/pwn/ai/anthropic_spec.rb +15 -0
- data/spec/lib/pwn/bounty/lifecycle_authz_replay_spec.rb +53 -0
- data/spec/lib/pwn/bounty_spec.rb +10 -0
- data/spec/lib/pwn/cron_spec.rb +15 -0
- data/spec/lib/pwn/memory_spec.rb +24 -0
- data/spec/lib/pwn/sessions_spec.rb +25 -0
- data/spec/smoke/lifecycle_authz_replay_smoke_test.rb +59 -0
- data/third_party/pwn_rdoc.jsonl +69 -2
- data/upgrade_pwn.sh +1 -1
- metadata +50 -36
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
module PWN
|
|
9
|
+
module Bounty
|
|
10
|
+
# YAML-driven helper for capturing lifecycle authz evidence across
|
|
11
|
+
# pre/post state transitions (e.g., collaborator removal, role change,
|
|
12
|
+
# project visibility flips) with report-ready artifacts.
|
|
13
|
+
module LifecycleAuthzReplay
|
|
14
|
+
DEFAULT_CHECKPOINTS = %w[pre_change post_change_t0 post_change_tn].freeze
|
|
15
|
+
STATUS_VALUES = %w[missing accessible denied error unknown].freeze
|
|
16
|
+
|
|
17
|
+
# Supported Method Parameters::
|
|
18
|
+
# plan = PWN::Bounty::LifecycleAuthzReplay.load_plan(
|
|
19
|
+
# yaml_path: '/path/to/lifecycle_authz_replay.yaml'
|
|
20
|
+
# )
|
|
21
|
+
public_class_method def self.load_plan(opts = {})
|
|
22
|
+
yaml_path = opts[:yaml_path]
|
|
23
|
+
raise 'yaml_path is required' if yaml_path.to_s.strip.empty?
|
|
24
|
+
raise "YAML plan does not exist: #{yaml_path}" unless File.exist?(yaml_path)
|
|
25
|
+
|
|
26
|
+
raw_plan = YAML.safe_load_file(yaml_path, aliases: true) || {}
|
|
27
|
+
normalize_plan(plan: symbolize_obj(raw_plan), plan_id_hint: File.basename(yaml_path, File.extname(yaml_path)))
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
raise e
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Supported Method Parameters::
|
|
33
|
+
# run_obj = PWN::Bounty::LifecycleAuthzReplay.start_run(
|
|
34
|
+
# yaml_path: '/path/to/lifecycle_authz_replay.yaml',
|
|
35
|
+
# output_dir: '/tmp/evidence_bundle'
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# OR
|
|
39
|
+
# run_obj = PWN::Bounty::LifecycleAuthzReplay.start_run(
|
|
40
|
+
# plan: normalized_plan_hash,
|
|
41
|
+
# output_dir: '/tmp/evidence_bundle'
|
|
42
|
+
# )
|
|
43
|
+
public_class_method def self.start_run(opts = {})
|
|
44
|
+
output_dir = opts[:output_dir].to_s.strip
|
|
45
|
+
output_dir = Dir.pwd if output_dir.empty?
|
|
46
|
+
|
|
47
|
+
plan = opts[:plan]
|
|
48
|
+
plan = load_plan(yaml_path: opts[:yaml_path]) if plan.nil?
|
|
49
|
+
plan = normalize_plan(plan: plan) if plan.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
run_id = opts[:run_id].to_s.strip
|
|
52
|
+
run_id = "#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}-#{plan[:campaign][:id]}" if run_id.empty?
|
|
53
|
+
|
|
54
|
+
run_root = File.expand_path(File.join(output_dir, run_id))
|
|
55
|
+
artifacts_dir = File.join(run_root, 'artifacts')
|
|
56
|
+
FileUtils.mkdir_p(artifacts_dir)
|
|
57
|
+
|
|
58
|
+
run_obj = {
|
|
59
|
+
run_id: run_id,
|
|
60
|
+
run_root: run_root,
|
|
61
|
+
artifacts_dir: artifacts_dir,
|
|
62
|
+
started_at: Time.now.utc.iso8601,
|
|
63
|
+
plan: plan,
|
|
64
|
+
coverage_matrix: build_coverage_matrix(plan: plan),
|
|
65
|
+
observations: []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
write_json(path: File.join(run_root, 'coverage_matrix.json'), obj: run_obj[:coverage_matrix])
|
|
69
|
+
write_yaml(path: File.join(run_root, 'plan.normalized.yaml'), obj: plan)
|
|
70
|
+
write_runbook(run_obj: run_obj)
|
|
71
|
+
|
|
72
|
+
run_obj
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise e
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Supported Method Parameters::
|
|
78
|
+
# PWN::Bounty::LifecycleAuthzReplay.record_observation(
|
|
79
|
+
# run_obj: run_obj,
|
|
80
|
+
# checkpoint: 'post_change_t0',
|
|
81
|
+
# actor: 'revoked_user',
|
|
82
|
+
# surface: 'repo_settings_page',
|
|
83
|
+
# status: :accessible,
|
|
84
|
+
# request: { method: 'GET', path: '/org/repo/settings' },
|
|
85
|
+
# response: { http_status: 200 },
|
|
86
|
+
# notes: 'Still reachable after collaborator removal',
|
|
87
|
+
# artifact_paths: ['/tmp/screen.png']
|
|
88
|
+
# )
|
|
89
|
+
public_class_method def self.record_observation(opts = {})
|
|
90
|
+
run_obj = opts[:run_obj]
|
|
91
|
+
raise 'run_obj is required' unless run_obj.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
checkpoint = normalize_token(opts[:checkpoint])
|
|
94
|
+
actor = normalize_token(opts[:actor])
|
|
95
|
+
surface = normalize_token(opts[:surface])
|
|
96
|
+
status = normalize_token(opts[:status])
|
|
97
|
+
|
|
98
|
+
raise 'checkpoint is required' if checkpoint.empty?
|
|
99
|
+
raise 'actor is required' if actor.empty?
|
|
100
|
+
raise 'surface is required' if surface.empty?
|
|
101
|
+
|
|
102
|
+
status = 'unknown' if status.empty?
|
|
103
|
+
raise "unsupported status: #{status} (supported: #{STATUS_VALUES.join(', ')})" unless STATUS_VALUES.include?(status)
|
|
104
|
+
|
|
105
|
+
coverage_cell = find_coverage_cell(
|
|
106
|
+
coverage_matrix: run_obj[:coverage_matrix],
|
|
107
|
+
checkpoint: checkpoint,
|
|
108
|
+
actor: actor,
|
|
109
|
+
surface: surface
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
raise "unknown coverage cell checkpoint=#{checkpoint} actor=#{actor} surface=#{surface}" if coverage_cell.nil?
|
|
113
|
+
|
|
114
|
+
evidence = {
|
|
115
|
+
observed_at: Time.now.utc.iso8601,
|
|
116
|
+
checkpoint: checkpoint,
|
|
117
|
+
actor: actor,
|
|
118
|
+
surface: surface,
|
|
119
|
+
status: status,
|
|
120
|
+
request: symbolize_obj(opts[:request] || {}),
|
|
121
|
+
response: symbolize_obj(opts[:response] || {}),
|
|
122
|
+
notes: opts[:notes].to_s,
|
|
123
|
+
artifact_paths: Array(opts[:artifact_paths]).map(&:to_s)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
evidence_path = File.join(
|
|
127
|
+
run_obj[:artifacts_dir],
|
|
128
|
+
checkpoint,
|
|
129
|
+
actor,
|
|
130
|
+
"#{surface}.json"
|
|
131
|
+
)
|
|
132
|
+
write_json(path: evidence_path, obj: evidence)
|
|
133
|
+
|
|
134
|
+
coverage_cell[:status] = status
|
|
135
|
+
coverage_cell[:observed_at] = evidence[:observed_at]
|
|
136
|
+
coverage_cell[:evidence_path] = evidence_path
|
|
137
|
+
|
|
138
|
+
run_obj[:observations] << evidence.merge(evidence_path: evidence_path)
|
|
139
|
+
|
|
140
|
+
write_json(path: File.join(run_obj[:run_root], 'coverage_matrix.json'), obj: run_obj[:coverage_matrix])
|
|
141
|
+
write_coverage_markdown(run_obj: run_obj)
|
|
142
|
+
|
|
143
|
+
evidence
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
raise e
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Supported Method Parameters::
|
|
149
|
+
# summary = PWN::Bounty::LifecycleAuthzReplay.finalize_run(
|
|
150
|
+
# run_obj: run_obj
|
|
151
|
+
# )
|
|
152
|
+
public_class_method def self.finalize_run(opts = {})
|
|
153
|
+
run_obj = opts[:run_obj]
|
|
154
|
+
raise 'run_obj is required' unless run_obj.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
coverage_cells = run_obj[:coverage_matrix][:cells]
|
|
157
|
+
missing_cells = coverage_cells.select { |cell| cell[:status] == 'missing' }
|
|
158
|
+
stale_access_findings = find_stale_access_findings(run_obj: run_obj)
|
|
159
|
+
|
|
160
|
+
summary = {
|
|
161
|
+
run_id: run_obj[:run_id],
|
|
162
|
+
completed_at: Time.now.utc.iso8601,
|
|
163
|
+
campaign: run_obj[:plan][:campaign],
|
|
164
|
+
totals: {
|
|
165
|
+
checkpoints: run_obj[:plan][:checkpoints].length,
|
|
166
|
+
actors: run_obj[:plan][:actors].length,
|
|
167
|
+
surfaces: run_obj[:plan][:surfaces].length,
|
|
168
|
+
cells: coverage_cells.length,
|
|
169
|
+
captured_cells: coverage_cells.count { |cell| cell[:status] != 'missing' },
|
|
170
|
+
missing_cells: missing_cells.length,
|
|
171
|
+
stale_access_findings: stale_access_findings.length
|
|
172
|
+
},
|
|
173
|
+
stale_access_findings: stale_access_findings,
|
|
174
|
+
missing_cells: missing_cells
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
write_json(path: File.join(run_obj[:run_root], 'SUMMARY.json'), obj: summary)
|
|
178
|
+
write_report(run_obj: run_obj, summary: summary)
|
|
179
|
+
|
|
180
|
+
summary
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
raise e
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Supported Method Parameters::
|
|
186
|
+
# plan = PWN::Bounty::LifecycleAuthzReplay.normalize_plan(
|
|
187
|
+
# plan: {
|
|
188
|
+
# campaign: { id: 'acme-revoke' },
|
|
189
|
+
# actors: ['owner', 'revoked_user'],
|
|
190
|
+
# surfaces: ['repo_settings'],
|
|
191
|
+
# checkpoints: ['pre_change', 'post_change_t0']
|
|
192
|
+
# }
|
|
193
|
+
# )
|
|
194
|
+
public_class_method def self.normalize_plan(opts = {})
|
|
195
|
+
plan = symbolize_obj(opts[:plan] || {})
|
|
196
|
+
plan_id_hint = normalize_token(opts[:plan_id_hint])
|
|
197
|
+
|
|
198
|
+
campaign = symbolize_obj(plan[:campaign] || {})
|
|
199
|
+
campaign_id = normalize_token(campaign[:id])
|
|
200
|
+
campaign_id = normalize_token(campaign[:name]) if campaign_id.empty?
|
|
201
|
+
campaign_id = plan_id_hint if campaign_id.empty?
|
|
202
|
+
campaign_id = 'lifecycle-authz-replay' if campaign_id.empty?
|
|
203
|
+
|
|
204
|
+
actors = normalize_named_records(
|
|
205
|
+
list: Array(plan[:actors]),
|
|
206
|
+
fallback: [{ id: 'primary_actor', label: 'Primary Actor' }],
|
|
207
|
+
default_prefix: 'actor'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
surfaces = normalize_named_records(
|
|
211
|
+
list: Array(plan[:surfaces]),
|
|
212
|
+
fallback: [{ id: 'primary_surface', label: 'Primary Surface' }],
|
|
213
|
+
default_prefix: 'surface'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
checkpoints = Array(plan[:checkpoints]).map { |checkpoint| normalize_token(checkpoint) }.reject(&:empty?)
|
|
217
|
+
checkpoints = DEFAULT_CHECKPOINTS if checkpoints.empty?
|
|
218
|
+
|
|
219
|
+
expected_denied_after = Array(plan[:expected_denied_after]).map { |checkpoint| normalize_token(checkpoint) }.reject(&:empty?)
|
|
220
|
+
expected_denied_after = checkpoints.select { |checkpoint| checkpoint.start_with?('post_change') } if expected_denied_after.empty?
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
campaign: {
|
|
224
|
+
id: campaign_id,
|
|
225
|
+
label: campaign[:label].to_s.strip,
|
|
226
|
+
target: campaign[:target].to_s.strip,
|
|
227
|
+
change_event: campaign[:change_event].to_s.strip,
|
|
228
|
+
notes: campaign[:notes].to_s.strip
|
|
229
|
+
},
|
|
230
|
+
actors: actors,
|
|
231
|
+
surfaces: surfaces,
|
|
232
|
+
checkpoints: checkpoints,
|
|
233
|
+
expected_denied_after: expected_denied_after,
|
|
234
|
+
metadata: symbolize_obj(plan[:metadata] || {})
|
|
235
|
+
}
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
raise e
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
241
|
+
|
|
242
|
+
public_class_method def self.authors
|
|
243
|
+
"AUTHOR(S):
|
|
244
|
+
0day Inc. <support@0dayinc.com>
|
|
245
|
+
"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Display Usage Information
|
|
249
|
+
|
|
250
|
+
public_class_method def self.help
|
|
251
|
+
<<~HELP
|
|
252
|
+
Usage:
|
|
253
|
+
plan = PWN::Bounty::LifecycleAuthzReplay.load_plan(
|
|
254
|
+
yaml_path: '/path/to/lifecycle_authz_replay.yaml'
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
run_obj = PWN::Bounty::LifecycleAuthzReplay.start_run(
|
|
258
|
+
plan: plan,
|
|
259
|
+
output_dir: '/tmp/evidence-bundles'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
PWN::Bounty::LifecycleAuthzReplay.record_observation(
|
|
263
|
+
run_obj: run_obj,
|
|
264
|
+
checkpoint: 'post_change_t0',
|
|
265
|
+
actor: 'revoked_user',
|
|
266
|
+
surface: 'repo_settings_page',
|
|
267
|
+
status: :accessible,
|
|
268
|
+
request: { method: 'GET', path: '/org/repo/settings' },
|
|
269
|
+
response: { http_status: 200 },
|
|
270
|
+
notes: 'Still reachable after remove action',
|
|
271
|
+
artifact_paths: ['/tmp/screenshot.png']
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
summary = PWN::Bounty::LifecycleAuthzReplay.finalize_run(
|
|
275
|
+
run_obj: run_obj
|
|
276
|
+
)
|
|
277
|
+
HELP
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private_class_method def self.find_stale_access_findings(opts = {})
|
|
281
|
+
run_obj = opts[:run_obj]
|
|
282
|
+
expected_denied_after = run_obj[:plan][:expected_denied_after]
|
|
283
|
+
|
|
284
|
+
run_obj[:coverage_matrix][:cells].select do |cell|
|
|
285
|
+
expected_denied_after.include?(cell[:checkpoint]) && cell[:status] == 'accessible'
|
|
286
|
+
end
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
raise e
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private_class_method def self.find_coverage_cell(opts = {})
|
|
292
|
+
coverage_matrix = opts[:coverage_matrix]
|
|
293
|
+
checkpoint = opts[:checkpoint]
|
|
294
|
+
actor = opts[:actor]
|
|
295
|
+
surface = opts[:surface]
|
|
296
|
+
|
|
297
|
+
coverage_matrix[:cells].find do |cell|
|
|
298
|
+
cell[:checkpoint] == checkpoint && cell[:actor] == actor && cell[:surface] == surface
|
|
299
|
+
end
|
|
300
|
+
rescue StandardError => e
|
|
301
|
+
raise e
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private_class_method def self.build_coverage_matrix(opts = {})
|
|
305
|
+
plan = opts[:plan]
|
|
306
|
+
|
|
307
|
+
cells = []
|
|
308
|
+
plan[:checkpoints].each do |checkpoint|
|
|
309
|
+
plan[:actors].each do |actor|
|
|
310
|
+
plan[:surfaces].each do |surface|
|
|
311
|
+
cells << {
|
|
312
|
+
checkpoint: checkpoint,
|
|
313
|
+
actor: actor[:id],
|
|
314
|
+
surface: surface[:id],
|
|
315
|
+
status: 'missing',
|
|
316
|
+
observed_at: nil,
|
|
317
|
+
evidence_path: nil
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
generated_at: Time.now.utc.iso8601,
|
|
325
|
+
status_values: STATUS_VALUES,
|
|
326
|
+
cells: cells
|
|
327
|
+
}
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
raise e
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private_class_method def self.write_runbook(opts = {})
|
|
333
|
+
run_obj = opts[:run_obj]
|
|
334
|
+
plan = run_obj[:plan]
|
|
335
|
+
runbook_path = File.join(run_obj[:run_root], 'RUNBOOK.md')
|
|
336
|
+
|
|
337
|
+
runbook_lines = []
|
|
338
|
+
runbook_lines << '# Lifecycle Authz Replay Runbook'
|
|
339
|
+
runbook_lines <<
|
|
340
|
+
"Run ID: `#{run_obj[:run_id]}` " \
|
|
341
|
+
"Campaign: `#{plan[:campaign][:id]}` " \
|
|
342
|
+
"Target: `#{plan[:campaign][:target]}`"
|
|
343
|
+
runbook_lines << ''
|
|
344
|
+
runbook_lines << '## Checkpoint capture checklist'
|
|
345
|
+
|
|
346
|
+
plan[:checkpoints].each do |checkpoint|
|
|
347
|
+
expected_status = plan[:expected_denied_after].include?(checkpoint) ? 'denied' : 'accessible'
|
|
348
|
+
runbook_lines << ''
|
|
349
|
+
runbook_lines << "### #{checkpoint} (expected status: #{expected_status})"
|
|
350
|
+
|
|
351
|
+
plan[:actors].each do |actor|
|
|
352
|
+
plan[:surfaces].each do |surface|
|
|
353
|
+
runbook_lines << "- [ ] actor=`#{actor[:id]}` surface=`#{surface[:id]}`"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
runbook_lines << ''
|
|
359
|
+
runbook_lines << '## Artifact locations'
|
|
360
|
+
runbook_lines << '- coverage matrix: `coverage_matrix.json` + `coverage_matrix.md`'
|
|
361
|
+
runbook_lines << '- evidence: `artifacts/<checkpoint>/<actor>/<surface>.json`'
|
|
362
|
+
runbook_lines << '- report output: `SUMMARY.json` + `REPORT.md`'
|
|
363
|
+
|
|
364
|
+
File.write(runbook_path, runbook_lines.join("\n"))
|
|
365
|
+
|
|
366
|
+
write_coverage_markdown(run_obj: run_obj)
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
raise e
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
private_class_method def self.write_coverage_markdown(opts = {})
|
|
372
|
+
run_obj = opts[:run_obj]
|
|
373
|
+
coverage_path = File.join(run_obj[:run_root], 'coverage_matrix.md')
|
|
374
|
+
|
|
375
|
+
lines = []
|
|
376
|
+
lines << '# Coverage Matrix'
|
|
377
|
+
lines << ''
|
|
378
|
+
lines << '| Checkpoint | Actor | Surface | Status | Evidence |'
|
|
379
|
+
lines << '| --- | --- | --- | --- | --- |'
|
|
380
|
+
|
|
381
|
+
run_obj[:coverage_matrix][:cells].each do |cell|
|
|
382
|
+
evidence = cell[:evidence_path].to_s
|
|
383
|
+
evidence = File.basename(evidence) unless evidence.empty?
|
|
384
|
+
lines << "| #{cell[:checkpoint]} | #{cell[:actor]} | #{cell[:surface]} | #{cell[:status]} | #{evidence} |"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
File.write(coverage_path, lines.join("\n"))
|
|
388
|
+
rescue StandardError => e
|
|
389
|
+
raise e
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private_class_method def self.write_report(opts = {})
|
|
393
|
+
run_obj = opts[:run_obj]
|
|
394
|
+
summary = opts[:summary]
|
|
395
|
+
|
|
396
|
+
lines = []
|
|
397
|
+
lines << '# Lifecycle Authz Replay Report'
|
|
398
|
+
lines << ''
|
|
399
|
+
lines << "- Run ID: `#{summary[:run_id]}`"
|
|
400
|
+
lines << "- Campaign: `#{summary[:campaign][:id]}`"
|
|
401
|
+
lines << "- Completed At (UTC): `#{summary[:completed_at]}`"
|
|
402
|
+
lines << "- Captured Cells: `#{summary[:totals][:captured_cells]}` / `#{summary[:totals][:cells]}`"
|
|
403
|
+
lines << "- Missing Cells: `#{summary[:totals][:missing_cells]}`"
|
|
404
|
+
lines << ''
|
|
405
|
+
|
|
406
|
+
lines << '## Stale Access Findings'
|
|
407
|
+
if summary[:stale_access_findings].empty?
|
|
408
|
+
lines << '- No stale-access cells confirmed in expected-denied checkpoints.'
|
|
409
|
+
else
|
|
410
|
+
summary[:stale_access_findings].each do |finding|
|
|
411
|
+
lines << "- checkpoint=`#{finding[:checkpoint]}` actor=`#{finding[:actor]}` surface=`#{finding[:surface]}` evidence=`#{finding[:evidence_path]}`"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
lines << ''
|
|
416
|
+
lines << '## Missing Coverage Cells'
|
|
417
|
+
if summary[:missing_cells].empty?
|
|
418
|
+
lines << '- Coverage complete for planned cells.'
|
|
419
|
+
else
|
|
420
|
+
summary[:missing_cells].each do |cell|
|
|
421
|
+
lines << "- checkpoint=`#{cell[:checkpoint]}` actor=`#{cell[:actor]}` surface=`#{cell[:surface]}`"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
File.write(File.join(run_obj[:run_root], 'REPORT.md'), lines.join("\n"))
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
raise e
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
private_class_method def self.normalize_named_records(opts = {})
|
|
431
|
+
list = opts[:list]
|
|
432
|
+
fallback = opts[:fallback]
|
|
433
|
+
default_prefix = normalize_token(opts[:default_prefix])
|
|
434
|
+
|
|
435
|
+
list = fallback if list.empty?
|
|
436
|
+
|
|
437
|
+
normalized = []
|
|
438
|
+
list.each_with_index do |entry, index|
|
|
439
|
+
item = entry
|
|
440
|
+
item = { id: entry.to_s, label: entry.to_s } unless item.is_a?(Hash)
|
|
441
|
+
item = symbolize_obj(item)
|
|
442
|
+
|
|
443
|
+
id = normalize_token(item[:id])
|
|
444
|
+
id = normalize_token(item[:name]) if id.empty?
|
|
445
|
+
id = "#{default_prefix}_#{index + 1}" if id.empty?
|
|
446
|
+
|
|
447
|
+
label = item[:label].to_s.strip
|
|
448
|
+
label = item[:name].to_s.strip if label.empty?
|
|
449
|
+
label = id if label.empty?
|
|
450
|
+
|
|
451
|
+
normalized << {
|
|
452
|
+
id: id,
|
|
453
|
+
label: label,
|
|
454
|
+
metadata: symbolize_obj(item[:metadata] || {})
|
|
455
|
+
}
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
normalized
|
|
459
|
+
rescue StandardError => e
|
|
460
|
+
raise e
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
private_class_method def self.symbolize_obj(obj)
|
|
464
|
+
case obj
|
|
465
|
+
when Array
|
|
466
|
+
obj.map { |entry| symbolize_obj(entry) }
|
|
467
|
+
when Hash
|
|
468
|
+
obj.each_with_object({}) do |(key, value), accum|
|
|
469
|
+
symbolized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
470
|
+
accum[symbolized_key] = symbolize_obj(value)
|
|
471
|
+
end
|
|
472
|
+
else
|
|
473
|
+
obj
|
|
474
|
+
end
|
|
475
|
+
rescue StandardError => e
|
|
476
|
+
raise e
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
private_class_method def self.normalize_token(token)
|
|
480
|
+
token.to_s.strip.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_+|_+$/, '')
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
raise e
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
private_class_method def self.write_json(opts = {})
|
|
486
|
+
path = opts[:path]
|
|
487
|
+
obj = opts[:obj]
|
|
488
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
489
|
+
File.write(path, JSON.pretty_generate(obj))
|
|
490
|
+
rescue StandardError => e
|
|
491
|
+
raise e
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
private_class_method def self.write_yaml(opts = {})
|
|
495
|
+
path = opts[:path]
|
|
496
|
+
obj = opts[:obj]
|
|
497
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
498
|
+
yaml_obj = YAML.dump(obj).gsub(/^\s*:(\w+):/, '\\1:')
|
|
499
|
+
File.write(path, yaml_obj)
|
|
500
|
+
rescue StandardError => e
|
|
501
|
+
raise e
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
data/lib/pwn/bounty.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PWN
|
|
4
|
+
# This file, using the autoload directive loads Bounty modules
|
|
5
|
+
# into memory only when they're needed. For more information, see:
|
|
6
|
+
# http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html
|
|
7
|
+
module Bounty
|
|
8
|
+
autoload :LifecycleAuthzReplay, 'pwn/bounty/lifecycle_authz_replay'
|
|
9
|
+
|
|
10
|
+
# Display a List of Every PWN::Bounty Module
|
|
11
|
+
|
|
12
|
+
public_class_method def self.help
|
|
13
|
+
constants.sort
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|