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.
@@ -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