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.
@@ -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