scryer 0.3.0 → 1.1.1
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 +322 -0
- data/README.md +546 -43
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +29 -9
- data/lib/scryer/ast.rb +95 -6
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +209 -11
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_verifier.rb +82 -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 +637 -44
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
- 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 +100 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +48 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -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 +165 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
- 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 +110 -0
- data/lib/scryer/rules/security_headers_rule.rb +133 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +139 -0
- 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 +51 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +15 -0
- data/lib/tasks/scryer.rake +138 -13
- metadata +41 -12
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags insecure usage of the `jwt` gem's `JWT.decode`/`JWT.encode`:
|
|
4
|
+
# - `JWT.decode(token, secret, false, ...)` — the third positional arg
|
|
5
|
+
# literal `false` disables signature verification entirely, so any
|
|
6
|
+
# caller can forge a token that decodes successfully.
|
|
7
|
+
# - `algorithm: 'none'` / `'alg' => 'none'` — the "none" algorithm
|
|
8
|
+
# means the token isn't signed at all.
|
|
9
|
+
# - A plain string literal passed directly as the secret/key argument
|
|
10
|
+
# — distinct from HardcodedSecretRule, which only matches
|
|
11
|
+
# `x = "literal"` assignment targets, not a literal inline in a call
|
|
12
|
+
# argument.
|
|
13
|
+
class JwtInsecureRule < Rule
|
|
14
|
+
self.rule_id = "jwt_insecure_usage"
|
|
15
|
+
self.category = "security"
|
|
16
|
+
self.default_severity = "critical"
|
|
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"
|
|
21
|
+
|
|
22
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
23
|
+
|
|
24
|
+
def scan
|
|
25
|
+
findings = []
|
|
26
|
+
|
|
27
|
+
Ast.each_node(sexp) do |node|
|
|
28
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
29
|
+
|
|
30
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
31
|
+
receiver_and_name = Ast.call_name(inner)
|
|
32
|
+
next unless receiver_and_name
|
|
33
|
+
|
|
34
|
+
receiver, method_name = receiver_and_name
|
|
35
|
+
next unless const_receiver_name(receiver) == "JWT" && %w[decode encode].include?(method_name)
|
|
36
|
+
|
|
37
|
+
args = Ast.call_arguments(node)
|
|
38
|
+
line = Ast.line_of(node)
|
|
39
|
+
|
|
40
|
+
findings << verify_bypass_finding(line) if method_name == "decode" && Ast.false_literal?(args[2])
|
|
41
|
+
findings << algorithm_none_finding(method_name, line) if algorithm_none?(node)
|
|
42
|
+
findings.concat(inline_secret_finding(method_name, args, line))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def verify_bypass_finding(line)
|
|
51
|
+
finding(
|
|
52
|
+
line: line,
|
|
53
|
+
message: "`JWT.decode` is called with `false` for `verify` — signature verification is " \
|
|
54
|
+
"disabled entirely, so any caller can hand this code a forged token with " \
|
|
55
|
+
"arbitrary claims and it will be accepted as valid.",
|
|
56
|
+
suggested_fix: "Pass `true` for `verify` and supply the correct `algorithm:` option, e.g. " \
|
|
57
|
+
"`JWT.decode(token, secret, true, algorithm: 'HS256')`."
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def algorithm_none?(node)
|
|
62
|
+
Ast.each_node(node).any? do |n|
|
|
63
|
+
next false unless Ast.tagged?(n, :assoc_new)
|
|
64
|
+
|
|
65
|
+
key = assoc_key_text(n[1])
|
|
66
|
+
next false unless key && %w[algorithm alg].include?(key.downcase)
|
|
67
|
+
|
|
68
|
+
value = assoc_value_text(n[2])
|
|
69
|
+
value&.downcase == "none"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def algorithm_none_finding(method_name, line)
|
|
74
|
+
finding(
|
|
75
|
+
line: line,
|
|
76
|
+
message: "`JWT.#{method_name}` is called with `algorithm: 'none'` — the \"none\" " \
|
|
77
|
+
"algorithm means the token carries no signature at all, so its claims can be " \
|
|
78
|
+
"freely modified/forged by anyone.",
|
|
79
|
+
suggested_fix: "Use a real signing algorithm (e.g. `algorithm: 'HS256'` or `'RS256'`) and " \
|
|
80
|
+
"never accept `'none'` from user/client input when choosing it."
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inline_secret_finding(method_name, args, line)
|
|
85
|
+
secret_arg = args[1]
|
|
86
|
+
secret = secret_arg && Ast.plain_string_value(secret_arg)
|
|
87
|
+
return [] unless secret && !secret.strip.empty? && !PLACEHOLDER_VALUES.match?(secret.strip)
|
|
88
|
+
|
|
89
|
+
[
|
|
90
|
+
finding(
|
|
91
|
+
line: line,
|
|
92
|
+
message: "`JWT.#{method_name}` is called with a literal string as the signing " \
|
|
93
|
+
"secret/key — anyone with read access to this source (including git " \
|
|
94
|
+
"history) can forge or verify tokens with it.",
|
|
95
|
+
suggested_fix: "Move the secret out of source (`ENV.fetch(\"JWT_SECRET\")` or Rails " \
|
|
96
|
+
"encrypted credentials) and rotate this value since it's likely " \
|
|
97
|
+
"already exposed in git history."
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assoc_key_text(key_node)
|
|
103
|
+
return key_node[1].to_s.delete_suffix(":") if key_node.is_a?(Array) && key_node[0] == :@label
|
|
104
|
+
|
|
105
|
+
Ast.plain_string_value(key_node) || Ast.literal_text(key_node)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def assoc_value_text(value_node)
|
|
109
|
+
Ast.plain_string_value(value_node) || Ast.literal_text(value_node)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def const_receiver_name(node)
|
|
113
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
114
|
+
node[1][1]
|
|
115
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
116
|
+
left = const_receiver_name(node[1])
|
|
117
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
118
|
+
[left, right].compact.join("::")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -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,16 +100,17 @@ module Scryer
|
|
|
77
100
|
!NON_MODEL_RECEIVERS.include?(const_node[1])
|
|
78
101
|
end
|
|
79
102
|
|
|
80
|
-
|
|
103
|
+
def ivar_receiver?(node)
|
|
104
|
+
Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@ivar
|
|
105
|
+
end
|
|
106
|
+
|
|
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").
|
|
83
110
|
def references_raw_params?(node)
|
|
84
|
-
return false unless node.is_a?(Array)
|
|
85
111
|
return false if has_permit_call?(node)
|
|
86
112
|
|
|
87
|
-
Ast.
|
|
88
|
-
Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
|
|
89
|
-
end
|
|
113
|
+
Ast.references_params?(node)
|
|
90
114
|
end
|
|
91
115
|
|
|
92
116
|
def has_permit_call?(node)
|
|
@@ -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 = []
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a filesystem operation (`File.join/read/open/new/write/delete`,
|
|
4
|
+
# `Dir.glob/entries`, `send_file`) where an argument references `params`
|
|
5
|
+
# directly — without sanitization, `../../etc/passwd`-style path
|
|
6
|
+
# segments in the request let an attacker read (or write/delete) files
|
|
7
|
+
# outside whatever directory the code intended to restrict access to.
|
|
8
|
+
class PathTraversalRule < Rule
|
|
9
|
+
self.rule_id = "path_traversal"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "critical"
|
|
12
|
+
self.title = "Possible path traversal"
|
|
13
|
+
self.cwe = "CWE-22"
|
|
14
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
15
|
+
self.confidence = "medium"
|
|
16
|
+
|
|
17
|
+
DANGEROUS_CALLS = {
|
|
18
|
+
"File" => %w[join read open new write delete binread binwrite],
|
|
19
|
+
"Dir" => %w[glob entries]
|
|
20
|
+
}.freeze
|
|
21
|
+
BARE_METHODS = %w[send_file send_data].freeze
|
|
22
|
+
|
|
23
|
+
def scan
|
|
24
|
+
findings = []
|
|
25
|
+
|
|
26
|
+
Ast.each_node(sexp) do |node|
|
|
27
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
28
|
+
|
|
29
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
30
|
+
receiver_and_name = Ast.call_name(inner)
|
|
31
|
+
next unless receiver_and_name
|
|
32
|
+
|
|
33
|
+
receiver, method_name = receiver_and_name
|
|
34
|
+
next unless dangerous_call?(receiver, method_name)
|
|
35
|
+
|
|
36
|
+
args = Ast.call_arguments(node)
|
|
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) }
|
|
56
|
+
|
|
57
|
+
line = Ast.line_of(node)
|
|
58
|
+
findings << finding(
|
|
59
|
+
line: line,
|
|
60
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a path/argument " \
|
|
61
|
+
"that references `params` — a value like `../../config/master.key` reaches " \
|
|
62
|
+
"the filesystem unchanged, letting an attacker read or write files outside " \
|
|
63
|
+
"whatever directory this was meant to be scoped to.",
|
|
64
|
+
suggested_fix: "Reduce the input to a safe basename before using it " \
|
|
65
|
+
"(`File.basename(params[:name])`), and/or verify the resolved path " \
|
|
66
|
+
"stays inside the intended directory (compare " \
|
|
67
|
+
"`File.expand_path(...)` against the allowed root) before touching " \
|
|
68
|
+
"the filesystem."
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
findings
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def dangerous_call?(receiver, method_name)
|
|
78
|
+
return true if receiver.nil? && BARE_METHODS.include?(method_name)
|
|
79
|
+
|
|
80
|
+
DANGEROUS_CALLS[receiver_name(receiver)]&.include?(method_name) || false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def describe_call(receiver, method_name)
|
|
84
|
+
name = receiver_name(receiver)
|
|
85
|
+
name ? "#{name}.#{method_name}" : method_name
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def receiver_name(node)
|
|
89
|
+
return nil unless Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
90
|
+
|
|
91
|
+
node[1][1]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# True if `File.basename(...)` appears anywhere in the argument's
|
|
95
|
+
# subtree — the standard way to strip directory-traversal segments
|
|
96
|
+
# before using a request-controlled filename. Same "does the guard
|
|
97
|
+
# appear anywhere in this expression" looseness as
|
|
98
|
+
# MassAssignmentRule#has_permit_call? — doesn't verify it wraps
|
|
99
|
+
# *exactly* the params reference, just that it's present.
|
|
100
|
+
def sanitized_via_basename?(node)
|
|
101
|
+
Ast.each_node(node).any? do |n|
|
|
102
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
103
|
+
|
|
104
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
105
|
+
Ast.call_name(inner)&.last == "basename"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|