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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +322 -0
  3. data/README.md +546 -43
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +95 -6
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +209 -11
  11. data/lib/scryer/dependency_audit.rb +108 -5
  12. data/lib/scryer/finding.rb +6 -0
  13. data/lib/scryer/fix_verifier.rb +82 -0
  14. data/lib/scryer/minitest.rb +48 -0
  15. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  16. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  18. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  19. data/lib/scryer/report_renderer.rb +637 -44
  20. data/lib/scryer/rspec.rb +55 -0
  21. data/lib/scryer/rule.rb +22 -2
  22. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
  23. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
  24. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
  25. data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  27. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  28. data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
  29. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  30. data/lib/scryer/rules/force_ssl_rule.rb +48 -0
  31. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
  32. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
  33. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
  34. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  35. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  36. data/lib/scryer/rules/idor_rule.rb +165 -0
  37. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
  38. data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
  39. data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
  40. data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
  41. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  42. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  43. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  44. data/lib/scryer/rules/path_traversal_rule.rb +110 -0
  45. data/lib/scryer/rules/security_headers_rule.rb +133 -0
  46. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  47. data/lib/scryer/rules/ssrf_rule.rb +139 -0
  48. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  49. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  50. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  51. data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
  52. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  53. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  54. data/lib/scryer/version.rb +1 -1
  55. data/lib/scryer.rb +15 -0
  56. data/lib/tasks/scryer.rake +138 -13
  57. 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