scryer 0.2.0 → 1.0.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 +87 -0
- data/README.md +148 -32
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +33 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +152 -4
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +76 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +72 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +69 -0
- data/lib/scryer/rules/force_ssl_rule.rb +45 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +75 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +48 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +55 -0
- data/lib/scryer/rules/idor_rule.rb +111 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +44 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +98 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +120 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +2 -5
- data/lib/scryer/rules/path_traversal_rule.rb +89 -0
- data/lib/scryer/rules/security_headers_rule.rb +130 -0
- data/lib/scryer/rules/ssrf_rule.rb +85 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +48 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/tasks/scryer.rake +22 -10
- metadata +19 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags the well-known Rack::Cors antipattern: a wildcard origin
|
|
4
|
+
# (`origins '*'`) combined with `credentials: true` on a `resource` call.
|
|
5
|
+
# Per the CORS spec, browsers reject this combination in practice, but
|
|
6
|
+
# it's still the standard misconfiguration flagged in security reviews —
|
|
7
|
+
# either half alone (wildcard origin with no credentials, or credentials
|
|
8
|
+
# with a real origin allowlist) is fine.
|
|
9
|
+
#
|
|
10
|
+
# `config/initializers/cors.rb` is small and self-contained, so this
|
|
11
|
+
# checks "does `origins(...)` with a literal `'*'` appear anywhere in the
|
|
12
|
+
# file AND does a `resource` call with `credentials: true` appear
|
|
13
|
+
# anywhere in the file" rather than requiring both inside the exact same
|
|
14
|
+
# `allow do ... end` block — the same file-wide-evidence looseness
|
|
15
|
+
# IdorRule uses for its class-wide authorization check.
|
|
16
|
+
class CorsMisconfigurationRule < Rule
|
|
17
|
+
self.rule_id = "cors_misconfiguration"
|
|
18
|
+
self.category = "security"
|
|
19
|
+
self.default_severity = "critical"
|
|
20
|
+
self.title = "CORS wildcard origin combined with credentials"
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
return findings unless wildcard_origin_anywhere?(sexp)
|
|
25
|
+
|
|
26
|
+
each_credentialed_resource(sexp).each do |node|
|
|
27
|
+
findings << finding(
|
|
28
|
+
line: Ast.line_of(node),
|
|
29
|
+
message: "This file allows a wildcard origin (`origins '*'`) somewhere and this " \
|
|
30
|
+
"`resource` call sets `credentials: true` — browsers won't honor that " \
|
|
31
|
+
"combination for actual credentialed requests, and Rack::Cors handling it " \
|
|
32
|
+
"inconsistently is the standard CORS misconfiguration flagged in reviews. " \
|
|
33
|
+
"Either restrict origins to a real allowlist, or drop `credentials: true`.",
|
|
34
|
+
suggested_fix: "Replace the wildcard with an explicit origin allowlist wherever " \
|
|
35
|
+
"`credentials: true` is set: `origins 'https://app.example.com'` " \
|
|
36
|
+
"instead of `origins '*'` — a wildcard origin should only be paired " \
|
|
37
|
+
"with `credentials: false` (the default)."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
findings
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def wildcard_origin_anywhere?(node)
|
|
47
|
+
Ast.each_node(node).any? do |n|
|
|
48
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
49
|
+
|
|
50
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
51
|
+
next false unless Ast.call_name(inner)&.last == "origins"
|
|
52
|
+
|
|
53
|
+
Ast.call_arguments(n).any? { |a| Ast.plain_string_value(a) == "*" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def each_credentialed_resource(node)
|
|
58
|
+
Ast.each_node(node).select do |n|
|
|
59
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
60
|
+
|
|
61
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
62
|
+
next false unless Ast.call_name(inner)&.last == "resource"
|
|
63
|
+
|
|
64
|
+
Ast.true_literal?(Ast.keyword_arg(Ast.call_arguments(n), "credentials"))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
15
|
+
def scan
|
|
16
|
+
findings = []
|
|
17
|
+
|
|
18
|
+
Ast.each_node(sexp) do |node|
|
|
19
|
+
next unless Ast.tagged?(node, :assign)
|
|
20
|
+
|
|
21
|
+
target = node[1]
|
|
22
|
+
next unless Ast.tagged?(target, :field)
|
|
23
|
+
next unless Ast.ident_text(target[3]) == "force_ssl"
|
|
24
|
+
|
|
25
|
+
value = node[2]
|
|
26
|
+
next unless Ast.false_literal?(value)
|
|
27
|
+
|
|
28
|
+
line = Ast.line_of(node)
|
|
29
|
+
findings << finding(
|
|
30
|
+
line: line,
|
|
31
|
+
message: "`config.force_ssl = false` explicitly disables Rails' HTTPS enforcement " \
|
|
32
|
+
"(redirects, HSTS, and the secure flag on cookies) — traffic can be served " \
|
|
33
|
+
"and session cookies transmitted over plain HTTP.",
|
|
34
|
+
suggested_fix: "Set `config.force_ssl = true` (the default for a new Rails production " \
|
|
35
|
+
"environment) unless this app is deliberately terminating TLS " \
|
|
36
|
+
"elsewhere (a load balancer already enforcing HTTPS) — and if so, " \
|
|
37
|
+
"leave a comment explaining that so this doesn't look like an oversight."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
findings
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
class GraphqlMissingQueryLimitsRule < Rule
|
|
13
|
+
self.rule_id = "graphql_missing_query_limits"
|
|
14
|
+
self.category = "security"
|
|
15
|
+
self.default_severity = "warning"
|
|
16
|
+
self.title = "GraphQL schema without query depth/complexity limits"
|
|
17
|
+
|
|
18
|
+
LIMIT_METHODS = %w[max_depth max_complexity].freeze
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
Ast.each_node(sexp) do |node|
|
|
24
|
+
next unless Ast.tagged?(node, :class)
|
|
25
|
+
|
|
26
|
+
superclass = superclass_name(node[2])
|
|
27
|
+
next unless superclass == "GraphQL::Schema"
|
|
28
|
+
|
|
29
|
+
body = node[3]
|
|
30
|
+
next if each_call_names(body).any? { |name| LIMIT_METHODS.include?(name) }
|
|
31
|
+
|
|
32
|
+
class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
|
|
33
|
+
line = Ast.line_of(node)
|
|
34
|
+
findings << finding(
|
|
35
|
+
line: line,
|
|
36
|
+
message: "`#{class_name}` extends `GraphQL::Schema` but calls neither `max_depth` nor " \
|
|
37
|
+
"`max_complexity` — without either limit, a client can send an arbitrarily " \
|
|
38
|
+
"deep or expensive query and the server will attempt to resolve all of it, a " \
|
|
39
|
+
"common denial-of-service vector for GraphQL APIs.",
|
|
40
|
+
suggested_fix: "Add at least one limit to the schema, e.g. `max_depth 15` and/or " \
|
|
41
|
+
"`max_complexity 300` (tune both to what this API's real queries need)."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def each_call_names(body)
|
|
51
|
+
Ast.each_node(body).filter_map do |n|
|
|
52
|
+
next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
53
|
+
|
|
54
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
55
|
+
Ast.call_name(inner)&.last
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Textual name of a (possibly namespaced) constant superclass node —
|
|
60
|
+
# "GraphQL::Schema" for the `[:const_path_ref, ...]` chain a `< X::Y`
|
|
61
|
+
# superclass parses into, or the bare name for an unnamespaced
|
|
62
|
+
# superclass (`[:var_ref, [:@const, ...]]`). nil if there's no
|
|
63
|
+
# superclass (`node[2]` is nil for a plain `class X` with no `< ...`).
|
|
64
|
+
def superclass_name(node)
|
|
65
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
66
|
+
node[1][1]
|
|
67
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
68
|
+
left = superclass_name(node[1])
|
|
69
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
70
|
+
[left, right].compact.join("::")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
14
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?password|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
15
|
+
|
|
16
|
+
def scan
|
|
17
|
+
findings = []
|
|
18
|
+
|
|
19
|
+
Ast.each_node(sexp) do |node|
|
|
20
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
21
|
+
|
|
22
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
23
|
+
name_pair = Ast.call_name(inner)
|
|
24
|
+
next unless name_pair && name_pair[1] == "http_basic_authenticate_with"
|
|
25
|
+
|
|
26
|
+
args = Ast.call_arguments(node)
|
|
27
|
+
password_value = Ast.plain_string_value(Ast.keyword_arg(args, "password"))
|
|
28
|
+
next unless password_value && !password_value.strip.empty? && !PLACEHOLDER_VALUES.match?(password_value.strip)
|
|
29
|
+
|
|
30
|
+
line = Ast.line_of(node)
|
|
31
|
+
findings << finding(
|
|
32
|
+
line: line,
|
|
33
|
+
message: "`http_basic_authenticate_with` is called with a literal `password:` — " \
|
|
34
|
+
"anyone with read access to this source (including git history) has the " \
|
|
35
|
+
"credential, and it can't be rotated without a code change and deploy.",
|
|
36
|
+
suggested_fix: "Move the credential out of source: " \
|
|
37
|
+
"`http_basic_authenticate_with name: ENV.fetch(\"BASIC_AUTH_USER\"), " \
|
|
38
|
+
"password: ENV.fetch(\"BASIC_AUTH_PASSWORD\")` (or Rails encrypted " \
|
|
39
|
+
"credentials), and rotate this password since it's likely already " \
|
|
40
|
+
"exposed in git history."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
findings
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
23
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
24
|
+
|
|
25
|
+
def scan
|
|
26
|
+
findings = []
|
|
27
|
+
|
|
28
|
+
Ast.each_node(sexp) do |node|
|
|
29
|
+
next unless Ast.tagged?(node, :assign)
|
|
30
|
+
|
|
31
|
+
target = node[1]
|
|
32
|
+
next unless Ast.tagged?(target, :field)
|
|
33
|
+
next unless Ast.ident_text(target[3]) == "secret_key_base"
|
|
34
|
+
|
|
35
|
+
value = Ast.plain_string_value(node[2])
|
|
36
|
+
next unless value && !value.strip.empty? && !PLACEHOLDER_VALUES.match?(value.strip)
|
|
37
|
+
|
|
38
|
+
line = Ast.line_of(node)
|
|
39
|
+
findings << finding(
|
|
40
|
+
line: line,
|
|
41
|
+
message: "`secret_key_base` is assigned a literal string — this key signs/encrypts " \
|
|
42
|
+
"Rails sessions and other secrets; anyone with read access to this source " \
|
|
43
|
+
"(including git history) can forge session cookies and other signed data.",
|
|
44
|
+
suggested_fix: "Use `Rails.application.credentials.secret_key_base` (the Rails default, " \
|
|
45
|
+
"set via `bin/rails credentials:edit`) or `ENV.fetch(\"SECRET_KEY_BASE\")` " \
|
|
46
|
+
"instead, and rotate this key since it's likely already exposed in git " \
|
|
47
|
+
"history."
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
findings
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
|
|
25
|
+
FINDER_METHODS = %w[find find_by find_by!].freeze
|
|
26
|
+
|
|
27
|
+
# Same reasoning as MassAssignmentRule::NON_MODEL_RECEIVERS: common
|
|
28
|
+
# stdlib/gem constants with their own `.find`-style methods that have
|
|
29
|
+
# nothing to do with an ActiveRecord model lookup.
|
|
30
|
+
NON_MODEL_RECEIVERS = %w[
|
|
31
|
+
Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
|
|
32
|
+
JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
|
|
33
|
+
Enumerable File Dir
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
AUTHORIZATION_METHODS = %w[authorize authorize! policy_scope can? cannot?].freeze
|
|
37
|
+
|
|
38
|
+
def scan
|
|
39
|
+
findings = []
|
|
40
|
+
|
|
41
|
+
Ast.each_node(sexp) do |node|
|
|
42
|
+
next unless Ast.tagged?(node, :class)
|
|
43
|
+
|
|
44
|
+
class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
|
|
45
|
+
next unless class_name.to_s.end_with?("Controller")
|
|
46
|
+
|
|
47
|
+
body = node[3]
|
|
48
|
+
next if each_call_names(body).any? { |name| AUTHORIZATION_METHODS.include?(name) }
|
|
49
|
+
|
|
50
|
+
each_unscoped_find(body).each do |find_node, method_name|
|
|
51
|
+
line = Ast.line_of(find_node)
|
|
52
|
+
findings << finding(
|
|
53
|
+
line: line,
|
|
54
|
+
message: "`#{method_name}` looks up a record directly from `params`, and " \
|
|
55
|
+
"`#{class_name}` has no visible authorization check (no `authorize`, " \
|
|
56
|
+
"`policy_scope`, or `can?`/`cannot?` anywhere in it) — if this record " \
|
|
57
|
+
"belongs to a specific user/account, another user may be able to view or " \
|
|
58
|
+
"modify it just by changing the id in the request.",
|
|
59
|
+
suggested_fix: "Scope the lookup to the current actor instead of the bare model, " \
|
|
60
|
+
"e.g. `current_user.things.#{method_name}(params[:id])`, or add an " \
|
|
61
|
+
"explicit authorization check (`authorize @thing` for Pundit, " \
|
|
62
|
+
"`authorize! :show, @thing` for CanCanCan) before using the record."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
findings
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def each_call_names(node)
|
|
73
|
+
Ast.each_node(node).filter_map do |n|
|
|
74
|
+
next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
75
|
+
|
|
76
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
77
|
+
Ast.call_name(inner)&.last
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def each_unscoped_find(body)
|
|
82
|
+
Ast.each_node(body).filter_map do |node|
|
|
83
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
84
|
+
|
|
85
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
86
|
+
receiver_and_name = Ast.call_name(inner)
|
|
87
|
+
next unless receiver_and_name
|
|
88
|
+
|
|
89
|
+
receiver, method_name = receiver_and_name
|
|
90
|
+
next unless FINDER_METHODS.include?(method_name)
|
|
91
|
+
next unless likely_model_receiver?(receiver)
|
|
92
|
+
|
|
93
|
+
args = Ast.call_arguments(node)
|
|
94
|
+
next unless args.any? { |a| Ast.references_params?(a) }
|
|
95
|
+
|
|
96
|
+
[node, method_name]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def likely_model_receiver?(receiver)
|
|
101
|
+
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
|
+
|
|
104
|
+
const_node = receiver[1]
|
|
105
|
+
return false unless const_node.is_a?(Array) && const_node[0] == :@const
|
|
106
|
+
|
|
107
|
+
!NON_MODEL_RECEIVERS.include?(const_node[1])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
|
|
16
|
+
def scan
|
|
17
|
+
findings = []
|
|
18
|
+
|
|
19
|
+
Ast.each_node(sexp) do |node|
|
|
20
|
+
next unless Ast.tagged?(node, :assign)
|
|
21
|
+
|
|
22
|
+
target = node[1]
|
|
23
|
+
next unless Ast.tagged?(target, :field)
|
|
24
|
+
next unless Ast.ident_text(target[3]) == "cookies_serializer"
|
|
25
|
+
next unless Ast.literal_text(node[2]) == "marshal"
|
|
26
|
+
|
|
27
|
+
line = Ast.line_of(node)
|
|
28
|
+
findings << finding(
|
|
29
|
+
line: line,
|
|
30
|
+
message: "`cookies_serializer = :marshal` deserializes every cookie with " \
|
|
31
|
+
"`Marshal.load` — if the app's `secret_key_base` is ever leaked (or brute " \
|
|
32
|
+
"forced), a forged cookie deserialized this way can lead to remote code " \
|
|
33
|
+
"execution, not just a spoofed session.",
|
|
34
|
+
suggested_fix: "Use the default `:json` serializer instead (remove this line, or set " \
|
|
35
|
+
"`config.action_dispatch.cookies_serializer = :json` explicitly) — it " \
|
|
36
|
+
"can only produce plain data structures, never arbitrary objects."
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
findings
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
|
|
23
|
+
PERFORM_METHODS = %w[perform_async perform_later perform_now].freeze
|
|
24
|
+
|
|
25
|
+
def scan
|
|
26
|
+
findings = []
|
|
27
|
+
|
|
28
|
+
Ast.each_node(sexp) do |node|
|
|
29
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
30
|
+
|
|
31
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
32
|
+
receiver_and_name = Ast.call_name(inner)
|
|
33
|
+
next unless receiver_and_name
|
|
34
|
+
|
|
35
|
+
_receiver, method_name = receiver_and_name
|
|
36
|
+
next unless PERFORM_METHODS.include?(method_name)
|
|
37
|
+
|
|
38
|
+
args = Ast.call_arguments(node)
|
|
39
|
+
next unless args.any? { |a| raw_params_reference?(a) }
|
|
40
|
+
|
|
41
|
+
line = Ast.line_of(node)
|
|
42
|
+
findings << finding(
|
|
43
|
+
line: line,
|
|
44
|
+
message: "`#{method_name}` is called with an argument that references `params` — " \
|
|
45
|
+
"Sidekiq stores job arguments in Redis in plaintext (visible in its web UI), " \
|
|
46
|
+
"and both Sidekiq and ActiveJob log job arguments by default, so anything in " \
|
|
47
|
+
"the raw params hash (passwords, tokens, other sensitive fields) can end up " \
|
|
48
|
+
"somewhere it wasn't meant to be readable.",
|
|
49
|
+
suggested_fix: "Extract only the specific id/value(s) the job actually needs into " \
|
|
50
|
+
"local variables before enqueuing (e.g. `id = params[:id]; " \
|
|
51
|
+
"#{method_name}(id)`), instead of passing `params` (or a subscript of " \
|
|
52
|
+
"it) straight through, and have the job re-fetch/re-derive anything " \
|
|
53
|
+
"else it needs from that id."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
findings
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# True if `node`'s subtree references `params` in a way broader than a
|
|
63
|
+
# single-key subscript (`params[:id]`) — a bare `params` value, or
|
|
64
|
+
# `params` as the receiver of anything other than `[]` (`.to_json`,
|
|
65
|
+
# `.permit`, `.to_h`, `.dig`, ...). `params[:id]` (and chained scalar
|
|
66
|
+
# coercion on the result, like `params[:id].to_i`) is the extremely
|
|
67
|
+
# common, safe idiom of pulling one field out — flagging every such
|
|
68
|
+
# call would make this rule useless noise. Anything else risks the
|
|
69
|
+
# raw/broad params data actually reaching Sidekiq's log/Redis/UI.
|
|
70
|
+
def raw_params_reference?(node)
|
|
71
|
+
found = false
|
|
72
|
+
|
|
73
|
+
walk = lambda do |n|
|
|
74
|
+
next if found || !n.is_a?(Array)
|
|
75
|
+
|
|
76
|
+
if Ast.tagged?(n, :aref) && bare_params?(n[1])
|
|
77
|
+
walk.call(n[2]) if n[2].is_a?(Array)
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if bare_params?(n)
|
|
82
|
+
found = true
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
n.each { |c| walk.call(c) if c.is_a?(Array) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
walk.call(node)
|
|
90
|
+
found
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bare_params?(node)
|
|
94
|
+
Ast.tagged?(node, :vcall, :var_ref, :fcall) && Ast.ident_text(node[1]) == "params"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
|
|
19
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
20
|
+
|
|
21
|
+
def scan
|
|
22
|
+
findings = []
|
|
23
|
+
|
|
24
|
+
Ast.each_node(sexp) do |node|
|
|
25
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
26
|
+
|
|
27
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
28
|
+
receiver_and_name = Ast.call_name(inner)
|
|
29
|
+
next unless receiver_and_name
|
|
30
|
+
|
|
31
|
+
receiver, method_name = receiver_and_name
|
|
32
|
+
next unless const_receiver_name(receiver) == "JWT" && %w[decode encode].include?(method_name)
|
|
33
|
+
|
|
34
|
+
args = Ast.call_arguments(node)
|
|
35
|
+
line = Ast.line_of(node)
|
|
36
|
+
|
|
37
|
+
findings << verify_bypass_finding(line) if method_name == "decode" && Ast.false_literal?(args[2])
|
|
38
|
+
findings << algorithm_none_finding(method_name, line) if algorithm_none?(node)
|
|
39
|
+
findings.concat(inline_secret_finding(method_name, args, line))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
findings
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def verify_bypass_finding(line)
|
|
48
|
+
finding(
|
|
49
|
+
line: line,
|
|
50
|
+
message: "`JWT.decode` is called with `false` for `verify` — signature verification is " \
|
|
51
|
+
"disabled entirely, so any caller can hand this code a forged token with " \
|
|
52
|
+
"arbitrary claims and it will be accepted as valid.",
|
|
53
|
+
suggested_fix: "Pass `true` for `verify` and supply the correct `algorithm:` option, e.g. " \
|
|
54
|
+
"`JWT.decode(token, secret, true, algorithm: 'HS256')`."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def algorithm_none?(node)
|
|
59
|
+
Ast.each_node(node).any? do |n|
|
|
60
|
+
next false unless Ast.tagged?(n, :assoc_new)
|
|
61
|
+
|
|
62
|
+
key = assoc_key_text(n[1])
|
|
63
|
+
next false unless key && %w[algorithm alg].include?(key.downcase)
|
|
64
|
+
|
|
65
|
+
value = assoc_value_text(n[2])
|
|
66
|
+
value&.downcase == "none"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def algorithm_none_finding(method_name, line)
|
|
71
|
+
finding(
|
|
72
|
+
line: line,
|
|
73
|
+
message: "`JWT.#{method_name}` is called with `algorithm: 'none'` — the \"none\" " \
|
|
74
|
+
"algorithm means the token carries no signature at all, so its claims can be " \
|
|
75
|
+
"freely modified/forged by anyone.",
|
|
76
|
+
suggested_fix: "Use a real signing algorithm (e.g. `algorithm: 'HS256'` or `'RS256'`) and " \
|
|
77
|
+
"never accept `'none'` from user/client input when choosing it."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inline_secret_finding(method_name, args, line)
|
|
82
|
+
secret_arg = args[1]
|
|
83
|
+
secret = secret_arg && Ast.plain_string_value(secret_arg)
|
|
84
|
+
return [] unless secret && !secret.strip.empty? && !PLACEHOLDER_VALUES.match?(secret.strip)
|
|
85
|
+
|
|
86
|
+
[
|
|
87
|
+
finding(
|
|
88
|
+
line: line,
|
|
89
|
+
message: "`JWT.#{method_name}` is called with a literal string as the signing " \
|
|
90
|
+
"secret/key — anyone with read access to this source (including git " \
|
|
91
|
+
"history) can forge or verify tokens with it.",
|
|
92
|
+
suggested_fix: "Move the secret out of source (`ENV.fetch(\"JWT_SECRET\")` or Rails " \
|
|
93
|
+
"encrypted credentials) and rotate this value since it's likely " \
|
|
94
|
+
"already exposed in git history."
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def assoc_key_text(key_node)
|
|
100
|
+
return key_node[1].to_s.delete_suffix(":") if key_node.is_a?(Array) && key_node[0] == :@label
|
|
101
|
+
|
|
102
|
+
Ast.plain_string_value(key_node) || Ast.literal_text(key_node)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def assoc_value_text(value_node)
|
|
106
|
+
Ast.plain_string_value(value_node) || Ast.literal_text(value_node)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def const_receiver_name(node)
|
|
110
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
111
|
+
node[1][1]
|
|
112
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
113
|
+
left = const_receiver_name(node[1])
|
|
114
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
115
|
+
[left, right].compact.join("::")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|