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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +158 -0
- data/LICENSE.txt +202 -0
- data/README.md +247 -0
- data/lib/kiosk/redteam/battery.rb +148 -0
- data/lib/kiosk/redteam/leak_scan.rb +104 -0
- data/lib/kiosk/redteam/profile.rb +58 -0
- data/lib/kiosk/redteam/runner.rb +37 -0
- data/lib/kiosk/redteam/scenario.rb +34 -0
- data/lib/kiosk/redteam/scenarios/cross_tenant_read.rb +57 -0
- data/lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb +117 -0
- data/lib/kiosk/redteam/scenarios/expired_kyc.rb +27 -0
- data/lib/kiosk/redteam/scenarios/forged_kyc.rb +27 -0
- data/lib/kiosk/redteam/scenarios/forged_user_id.rb +108 -0
- data/lib/kiosk/redteam/scenarios/kyc_attack.rb +43 -0
- data/lib/kiosk/redteam/scenarios/mandate_principal_swap.rb +32 -0
- data/lib/kiosk/redteam/scenarios/mandate_replay.rb +42 -0
- data/lib/kiosk/redteam/scenarios/missing_kyc.rb +38 -0
- data/lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb +59 -0
- data/lib/kiosk/redteam/scenarios/privilege_self_selection.rb +51 -0
- data/lib/kiosk/redteam/scenarios/registration_without_pow.rb +70 -0
- data/lib/kiosk/redteam/scenarios/spent_resource_reuse.rb +47 -0
- data/lib/kiosk/redteam/scenarios/token_tampering.rb +40 -0
- data/lib/kiosk/redteam/scenarios/unpaid_gated_action.rb +37 -0
- data/lib/kiosk/redteam/scenarios/wrong_currency_cart.rb +39 -0
- data/lib/kiosk/redteam/staging.rb +45 -0
- data/lib/kiosk/redteam/token_claims.rb +51 -0
- data/lib/kiosk/redteam/verdict.rb +13 -0
- data/lib/kiosk/redteam/verdicts.rb +130 -0
- data/lib/kiosk/redteam/version.rb +7 -0
- data/lib/kiosk/redteam.rb +94 -0
- 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
|