scryer 1.0.0 → 1.2.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
@@ -21,6 +21,9 @@ module Scryer
21
21
  self.category = "security"
22
22
  self.default_severity = "warning"
23
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"
24
27
 
25
28
  FINDER_METHODS = %w[find find_by find_by!].freeze
26
29
 
@@ -33,7 +36,30 @@ module Scryer
33
36
  Enumerable File Dir
34
37
  ].freeze
35
38
 
36
- AUTHORIZATION_METHODS = %w[authorize authorize! policy_scope can? cannot?].freeze
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
37
63
 
38
64
  def scan
39
65
  findings = []
@@ -41,7 +67,7 @@ module Scryer
41
67
  Ast.each_node(sexp) do |node|
42
68
  next unless Ast.tagged?(node, :class)
43
69
 
44
- class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
70
+ class_name = Ast.class_name(node[1])
45
71
  next unless class_name.to_s.end_with?("Controller")
46
72
 
47
73
  body = node[3]
@@ -69,12 +95,21 @@ module Scryer
69
95
 
70
96
  private
71
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.
72
105
  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)
106
+ Ast.each_node(node).flat_map do |n|
107
+ next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
75
108
 
76
109
  inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
77
- Ast.call_name(inner)&.last
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
78
113
  end
79
114
  end
80
115
 
@@ -97,14 +132,33 @@ module Scryer
97
132
  end
98
133
  end
99
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.
100
147
  def likely_model_receiver?(receiver)
101
148
  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
149
 
104
- const_node = receiver[1]
105
- return false unless const_node.is_a?(Array) && const_node[0] == :@const
150
+ const_name = const_receiver_name(receiver)
151
+ return false unless const_name
152
+
153
+ !NON_MODEL_RECEIVERS.include?(const_name)
154
+ end
106
155
 
107
- !NON_MODEL_RECEIVERS.include?(const_node[1])
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
108
162
  end
109
163
  end
110
164
  end
@@ -12,6 +12,9 @@ module Scryer
12
12
  self.category = "security"
13
13
  self.default_severity = "critical"
14
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"
15
18
 
16
19
  def scan
17
20
  findings = []
@@ -19,6 +19,9 @@ module Scryer
19
19
  self.category = "security"
20
20
  self.default_severity = "warning"
21
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"
22
25
 
23
26
  PERFORM_METHODS = %w[perform_async perform_later perform_now].freeze
24
27
 
@@ -60,13 +63,28 @@ module Scryer
60
63
  private
61
64
 
62
65
  # 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.
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
+
70
88
  def raw_params_reference?(node)
71
89
  found = false
72
90
 
@@ -78,6 +96,10 @@ module Scryer
78
96
  next
79
97
  end
80
98
 
99
+ if narrow_params_call?(n)
100
+ next
101
+ end
102
+
81
103
  if bare_params?(n)
82
104
  found = true
83
105
  next
@@ -90,6 +112,17 @@ module Scryer
90
112
  found
91
113
  end
92
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
+
93
126
  def bare_params?(node)
94
127
  Ast.tagged?(node, :vcall, :var_ref, :fcall) && Ast.ident_text(node[1]) == "params"
95
128
  end
@@ -15,6 +15,9 @@ module Scryer
15
15
  self.category = "security"
16
16
  self.default_severity = "critical"
17
17
  self.title = "Insecure JWT.decode/JWT.encode usage"
18
+ self.cwe = "CWE-347"
19
+ self.owasp_category = "A02:2021-Cryptographic Failures"
20
+ self.confidence = "high"
18
21
 
19
22
  PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
20
23
 
@@ -10,9 +10,28 @@ module Scryer
10
10
  self.category = "security"
11
11
  self.default_severity = "critical"
12
12
  self.title = "Unpermitted mass assignment from params"
13
+ self.cwe = "CWE-915"
14
+ self.owasp_category = "A08:2021-Software and Data Integrity Failures"
15
+ self.confidence = "medium"
13
16
 
14
17
  ASSIGNMENT_METHODS = %w[new create create! update update! assign_attributes attributes=].freeze
15
18
 
19
+ # `update`/`update!`/`assign_attributes`/`attributes=` on an instance
20
+ # variable (`@order.update(params[:order])`) is at least as common a
21
+ # real-world shape as `Model.new(...)`/`Model.create(...)` on a bare
22
+ # constant — the standard `before_action :set_thing` + `@thing.update`
23
+ # scaffold pattern — but was invisible to this rule entirely until
24
+ # found via the accuracy benchmark (benchmark/corpus.rb's
25
+ # "mass_assignment" vulnerable sample), since likely_model_receiver?
26
+ # only accepted a bare constant or implicit (nil) receiver. `.new`/
27
+ # `create`/`create!` deliberately stay constant/nil-only: those verbs
28
+ # only make sense as a receiver-less/class-level call in normal Rails
29
+ # code, whereas an ivar could just as easily be a plain Hash (`Hash#
30
+ # update` is a real `merge!` alias) — restricting the ivar allowance to
31
+ # only the update-style verbs keeps this from also matching things like
32
+ # `@filters.update(params[:filters])` on a Hash.
33
+ IVAR_RECEIVER_METHODS = %w[update update! assign_attributes attributes=].freeze
34
+
16
35
  # Common stdlib/gem constants with their own `.new`/`.create`-style
17
36
  # factory methods that have nothing to do with ActiveRecord mass
18
37
  # assignment (e.g. `BCrypt::Password.create(params[:password])` is
@@ -37,7 +56,7 @@ module Scryer
37
56
 
38
57
  receiver, method_name = receiver_and_name
39
58
  next unless ASSIGNMENT_METHODS.include?(method_name)
40
- next unless likely_model_receiver?(receiver)
59
+ next unless likely_model_receiver?(receiver, method_name)
41
60
 
42
61
  args = Ast.call_arguments(node)
43
62
  next if args.empty?
@@ -64,11 +83,15 @@ module Scryer
64
83
  private
65
84
 
66
85
  # true for an implicit receiver (bare `create(...)` inside the model
67
- # itself) or a plain unnamespaced constant reference (`Order`) that
68
- # isn't a known non-model stdlib/gem constant; false for namespaced
69
- # constant paths (`BCrypt::Password`) or anything else.
70
- def likely_model_receiver?(receiver)
86
+ # itself), a plain unnamespaced constant reference (`Order`) that isn't
87
+ # a known non-model stdlib/gem constant, or — for the update-style
88
+ # verbs only, see IVAR_RECEIVER_METHODS — an instance variable
89
+ # (`@order`); false for namespaced constant paths (`BCrypt::Password`,
90
+ # `Admin::Order` — a real gap, see mass_assignment_rule's class comment
91
+ # and the benchmark corpus's "KNOWN LIMITATION" sample) or anything else.
92
+ def likely_model_receiver?(receiver, method_name)
71
93
  return true if receiver.nil?
94
+ return true if IVAR_RECEIVER_METHODS.include?(method_name) && ivar_receiver?(receiver)
72
95
  return false unless Ast.tagged?(receiver, :var_ref)
73
96
 
74
97
  const_node = receiver[1]
@@ -77,6 +100,10 @@ module Scryer
77
100
  !NON_MODEL_RECEIVERS.include?(const_node[1])
78
101
  end
79
102
 
103
+ def ivar_receiver?(node)
104
+ Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@ivar
105
+ end
106
+
80
107
  # True if `node` references `params` (see Ast.references_params?)
81
108
  # without a `.permit`/`.permit!` call wrapping it (permit! is itself
82
109
  # flagged as unsafe too, so it doesn't count as "safe").
@@ -0,0 +1,103 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags a `*Controller` class that defines `create`/`update`/`destroy`
4
+ # (Rails' three standard write actions) with NO authorization evidence
5
+ # anywhere in the class — broader than IdorRule, which only fires when
6
+ # there's also a params-derived `Model.find` call in the same class.
7
+ # Plenty of write actions don't call `.find` at all (`Model.create(...)`
8
+ # needs no lookup; a `destroy` might act on an object set up in a
9
+ # `before_action`), so IdorRule's narrower pattern misses them even
10
+ # though "this write action has no visible authorization check at all"
11
+ # is exactly as real a concern. Restricted to the three standard write
12
+ # actions (not every public method) specifically to avoid flagging
13
+ # genuinely public controllers (a marketing page, a login form) that
14
+ # have no write actions at all and legitimately need no authorization —
15
+ # a controller that defines `create`/`update`/`destroy` at all is
16
+ # already a much stronger "this manages a real resource" signal than
17
+ # any action name would be.
18
+ #
19
+ # Expect overlap with `idor` on controllers that both `.find` a record
20
+ # AND have no authorization — that's two different rules independently
21
+ # confirming the same underlying gap from two different code shapes
22
+ # (a lookup vs. a write action), not double-counting a bug.
23
+ class MissingAuthorizationRule < Rule
24
+ self.rule_id = "missing_authorization"
25
+ self.category = "security"
26
+ self.default_severity = "warning"
27
+ self.title = "Write action with no visible authorization check"
28
+ self.cwe = "CWE-862"
29
+ self.owasp_category = "A01:2021-Broken Access Control"
30
+ self.confidence = "low"
31
+
32
+ WRITE_ACTIONS = %w[create update destroy].freeze
33
+
34
+ # Duplicated from IdorRule intentionally, same as NON_MODEL_RECEIVERS
35
+ # is duplicated between IdorRule and MassAssignmentRule — a small,
36
+ # stable list not worth a shared-module indirection for two callers.
37
+ AUTHORIZATION_METHODS = %w[
38
+ authorize authorize! policy_scope can? cannot?
39
+ load_and_authorize_resource authorize_resource
40
+ verify_authorized verify_policy_scoped
41
+ ].freeze
42
+
43
+ def scan
44
+ findings = []
45
+
46
+ Ast.each_node(sexp) do |node|
47
+ next unless Ast.tagged?(node, :class)
48
+
49
+ class_name = Ast.class_name(node[1])
50
+ next unless class_name.to_s.end_with?("Controller")
51
+
52
+ body = node[3]
53
+ next if each_call_names(body).any? { |name| AUTHORIZATION_METHODS.include?(name) }
54
+
55
+ each_write_action_def(body).each do |def_node, action_name|
56
+ findings << finding(
57
+ line: Ast.line_of(def_node),
58
+ message: "`#{class_name}##{action_name}` writes data (a standard Rails write " \
59
+ "action), and `#{class_name}` has no visible authorization check anywhere " \
60
+ "in it (no `authorize`, `policy_scope`, `can?`/`cannot?`, or CanCanCan/" \
61
+ "Pundit callback) — any logged-in (or, if this controller skips " \
62
+ "authentication too, any) user may be able to call this action.",
63
+ suggested_fix: "Add an explicit authorization check before the write happens — " \
64
+ "`authorize @thing` (Pundit) or `authorize! :#{action_name}, @thing` " \
65
+ "(CanCanCan) — or `load_and_authorize_resource`/`after_action " \
66
+ ":verify_authorized` at the class level if every action here should " \
67
+ "be gated the same way."
68
+ )
69
+ end
70
+ end
71
+
72
+ findings
73
+ end
74
+
75
+ private
76
+
77
+ # Same "call name or literal symbol/string argument" gathering as
78
+ # IdorRule#each_call_names — see that method's comment for why the
79
+ # argument half matters (Pundit's verify_authorized/verify_policy_scoped
80
+ # are registered as before_action/after_action callback names, not
81
+ # called directly).
82
+ def each_call_names(node)
83
+ Ast.each_node(node).flat_map do |n|
84
+ next [] unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
85
+
86
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
87
+ name = Ast.call_name(inner)&.last
88
+ arg_names = Ast.call_arguments(n).filter_map { |a| Ast.literal_text(a) }
89
+ [name, *arg_names].compact
90
+ end
91
+ end
92
+
93
+ def each_write_action_def(body)
94
+ Ast.each_node(body).filter_map do |n|
95
+ next unless Ast.tagged?(n, :def)
96
+
97
+ name = Ast.ident_text(n[1])
98
+ [n, name] if WRITE_ACTIONS.include?(name)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,134 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags a controller's `index` action that queries a model directly
4
+ # (`Model.all`/`Model.where(...)`) instead of through Pundit's
5
+ # `policy_scope`, in a controller that clearly *uses* Pundit elsewhere
6
+ # (an `authorize` call appears somewhere in the class) — the well-known
7
+ # Pundit gotcha where a team remembers `authorize` on show/update/destroy
8
+ # (each of which checks one record) but forgets that `index` needs
9
+ # `policy_scope` instead, since there's no single record for `authorize`
10
+ # to check. Scoped to controllers that already show Pundit usage
11
+ # specifically so this doesn't fire on apps that don't use Pundit at all
12
+ # (no `authorize` anywhere) or that scope their index some other way
13
+ # entirely (a `current_user.posts.all` association instead of a bare
14
+ # `Post.all` is already scoped, and isn't flagged — see
15
+ # `references_unscoped_query?`).
16
+ #
17
+ # Same looseness as MassAssignmentRule#has_permit_call? and
18
+ # PathTraversalRule#sanitized_via_basename?: checks whether
19
+ # `policy_scope` appears *anywhere* in the `index` method body, not that
20
+ # it specifically wraps the `Model.all`/`.where` call — good enough to
21
+ # catch the common case (a bare unscoped query with no policy_scope call
22
+ # at all in sight) without a much harder "does this specific call sit
23
+ # inside that specific block" analysis.
24
+ class MissingPolicyScopeRule < Rule
25
+ self.rule_id = "missing_policy_scope"
26
+ self.category = "security"
27
+ self.default_severity = "warning"
28
+ self.title = "index action queries a model without policy_scope"
29
+ self.cwe = "CWE-862"
30
+ self.owasp_category = "A01:2021-Broken Access Control"
31
+ self.confidence = "medium"
32
+
33
+ UNSCOPED_METHODS = %w[all where].freeze
34
+ # Same list IdorRule/MassAssignmentRule use for the same reason —
35
+ # stdlib/gem constants with their own `.all`/`.where`-shaped methods
36
+ # that have nothing to do with an ActiveRecord model query.
37
+ NON_MODEL_RECEIVERS = %w[
38
+ Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
39
+ JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
40
+ Enumerable File Dir
41
+ ].freeze
42
+
43
+ def scan
44
+ findings = []
45
+
46
+ Ast.each_node(sexp) do |node|
47
+ next unless Ast.tagged?(node, :class)
48
+
49
+ class_name = Ast.class_name(node[1])
50
+ next unless class_name.to_s.end_with?("Controller")
51
+
52
+ body = node[3]
53
+ next unless each_call_names(body).include?("authorize") # uses Pundit at all
54
+
55
+ each_index_def(body).each do |def_node|
56
+ index_body = def_node[3]
57
+ next if each_call_names(index_body).include?("policy_scope")
58
+
59
+ query_node = find_unscoped_query(index_body)
60
+ next unless query_node
61
+
62
+ findings << finding(
63
+ line: Ast.line_of(query_node),
64
+ message: "`#{class_name}#index` queries a model directly (`#{describe_call(query_node)}`) " \
65
+ "instead of through `policy_scope` — this controller uses Pundit's `authorize` " \
66
+ "elsewhere, but `index` needs `policy_scope` instead (there's no single record " \
67
+ "for `authorize` to check), so every record is visible here regardless of who's " \
68
+ "allowed to see what.",
69
+ suggested_fix: "Wrap the query in `policy_scope`, e.g. `policy_scope(#{receiver_name(query_node)}).all` " \
70
+ "instead of `#{describe_call(query_node)}`, and define the corresponding " \
71
+ "Scope class in this model's policy."
72
+ )
73
+ end
74
+ end
75
+
76
+ findings
77
+ end
78
+
79
+ private
80
+
81
+ def each_call_names(node)
82
+ Ast.each_node(node).flat_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].compact
87
+ end
88
+ end
89
+
90
+ def each_index_def(body)
91
+ Ast.each_node(body).select { |n| Ast.tagged?(n, :def) && Ast.ident_text(n[1]) == "index" }
92
+ end
93
+
94
+ def find_unscoped_query(node)
95
+ Ast.each_node(node).find do |n|
96
+ next false unless Ast.tagged?(n, :method_add_arg, :call, :command, :command_call)
97
+
98
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
99
+ receiver_and_name = Ast.call_name(inner)
100
+ next false unless receiver_and_name
101
+
102
+ receiver, method_name = receiver_and_name
103
+ UNSCOPED_METHODS.include?(method_name) && likely_model_receiver?(receiver)
104
+ end
105
+ end
106
+
107
+ def likely_model_receiver?(receiver)
108
+ const_name = const_receiver_name(receiver)
109
+ return false unless const_name
110
+
111
+ !NON_MODEL_RECEIVERS.include?(const_name)
112
+ end
113
+
114
+ def const_receiver_name(node)
115
+ if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
116
+ node[1][1]
117
+ elsif Ast.tagged?(node, :const_path_ref) && node[2].is_a?(Array) && node[2][0] == :@const
118
+ node[2][1]
119
+ end
120
+ end
121
+
122
+ def receiver_name(query_node)
123
+ inner = Ast.tagged?(query_node, :method_add_arg) ? query_node[1] : query_node
124
+ const_receiver_name(Ast.call_name(inner)&.first) || "Model"
125
+ end
126
+
127
+ def describe_call(query_node)
128
+ inner = Ast.tagged?(query_node, :method_add_arg) ? query_node[1] : query_node
129
+ receiver, method_name = Ast.call_name(inner)
130
+ "#{const_receiver_name(receiver)}.#{method_name}"
131
+ end
132
+ end
133
+ end
134
+ end
@@ -10,6 +10,9 @@ module Scryer
10
10
  self.category = "security"
11
11
  self.default_severity = "warning"
12
12
  self.title = "Possible open redirect via unvalidated params"
13
+ self.cwe = "CWE-601"
14
+ self.owasp_category = "A01:2021-Broken Access Control"
15
+ self.confidence = "high"
13
16
 
14
17
  def scan
15
18
  findings = []
@@ -10,6 +10,9 @@ module Scryer
10
10
  self.category = "security"
11
11
  self.default_severity = "critical"
12
12
  self.title = "Possible path traversal"
13
+ self.cwe = "CWE-22"
14
+ self.owasp_category = "A01:2021-Broken Access Control"
15
+ self.confidence = "medium"
13
16
 
14
17
  DANGEROUS_CALLS = {
15
18
  "File" => %w[join read open new write delete binread binwrite],
@@ -31,7 +34,25 @@ module Scryer
31
34
  next unless dangerous_call?(receiver, method_name)
32
35
 
33
36
  args = Ast.call_arguments(node)
34
- next unless args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
37
+ # `send_file`/`send_data`'s keyword options (`filename:`, `type:`,
38
+ # `disposition:`) control response metadata (the Content-Disposition
39
+ # filename the browser shows, the content-type header, inline vs.
40
+ # attachment) — none of them ever touch the filesystem. Only the
41
+ # first *positional* argument (the actual path for send_file, the
42
+ # raw bytes for send_data) is ever used as a path, so a `params`
43
+ # reference confined to the keyword-args hash isn't a path
44
+ # traversal risk and would otherwise be a false positive on the
45
+ # extremely common `send_file path, filename: params[:filename]`
46
+ # idiom. This exclusion is scoped to BARE_METHODS specifically —
47
+ # File.join/Dir.glob/etc. never take a keyword-args hash in
48
+ # practice, so they're unaffected.
49
+ path_args =
50
+ if receiver.nil? && BARE_METHODS.include?(method_name)
51
+ args.reject { |a| Ast.tagged?(a, :bare_assoc_hash) }
52
+ else
53
+ args
54
+ end
55
+ next unless path_args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
35
56
 
36
57
  line = Ast.line_of(node)
37
58
  findings << finding(
@@ -16,6 +16,9 @@ module Scryer
16
16
  self.category = "security"
17
17
  self.default_severity = "critical"
18
18
  self.title = "Rails default security header explicitly disabled"
19
+ self.cwe = "CWE-1021"
20
+ self.owasp_category = "A05:2021-Security Misconfiguration"
21
+ self.confidence = "high"
19
22
 
20
23
  HEADERS = ["X-Frame-Options", "X-Content-Type-Options"].freeze
21
24
 
@@ -10,6 +10,9 @@ module Scryer
10
10
  self.category = "security"
11
11
  self.default_severity = "critical"
12
12
  self.title = "Possible SQL injection via string interpolation"
13
+ self.cwe = "CWE-89"
14
+ self.owasp_category = "A03:2021-Injection"
15
+ self.confidence = "high"
13
16
 
14
17
  QUERY_METHODS = %w[
15
18
  where find_by find_by! order pluck select group having