scryer 0.3.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 +69 -0
- data/README.md +104 -19
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +20 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +102 -2
- 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,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
|
|
@@ -77,16 +77,13 @@ module Scryer
|
|
|
77
77
|
!NON_MODEL_RECEIVERS.include?(const_node[1])
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
# True if `node`
|
|
80
|
+
# True if `node` references `params` (see Ast.references_params?)
|
|
81
81
|
# without a `.permit`/`.permit!` call wrapping it (permit! is itself
|
|
82
82
|
# flagged as unsafe too, so it doesn't count as "safe").
|
|
83
83
|
def references_raw_params?(node)
|
|
84
|
-
return false unless node.is_a?(Array)
|
|
85
84
|
return false if has_permit_call?(node)
|
|
86
85
|
|
|
87
|
-
Ast.
|
|
88
|
-
Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
|
|
89
|
-
end
|
|
86
|
+
Ast.references_params?(node)
|
|
90
87
|
end
|
|
91
88
|
|
|
92
89
|
def has_permit_call?(node)
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
|
|
14
|
+
DANGEROUS_CALLS = {
|
|
15
|
+
"File" => %w[join read open new write delete binread binwrite],
|
|
16
|
+
"Dir" => %w[glob entries]
|
|
17
|
+
}.freeze
|
|
18
|
+
BARE_METHODS = %w[send_file send_data].freeze
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
Ast.each_node(sexp) do |node|
|
|
24
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
25
|
+
|
|
26
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
27
|
+
receiver_and_name = Ast.call_name(inner)
|
|
28
|
+
next unless receiver_and_name
|
|
29
|
+
|
|
30
|
+
receiver, method_name = receiver_and_name
|
|
31
|
+
next unless dangerous_call?(receiver, method_name)
|
|
32
|
+
|
|
33
|
+
args = Ast.call_arguments(node)
|
|
34
|
+
next unless args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
|
|
35
|
+
|
|
36
|
+
line = Ast.line_of(node)
|
|
37
|
+
findings << finding(
|
|
38
|
+
line: line,
|
|
39
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a path/argument " \
|
|
40
|
+
"that references `params` — a value like `../../config/master.key` reaches " \
|
|
41
|
+
"the filesystem unchanged, letting an attacker read or write files outside " \
|
|
42
|
+
"whatever directory this was meant to be scoped to.",
|
|
43
|
+
suggested_fix: "Reduce the input to a safe basename before using it " \
|
|
44
|
+
"(`File.basename(params[:name])`), and/or verify the resolved path " \
|
|
45
|
+
"stays inside the intended directory (compare " \
|
|
46
|
+
"`File.expand_path(...)` against the allowed root) before touching " \
|
|
47
|
+
"the filesystem."
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
findings
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def dangerous_call?(receiver, method_name)
|
|
57
|
+
return true if receiver.nil? && BARE_METHODS.include?(method_name)
|
|
58
|
+
|
|
59
|
+
DANGEROUS_CALLS[receiver_name(receiver)]&.include?(method_name) || false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe_call(receiver, method_name)
|
|
63
|
+
name = receiver_name(receiver)
|
|
64
|
+
name ? "#{name}.#{method_name}" : method_name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def receiver_name(node)
|
|
68
|
+
return nil unless Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
69
|
+
|
|
70
|
+
node[1][1]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True if `File.basename(...)` appears anywhere in the argument's
|
|
74
|
+
# subtree — the standard way to strip directory-traversal segments
|
|
75
|
+
# before using a request-controlled filename. Same "does the guard
|
|
76
|
+
# appear anywhere in this expression" looseness as
|
|
77
|
+
# MassAssignmentRule#has_permit_call? — doesn't verify it wraps
|
|
78
|
+
# *exactly* the params reference, just that it's present.
|
|
79
|
+
def sanitized_via_basename?(node)
|
|
80
|
+
Ast.each_node(node).any? do |n|
|
|
81
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
82
|
+
|
|
83
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
84
|
+
Ast.call_name(inner)&.last == "basename"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags an explicit insecure override of Rails' default security
|
|
4
|
+
# headers/config — NOT absence of a header, which would require knowing
|
|
5
|
+
# every environment file and initializer in the app (too noisy/
|
|
6
|
+
# environment-dependent for a per-file static rule). Only the explicit
|
|
7
|
+
# opt-out is a reliable, low-noise signal on its own — same reasoning as
|
|
8
|
+
# ForceSslRule.
|
|
9
|
+
#
|
|
10
|
+
# config.action_dispatch.default_headers['X-Frame-Options'] = 'ALLOWALL' (or false)
|
|
11
|
+
# config.action_dispatch.default_headers['X-Content-Type-Options'] = false
|
|
12
|
+
# config.action_dispatch.default_headers.merge!('X-Frame-Options' => 'ALLOWALL', ...)
|
|
13
|
+
# config.content_security_policy = nil
|
|
14
|
+
class SecurityHeadersRule < Rule
|
|
15
|
+
self.rule_id = "security_headers_disabled"
|
|
16
|
+
self.category = "security"
|
|
17
|
+
self.default_severity = "critical"
|
|
18
|
+
self.title = "Rails default security header explicitly disabled"
|
|
19
|
+
|
|
20
|
+
HEADERS = ["X-Frame-Options", "X-Content-Type-Options"].freeze
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
|
|
25
|
+
Ast.each_node(sexp) do |node|
|
|
26
|
+
if Ast.tagged?(node, :assign)
|
|
27
|
+
scan_assign(findings, node)
|
|
28
|
+
elsif Ast.tagged?(node, :method_add_arg)
|
|
29
|
+
scan_merge_bang(findings, node)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
findings
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def scan_assign(findings, node)
|
|
39
|
+
target = node[1]
|
|
40
|
+
value = node[2]
|
|
41
|
+
|
|
42
|
+
if Ast.tagged?(target, :aref_field) && default_headers_receiver?(target[1])
|
|
43
|
+
key = Ast.unwrap_args(target[2]).first
|
|
44
|
+
header = Ast.plain_string_value(key)
|
|
45
|
+
add_header_finding(findings, header, value, Ast.line_of(node)) if header
|
|
46
|
+
elsif Ast.tagged?(target, :field) && Ast.ident_text(target[3]) == "content_security_policy"
|
|
47
|
+
return unless Ast.kw_literal?(value) == "nil"
|
|
48
|
+
|
|
49
|
+
findings << finding(
|
|
50
|
+
line: Ast.line_of(node),
|
|
51
|
+
message: "`config.content_security_policy = nil` explicitly disables Content-Security-Policy — " \
|
|
52
|
+
"a header that mitigates XSS/data-injection by restricting which sources scripts, " \
|
|
53
|
+
"styles, and other resources can load from.",
|
|
54
|
+
suggested_fix: "Configure an actual policy instead of nil-ing it out — see " \
|
|
55
|
+
"`config.content_security_policy do |policy| ... end` in " \
|
|
56
|
+
"config/initializers/content_security_policy.rb."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def scan_merge_bang(findings, node)
|
|
62
|
+
inner = node[1]
|
|
63
|
+
name_pair = Ast.call_name(inner)
|
|
64
|
+
return unless name_pair && name_pair[1] == "merge!"
|
|
65
|
+
return unless default_headers_receiver?(name_pair[0])
|
|
66
|
+
|
|
67
|
+
each_assoc_pair(node).each do |key_node, value_node|
|
|
68
|
+
header = Ast.plain_string_value(key_node)
|
|
69
|
+
add_header_finding(findings, header, value_node, Ast.line_of(node)) if header
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_header_finding(findings, header, value_node, line)
|
|
74
|
+
return unless HEADERS.include?(header)
|
|
75
|
+
return unless disabling_value?(header, value_node)
|
|
76
|
+
|
|
77
|
+
findings << finding(
|
|
78
|
+
line: line,
|
|
79
|
+
message: header_message(header),
|
|
80
|
+
suggested_fix: header_fix(header)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def disabling_value?(header, value_node)
|
|
85
|
+
return true if Ast.false_literal?(value_node) || Ast.kw_literal?(value_node) == "nil"
|
|
86
|
+
|
|
87
|
+
header == "X-Frame-Options" && Ast.plain_string_value(value_node).to_s.upcase == "ALLOWALL"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def header_message(header)
|
|
91
|
+
case header
|
|
92
|
+
when "X-Frame-Options"
|
|
93
|
+
"The `X-Frame-Options` header is explicitly overridden to allow framing (`ALLOWALL`/falsy) — " \
|
|
94
|
+
"this removes Rails' default clickjacking protection, letting this app be embedded in an " \
|
|
95
|
+
"attacker-controlled `<iframe>`."
|
|
96
|
+
when "X-Content-Type-Options"
|
|
97
|
+
"The `X-Content-Type-Options` header is explicitly disabled — this removes Rails' default " \
|
|
98
|
+
"MIME-sniffing protection, letting browsers reinterpret a response's content type (e.g. " \
|
|
99
|
+
"executing an uploaded file as script)."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def header_fix(header)
|
|
104
|
+
case header
|
|
105
|
+
when "X-Frame-Options"
|
|
106
|
+
"Remove this override (Rails defaults to `SAMEORIGIN`), or set an explicit safe value if " \
|
|
107
|
+
"framing from a specific trusted origin is actually needed."
|
|
108
|
+
when "X-Content-Type-Options"
|
|
109
|
+
"Remove this override — leave `X-Content-Type-Options` at Rails' default (`nosniff`)."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True if `node` is a call chain ending in `.default_headers` (any
|
|
114
|
+
# receiver — usually `config.action_dispatch`, but the receiver chain
|
|
115
|
+
# itself isn't verified, same looseness as WeakSessionCookieRule not
|
|
116
|
+
# verifying its receiver is really `config`).
|
|
117
|
+
def default_headers_receiver?(node)
|
|
118
|
+
Ast.call_name(node)&.last == "default_headers"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def each_assoc_pair(call_node)
|
|
122
|
+
Ast.call_arguments(call_node).each_with_object([]) do |arg, acc|
|
|
123
|
+
next unless Ast.tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)
|
|
124
|
+
|
|
125
|
+
arg[1].each { |p| acc << [p[1], p[2]] if Ast.tagged?(p, :assoc_new) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|