kiosk-redteam 0.5.20

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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +158 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +247 -0
  5. data/lib/kiosk/redteam/battery.rb +148 -0
  6. data/lib/kiosk/redteam/leak_scan.rb +104 -0
  7. data/lib/kiosk/redteam/profile.rb +58 -0
  8. data/lib/kiosk/redteam/runner.rb +37 -0
  9. data/lib/kiosk/redteam/scenario.rb +34 -0
  10. data/lib/kiosk/redteam/scenarios/cross_tenant_read.rb +57 -0
  11. data/lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb +117 -0
  12. data/lib/kiosk/redteam/scenarios/expired_kyc.rb +27 -0
  13. data/lib/kiosk/redteam/scenarios/forged_kyc.rb +27 -0
  14. data/lib/kiosk/redteam/scenarios/forged_user_id.rb +108 -0
  15. data/lib/kiosk/redteam/scenarios/kyc_attack.rb +43 -0
  16. data/lib/kiosk/redteam/scenarios/mandate_principal_swap.rb +32 -0
  17. data/lib/kiosk/redteam/scenarios/mandate_replay.rb +42 -0
  18. data/lib/kiosk/redteam/scenarios/missing_kyc.rb +38 -0
  19. data/lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb +59 -0
  20. data/lib/kiosk/redteam/scenarios/privilege_self_selection.rb +51 -0
  21. data/lib/kiosk/redteam/scenarios/registration_without_pow.rb +70 -0
  22. data/lib/kiosk/redteam/scenarios/spent_resource_reuse.rb +47 -0
  23. data/lib/kiosk/redteam/scenarios/token_tampering.rb +40 -0
  24. data/lib/kiosk/redteam/scenarios/unpaid_gated_action.rb +37 -0
  25. data/lib/kiosk/redteam/scenarios/wrong_currency_cart.rb +39 -0
  26. data/lib/kiosk/redteam/staging.rb +45 -0
  27. data/lib/kiosk/redteam/token_claims.rb +51 -0
  28. data/lib/kiosk/redteam/verdict.rb +13 -0
  29. data/lib/kiosk/redteam/verdicts.rb +130 -0
  30. data/lib/kiosk/redteam/version.rb +7 -0
  31. data/lib/kiosk/redteam.rb +94 -0
  32. metadata +113 -0
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ # One ledger and one exit status for a red-team run, filing hand-written
8
+ # beats and library Scenarios alike. #report! answers 0 only when at least one
9
+ # attack was blocked, none breached, every attack was tested, and the skips are
10
+ # exactly the expected ones.
11
+ class Battery
12
+ # state is :blocked, :breach, :untested or :skipped.
13
+ Entry = Data.define(:name, :state, :detail)
14
+
15
+ def initialize(io: $stdout)
16
+ @io = io
17
+ @entries = []
18
+ end
19
+
20
+ # untested: a setup step failed, so the attack never reached its gate.
21
+ def record(name, blocked, detail = '', untested: false)
22
+ state = blocked ? :blocked : :breach
23
+ file(Entry.new(name: name.to_s, state: untested ? :untested : state, detail: detail.to_s))
24
+ end
25
+
26
+ # on_skip: :breach when this origin has the surface, so a skip is a harness defect.
27
+ def scenario(scenario, client:, profile:, on_skip: :skip)
28
+ verdict = scenario.call(client, profile)
29
+ absorb_verdict(scenario.name, verdict, on_skip: on_skip)
30
+ end
31
+
32
+ # Runner#run has already printed each line, hence echo: false.
33
+ def absorb(results, on_skip: :skip)
34
+ Array(results).map do |r|
35
+ absorb_verdict(r[:scenario].name, r[:verdict], on_skip: on_skip, echo: false)
36
+ end
37
+ end
38
+
39
+ def blocked = @entries.select { |e| e.state == :blocked }
40
+
41
+ def breaches = @entries.select { |e| e.state == :breach }
42
+
43
+ def skipped = @entries.select { |e| e.state == :skipped }
44
+
45
+ def untested = @entries.select { |e| e.state == :untested }
46
+
47
+ # Returns the exit status: 0 green, 1 breach, untested attack or no proof, 2 skip set differs from expected_skips.
48
+ def report!(expected_skips: [])
49
+ actual = skipped.map(&:name).sort
50
+ expected = Array(expected_skips).map(&:to_s).sort
51
+
52
+ print_summary
53
+ status = outcome_status
54
+ status = 2 if skips_differ?(actual, expected) && status.zero?
55
+ print_json(actual, status)
56
+ status
57
+ end
58
+
59
+ private
60
+
61
+ def print_summary
62
+ @io.puts "\n── Summary ──"
63
+ @io.puts(*[blocked, skipped, untested, breaches].flatten.map { line(it, summary: true) })
64
+ @io.puts ''
65
+ end
66
+
67
+ def line(entry, summary: false)
68
+ case entry.state
69
+ when :blocked then " BLOCKED ✓ #{entry.name}#{" — #{entry.detail}" unless summary || entry.detail.empty?}"
70
+ when :skipped then " SKIP — #{entry.name} (#{entry.detail})"
71
+ when :untested then " UNTESTED ! #{entry.name} — #{entry.detail}"
72
+ else " BREACH ✗ #{entry.name} — #{entry.detail}"
73
+ end
74
+ end
75
+
76
+ # Prints the tally line and answers 0 when every attack was blocked, else 1.
77
+ def outcome_status
78
+ return tally(1, " #{@entries.size} scenarios, 0 BLOCKED — this run proved NOTHING, which is not a pass.") if blocked.empty?
79
+ unless breaches.empty?
80
+ return tally(1, "#{counts} #{breaches.size} BREACH — FIX REQUIRED",
81
+ ' BREACH means a real hole in the provider — fix the app, not the scenario.')
82
+ end
83
+ return tally(0, all_blocked_line) if untested.empty?
84
+
85
+ tally(1, "#{counts} 0 BREACH — NOT A PASS",
86
+ ' UNTESTED means a setup step or the environment failed, and the attack never reached its gate. ' \
87
+ 'Fix the step it names, then run again.')
88
+ end
89
+
90
+ def counts = " #{blocked.size} BLOCKED, #{skipped.size} SKIPPED, #{untested.size} UNTESTED,"
91
+
92
+ def all_blocked_line
93
+ " #{blocked.size} BLOCKED, #{skipped.size} SKIPPED, 0 BREACH — all attacks blocked."
94
+ end
95
+
96
+ def tally(status, *lines)
97
+ @io.puts(*lines)
98
+ status
99
+ end
100
+
101
+ # Prints the mismatch and answers true when the skip set is not the expected one.
102
+ def skips_differ?(actual, expected)
103
+ return false if actual == expected
104
+
105
+ @io.puts ''
106
+ @io.puts ' EXPECTED-APPLICABLE ASSERTION FAILED:'
107
+ @io.puts " Expected skips: #{expected.inspect}"
108
+ @io.puts " Actual skips: #{actual.inspect}"
109
+ @io.puts ' A profile key may have been set to nil, disabling a gate scenario.'
110
+ true
111
+ end
112
+
113
+ def print_json(actual, status)
114
+ @io.puts JSON.generate(scenarios: @entries.size, blocked: blocked.size, skipped: actual,
115
+ breaches: breaches.map(&:name), untested: untested.map(&:name), exit: status)
116
+ end
117
+
118
+ def absorb_verdict(name, verdict, on_skip:, echo: true)
119
+ entry = entry_for(name, verdict, on_skip)
120
+ return file(entry) if echo
121
+
122
+ @entries << entry
123
+ entry
124
+ end
125
+
126
+ def entry_for(name, verdict, on_skip)
127
+ return skip_entry(name, verdict, on_skip) if verdict.skipped
128
+ return Entry.new(name: name, state: :blocked, detail: "HTTP #{verdict.status}") if verdict.blocked
129
+ return Entry.new(name: name, state: :untested, detail: verdict.detail.to_s) if verdict.untested
130
+
131
+ Entry.new(name: name, state: :breach, detail: verdict.detail.to_s)
132
+ end
133
+
134
+ def skip_entry(name, verdict, on_skip)
135
+ reason = verdict.detail.to_s.delete_prefix('SKIP — ')
136
+ return Entry.new(name: name, state: :skipped, detail: reason) unless on_skip == :breach
137
+
138
+ Entry.new(name: name, state: :breach, detail: "SKIPPED, which this origin must never do — #{reason}")
139
+ end
140
+
141
+ def file(entry)
142
+ @entries << entry
143
+ @io.puts line(entry)
144
+ entry
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ # Finds runtime vocabulary (a Ruby class, a PostgreSQL error) in an error
8
+ # body without counting the probe's own echoed value as a leak. A needle is
9
+ # discounted only when it lies wholly inside one span of bytes the probe
10
+ # supplied; discounted needles are reported in Result#note.
11
+ module LeakScan
12
+ Result = Data.define(:leak, :echoed) do
13
+ def leak? = !leak.nil?
14
+
15
+ def note
16
+ return '' if echoed.empty?
17
+
18
+ " [echoed, not leaked: #{echoed.join(', ')} — these bytes came back " \
19
+ "from the probe's own value, so they are not the runtime speaking]"
20
+ end
21
+ end
22
+
23
+ module_function
24
+
25
+ # +supplied+ is what the probe put on the wire; nil discounts nothing.
26
+ def scan(body, needles, supplied: nil)
27
+ raw = body.is_a?(String) ? body : JSON.generate(body)
28
+ echoed, leaked = sightings(raw, needles, echo_spans(raw, supplied)).partition(&:last)
29
+ Result.new(leak: leaked.first&.first, echoed: echoed.map(&:first).freeze)
30
+ end
31
+
32
+ # [needle, echoed?] for each needle found in +raw+, in needle order.
33
+ def sightings(raw, needles, spans)
34
+ needles.filter_map do |needle|
35
+ next if needle.nil? || needle.empty?
36
+
37
+ hits = occurrences(raw, needle)
38
+ [needle, hits.all? { |at| inside_one_span?(at, needle.length, spans) }] unless hits.empty?
39
+ end
40
+ end
41
+
42
+ def leak(body, needles, supplied: nil)
43
+ scan(body, needles, supplied: supplied).leak
44
+ end
45
+
46
+ def echo_spans(raw, supplied)
47
+ spellings(supplied).flat_map do |spelling|
48
+ occurrences(raw, spelling).map { |at| [at, at + spelling.length] }
49
+ end
50
+ end
51
+
52
+ # Every form a supplied value can take in a serialized body: JSON, the bare
53
+ # JSON string content, Ruby `inspect`, and that `inspect` JSON-escaped.
54
+ def spellings(value, acc = [])
55
+ nested_values(value).each { |element| spellings(element, acc) }
56
+ acc.concat(own_spellings(value))
57
+ acc.reject! { |spelling| spelling.nil? || spelling.empty? }
58
+ acc.uniq!
59
+ acc
60
+ end
61
+
62
+ def nested_values(value)
63
+ case value
64
+ when Array then value
65
+ when Hash then value.flat_map { |key, element| [key, element] }
66
+ else []
67
+ end
68
+ end
69
+
70
+ def own_spellings(value)
71
+ json = json_fragment(value)
72
+ inspected = value.inspect
73
+ [json, bare_string(json, value), inspected, json_fragment(inspected)&.slice(1..-2)]
74
+ end
75
+
76
+ # The JSON string content without its quotes, or nil when +value+ is not a String.
77
+ def bare_string(json, value)
78
+ json[1..-2] if json && value.is_a?(String) && json.length > 2
79
+ end
80
+
81
+ def json_fragment(value)
82
+ JSON.generate([value])[1..-2]
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def occurrences(haystack, needle)
88
+ found = []
89
+ at = 0
90
+ while (at = haystack.index(needle, at))
91
+ found << at
92
+ at += 1
93
+ end
94
+ found
95
+ end
96
+
97
+ # One span, not their union: two adjacent echoes must not cover a needle between them.
98
+ def inside_one_span?(at, length, spans)
99
+ finish = at + length
100
+ spans.any? { |from, to| from <= at && finish <= to }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ # What an origin tells the generic scenarios about itself. A nil field skips
6
+ # the scenarios that need that surface.
7
+ #
8
+ # pow_difficulty >0 when /register is gated by a PoW toll; 0 skips RegistrationWithoutPow
9
+ # declared_roles the roles the origin declares; DeviceGrantRoleSelfSelection needs a real one
10
+ # requires_kyc whether the gated action needs a KYC attestation
11
+ # currency the ISO 4217 code the origin prices in, lower case (WrongCurrencyCart)
12
+ # per_user_query query returning the caller's own rows (CrossTenantRead)
13
+ # row_id_key key of a row's id in query results
14
+ # result_id_key key of the new resource's id in forge_action's result
15
+ # create_owned (client, principal) -> Hash with at least :id
16
+ # forge_action action whose caller-supplied user_id the server must ignore
17
+ # forge_args (client, principal_a, principal_b) -> Hash, without user_id
18
+ # gated_action action gated behind payment (and KYC when requires_kyc)
19
+ # gated_action_consumes false when calling gated_action twice is correct (skips SpentResourceReuse)
20
+ # gated_args (owned_ref) -> Hash
21
+ # pay_for (client, principal, owned_ref) -> { intent:, cart: }
22
+ # kyc_valid (user_id) -> a valid attestation JWS
23
+ # kyc_expired (user_id) -> an expired attestation JWS
24
+ # kyc_forged (user_id) -> an attestation with a wrong issuer or signature
25
+ class Profile
26
+ DEFAULTS = {
27
+ pow_difficulty: 0,
28
+ declared_roles: [],
29
+ currency: nil,
30
+ requires_kyc: false,
31
+ per_user_query: nil,
32
+ row_id_key: 'id',
33
+ result_id_key: nil,
34
+ create_owned: nil,
35
+ forge_action: nil,
36
+ forge_args: nil,
37
+ gated_action: nil,
38
+ gated_action_consumes: true,
39
+ gated_args: nil,
40
+ pay_for: nil,
41
+ kyc_valid: nil,
42
+ kyc_expired: nil,
43
+ kyc_forged: nil
44
+ }.freeze
45
+
46
+ attr_reader(*DEFAULTS.keys)
47
+
48
+ def initialize(**fields)
49
+ unknown = fields.keys - DEFAULTS.keys
50
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
51
+
52
+ DEFAULTS.merge(fields).each { |field, value| instance_variable_set(:"@#{field}", value) }
53
+ @declared_roles = Array(declared_roles).map(&:to_s).reject(&:empty?).uniq
54
+ @result_id_key ||= row_id_key
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ # Runs scenarios, prints a BLOCKED / SKIP / UNTESTED / BREACH line for each and returns
6
+ # the `{ scenario:, verdict: }` results; `Battery#absorb` scores them.
7
+ class Runner
8
+ def initialize(base_url:, profile:)
9
+ @client = Kiosk::TestHelpers::Assistant.new(base_url:)
10
+ @profile = profile
11
+ end
12
+
13
+ def run(scenarios)
14
+ scenarios.map do |scenario|
15
+ verdict = scenario.call(@client, @profile)
16
+ print_line(scenario, verdict)
17
+ { scenario:, verdict: }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def print_line(scenario, verdict)
24
+ if verdict.skipped
25
+ puts " SKIP — #{scenario.name} (#{verdict.detail.delete_prefix('SKIP — ')})"
26
+ elsif verdict.blocked
27
+ # The status says which gate answered.
28
+ puts " BLOCKED ✓ #{scenario.name} (HTTP #{verdict.status})"
29
+ elsif verdict.untested
30
+ puts " UNTESTED ! #{scenario.name} — #{verdict.detail}"
31
+ else
32
+ puts " BREACH ✗ #{scenario.name} — #{verdict.detail}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/redteam/token_claims'
4
+ require 'kiosk/redteam/verdicts'
5
+ require 'kiosk/redteam/staging'
6
+
7
+ module Kiosk
8
+ module Redteam
9
+ # Base class for adversarial scenarios. Subclasses implement #call and
10
+ # return a Verdict: blocked when the origin refused the attack, skipped
11
+ # when the profile lacks the surface the scenario needs.
12
+ class Scenario
13
+ include Verdicts
14
+ include Staging
15
+
16
+ attr_reader :name
17
+
18
+ def initialize(name:)
19
+ @name = name
20
+ end
21
+
22
+ def call(client, profile)
23
+ raise NotImplementedError, "#{self.class}#call is not implemented"
24
+ end
25
+
26
+ private
27
+
28
+ # Changes a payload claim and keeps the original signature.
29
+ def tamper_token(token)
30
+ TokenClaims.tamper(token)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ module Scenarios
6
+ # B's per_user_query must answer 200 without A's row, and A's own query
7
+ # must return it: without that control, an empty answer proves nothing.
8
+ class CrossTenantRead < Scenario
9
+ def initialize
10
+ super(name: 'CrossTenantRead')
11
+ end
12
+
13
+ def call(client, profile)
14
+ skip_unless(profile, per_user_query: 'no per_user_query', create_owned: 'no create_owned') ||
15
+ attack(client, profile)
16
+ end
17
+
18
+ private
19
+
20
+ def attack(client, profile)
21
+ a = client.register!
22
+ owned_id = profile.create_owned.call(client, a)[:id].to_s
23
+ control_failure(client.query(a, name: profile.per_user_query), profile, owned_id) ||
24
+ isolation_verdict(client.query(client.register!, name: profile.per_user_query), profile, owned_id)
25
+ end
26
+
27
+ def control_failure(control, profile, owned_id)
28
+ return nil if control.status == 200 && rows_contain?(control, profile.row_id_key, owned_id)
29
+
30
+ breach_verdict(
31
+ control.status,
32
+ "CONTROL FAILED: A's own #{profile.per_user_query} must answer 200 and list " \
33
+ "A's own resource id=#{owned_id} under row_id_key=" \
34
+ "#{profile.row_id_key.inspect} — got HTTP #{control.status} " \
35
+ "#{control.body.inspect}. Until it does, B seeing nothing proves nothing."
36
+ )
37
+ end
38
+
39
+ def isolation_verdict(resp, profile, owned_id)
40
+ # An unanswered query is not isolation: an error envelope also reads as no rows.
41
+ unless resp.status == 200
42
+ return breach_verdict(resp.status,
43
+ "B's #{profile.per_user_query} was not answered (HTTP #{resp.status}: " \
44
+ "#{resp.body.inspect}); an unanswered query is not proof of isolation")
45
+ end
46
+ return blocked_verdict(resp.status) unless rows_contain?(resp, profile.row_id_key, owned_id)
47
+
48
+ breach_verdict(resp.status, "A's resource id=#{owned_id} visible in B's #{profile.per_user_query} result")
49
+ end
50
+
51
+ def rows_contain?(response, row_id_key, owned_id)
52
+ rows_from(response).any? { |row| row[row_id_key].to_s == owned_id }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ module Scenarios
8
+ # The unauthenticated device_authorization request must refuse a
9
+ # client-chosen role or scope. Probed with roles the origin actually
10
+ # declares, since a vulnerable origin refuses an undeclared one too; the
11
+ # role-less request must still open the ceremony.
12
+ class DeviceGrantRoleSelfSelection < Scenario
13
+ # Refused by a vulnerable origin too; probed only to show both filters in the detail.
14
+ UNDECLARED_ROLE = 'master'
15
+
16
+ # RFC 8628 §3.2 user codes as this engine mints them.
17
+ USER_CODE = /\A[A-Z0-9]{4}-[A-Z0-9]{4}\z/
18
+
19
+ def initialize
20
+ super(name: 'DeviceGrantRoleSelfSelection')
21
+ end
22
+
23
+ def call(client, profile)
24
+ wire_role, setup = wire_declared_role(client)
25
+ return setup if setup
26
+
27
+ declared = (profile.declared_roles + [wire_role]).compact.uniq
28
+ return skip_verdict(no_declared_role_reason) if declared.empty?
29
+
30
+ probes = probe(client, declared)
31
+ control_ok, control_detail = control(client)
32
+ refused = probes.all?(&:first)
33
+ Verdict.new(blocked: refused && control_ok, skipped: false, status: refused ? 400 : 200,
34
+ detail: summary(probes, control_detail, declared, wire_role))
35
+ end
36
+
37
+ private
38
+
39
+ # [refused?, detail] for each role or scope the request names.
40
+ def probe(client, declared)
41
+ pem = OpenSSL::PKey::RSA.generate(2048).public_key.to_pem
42
+ probe_labels(declared).map do |label, param, value|
43
+ resp = client.device_authorization(
44
+ client_id: 'redteam-device-grant-role', public_key: pem, param => value
45
+ )
46
+ [resp.status == 400 && body_field(resp, 'error') == 'invalid_request',
47
+ "#{label} -> HTTP #{resp.status} error=#{body_field(resp, 'error').inspect}"]
48
+ end
49
+ end
50
+
51
+ def summary(probes, control_detail, declared, wire_role)
52
+ "#{probes.map(&:last).join('; ')}; #{control_detail} " \
53
+ "[declared roles probed: #{declared.inspect} " \
54
+ "(#{wire_role.inspect} read off this origin's own registration token); " \
55
+ 'want every role/scope refused 400 invalid_request AND the role-less ' \
56
+ 'ceremony still opening]'
57
+ end
58
+
59
+ def body_field(resp, key)
60
+ resp.body.is_a?(Hash) ? resp.body[key] : nil
61
+ end
62
+
63
+ def probe_labels(declared)
64
+ declared.flat_map do |role|
65
+ %i[role scope].map do |param|
66
+ ["#{param}=#{role} (DECLARED by this origin — the escalation itself)", param, role]
67
+ end
68
+ end + %i[role scope].map do |param|
69
+ ["#{param}=#{UNDECLARED_ROLE} (undeclared; refused by the vulnerable code too, " \
70
+ 'so it proves nothing alone)', param, UNDECLARED_ROLE]
71
+ end
72
+ end
73
+
74
+ def control(client)
75
+ key = OpenSSL::PKey::RSA.generate(2048)
76
+ resp = client.device_authorization(
77
+ client_id: 'redteam-device-grant-role', public_key: key.public_key.to_pem
78
+ )
79
+ code = resp.body.is_a?(Hash) ? resp.body['user_code'].to_s : ''
80
+ [resp.status == 200 && code.match?(USER_CODE),
81
+ "CONTROL role-less request -> HTTP #{resp.status} user_code=#{code.inspect}"]
82
+ end
83
+
84
+ # The `role` claim of a token this origin mints at registration.
85
+ def wire_declared_role(client)
86
+ resp = client.register_raw
87
+ failure = (registration_failure(resp) unless resp.status == 201)
88
+ return [nil, failure] if failure
89
+
90
+ [token_role(body_field(resp, 'access_token')), nil]
91
+ end
92
+
93
+ def registration_failure(resp)
94
+ setup_failure(
95
+ resp,
96
+ step: 'the CONTROL registration this scenario reads a declared role from',
97
+ because: 'without a role the origin actually declares, the only probe left is an ' \
98
+ 'invented one — which even a vulnerable origin refuses, so the battery ' \
99
+ 'would print BLOCKED without testing anything.'
100
+ )
101
+ end
102
+
103
+ def no_declared_role_reason
104
+ 'this origin declares no role — `declared_roles` is empty in the profile AND the ' \
105
+ 'token it minted at registration carries no `role` claim, so a client-chosen ' \
106
+ 'DECLARED role has nothing to name here'
107
+ end
108
+
109
+ # Unverified: only what the server put there matters.
110
+ def token_role(token)
111
+ role = TokenClaims.claims(token)&.fetch('role', nil)
112
+ role.nil? || role.to_s.empty? ? nil : role.to_s
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/redteam/scenarios/kyc_attack'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ module Scenarios
8
+ # An attestation whose exp is past must be refused, either at /kyc or at the gated action.
9
+ class ExpiredKyc < Scenario
10
+ include KycAttack
11
+
12
+ def initialize
13
+ super(name: 'ExpiredKyc')
14
+ end
15
+
16
+ def call(client, profile)
17
+ skip_unless(profile, requires_kyc: 'requires_kyc is false', kyc_expired: 'no kyc_expired callable',
18
+ gated_action: 'no gated_action', create_owned: 'no create_owned',
19
+ pay_for: 'no pay_for') ||
20
+ # kyc_required or unauthenticated, depending on where the origin checks exp.
21
+ kyc_attack(client, profile, attestation: profile.kyc_expired, kind: 'expired',
22
+ check: 'KYC expiry check')
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/redteam/scenarios/kyc_attack'
4
+
5
+ module Kiosk
6
+ module Redteam
7
+ module Scenarios
8
+ # An attestation from an untrusted issuer must be refused, at /kyc or at the gated action.
9
+ class ForgedKyc < Scenario
10
+ include KycAttack
11
+
12
+ def initialize
13
+ super(name: 'ForgedKyc')
14
+ end
15
+
16
+ def call(client, profile)
17
+ skip_unless(profile, requires_kyc: 'requires_kyc is false', kyc_forged: 'no kyc_forged callable',
18
+ gated_action: 'no gated_action', create_owned: 'no create_owned',
19
+ pay_for: 'no pay_for') ||
20
+ # kyc_required or unauthenticated, depending on the origin.
21
+ kyc_attack(client, profile, attestation: profile.kyc_forged, kind: 'forged',
22
+ check: 'KYC issuer check')
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end