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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module Kiosk
7
+ module Redteam
8
+ # Reads and edits the payload of a compact JWS. Nothing here verifies a signature.
9
+ module TokenClaims
10
+ module_function
11
+
12
+ # The payload claims as a Hash, or nil when the token does not decode to one.
13
+ def claims(token)
14
+ payload_b64 = token.to_s.split('.')[1]
15
+ return nil if payload_b64.nil?
16
+
17
+ parsed = decode(payload_b64)
18
+ parsed.is_a?(Hash) ? parsed : nil
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ # Changes one payload claim and keeps the original signature.
24
+ def tamper(token)
25
+ header, payload_b64, sig = token.split('.', 3)
26
+ return token if payload_b64.nil?
27
+
28
+ new_payload = Base64.urlsafe_encode64(JSON.generate(flip(decode(payload_b64))), padding: false)
29
+ [header, new_payload, sig].join('.')
30
+ end
31
+
32
+ def decode(payload_b64)
33
+ padded = payload_b64 + ('=' * ((4 - (payload_b64.length % 4)) % 4))
34
+ JSON.parse(Base64.urlsafe_decode64(padded))
35
+ end
36
+
37
+ def flip(claims)
38
+ if claims.key?('role')
39
+ claims['role'] = claims['role'] == 'admin' ? 'superadmin' : 'admin'
40
+ elsif claims.key?('sub')
41
+ claims['sub'] = "#{claims['sub']}-tampered"
42
+ elsif claims.key?('exp')
43
+ claims['exp'] = (claims['exp'].to_i + 999_999)
44
+ else
45
+ claims['__tamper__'] = true
46
+ end
47
+ claims
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ # BLOCKED: blocked. UNTESTED: the attack never reached the gate under test,
6
+ # because a setup step or the environment failed. BREACH: none of these.
7
+ # SKIPPED: the profile lacks the surface, which is not a pass. status is 0
8
+ # for a skip.
9
+ Verdict = Data.define(:blocked, :skipped, :status, :detail, :untested) do
10
+ def initialize(untested: false, **) = super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ # Builds the Verdict a Scenario returns. Mixed into Scenario as private methods.
6
+ module Verdicts
7
+ private
8
+
9
+ def skip_verdict(reason)
10
+ Verdict.new(blocked: false, skipped: true, status: 0, detail: "SKIP — #{reason}")
11
+ end
12
+
13
+ # The first profile field that is unset, as a skip verdict; nil when every field is set.
14
+ def skip_unless(profile, reasons)
15
+ _field, reason = reasons.find { |field, _| !profile.public_send(field) }
16
+ reason && skip_verdict(reason)
17
+ end
18
+
19
+ def blocked_verdict(status, detail = '')
20
+ Verdict.new(blocked: true, skipped: false, status: status, detail: detail)
21
+ end
22
+
23
+ def breach_verdict(status, detail)
24
+ Verdict.new(blocked: false, skipped: false, status: status, detail: detail)
25
+ end
26
+
27
+ def untested_verdict(status, detail)
28
+ Verdict.new(blocked: false, skipped: false, status: status, detail: detail, untested: true)
29
+ end
30
+
31
+ # The verdict of a beat that sends several probes: a breach when a probe
32
+ # failed, untested when none failed but one was answered before the origin
33
+ # evaluated it, and blocked otherwise.
34
+ def probes_verdict(status, failures, unevaluated)
35
+ return breach_verdict(status, failures.join(' | ')) unless failures.empty?
36
+ return untested_verdict(status, "COULD NOT TEST: #{unevaluated.join(' | ')}") unless unevaluated.empty?
37
+
38
+ blocked_verdict(status)
39
+ end
40
+
41
+ # +expect+ / +expect_code+ name the gate that must refuse; with neither,
42
+ # any status or code Kiosk::Redteam.blocked? accepts counts. An answer given
43
+ # before the attack was evaluated is untested unless +expect_code+ names it.
44
+ def verdict_from(response, expect: nil, expect_code: nil, detail: nil)
45
+ return any_gate_verdict(response, detail) if expect.nil? && expect_code.nil?
46
+ return payment_required_stall(response) if unevaluated_unnamed?(response, expect_code)
47
+
48
+ misses = gate_misses(response, expect, expect_code)
49
+ return blocked_verdict(response.status) if misses.empty?
50
+
51
+ named_gate_breach(response, misses, detail)
52
+ end
53
+
54
+ def named_gate_breach(response, misses, detail)
55
+ breach_verdict(response.status,
56
+ "#{detail || 'attack was not refused by the named gate'} " \
57
+ "[#{misses.join('; ')}; got HTTP #{response.status} " \
58
+ "code=#{error_code(response).inspect}, body=#{response.body.inspect}]")
59
+ end
60
+
61
+ def unevaluated_unnamed?(response, expect_code)
62
+ Kiosk::Redteam.unevaluated?(response) && !Array(expect_code).include?(error_code(response))
63
+ end
64
+
65
+ def any_gate_verdict(response, detail)
66
+ stall = payment_required_stall(response)
67
+ return stall if stall
68
+ return blocked_verdict(response.status) if Kiosk::Redteam.blocked?(response)
69
+
70
+ breach_verdict(response.status, detail || "HTTP #{response.status}: #{response.body.inspect}")
71
+ end
72
+
73
+ def gate_misses(response, expect, expect_code)
74
+ [
75
+ status_miss(response.status, expect),
76
+ code_miss(error_code(response), expect_code),
77
+ ('5xx is never a block' if response.status >= 500),
78
+ (unpinned_payment_miss if response.status == 402 && expect_code.nil?)
79
+ ].compact
80
+ end
81
+
82
+ def status_miss(status, expect)
83
+ return nil if expect.nil? || Array(expect).include?(status)
84
+
85
+ "want status #{Array(expect).join('/')}"
86
+ end
87
+
88
+ def code_miss(code, expect_code)
89
+ return nil if expect_code.nil? || Array(expect_code).include?(code)
90
+
91
+ "want error.code #{Array(expect_code).map(&:inspect).join('/')}"
92
+ end
93
+
94
+ def unpinned_payment_miss
95
+ 'HTTP 402 is conclusive only with an explicit expect_code — ' \
96
+ "#{Kiosk::Redteam::PAYMENT_REQUIRED_CODES.keys.join('/')} all ride that status"
97
+ end
98
+
99
+ # A payment-required answer proves no refusal. It is untested when it came
100
+ # before the attack was evaluated, and a breach otherwise.
101
+ def payment_required_stall(response, step: nil)
102
+ reason = Kiosk::Redteam.payment_required_reason(response)
103
+ return nil unless reason
104
+
105
+ answered = "#{step || 'the attack under test'} was answered #{reason}."
106
+ return breach_verdict(response.status, "#{answered} No gate refused the attack.") unless Kiosk::Redteam.unevaluated?(response)
107
+
108
+ untested_verdict(
109
+ response.status,
110
+ "COULD NOT TEST: #{answered} That is neither a refusal of this attack nor a breach — nothing was proved " \
111
+ 'either way. A provider that means a payment gate HERE must have the scenario ' \
112
+ 'name the code it accepts (verdict_from expect_code:).'
113
+ )
114
+ end
115
+
116
+ # A failed setup step must not let a later refusal from another gate pass as this one's.
117
+ def setup_failure(response, step:, because:)
118
+ return nil if response.nil? || response.status == 200
119
+
120
+ untested_verdict(response.status,
121
+ "SETUP FAILED: #{step} returned HTTP #{response.status} " \
122
+ "#{response.body.inspect}. #{because}")
123
+ end
124
+
125
+ def error_code(response)
126
+ Kiosk::Redteam.error_code(response)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Redteam
5
+ VERSION = '0.5.20'
6
+ end
7
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/test_helpers/assistant'
4
+ require 'kiosk/redteam/version'
5
+ require 'kiosk/redteam/verdict'
6
+ require 'kiosk/redteam/leak_scan'
7
+ require 'kiosk/redteam/scenario'
8
+ require 'kiosk/redteam/runner'
9
+ require 'kiosk/redteam/profile'
10
+ require 'kiosk/redteam/battery'
11
+
12
+ require 'kiosk/redteam/scenarios/cross_tenant_read'
13
+ require 'kiosk/redteam/scenarios/forged_user_id'
14
+ require 'kiosk/redteam/scenarios/mandate_principal_swap'
15
+ require 'kiosk/redteam/scenarios/mandate_replay'
16
+ require 'kiosk/redteam/scenarios/token_tampering'
17
+ require 'kiosk/redteam/scenarios/registration_without_pow'
18
+ require 'kiosk/redteam/scenarios/privilege_self_selection'
19
+ require 'kiosk/redteam/scenarios/device_grant_role_self_selection'
20
+ require 'kiosk/redteam/scenarios/unpaid_gated_action'
21
+ require 'kiosk/redteam/scenarios/missing_kyc'
22
+ require 'kiosk/redteam/scenarios/expired_kyc'
23
+ require 'kiosk/redteam/scenarios/forged_kyc'
24
+ require 'kiosk/redteam/scenarios/spent_resource_reuse'
25
+ require 'kiosk/redteam/scenarios/pay_for_other_use_self'
26
+ require 'kiosk/redteam/scenarios/wrong_currency_cart'
27
+
28
+ module Kiosk
29
+ # Adversarial regression harness: drives hostile HTTP scenarios against a
30
+ # Kiosk origin and asserts each attack is refused.
31
+ module Redteam
32
+ # 402 is absent: see PAYMENT_REQUIRED_CODES.
33
+ BLOCKED_STATUSES = [401, 403].freeze
34
+
35
+ # The three codes kiosk-server maps onto HTTP 402, each with why it is not a refusal.
36
+ PAYMENT_REQUIRED_CODES = {
37
+ 'pow_required' =>
38
+ 'a toll was DEMANDED, not a refusal — the request was deferred until a proof is ' \
39
+ 'supplied. This harness PAYS that toll on every verb, so seeing this code here ' \
40
+ 'means the demand survived a paid retry (or arrived with no challenges to ' \
41
+ 'solve); either way the attack itself was never evaluated',
42
+ 'payment_setup_required' =>
43
+ "the principal has no payment instrument on file — a gap in the ATTACKER's setup, " \
44
+ 'not a decision about the attack',
45
+ 'payment_failed' =>
46
+ 'the payment rail declined the charge AFTER the mandate trail verified — every ' \
47
+ 'authorization gate the request passed through said yes; a declined card is not a gate'
48
+ }.freeze
49
+
50
+ # The 402 codes kiosk-server answers before it evaluates the request.
51
+ UNEVALUATED_CODES = %w[pow_required payment_setup_required].freeze
52
+
53
+ # A validation error (bad_request) is not evidence of an authorization gate.
54
+ BLOCKED_ERROR_CODES = %w[forbidden unauthenticated rls_denied].freeze
55
+
56
+ # A successful query answers a bare Array, so the body may not be a Hash.
57
+ def self.error_code(response)
58
+ body = response.body
59
+ return nil unless body.is_a?(Hash)
60
+
61
+ body['code']
62
+ end
63
+
64
+ # Why a payment-required answer cannot settle a verdict, or nil when it is not one.
65
+ def self.payment_required_reason(response)
66
+ code = error_code(response)
67
+ return nil unless response.status == 402 || PAYMENT_REQUIRED_CODES.key?(code)
68
+
69
+ why = PAYMENT_REQUIRED_CODES[code] ||
70
+ "kiosk-server maps three codes onto 402 — #{PAYMENT_REQUIRED_CODES.keys.join(', ')} — " \
71
+ 'and this answer named none of them, so which gate fired (if any did) is unknowable'
72
+ "HTTP #{response.status} code=#{code.inspect}:#{paid_retry_note(response)} #{why}"
73
+ end
74
+
75
+ def self.paid_retry_note(response)
76
+ return '' unless response.pow_retried?
77
+
78
+ ' [the harness already solved every issued challenge and re-sent the identical request once]'
79
+ end
80
+
81
+ # True when the origin answered before it evaluated the request: a toll it
82
+ # demands, or a payment refused before the mandates are read.
83
+ def self.unevaluated?(response) = UNEVALUATED_CODES.include?(error_code(response))
84
+
85
+ def self.blocked?(response)
86
+ # Status first: a crash whose body carries `forbidden` is not a block. 0 is a connection error.
87
+ return false if response.status >= 500 || response.status.zero?
88
+ return false if payment_required_reason(response)
89
+ return true if BLOCKED_STATUSES.include?(response.status)
90
+
91
+ BLOCKED_ERROR_CODES.include?(error_code(response))
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kiosk-redteam
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.20
5
+ platform: ruby
6
+ authors:
7
+ - Phil Pirozhkov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: kiosk-test-support
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.5.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.5.0
40
+ description: |
41
+ kiosk-redteam drives hostile HTTP scenarios against any Kiosk provider
42
+ and asserts each attack is correctly blocked — a deliberate refusal, never
43
+ a crash and never a toll the harness could not settle. A scenario that
44
+ finds a real breach fails loudly.
45
+
46
+ Ships a Scenario/Verdict/Runner framework and a library of generic attack
47
+ scenarios parameterised by a per-provider Profile. It attacks through the
48
+ assistant client kiosk-test-support ships.
49
+
50
+ No dependency on Rails.
51
+ email:
52
+ - hello@fili.pp.ru
53
+ executables: []
54
+ extensions: []
55
+ extra_rdoc_files: []
56
+ files:
57
+ - CHANGELOG.md
58
+ - LICENSE.txt
59
+ - README.md
60
+ - lib/kiosk/redteam.rb
61
+ - lib/kiosk/redteam/battery.rb
62
+ - lib/kiosk/redteam/leak_scan.rb
63
+ - lib/kiosk/redteam/profile.rb
64
+ - lib/kiosk/redteam/runner.rb
65
+ - lib/kiosk/redteam/scenario.rb
66
+ - lib/kiosk/redteam/scenarios/cross_tenant_read.rb
67
+ - lib/kiosk/redteam/scenarios/device_grant_role_self_selection.rb
68
+ - lib/kiosk/redteam/scenarios/expired_kyc.rb
69
+ - lib/kiosk/redteam/scenarios/forged_kyc.rb
70
+ - lib/kiosk/redteam/scenarios/forged_user_id.rb
71
+ - lib/kiosk/redteam/scenarios/kyc_attack.rb
72
+ - lib/kiosk/redteam/scenarios/mandate_principal_swap.rb
73
+ - lib/kiosk/redteam/scenarios/mandate_replay.rb
74
+ - lib/kiosk/redteam/scenarios/missing_kyc.rb
75
+ - lib/kiosk/redteam/scenarios/pay_for_other_use_self.rb
76
+ - lib/kiosk/redteam/scenarios/privilege_self_selection.rb
77
+ - lib/kiosk/redteam/scenarios/registration_without_pow.rb
78
+ - lib/kiosk/redteam/scenarios/spent_resource_reuse.rb
79
+ - lib/kiosk/redteam/scenarios/token_tampering.rb
80
+ - lib/kiosk/redteam/scenarios/unpaid_gated_action.rb
81
+ - lib/kiosk/redteam/scenarios/wrong_currency_cart.rb
82
+ - lib/kiosk/redteam/staging.rb
83
+ - lib/kiosk/redteam/token_claims.rb
84
+ - lib/kiosk/redteam/verdict.rb
85
+ - lib/kiosk/redteam/verdicts.rb
86
+ - lib/kiosk/redteam/version.rb
87
+ homepage: https://kiosk.tech
88
+ licenses:
89
+ - Apache-2.0
90
+ metadata:
91
+ homepage_uri: https://kiosk.tech
92
+ source_code_uri: https://github.com/kiosk-hq/kiosk
93
+ changelog_uri: https://github.com/kiosk-hq/kiosk/blob/main/kiosk-redteam/CHANGELOG.md
94
+ bug_tracker_uri: https://github.com/kiosk-hq/kiosk/issues
95
+ rubygems_mfa_required: 'true'
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '4.0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 4.0.3
111
+ specification_version: 4
112
+ summary: Adversarial regression harness for Kiosk providers
113
+ test_files: []