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,123 @@
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
+ self.cwe = "CWE-347"
19
+ self.owasp_category = "A02:2021-Cryptographic Failures"
20
+ self.confidence = "high"
21
+
22
+ PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
23
+
24
+ def scan
25
+ findings = []
26
+
27
+ Ast.each_node(sexp) do |node|
28
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
29
+
30
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
31
+ receiver_and_name = Ast.call_name(inner)
32
+ next unless receiver_and_name
33
+
34
+ receiver, method_name = receiver_and_name
35
+ next unless const_receiver_name(receiver) == "JWT" && %w[decode encode].include?(method_name)
36
+
37
+ args = Ast.call_arguments(node)
38
+ line = Ast.line_of(node)
39
+
40
+ findings << verify_bypass_finding(line) if method_name == "decode" && Ast.false_literal?(args[2])
41
+ findings << algorithm_none_finding(method_name, line) if algorithm_none?(node)
42
+ findings.concat(inline_secret_finding(method_name, args, line))
43
+ end
44
+
45
+ findings
46
+ end
47
+
48
+ private
49
+
50
+ def verify_bypass_finding(line)
51
+ finding(
52
+ line: line,
53
+ message: "`JWT.decode` is called with `false` for `verify` — signature verification is " \
54
+ "disabled entirely, so any caller can hand this code a forged token with " \
55
+ "arbitrary claims and it will be accepted as valid.",
56
+ suggested_fix: "Pass `true` for `verify` and supply the correct `algorithm:` option, e.g. " \
57
+ "`JWT.decode(token, secret, true, algorithm: 'HS256')`."
58
+ )
59
+ end
60
+
61
+ def algorithm_none?(node)
62
+ Ast.each_node(node).any? do |n|
63
+ next false unless Ast.tagged?(n, :assoc_new)
64
+
65
+ key = assoc_key_text(n[1])
66
+ next false unless key && %w[algorithm alg].include?(key.downcase)
67
+
68
+ value = assoc_value_text(n[2])
69
+ value&.downcase == "none"
70
+ end
71
+ end
72
+
73
+ def algorithm_none_finding(method_name, line)
74
+ finding(
75
+ line: line,
76
+ message: "`JWT.#{method_name}` is called with `algorithm: 'none'` — the \"none\" " \
77
+ "algorithm means the token carries no signature at all, so its claims can be " \
78
+ "freely modified/forged by anyone.",
79
+ suggested_fix: "Use a real signing algorithm (e.g. `algorithm: 'HS256'` or `'RS256'`) and " \
80
+ "never accept `'none'` from user/client input when choosing it."
81
+ )
82
+ end
83
+
84
+ def inline_secret_finding(method_name, args, line)
85
+ secret_arg = args[1]
86
+ secret = secret_arg && Ast.plain_string_value(secret_arg)
87
+ return [] unless secret && !secret.strip.empty? && !PLACEHOLDER_VALUES.match?(secret.strip)
88
+
89
+ [
90
+ finding(
91
+ line: line,
92
+ message: "`JWT.#{method_name}` is called with a literal string as the signing " \
93
+ "secret/key — anyone with read access to this source (including git " \
94
+ "history) can forge or verify tokens with it.",
95
+ suggested_fix: "Move the secret out of source (`ENV.fetch(\"JWT_SECRET\")` or Rails " \
96
+ "encrypted credentials) and rotate this value since it's likely " \
97
+ "already exposed in git history."
98
+ )
99
+ ]
100
+ end
101
+
102
+ def assoc_key_text(key_node)
103
+ return key_node[1].to_s.delete_suffix(":") if key_node.is_a?(Array) && key_node[0] == :@label
104
+
105
+ Ast.plain_string_value(key_node) || Ast.literal_text(key_node)
106
+ end
107
+
108
+ def assoc_value_text(value_node)
109
+ Ast.plain_string_value(value_node) || Ast.literal_text(value_node)
110
+ end
111
+
112
+ def const_receiver_name(node)
113
+ if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
114
+ node[1][1]
115
+ elsif Ast.tagged?(node, :const_path_ref)
116
+ left = const_receiver_name(node[1])
117
+ right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
118
+ [left, right].compact.join("::")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -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,16 +100,17 @@ module Scryer
77
100
  !NON_MODEL_RECEIVERS.include?(const_node[1])
78
101
  end
79
102
 
80
- # True if `node` is `params`, `params[:x]`, or contains such a reference
103
+ def ivar_receiver?(node)
104
+ Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@ivar
105
+ end
106
+
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").
83
110
  def references_raw_params?(node)
84
- return false unless node.is_a?(Array)
85
111
  return false if has_permit_call?(node)
86
112
 
87
- Ast.each_node(node).any? do |n|
88
- Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
89
- end
113
+ Ast.references_params?(node)
90
114
  end
91
115
 
92
116
  def has_permit_call?(node)
@@ -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 = []
@@ -0,0 +1,110 @@
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
+ self.cwe = "CWE-22"
14
+ self.owasp_category = "A01:2021-Broken Access Control"
15
+ self.confidence = "medium"
16
+
17
+ DANGEROUS_CALLS = {
18
+ "File" => %w[join read open new write delete binread binwrite],
19
+ "Dir" => %w[glob entries]
20
+ }.freeze
21
+ BARE_METHODS = %w[send_file send_data].freeze
22
+
23
+ def scan
24
+ findings = []
25
+
26
+ Ast.each_node(sexp) do |node|
27
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
28
+
29
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
30
+ receiver_and_name = Ast.call_name(inner)
31
+ next unless receiver_and_name
32
+
33
+ receiver, method_name = receiver_and_name
34
+ next unless dangerous_call?(receiver, method_name)
35
+
36
+ args = Ast.call_arguments(node)
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) }
56
+
57
+ line = Ast.line_of(node)
58
+ findings << finding(
59
+ line: line,
60
+ message: "`#{describe_call(receiver, method_name)}` is called with a path/argument " \
61
+ "that references `params` — a value like `../../config/master.key` reaches " \
62
+ "the filesystem unchanged, letting an attacker read or write files outside " \
63
+ "whatever directory this was meant to be scoped to.",
64
+ suggested_fix: "Reduce the input to a safe basename before using it " \
65
+ "(`File.basename(params[:name])`), and/or verify the resolved path " \
66
+ "stays inside the intended directory (compare " \
67
+ "`File.expand_path(...)` against the allowed root) before touching " \
68
+ "the filesystem."
69
+ )
70
+ end
71
+
72
+ findings
73
+ end
74
+
75
+ private
76
+
77
+ def dangerous_call?(receiver, method_name)
78
+ return true if receiver.nil? && BARE_METHODS.include?(method_name)
79
+
80
+ DANGEROUS_CALLS[receiver_name(receiver)]&.include?(method_name) || false
81
+ end
82
+
83
+ def describe_call(receiver, method_name)
84
+ name = receiver_name(receiver)
85
+ name ? "#{name}.#{method_name}" : method_name
86
+ end
87
+
88
+ def receiver_name(node)
89
+ return nil unless Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
90
+
91
+ node[1][1]
92
+ end
93
+
94
+ # True if `File.basename(...)` appears anywhere in the argument's
95
+ # subtree — the standard way to strip directory-traversal segments
96
+ # before using a request-controlled filename. Same "does the guard
97
+ # appear anywhere in this expression" looseness as
98
+ # MassAssignmentRule#has_permit_call? — doesn't verify it wraps
99
+ # *exactly* the params reference, just that it's present.
100
+ def sanitized_via_basename?(node)
101
+ Ast.each_node(node).any? do |n|
102
+ next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
103
+
104
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
105
+ Ast.call_name(inner)&.last == "basename"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end