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,48 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.force_ssl = false` — an explicit opt-out of Rails'
|
|
4
|
+
# built-in HTTPS enforcement (redirects, HSTS, secure cookie flag).
|
|
5
|
+
# Absence of `config.force_ssl = true` isn't flagged — that would require
|
|
6
|
+
# confirming no environment file sets it anywhere, which needs whole-app
|
|
7
|
+
# context this per-file rule doesn't have. Only the explicit opt-out is a
|
|
8
|
+
# reliable, low-noise signal on its own.
|
|
9
|
+
class ForceSslRule < Rule
|
|
10
|
+
self.rule_id = "force_ssl_disabled"
|
|
11
|
+
self.category = "security"
|
|
12
|
+
self.default_severity = "critical"
|
|
13
|
+
self.title = "HTTPS enforcement explicitly disabled"
|
|
14
|
+
self.cwe = "CWE-319"
|
|
15
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
16
|
+
self.confidence = "high"
|
|
17
|
+
|
|
18
|
+
def scan
|
|
19
|
+
findings = []
|
|
20
|
+
|
|
21
|
+
Ast.each_node(sexp) do |node|
|
|
22
|
+
next unless Ast.tagged?(node, :assign)
|
|
23
|
+
|
|
24
|
+
target = node[1]
|
|
25
|
+
next unless Ast.tagged?(target, :field)
|
|
26
|
+
next unless Ast.ident_text(target[3]) == "force_ssl"
|
|
27
|
+
|
|
28
|
+
value = node[2]
|
|
29
|
+
next unless Ast.false_literal?(value)
|
|
30
|
+
|
|
31
|
+
line = Ast.line_of(node)
|
|
32
|
+
findings << finding(
|
|
33
|
+
line: line,
|
|
34
|
+
message: "`config.force_ssl = false` explicitly disables Rails' HTTPS enforcement " \
|
|
35
|
+
"(redirects, HSTS, and the secure flag on cookies) — traffic can be served " \
|
|
36
|
+
"and session cookies transmitted over plain HTTP.",
|
|
37
|
+
suggested_fix: "Set `config.force_ssl = true` (the default for a new Rails production " \
|
|
38
|
+
"environment) unless this app is deliberately terminating TLS " \
|
|
39
|
+
"elsewhere (a load balancer already enforcing HTTPS) — and if so, " \
|
|
40
|
+
"leave a comment explaining that so this doesn't look like an oversight."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
findings
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a `class X < GraphQL::Schema` (or a class whose superclass
|
|
4
|
+
# constant path text is exactly `GraphQL::Schema`) whose body calls
|
|
5
|
+
# neither `max_depth` nor `max_complexity` anywhere — without at least
|
|
6
|
+
# one of those, a client can send an arbitrarily deep/expensive query
|
|
7
|
+
# and the server will try to resolve all of it, a common
|
|
8
|
+
# denial-of-service vector for GraphQL APIs. Both missing is flagged as
|
|
9
|
+
# a single finding per class (not one per missing directive), the same
|
|
10
|
+
# "did the class do the safe thing at all" pattern as
|
|
11
|
+
# ActiveStorageMissingContentTypeValidationRule/IdorRule.
|
|
12
|
+
#
|
|
13
|
+
# Known precision limits, both around indirection this rule can't (or
|
|
14
|
+
# doesn't try to) resolve, same disclosed-gap spirit as IdorRule:
|
|
15
|
+
#
|
|
16
|
+
# - A schema that inherits from a shared custom base class (e.g.
|
|
17
|
+
# `class MySchema < BaseSchema`, where `BaseSchema` is the one that
|
|
18
|
+
# actually extends `GraphQL::Schema` and calls `max_depth`/
|
|
19
|
+
# `max_complexity`) is never even examined by this rule — the
|
|
20
|
+
# superclass-name check only matches a literal `GraphQL::Schema`
|
|
21
|
+
# superclass, so `MySchema` here isn't checked at all (a
|
|
22
|
+
# false-negative blind spot, not a false positive: verified via
|
|
23
|
+
# `Scryer::Scanner` that such a subclass produces no finding either
|
|
24
|
+
# way).
|
|
25
|
+
# - Conversely, a class that DOES extend `GraphQL::Schema` directly
|
|
26
|
+
# but gets its limits from an `include`d module (e.g. `include
|
|
27
|
+
# QueryLimits`, where the module sets `max_depth`/`max_complexity`
|
|
28
|
+
# via its own `included do ... end` block in a different file) WILL
|
|
29
|
+
# be flagged as missing them, even though it isn't — confirmed via
|
|
30
|
+
# `Scryer::Scanner` against exactly this fixture. Resolving what an
|
|
31
|
+
# `include`d module does requires following it to its own
|
|
32
|
+
# definition, potentially in another file entirely — real
|
|
33
|
+
# cross-file resolution, not a same-file AST tweak — so rather than
|
|
34
|
+
# risk a broad/wrong exemption (e.g. "any class that calls
|
|
35
|
+
# `include` at all is exempt" would silence the rule for classes
|
|
36
|
+
# that include something unrelated and still have no real limit),
|
|
37
|
+
# this gap is left as-is and disclosed here instead. Treat a
|
|
38
|
+
# finding on a schema using a shared limits module as worth a
|
|
39
|
+
# second look, not a confirmed bug.
|
|
40
|
+
class GraphqlMissingQueryLimitsRule < Rule
|
|
41
|
+
self.rule_id = "graphql_missing_query_limits"
|
|
42
|
+
self.category = "security"
|
|
43
|
+
self.default_severity = "warning"
|
|
44
|
+
self.title = "GraphQL schema without query depth/complexity limits"
|
|
45
|
+
self.cwe = "CWE-770"
|
|
46
|
+
self.owasp_category = "A04:2021-Insecure Design"
|
|
47
|
+
self.confidence = "medium"
|
|
48
|
+
|
|
49
|
+
LIMIT_METHODS = %w[max_depth max_complexity].freeze
|
|
50
|
+
|
|
51
|
+
def scan
|
|
52
|
+
findings = []
|
|
53
|
+
|
|
54
|
+
Ast.each_node(sexp) do |node|
|
|
55
|
+
next unless Ast.tagged?(node, :class)
|
|
56
|
+
|
|
57
|
+
superclass = superclass_name(node[2])
|
|
58
|
+
next unless superclass == "GraphQL::Schema"
|
|
59
|
+
|
|
60
|
+
body = node[3]
|
|
61
|
+
next if each_call_names(body).any? { |name| LIMIT_METHODS.include?(name) }
|
|
62
|
+
|
|
63
|
+
class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
|
|
64
|
+
line = Ast.line_of(node)
|
|
65
|
+
findings << finding(
|
|
66
|
+
line: line,
|
|
67
|
+
message: "`#{class_name}` extends `GraphQL::Schema` but calls neither `max_depth` nor " \
|
|
68
|
+
"`max_complexity` — without either limit, a client can send an arbitrarily " \
|
|
69
|
+
"deep or expensive query and the server will attempt to resolve all of it, a " \
|
|
70
|
+
"common denial-of-service vector for GraphQL APIs.",
|
|
71
|
+
suggested_fix: "Add at least one limit to the schema, e.g. `max_depth 15` and/or " \
|
|
72
|
+
"`max_complexity 300` (tune both to what this API's real queries need)."
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
findings
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def each_call_names(body)
|
|
82
|
+
Ast.each_node(body).filter_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
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Textual name of a (possibly namespaced) constant superclass node —
|
|
91
|
+
# "GraphQL::Schema" for the `[:const_path_ref, ...]` chain a `< X::Y`
|
|
92
|
+
# superclass parses into, or the bare name for an unnamespaced
|
|
93
|
+
# superclass (`[:var_ref, [:@const, ...]]`). nil if there's no
|
|
94
|
+
# superclass (`node[2]` is nil for a plain `class X` with no `< ...`).
|
|
95
|
+
def superclass_name(node)
|
|
96
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
97
|
+
node[1][1]
|
|
98
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
99
|
+
left = superclass_name(node[1])
|
|
100
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
101
|
+
[left, right].compact.join("::")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `http_basic_authenticate_with` calls whose `password:` (or
|
|
4
|
+
# `name:`) keyword argument is a plain string literal — a shape
|
|
5
|
+
# `HardcodedSecretRule` doesn't cover (that rule only looks at
|
|
6
|
+
# `x = "literal"` assignment targets, not keyword-argument values in a
|
|
7
|
+
# method call).
|
|
8
|
+
class HardcodedBasicAuthRule < Rule
|
|
9
|
+
self.rule_id = "hardcoded_basic_auth"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "critical"
|
|
12
|
+
self.title = "Hardcoded HTTP Basic Auth credential"
|
|
13
|
+
self.cwe = "CWE-798"
|
|
14
|
+
self.owasp_category = "A07:2021-Identification and Authentication Failures"
|
|
15
|
+
self.confidence = "high"
|
|
16
|
+
|
|
17
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?password|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
18
|
+
|
|
19
|
+
def scan
|
|
20
|
+
findings = []
|
|
21
|
+
|
|
22
|
+
Ast.each_node(sexp) do |node|
|
|
23
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
24
|
+
|
|
25
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
26
|
+
name_pair = Ast.call_name(inner)
|
|
27
|
+
next unless name_pair && name_pair[1] == "http_basic_authenticate_with"
|
|
28
|
+
|
|
29
|
+
args = Ast.call_arguments(node)
|
|
30
|
+
password_value = Ast.plain_string_value(Ast.keyword_arg(args, "password"))
|
|
31
|
+
next unless password_value && !password_value.strip.empty? && !PLACEHOLDER_VALUES.match?(password_value.strip)
|
|
32
|
+
|
|
33
|
+
line = Ast.line_of(node)
|
|
34
|
+
findings << finding(
|
|
35
|
+
line: line,
|
|
36
|
+
message: "`http_basic_authenticate_with` is called with a literal `password:` — " \
|
|
37
|
+
"anyone with read access to this source (including git history) has the " \
|
|
38
|
+
"credential, and it can't be rotated without a code change and deploy.",
|
|
39
|
+
suggested_fix: "Move the credential out of source: " \
|
|
40
|
+
"`http_basic_authenticate_with name: ENV.fetch(\"BASIC_AUTH_USER\"), " \
|
|
41
|
+
"password: ENV.fetch(\"BASIC_AUTH_PASSWORD\")` (or Rails encrypted " \
|
|
42
|
+
"credentials), and rotate this password since it's likely already " \
|
|
43
|
+
"exposed in git history."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
findings
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.secret_key_base = "literal"` (or
|
|
4
|
+
# `Rails.application.config.secret_key_base = "literal"`) — a plain
|
|
5
|
+
# string literal assigned to `secret_key_base` via a `.field=` target.
|
|
6
|
+
#
|
|
7
|
+
# HardcodedSecretRule doesn't catch this shape: its `target_name` walks
|
|
8
|
+
# the assignment target depth-first and returns the *first* identifier
|
|
9
|
+
# it finds, which for a `.field=` target with a receiver is the
|
|
10
|
+
# receiver's own name (`config`) rather than the attribute being
|
|
11
|
+
# assigned (`secret_key_base`) — so `config.secret_key_base = "..."`
|
|
12
|
+
# never matches its NAME_PATTERN, even though the value is exactly the
|
|
13
|
+
# kind of credential that rule exists to catch. Bare assignment
|
|
14
|
+
# (`secret_key_base = "..."` with no receiver) IS already caught by
|
|
15
|
+
# HardcodedSecretRule, since there the identifier IS the target name —
|
|
16
|
+
# this rule only covers the receiver'd shape it misses.
|
|
17
|
+
class HardcodedSecretKeyBaseRule < Rule
|
|
18
|
+
self.rule_id = "hardcoded_secret_key_base"
|
|
19
|
+
self.category = "security"
|
|
20
|
+
self.default_severity = "critical"
|
|
21
|
+
self.title = "Hardcoded secret_key_base"
|
|
22
|
+
self.cwe = "CWE-798"
|
|
23
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
24
|
+
self.confidence = "high"
|
|
25
|
+
|
|
26
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
27
|
+
|
|
28
|
+
def scan
|
|
29
|
+
findings = []
|
|
30
|
+
|
|
31
|
+
Ast.each_node(sexp) do |node|
|
|
32
|
+
next unless Ast.tagged?(node, :assign)
|
|
33
|
+
|
|
34
|
+
target = node[1]
|
|
35
|
+
next unless Ast.tagged?(target, :field)
|
|
36
|
+
next unless Ast.ident_text(target[3]) == "secret_key_base"
|
|
37
|
+
|
|
38
|
+
value = Ast.plain_string_value(node[2])
|
|
39
|
+
next unless value && !value.strip.empty? && !PLACEHOLDER_VALUES.match?(value.strip)
|
|
40
|
+
|
|
41
|
+
line = Ast.line_of(node)
|
|
42
|
+
findings << finding(
|
|
43
|
+
line: line,
|
|
44
|
+
message: "`secret_key_base` is assigned a literal string — this key signs/encrypts " \
|
|
45
|
+
"Rails sessions and other secrets; anyone with read access to this source " \
|
|
46
|
+
"(including git history) can forge session cookies and other signed data.",
|
|
47
|
+
suggested_fix: "Use `Rails.application.credentials.secret_key_base` (the Rails default, " \
|
|
48
|
+
"set via `bin/rails credentials:edit`) or `ENV.fetch(\"SECRET_KEY_BASE\")` " \
|
|
49
|
+
"instead, and rotate this key since it's likely already exposed in git " \
|
|
50
|
+
"history."
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
findings
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -9,6 +9,9 @@ module Scryer
|
|
|
9
9
|
self.category = "security"
|
|
10
10
|
self.default_severity = "critical"
|
|
11
11
|
self.title = "Hardcoded credential or API key"
|
|
12
|
+
self.cwe = "CWE-798"
|
|
13
|
+
self.owasp_category = "A07:2021-Identification and Authentication Failures"
|
|
14
|
+
self.confidence = "medium"
|
|
12
15
|
|
|
13
16
|
NAME_PATTERN = /(api[_-]?key|secret|token|password|passwd|access[_-]?key|private[_-]?key|auth)/i.freeze
|
|
14
17
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.hosts.clear` (in any file — `Rails.application.config.
|
|
4
|
+
# hosts.clear` and similar longer chains match too, since only the last
|
|
5
|
+
# two segments of the call chain matter). Rails 6+ checks the `Host`
|
|
6
|
+
# header on every request against `config.hosts` by default
|
|
7
|
+
# (ActionDispatch::HostAuthorization) specifically to block DNS-rebinding
|
|
8
|
+
# and Host-header-injection attacks; `.clear` empties that allowlist,
|
|
9
|
+
# which disables the check entirely rather than narrowing it — unlike
|
|
10
|
+
# adding a specific host to the list, there's no legitimate narrowing use
|
|
11
|
+
# of `.clear` itself (a project that wants to allow every host would
|
|
12
|
+
# still say so more precisely, e.g. a regex covering its actual domains).
|
|
13
|
+
class HostAuthorizationDisabledRule < Rule
|
|
14
|
+
self.rule_id = "host_authorization_disabled"
|
|
15
|
+
self.category = "security"
|
|
16
|
+
self.default_severity = "warning"
|
|
17
|
+
self.title = "Host header authorization allowlist cleared"
|
|
18
|
+
self.cwe = "CWE-350"
|
|
19
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
20
|
+
self.confidence = "high"
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
|
|
25
|
+
Ast.each_node(sexp) do |node|
|
|
26
|
+
next unless Ast.tagged?(node, :call)
|
|
27
|
+
next unless Ast.ident_text(node[3]) == "clear"
|
|
28
|
+
|
|
29
|
+
receiver = node[1]
|
|
30
|
+
next unless Ast.tagged?(receiver, :call)
|
|
31
|
+
next unless Ast.ident_text(receiver[3]) == "hosts"
|
|
32
|
+
|
|
33
|
+
findings << finding(
|
|
34
|
+
line: Ast.line_of(node),
|
|
35
|
+
message: "`config.hosts.clear` empties Rails' Host-header allowlist " \
|
|
36
|
+
"(ActionDispatch::HostAuthorization), disabling its check against DNS-" \
|
|
37
|
+
"rebinding and Host-header-injection attacks entirely rather than narrowing it.",
|
|
38
|
+
suggested_fix: "Add this app's actual host(s) to the allowlist instead of clearing it " \
|
|
39
|
+
"— e.g. `config.hosts << \"example.com\"` — or, if requests genuinely " \
|
|
40
|
+
"come from unpredictable hosts (a multi-tenant app resolving hosts at " \
|
|
41
|
+
"runtime), use a regex/proc that still validates against a known pattern " \
|
|
42
|
+
"rather than accepting every host."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
findings
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `Model.find(params[...])` / `Model.find_by(...params...)` inside
|
|
4
|
+
# a controller where the receiver is a bare, unnamespaced constant (not
|
|
5
|
+
# scoped through e.g. `current_user.things.find(...)`) and no
|
|
6
|
+
# authorization call (`authorize`, `authorize!`, `policy_scope`, `can?`,
|
|
7
|
+
# `cannot?` — the common Pundit/CanCanCan method names) appears anywhere
|
|
8
|
+
# else in the same controller class.
|
|
9
|
+
#
|
|
10
|
+
# This is the least precise rule in the gem, by nature of the problem:
|
|
11
|
+
# whether a given `find` is actually scoped to the current user is a
|
|
12
|
+
# question about the whole app's authorization model, not something
|
|
13
|
+
# visible from one file's AST. Expect real false positives — e.g. an
|
|
14
|
+
# admin-only controller already gated by a class-level `before_action`,
|
|
15
|
+
# or a genuinely global/unowned model (`Country.find(params[:id])`).
|
|
16
|
+
# Treat every finding as "worth a second look," not a confirmed bug —
|
|
17
|
+
# same spirit as CsrfProtectionRule's class-wide safeguard check, applied
|
|
18
|
+
# to a harder problem.
|
|
19
|
+
class IdorRule < Rule
|
|
20
|
+
self.rule_id = "idor"
|
|
21
|
+
self.category = "security"
|
|
22
|
+
self.default_severity = "warning"
|
|
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"
|
|
27
|
+
|
|
28
|
+
FINDER_METHODS = %w[find find_by find_by!].freeze
|
|
29
|
+
|
|
30
|
+
# Same reasoning as MassAssignmentRule::NON_MODEL_RECEIVERS: common
|
|
31
|
+
# stdlib/gem constants with their own `.find`-style methods that have
|
|
32
|
+
# nothing to do with an ActiveRecord model lookup.
|
|
33
|
+
NON_MODEL_RECEIVERS = %w[
|
|
34
|
+
Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
|
|
35
|
+
JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
|
|
36
|
+
Enumerable File Dir
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
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
|
|
63
|
+
|
|
64
|
+
def scan
|
|
65
|
+
findings = []
|
|
66
|
+
|
|
67
|
+
Ast.each_node(sexp) do |node|
|
|
68
|
+
next unless Ast.tagged?(node, :class)
|
|
69
|
+
|
|
70
|
+
class_name = Ast.class_name(node[1])
|
|
71
|
+
next unless class_name.to_s.end_with?("Controller")
|
|
72
|
+
|
|
73
|
+
body = node[3]
|
|
74
|
+
next if each_call_names(body).any? { |name| AUTHORIZATION_METHODS.include?(name) }
|
|
75
|
+
|
|
76
|
+
each_unscoped_find(body).each do |find_node, method_name|
|
|
77
|
+
line = Ast.line_of(find_node)
|
|
78
|
+
findings << finding(
|
|
79
|
+
line: line,
|
|
80
|
+
message: "`#{method_name}` looks up a record directly from `params`, and " \
|
|
81
|
+
"`#{class_name}` has no visible authorization check (no `authorize`, " \
|
|
82
|
+
"`policy_scope`, or `can?`/`cannot?` anywhere in it) — if this record " \
|
|
83
|
+
"belongs to a specific user/account, another user may be able to view or " \
|
|
84
|
+
"modify it just by changing the id in the request.",
|
|
85
|
+
suggested_fix: "Scope the lookup to the current actor instead of the bare model, " \
|
|
86
|
+
"e.g. `current_user.things.#{method_name}(params[:id])`, or add an " \
|
|
87
|
+
"explicit authorization check (`authorize @thing` for Pundit, " \
|
|
88
|
+
"`authorize! :show, @thing` for CanCanCan) before using the record."
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
findings
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
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.
|
|
105
|
+
def each_call_names(node)
|
|
106
|
+
Ast.each_node(node).flat_map do |n|
|
|
107
|
+
next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
108
|
+
|
|
109
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
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
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def each_unscoped_find(body)
|
|
117
|
+
Ast.each_node(body).filter_map do |node|
|
|
118
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
119
|
+
|
|
120
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
121
|
+
receiver_and_name = Ast.call_name(inner)
|
|
122
|
+
next unless receiver_and_name
|
|
123
|
+
|
|
124
|
+
receiver, method_name = receiver_and_name
|
|
125
|
+
next unless FINDER_METHODS.include?(method_name)
|
|
126
|
+
next unless likely_model_receiver?(receiver)
|
|
127
|
+
|
|
128
|
+
args = Ast.call_arguments(node)
|
|
129
|
+
next unless args.any? { |a| Ast.references_params?(a) }
|
|
130
|
+
|
|
131
|
+
[node, method_name]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
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.
|
|
147
|
+
def likely_model_receiver?(receiver)
|
|
148
|
+
return false if receiver.nil? # bare find(...) inside the model itself, not a controller lookup
|
|
149
|
+
|
|
150
|
+
const_name = const_receiver_name(receiver)
|
|
151
|
+
return false unless const_name
|
|
152
|
+
|
|
153
|
+
!NON_MODEL_RECEIVERS.include?(const_name)
|
|
154
|
+
end
|
|
155
|
+
|
|
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
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.action_dispatch.cookies_serializer = :marshal`. Rails
|
|
4
|
+
# defaults to `:json` since Rails 4.1 specifically because deserializing
|
|
5
|
+
# a `Marshal`-encoded cookie can be turned into remote code execution if
|
|
6
|
+
# the cookie's secret is ever compromised (the same class of bug
|
|
7
|
+
# `UnsafeDeserializationRule` flags for `Marshal.load` directly) — only
|
|
8
|
+
# an explicit opt back into `:marshal` is flagged, never the (safe)
|
|
9
|
+
# default of not setting this at all.
|
|
10
|
+
class InsecureCookieSerializerRule < Rule
|
|
11
|
+
self.rule_id = "insecure_cookie_serializer"
|
|
12
|
+
self.category = "security"
|
|
13
|
+
self.default_severity = "critical"
|
|
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"
|
|
18
|
+
|
|
19
|
+
def scan
|
|
20
|
+
findings = []
|
|
21
|
+
|
|
22
|
+
Ast.each_node(sexp) do |node|
|
|
23
|
+
next unless Ast.tagged?(node, :assign)
|
|
24
|
+
|
|
25
|
+
target = node[1]
|
|
26
|
+
next unless Ast.tagged?(target, :field)
|
|
27
|
+
next unless Ast.ident_text(target[3]) == "cookies_serializer"
|
|
28
|
+
next unless Ast.literal_text(node[2]) == "marshal"
|
|
29
|
+
|
|
30
|
+
line = Ast.line_of(node)
|
|
31
|
+
findings << finding(
|
|
32
|
+
line: line,
|
|
33
|
+
message: "`cookies_serializer = :marshal` deserializes every cookie with " \
|
|
34
|
+
"`Marshal.load` — if the app's `secret_key_base` is ever leaked (or brute " \
|
|
35
|
+
"forced), a forged cookie deserialized this way can lead to remote code " \
|
|
36
|
+
"execution, not just a spoofed session.",
|
|
37
|
+
suggested_fix: "Use the default `:json` serializer instead (remove this line, or set " \
|
|
38
|
+
"`config.action_dispatch.cookies_serializer = :json` explicitly) — it " \
|
|
39
|
+
"can only produce plain data structures, never arbitrary objects."
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
findings
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `SomeJob.perform_async(...)` / `.perform_later(...)` /
|
|
4
|
+
# `.perform_now(...)` (or the bare form inside the job's own class) where
|
|
5
|
+
# an argument references `params` more broadly than a single-key
|
|
6
|
+
# subscript — see raw_params_reference? below for exactly what's exempt
|
|
7
|
+
# (`params[:id]` and friends) versus what isn't.
|
|
8
|
+
#
|
|
9
|
+
# Sidekiq stores job arguments in Redis in plaintext and displays them in
|
|
10
|
+
# its web UI; both Sidekiq and ActiveJob log job arguments by default.
|
|
11
|
+
# Passing a raw `params` hash risks leaking whatever it contains —
|
|
12
|
+
# passwords, tokens, full user-submitted fields — into logs, Redis, and
|
|
13
|
+
# the Sidekiq UI, instead of passing just the specific id/value the job
|
|
14
|
+
# actually needs. This is a distinct, well-documented Sidekiq/ActiveJob
|
|
15
|
+
# concern from mass assignment (which is about writing untrusted params
|
|
16
|
+
# to a model, not about where params end up once queued).
|
|
17
|
+
class JobRawParamsRule < Rule
|
|
18
|
+
self.rule_id = "job_raw_params"
|
|
19
|
+
self.category = "security"
|
|
20
|
+
self.default_severity = "warning"
|
|
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"
|
|
25
|
+
|
|
26
|
+
PERFORM_METHODS = %w[perform_async perform_later perform_now].freeze
|
|
27
|
+
|
|
28
|
+
def scan
|
|
29
|
+
findings = []
|
|
30
|
+
|
|
31
|
+
Ast.each_node(sexp) do |node|
|
|
32
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
33
|
+
|
|
34
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
35
|
+
receiver_and_name = Ast.call_name(inner)
|
|
36
|
+
next unless receiver_and_name
|
|
37
|
+
|
|
38
|
+
_receiver, method_name = receiver_and_name
|
|
39
|
+
next unless PERFORM_METHODS.include?(method_name)
|
|
40
|
+
|
|
41
|
+
args = Ast.call_arguments(node)
|
|
42
|
+
next unless args.any? { |a| raw_params_reference?(a) }
|
|
43
|
+
|
|
44
|
+
line = Ast.line_of(node)
|
|
45
|
+
findings << finding(
|
|
46
|
+
line: line,
|
|
47
|
+
message: "`#{method_name}` is called with an argument that references `params` — " \
|
|
48
|
+
"Sidekiq stores job arguments in Redis in plaintext (visible in its web UI), " \
|
|
49
|
+
"and both Sidekiq and ActiveJob log job arguments by default, so anything in " \
|
|
50
|
+
"the raw params hash (passwords, tokens, other sensitive fields) can end up " \
|
|
51
|
+
"somewhere it wasn't meant to be readable.",
|
|
52
|
+
suggested_fix: "Extract only the specific id/value(s) the job actually needs into " \
|
|
53
|
+
"local variables before enqueuing (e.g. `id = params[:id]; " \
|
|
54
|
+
"#{method_name}(id)`), instead of passing `params` (or a subscript of " \
|
|
55
|
+
"it) straight through, and have the job re-fetch/re-derive anything " \
|
|
56
|
+
"else it needs from that id."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
findings
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# True if `node`'s subtree references `params` in a way broader than a
|
|
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
|
+
|
|
88
|
+
def raw_params_reference?(node)
|
|
89
|
+
found = false
|
|
90
|
+
|
|
91
|
+
walk = lambda do |n|
|
|
92
|
+
next if found || !n.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
if Ast.tagged?(n, :aref) && bare_params?(n[1])
|
|
95
|
+
walk.call(n[2]) if n[2].is_a?(Array)
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if narrow_params_call?(n)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if bare_params?(n)
|
|
104
|
+
found = true
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
n.each { |c| walk.call(c) if c.is_a?(Array) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
walk.call(node)
|
|
112
|
+
found
|
|
113
|
+
end
|
|
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
|
+
|
|
126
|
+
def bare_params?(node)
|
|
127
|
+
Ast.tagged?(node, :vcall, :var_ref, :fcall) && Ast.ident_text(node[1]) == "params"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|