scryer 1.0.0 → 1.2.0
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/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- metadata +52 -12
|
@@ -21,6 +21,9 @@ module Scryer
|
|
|
21
21
|
self.category = "security"
|
|
22
22
|
self.default_severity = "warning"
|
|
23
23
|
self.title = "Possible insecure direct object reference (IDOR)"
|
|
24
|
+
self.cwe = "CWE-639"
|
|
25
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
26
|
+
self.confidence = "low"
|
|
24
27
|
|
|
25
28
|
FINDER_METHODS = %w[find find_by find_by!].freeze
|
|
26
29
|
|
|
@@ -33,7 +36,30 @@ module Scryer
|
|
|
33
36
|
Enumerable File Dir
|
|
34
37
|
].freeze
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
# Pundit's `authorize`/`policy_scope`/`can?`/`cannot?` plus two more
|
|
40
|
+
# well-established framework-provided safeguards, deliberately not an
|
|
41
|
+
# attempt at an exhaustive list of every app's custom guard method
|
|
42
|
+
# (e.g. a homegrown `require_admin!` before_action) — this rule's own
|
|
43
|
+
# documented limitation above already covers that case as expected
|
|
44
|
+
# noise, since there's no reliable way to know a custom method name
|
|
45
|
+
# actually performs record-level authorization rather than something
|
|
46
|
+
# unrelated:
|
|
47
|
+
# - `load_and_authorize_resource` / `authorize_resource` — CanCanCan's
|
|
48
|
+
# own controller macros; declaring either one authorizes every
|
|
49
|
+
# action in the controller (the same effect as calling `authorize!`
|
|
50
|
+
# in each action by hand), so a controller using it has the same
|
|
51
|
+
# safeguard this rule already accepts for a manual `authorize!` call.
|
|
52
|
+
# - `verify_authorized` / `verify_policy_scoped` — Pundit's own
|
|
53
|
+
# safety-net `after_action` callbacks (`after_action
|
|
54
|
+
# :verify_authorized`), which raise unless some `authorize`/
|
|
55
|
+
# `policy_scope` call already happened during the action. A
|
|
56
|
+
# controller using this callback is *more* rigorously guarded than
|
|
57
|
+
# one with a bare `authorize` call, not less.
|
|
58
|
+
AUTHORIZATION_METHODS = %w[
|
|
59
|
+
authorize authorize! policy_scope can? cannot?
|
|
60
|
+
load_and_authorize_resource authorize_resource
|
|
61
|
+
verify_authorized verify_policy_scoped
|
|
62
|
+
].freeze
|
|
37
63
|
|
|
38
64
|
def scan
|
|
39
65
|
findings = []
|
|
@@ -41,7 +67,7 @@ module Scryer
|
|
|
41
67
|
Ast.each_node(sexp) do |node|
|
|
42
68
|
next unless Ast.tagged?(node, :class)
|
|
43
69
|
|
|
44
|
-
class_name = Ast.
|
|
70
|
+
class_name = Ast.class_name(node[1])
|
|
45
71
|
next unless class_name.to_s.end_with?("Controller")
|
|
46
72
|
|
|
47
73
|
body = node[3]
|
|
@@ -69,12 +95,21 @@ module Scryer
|
|
|
69
95
|
|
|
70
96
|
private
|
|
71
97
|
|
|
98
|
+
# Gathers both the call's own method name (`authorize` in `authorize
|
|
99
|
+
# @thing`, `load_and_authorize_resource` in the bare macro call) *and*
|
|
100
|
+
# any literal symbol/string arguments passed to it (`verify_authorized`
|
|
101
|
+
# in `after_action :verify_authorized`) — Pundit's `verify_authorized`/
|
|
102
|
+
# `verify_policy_scoped` safeguards are registered as callback names
|
|
103
|
+
# via `before_action`/`after_action`, not invoked directly, so checking
|
|
104
|
+
# only the enclosing call's name would miss them entirely.
|
|
72
105
|
def each_call_names(node)
|
|
73
|
-
Ast.each_node(node).
|
|
74
|
-
next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
106
|
+
Ast.each_node(node).flat_map do |n|
|
|
107
|
+
next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
75
108
|
|
|
76
109
|
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
77
|
-
Ast.call_name(inner)&.last
|
|
110
|
+
name = Ast.call_name(inner)&.last
|
|
111
|
+
arg_names = Ast.call_arguments(n).filter_map { |a| Ast.literal_text(a) }
|
|
112
|
+
[name, *arg_names].compact
|
|
78
113
|
end
|
|
79
114
|
end
|
|
80
115
|
|
|
@@ -97,14 +132,33 @@ module Scryer
|
|
|
97
132
|
end
|
|
98
133
|
end
|
|
99
134
|
|
|
135
|
+
# A namespaced model (`Admin::Post.find(...)`, `Api::V1::User.find(...)`
|
|
136
|
+
# — a common real-world pattern for admin-scoped or API-versioned
|
|
137
|
+
# resources) parses as `:const_path_ref`, not `:var_ref` — verified via
|
|
138
|
+
# `Ripper.sexp("Admin::Post.find(params[:id])")`. The original version
|
|
139
|
+
# of this check only handled bare `:var_ref` receivers, so a
|
|
140
|
+
# namespaced model's `.find(params[...])` was silently never examined
|
|
141
|
+
# at all (a false negative, not a false positive — worth fixing since
|
|
142
|
+
# namespacing under a module is an extremely common Rails convention).
|
|
143
|
+
# Checked against the *last* segment (`"Post"`, not `"Admin"`), same
|
|
144
|
+
# exclusion list either way — none of NON_MODEL_RECEIVERS are commonly
|
|
145
|
+
# used in namespaced form for this purpose, but checking the actual
|
|
146
|
+
# class name being looked up is the more correct match regardless.
|
|
100
147
|
def likely_model_receiver?(receiver)
|
|
101
148
|
return false if receiver.nil? # bare find(...) inside the model itself, not a controller lookup
|
|
102
|
-
return false unless Ast.tagged?(receiver, :var_ref)
|
|
103
149
|
|
|
104
|
-
|
|
105
|
-
return false unless
|
|
150
|
+
const_name = const_receiver_name(receiver)
|
|
151
|
+
return false unless const_name
|
|
152
|
+
|
|
153
|
+
!NON_MODEL_RECEIVERS.include?(const_name)
|
|
154
|
+
end
|
|
106
155
|
|
|
107
|
-
|
|
156
|
+
def const_receiver_name(node)
|
|
157
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
158
|
+
node[1][1]
|
|
159
|
+
elsif Ast.tagged?(node, :const_path_ref) && node[2].is_a?(Array) && node[2][0] == :@const
|
|
160
|
+
node[2][1]
|
|
161
|
+
end
|
|
108
162
|
end
|
|
109
163
|
end
|
|
110
164
|
end
|
|
@@ -12,6 +12,9 @@ module Scryer
|
|
|
12
12
|
self.category = "security"
|
|
13
13
|
self.default_severity = "critical"
|
|
14
14
|
self.title = "Marshal cookie serializer enabled"
|
|
15
|
+
self.cwe = "CWE-502"
|
|
16
|
+
self.owasp_category = "A08:2021-Software and Data Integrity Failures"
|
|
17
|
+
self.confidence = "high"
|
|
15
18
|
|
|
16
19
|
def scan
|
|
17
20
|
findings = []
|
|
@@ -19,6 +19,9 @@ module Scryer
|
|
|
19
19
|
self.category = "security"
|
|
20
20
|
self.default_severity = "warning"
|
|
21
21
|
self.title = "Raw params passed to a background job"
|
|
22
|
+
self.cwe = "CWE-532"
|
|
23
|
+
self.owasp_category = "A09:2021-Security Logging and Monitoring Failures"
|
|
24
|
+
self.confidence = "medium"
|
|
22
25
|
|
|
23
26
|
PERFORM_METHODS = %w[perform_async perform_later perform_now].freeze
|
|
24
27
|
|
|
@@ -60,13 +63,28 @@ module Scryer
|
|
|
60
63
|
private
|
|
61
64
|
|
|
62
65
|
# True if `node`'s subtree references `params` in a way broader than a
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
66
|
+
# narrow, controlled extraction — a bare `params` value, or `params` as
|
|
67
|
+
# the receiver of anything other than `[]`, `.dig`, or `.permit`.
|
|
68
|
+
#
|
|
69
|
+
# `params[:id]` and `params.dig(:id)` are equivalent single-key
|
|
70
|
+
# extractions (`ActionController::Parameters#dig` behaves like `#[]`
|
|
71
|
+
# for one key, and digging through several keys still only ever
|
|
72
|
+
# returns one scalar/sub-value, never the rest of the hash) — flagging
|
|
73
|
+
# one but not the other would be an arbitrary inconsistency, not a
|
|
74
|
+
# risk-based distinction. `params.permit(:id, :name)` (optionally
|
|
75
|
+
# chained into `.to_h`/`.to_unsafe_h`/etc.) is Rails' own sanctioned
|
|
76
|
+
# allowlisting idiom — MassAssignmentRule already accepts a `.permit`
|
|
77
|
+
# call as sufficient sanitization elsewhere in this gem, so treating it
|
|
78
|
+
# as still "raw" here would send a contradictory message about the
|
|
79
|
+
# same idiom. None of this is zero-risk (a permitted field can still
|
|
80
|
+
# be a name/email/phone; a dug-up value can still be a token) — but
|
|
81
|
+
# flagging Rails' own recommended patterns here would just be noise
|
|
82
|
+
# that erodes trust in every other finding this rule reports.
|
|
83
|
+
# Everything else — bare `params`, `.to_json`, `.to_h`/`.to_unsafe_h`
|
|
84
|
+
# with no preceding `.permit`, `.merge`, `.except`, ... — still flags,
|
|
85
|
+
# since those really can carry the full, uncontrolled params payload.
|
|
86
|
+
NARROW_PARAMS_METHODS = %w[dig permit].freeze
|
|
87
|
+
|
|
70
88
|
def raw_params_reference?(node)
|
|
71
89
|
found = false
|
|
72
90
|
|
|
@@ -78,6 +96,10 @@ module Scryer
|
|
|
78
96
|
next
|
|
79
97
|
end
|
|
80
98
|
|
|
99
|
+
if narrow_params_call?(n)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
81
103
|
if bare_params?(n)
|
|
82
104
|
found = true
|
|
83
105
|
next
|
|
@@ -90,6 +112,17 @@ module Scryer
|
|
|
90
112
|
found
|
|
91
113
|
end
|
|
92
114
|
|
|
115
|
+
def narrow_params_call?(node)
|
|
116
|
+
return false unless Ast.tagged?(node, :method_add_arg, :call, :command_call)
|
|
117
|
+
|
|
118
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
119
|
+
receiver_and_name = Ast.call_name(inner)
|
|
120
|
+
return false unless receiver_and_name
|
|
121
|
+
|
|
122
|
+
receiver, method_name = receiver_and_name
|
|
123
|
+
bare_params?(receiver) && NARROW_PARAMS_METHODS.include?(method_name)
|
|
124
|
+
end
|
|
125
|
+
|
|
93
126
|
def bare_params?(node)
|
|
94
127
|
Ast.tagged?(node, :vcall, :var_ref, :fcall) && Ast.ident_text(node[1]) == "params"
|
|
95
128
|
end
|
|
@@ -15,6 +15,9 @@ module Scryer
|
|
|
15
15
|
self.category = "security"
|
|
16
16
|
self.default_severity = "critical"
|
|
17
17
|
self.title = "Insecure JWT.decode/JWT.encode usage"
|
|
18
|
+
self.cwe = "CWE-347"
|
|
19
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
20
|
+
self.confidence = "high"
|
|
18
21
|
|
|
19
22
|
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
20
23
|
|
|
@@ -10,9 +10,28 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "critical"
|
|
12
12
|
self.title = "Unpermitted mass assignment from params"
|
|
13
|
+
self.cwe = "CWE-915"
|
|
14
|
+
self.owasp_category = "A08:2021-Software and Data Integrity Failures"
|
|
15
|
+
self.confidence = "medium"
|
|
13
16
|
|
|
14
17
|
ASSIGNMENT_METHODS = %w[new create create! update update! assign_attributes attributes=].freeze
|
|
15
18
|
|
|
19
|
+
# `update`/`update!`/`assign_attributes`/`attributes=` on an instance
|
|
20
|
+
# variable (`@order.update(params[:order])`) is at least as common a
|
|
21
|
+
# real-world shape as `Model.new(...)`/`Model.create(...)` on a bare
|
|
22
|
+
# constant — the standard `before_action :set_thing` + `@thing.update`
|
|
23
|
+
# scaffold pattern — but was invisible to this rule entirely until
|
|
24
|
+
# found via the accuracy benchmark (benchmark/corpus.rb's
|
|
25
|
+
# "mass_assignment" vulnerable sample), since likely_model_receiver?
|
|
26
|
+
# only accepted a bare constant or implicit (nil) receiver. `.new`/
|
|
27
|
+
# `create`/`create!` deliberately stay constant/nil-only: those verbs
|
|
28
|
+
# only make sense as a receiver-less/class-level call in normal Rails
|
|
29
|
+
# code, whereas an ivar could just as easily be a plain Hash (`Hash#
|
|
30
|
+
# update` is a real `merge!` alias) — restricting the ivar allowance to
|
|
31
|
+
# only the update-style verbs keeps this from also matching things like
|
|
32
|
+
# `@filters.update(params[:filters])` on a Hash.
|
|
33
|
+
IVAR_RECEIVER_METHODS = %w[update update! assign_attributes attributes=].freeze
|
|
34
|
+
|
|
16
35
|
# Common stdlib/gem constants with their own `.new`/`.create`-style
|
|
17
36
|
# factory methods that have nothing to do with ActiveRecord mass
|
|
18
37
|
# assignment (e.g. `BCrypt::Password.create(params[:password])` is
|
|
@@ -37,7 +56,7 @@ module Scryer
|
|
|
37
56
|
|
|
38
57
|
receiver, method_name = receiver_and_name
|
|
39
58
|
next unless ASSIGNMENT_METHODS.include?(method_name)
|
|
40
|
-
next unless likely_model_receiver?(receiver)
|
|
59
|
+
next unless likely_model_receiver?(receiver, method_name)
|
|
41
60
|
|
|
42
61
|
args = Ast.call_arguments(node)
|
|
43
62
|
next if args.empty?
|
|
@@ -64,11 +83,15 @@ module Scryer
|
|
|
64
83
|
private
|
|
65
84
|
|
|
66
85
|
# true for an implicit receiver (bare `create(...)` inside the model
|
|
67
|
-
# itself)
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
86
|
+
# itself), a plain unnamespaced constant reference (`Order`) that isn't
|
|
87
|
+
# a known non-model stdlib/gem constant, or — for the update-style
|
|
88
|
+
# verbs only, see IVAR_RECEIVER_METHODS — an instance variable
|
|
89
|
+
# (`@order`); false for namespaced constant paths (`BCrypt::Password`,
|
|
90
|
+
# `Admin::Order` — a real gap, see mass_assignment_rule's class comment
|
|
91
|
+
# and the benchmark corpus's "KNOWN LIMITATION" sample) or anything else.
|
|
92
|
+
def likely_model_receiver?(receiver, method_name)
|
|
71
93
|
return true if receiver.nil?
|
|
94
|
+
return true if IVAR_RECEIVER_METHODS.include?(method_name) && ivar_receiver?(receiver)
|
|
72
95
|
return false unless Ast.tagged?(receiver, :var_ref)
|
|
73
96
|
|
|
74
97
|
const_node = receiver[1]
|
|
@@ -77,6 +100,10 @@ module Scryer
|
|
|
77
100
|
!NON_MODEL_RECEIVERS.include?(const_node[1])
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
def ivar_receiver?(node)
|
|
104
|
+
Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@ivar
|
|
105
|
+
end
|
|
106
|
+
|
|
80
107
|
# True if `node` references `params` (see Ast.references_params?)
|
|
81
108
|
# without a `.permit`/`.permit!` call wrapping it (permit! is itself
|
|
82
109
|
# flagged as unsafe too, so it doesn't count as "safe").
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a `*Controller` class that defines `create`/`update`/`destroy`
|
|
4
|
+
# (Rails' three standard write actions) with NO authorization evidence
|
|
5
|
+
# anywhere in the class — broader than IdorRule, which only fires when
|
|
6
|
+
# there's also a params-derived `Model.find` call in the same class.
|
|
7
|
+
# Plenty of write actions don't call `.find` at all (`Model.create(...)`
|
|
8
|
+
# needs no lookup; a `destroy` might act on an object set up in a
|
|
9
|
+
# `before_action`), so IdorRule's narrower pattern misses them even
|
|
10
|
+
# though "this write action has no visible authorization check at all"
|
|
11
|
+
# is exactly as real a concern. Restricted to the three standard write
|
|
12
|
+
# actions (not every public method) specifically to avoid flagging
|
|
13
|
+
# genuinely public controllers (a marketing page, a login form) that
|
|
14
|
+
# have no write actions at all and legitimately need no authorization —
|
|
15
|
+
# a controller that defines `create`/`update`/`destroy` at all is
|
|
16
|
+
# already a much stronger "this manages a real resource" signal than
|
|
17
|
+
# any action name would be.
|
|
18
|
+
#
|
|
19
|
+
# Expect overlap with `idor` on controllers that both `.find` a record
|
|
20
|
+
# AND have no authorization — that's two different rules independently
|
|
21
|
+
# confirming the same underlying gap from two different code shapes
|
|
22
|
+
# (a lookup vs. a write action), not double-counting a bug.
|
|
23
|
+
class MissingAuthorizationRule < Rule
|
|
24
|
+
self.rule_id = "missing_authorization"
|
|
25
|
+
self.category = "security"
|
|
26
|
+
self.default_severity = "warning"
|
|
27
|
+
self.title = "Write action with no visible authorization check"
|
|
28
|
+
self.cwe = "CWE-862"
|
|
29
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
30
|
+
self.confidence = "low"
|
|
31
|
+
|
|
32
|
+
WRITE_ACTIONS = %w[create update destroy].freeze
|
|
33
|
+
|
|
34
|
+
# Duplicated from IdorRule intentionally, same as NON_MODEL_RECEIVERS
|
|
35
|
+
# is duplicated between IdorRule and MassAssignmentRule — a small,
|
|
36
|
+
# stable list not worth a shared-module indirection for two callers.
|
|
37
|
+
AUTHORIZATION_METHODS = %w[
|
|
38
|
+
authorize authorize! policy_scope can? cannot?
|
|
39
|
+
load_and_authorize_resource authorize_resource
|
|
40
|
+
verify_authorized verify_policy_scoped
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
def scan
|
|
44
|
+
findings = []
|
|
45
|
+
|
|
46
|
+
Ast.each_node(sexp) do |node|
|
|
47
|
+
next unless Ast.tagged?(node, :class)
|
|
48
|
+
|
|
49
|
+
class_name = Ast.class_name(node[1])
|
|
50
|
+
next unless class_name.to_s.end_with?("Controller")
|
|
51
|
+
|
|
52
|
+
body = node[3]
|
|
53
|
+
next if each_call_names(body).any? { |name| AUTHORIZATION_METHODS.include?(name) }
|
|
54
|
+
|
|
55
|
+
each_write_action_def(body).each do |def_node, action_name|
|
|
56
|
+
findings << finding(
|
|
57
|
+
line: Ast.line_of(def_node),
|
|
58
|
+
message: "`#{class_name}##{action_name}` writes data (a standard Rails write " \
|
|
59
|
+
"action), and `#{class_name}` has no visible authorization check anywhere " \
|
|
60
|
+
"in it (no `authorize`, `policy_scope`, `can?`/`cannot?`, or CanCanCan/" \
|
|
61
|
+
"Pundit callback) — any logged-in (or, if this controller skips " \
|
|
62
|
+
"authentication too, any) user may be able to call this action.",
|
|
63
|
+
suggested_fix: "Add an explicit authorization check before the write happens — " \
|
|
64
|
+
"`authorize @thing` (Pundit) or `authorize! :#{action_name}, @thing` " \
|
|
65
|
+
"(CanCanCan) — or `load_and_authorize_resource`/`after_action " \
|
|
66
|
+
":verify_authorized` at the class level if every action here should " \
|
|
67
|
+
"be gated the same way."
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
findings
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Same "call name or literal symbol/string argument" gathering as
|
|
78
|
+
# IdorRule#each_call_names — see that method's comment for why the
|
|
79
|
+
# argument half matters (Pundit's verify_authorized/verify_policy_scoped
|
|
80
|
+
# are registered as before_action/after_action callback names, not
|
|
81
|
+
# called directly).
|
|
82
|
+
def each_call_names(node)
|
|
83
|
+
Ast.each_node(node).flat_map do |n|
|
|
84
|
+
next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
85
|
+
|
|
86
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
87
|
+
name = Ast.call_name(inner)&.last
|
|
88
|
+
arg_names = Ast.call_arguments(n).filter_map { |a| Ast.literal_text(a) }
|
|
89
|
+
[name, *arg_names].compact
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def each_write_action_def(body)
|
|
94
|
+
Ast.each_node(body).filter_map do |n|
|
|
95
|
+
next unless Ast.tagged?(n, :def)
|
|
96
|
+
|
|
97
|
+
name = Ast.ident_text(n[1])
|
|
98
|
+
[n, name] if WRITE_ACTIONS.include?(name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a controller's `index` action that queries a model directly
|
|
4
|
+
# (`Model.all`/`Model.where(...)`) instead of through Pundit's
|
|
5
|
+
# `policy_scope`, in a controller that clearly *uses* Pundit elsewhere
|
|
6
|
+
# (an `authorize` call appears somewhere in the class) — the well-known
|
|
7
|
+
# Pundit gotcha where a team remembers `authorize` on show/update/destroy
|
|
8
|
+
# (each of which checks one record) but forgets that `index` needs
|
|
9
|
+
# `policy_scope` instead, since there's no single record for `authorize`
|
|
10
|
+
# to check. Scoped to controllers that already show Pundit usage
|
|
11
|
+
# specifically so this doesn't fire on apps that don't use Pundit at all
|
|
12
|
+
# (no `authorize` anywhere) or that scope their index some other way
|
|
13
|
+
# entirely (a `current_user.posts.all` association instead of a bare
|
|
14
|
+
# `Post.all` is already scoped, and isn't flagged — see
|
|
15
|
+
# `references_unscoped_query?`).
|
|
16
|
+
#
|
|
17
|
+
# Same looseness as MassAssignmentRule#has_permit_call? and
|
|
18
|
+
# PathTraversalRule#sanitized_via_basename?: checks whether
|
|
19
|
+
# `policy_scope` appears *anywhere* in the `index` method body, not that
|
|
20
|
+
# it specifically wraps the `Model.all`/`.where` call — good enough to
|
|
21
|
+
# catch the common case (a bare unscoped query with no policy_scope call
|
|
22
|
+
# at all in sight) without a much harder "does this specific call sit
|
|
23
|
+
# inside that specific block" analysis.
|
|
24
|
+
class MissingPolicyScopeRule < Rule
|
|
25
|
+
self.rule_id = "missing_policy_scope"
|
|
26
|
+
self.category = "security"
|
|
27
|
+
self.default_severity = "warning"
|
|
28
|
+
self.title = "index action queries a model without policy_scope"
|
|
29
|
+
self.cwe = "CWE-862"
|
|
30
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
31
|
+
self.confidence = "medium"
|
|
32
|
+
|
|
33
|
+
UNSCOPED_METHODS = %w[all where].freeze
|
|
34
|
+
# Same list IdorRule/MassAssignmentRule use for the same reason —
|
|
35
|
+
# stdlib/gem constants with their own `.all`/`.where`-shaped methods
|
|
36
|
+
# that have nothing to do with an ActiveRecord model query.
|
|
37
|
+
NON_MODEL_RECEIVERS = %w[
|
|
38
|
+
Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
|
|
39
|
+
JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
|
|
40
|
+
Enumerable File Dir
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
def scan
|
|
44
|
+
findings = []
|
|
45
|
+
|
|
46
|
+
Ast.each_node(sexp) do |node|
|
|
47
|
+
next unless Ast.tagged?(node, :class)
|
|
48
|
+
|
|
49
|
+
class_name = Ast.class_name(node[1])
|
|
50
|
+
next unless class_name.to_s.end_with?("Controller")
|
|
51
|
+
|
|
52
|
+
body = node[3]
|
|
53
|
+
next unless each_call_names(body).include?("authorize") # uses Pundit at all
|
|
54
|
+
|
|
55
|
+
each_index_def(body).each do |def_node|
|
|
56
|
+
index_body = def_node[3]
|
|
57
|
+
next if each_call_names(index_body).include?("policy_scope")
|
|
58
|
+
|
|
59
|
+
query_node = find_unscoped_query(index_body)
|
|
60
|
+
next unless query_node
|
|
61
|
+
|
|
62
|
+
findings << finding(
|
|
63
|
+
line: Ast.line_of(query_node),
|
|
64
|
+
message: "`#{class_name}#index` queries a model directly (`#{describe_call(query_node)}`) " \
|
|
65
|
+
"instead of through `policy_scope` — this controller uses Pundit's `authorize` " \
|
|
66
|
+
"elsewhere, but `index` needs `policy_scope` instead (there's no single record " \
|
|
67
|
+
"for `authorize` to check), so every record is visible here regardless of who's " \
|
|
68
|
+
"allowed to see what.",
|
|
69
|
+
suggested_fix: "Wrap the query in `policy_scope`, e.g. `policy_scope(#{receiver_name(query_node)}).all` " \
|
|
70
|
+
"instead of `#{describe_call(query_node)}`, and define the corresponding " \
|
|
71
|
+
"Scope class in this model's policy."
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
findings
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def each_call_names(node)
|
|
82
|
+
Ast.each_node(node).flat_map do |n|
|
|
83
|
+
next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
84
|
+
|
|
85
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
86
|
+
[Ast.call_name(inner)&.last].compact
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def each_index_def(body)
|
|
91
|
+
Ast.each_node(body).select { |n| Ast.tagged?(n, :def) && Ast.ident_text(n[1]) == "index" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def find_unscoped_query(node)
|
|
95
|
+
Ast.each_node(node).find do |n|
|
|
96
|
+
next false unless Ast.tagged?(n, :method_add_arg, :call, :command, :command_call)
|
|
97
|
+
|
|
98
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
99
|
+
receiver_and_name = Ast.call_name(inner)
|
|
100
|
+
next false unless receiver_and_name
|
|
101
|
+
|
|
102
|
+
receiver, method_name = receiver_and_name
|
|
103
|
+
UNSCOPED_METHODS.include?(method_name) && likely_model_receiver?(receiver)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def likely_model_receiver?(receiver)
|
|
108
|
+
const_name = const_receiver_name(receiver)
|
|
109
|
+
return false unless const_name
|
|
110
|
+
|
|
111
|
+
!NON_MODEL_RECEIVERS.include?(const_name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def const_receiver_name(node)
|
|
115
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
116
|
+
node[1][1]
|
|
117
|
+
elsif Ast.tagged?(node, :const_path_ref) && node[2].is_a?(Array) && node[2][0] == :@const
|
|
118
|
+
node[2][1]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def receiver_name(query_node)
|
|
123
|
+
inner = Ast.tagged?(query_node, :method_add_arg) ? query_node[1] : query_node
|
|
124
|
+
const_receiver_name(Ast.call_name(inner)&.first) || "Model"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def describe_call(query_node)
|
|
128
|
+
inner = Ast.tagged?(query_node, :method_add_arg) ? query_node[1] : query_node
|
|
129
|
+
receiver, method_name = Ast.call_name(inner)
|
|
130
|
+
"#{const_receiver_name(receiver)}.#{method_name}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "warning"
|
|
12
12
|
self.title = "Possible open redirect via unvalidated params"
|
|
13
|
+
self.cwe = "CWE-601"
|
|
14
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
15
|
+
self.confidence = "high"
|
|
13
16
|
|
|
14
17
|
def scan
|
|
15
18
|
findings = []
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "critical"
|
|
12
12
|
self.title = "Possible path traversal"
|
|
13
|
+
self.cwe = "CWE-22"
|
|
14
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
15
|
+
self.confidence = "medium"
|
|
13
16
|
|
|
14
17
|
DANGEROUS_CALLS = {
|
|
15
18
|
"File" => %w[join read open new write delete binread binwrite],
|
|
@@ -31,7 +34,25 @@ module Scryer
|
|
|
31
34
|
next unless dangerous_call?(receiver, method_name)
|
|
32
35
|
|
|
33
36
|
args = Ast.call_arguments(node)
|
|
34
|
-
|
|
37
|
+
# `send_file`/`send_data`'s keyword options (`filename:`, `type:`,
|
|
38
|
+
# `disposition:`) control response metadata (the Content-Disposition
|
|
39
|
+
# filename the browser shows, the content-type header, inline vs.
|
|
40
|
+
# attachment) — none of them ever touch the filesystem. Only the
|
|
41
|
+
# first *positional* argument (the actual path for send_file, the
|
|
42
|
+
# raw bytes for send_data) is ever used as a path, so a `params`
|
|
43
|
+
# reference confined to the keyword-args hash isn't a path
|
|
44
|
+
# traversal risk and would otherwise be a false positive on the
|
|
45
|
+
# extremely common `send_file path, filename: params[:filename]`
|
|
46
|
+
# idiom. This exclusion is scoped to BARE_METHODS specifically —
|
|
47
|
+
# File.join/Dir.glob/etc. never take a keyword-args hash in
|
|
48
|
+
# practice, so they're unaffected.
|
|
49
|
+
path_args =
|
|
50
|
+
if receiver.nil? && BARE_METHODS.include?(method_name)
|
|
51
|
+
args.reject { |a| Ast.tagged?(a, :bare_assoc_hash) }
|
|
52
|
+
else
|
|
53
|
+
args
|
|
54
|
+
end
|
|
55
|
+
next unless path_args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
|
|
35
56
|
|
|
36
57
|
line = Ast.line_of(node)
|
|
37
58
|
findings << finding(
|
|
@@ -16,6 +16,9 @@ module Scryer
|
|
|
16
16
|
self.category = "security"
|
|
17
17
|
self.default_severity = "critical"
|
|
18
18
|
self.title = "Rails default security header explicitly disabled"
|
|
19
|
+
self.cwe = "CWE-1021"
|
|
20
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
21
|
+
self.confidence = "high"
|
|
19
22
|
|
|
20
23
|
HEADERS = ["X-Frame-Options", "X-Content-Type-Options"].freeze
|
|
21
24
|
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "critical"
|
|
12
12
|
self.title = "Possible SQL injection via string interpolation"
|
|
13
|
+
self.cwe = "CWE-89"
|
|
14
|
+
self.owasp_category = "A03:2021-Injection"
|
|
15
|
+
self.confidence = "high"
|
|
13
16
|
|
|
14
17
|
QUERY_METHODS = %w[
|
|
15
18
|
where find_by find_by! order pluck select group having
|