scryer 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `Model.find(params[...])` / `Model.find_by(...params...)` inside
4
+ # a controller where the receiver is a bare, unnamespaced constant (not
5
+ # scoped through e.g. `current_user.things.find(...)`) and no
6
+ # authorization call (`authorize`, `authorize!`, `policy_scope`, `can?`,
7
+ # `cannot?` — the common Pundit/CanCanCan method names) appears anywhere
8
+ # else in the same controller class.
9
+ #
10
+ # This is the least precise rule in the gem, by nature of the problem:
11
+ # whether a given `find` is actually scoped to the current user is a
12
+ # question about the whole app's authorization model, not something
13
+ # visible from one file's AST. Expect real false positives — e.g. an
14
+ # admin-only controller already gated by a class-level `before_action`,
15
+ # or a genuinely global/unowned model (`Country.find(params[:id])`).
16
+ # Treat every finding as "worth a second look," not a confirmed bug —
17
+ # same spirit as CsrfProtectionRule's class-wide safeguard check, applied
18
+ # to a harder problem.
19
+ class IdorRule < Rule
20
+ self.rule_id = "idor"
21
+ self.category = "security"
22
+ self.default_severity = "warning"
23
+ self.title = "Possible insecure direct object reference (IDOR)"
24
+
25
+ FINDER_METHODS = %w[find find_by find_by!].freeze
26
+
27
+ # Same reasoning as MassAssignmentRule::NON_MODEL_RECEIVERS: common
28
+ # stdlib/gem constants with their own `.find`-style methods that have
29
+ # nothing to do with an ActiveRecord model lookup.
30
+ NON_MODEL_RECEIVERS = %w[
31
+ Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
32
+ JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
33
+ Enumerable File Dir
34
+ ].freeze
35
+
36
+ AUTHORIZATION_METHODS = %w[authorize authorize! policy_scope can? cannot?].freeze
37
+
38
+ def scan
39
+ findings = []
40
+
41
+ Ast.each_node(sexp) do |node|
42
+ next unless Ast.tagged?(node, :class)
43
+
44
+ class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
45
+ next unless class_name.to_s.end_with?("Controller")
46
+
47
+ body = node[3]
48
+ next if each_call_names(body).any? { |name| AUTHORIZATION_METHODS.include?(name) }
49
+
50
+ each_unscoped_find(body).each do |find_node, method_name|
51
+ line = Ast.line_of(find_node)
52
+ findings << finding(
53
+ line: line,
54
+ message: "`#{method_name}` looks up a record directly from `params`, and " \
55
+ "`#{class_name}` has no visible authorization check (no `authorize`, " \
56
+ "`policy_scope`, or `can?`/`cannot?` anywhere in it) — if this record " \
57
+ "belongs to a specific user/account, another user may be able to view or " \
58
+ "modify it just by changing the id in the request.",
59
+ suggested_fix: "Scope the lookup to the current actor instead of the bare model, " \
60
+ "e.g. `current_user.things.#{method_name}(params[:id])`, or add an " \
61
+ "explicit authorization check (`authorize @thing` for Pundit, " \
62
+ "`authorize! :show, @thing` for CanCanCan) before using the record."
63
+ )
64
+ end
65
+ end
66
+
67
+ findings
68
+ end
69
+
70
+ private
71
+
72
+ def each_call_names(node)
73
+ Ast.each_node(node).filter_map do |n|
74
+ next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
75
+
76
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
77
+ Ast.call_name(inner)&.last
78
+ end
79
+ end
80
+
81
+ def each_unscoped_find(body)
82
+ Ast.each_node(body).filter_map do |node|
83
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
84
+
85
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
86
+ receiver_and_name = Ast.call_name(inner)
87
+ next unless receiver_and_name
88
+
89
+ receiver, method_name = receiver_and_name
90
+ next unless FINDER_METHODS.include?(method_name)
91
+ next unless likely_model_receiver?(receiver)
92
+
93
+ args = Ast.call_arguments(node)
94
+ next unless args.any? { |a| Ast.references_params?(a) }
95
+
96
+ [node, method_name]
97
+ end
98
+ end
99
+
100
+ def likely_model_receiver?(receiver)
101
+ return false if receiver.nil? # bare find(...) inside the model itself, not a controller lookup
102
+ return false unless Ast.tagged?(receiver, :var_ref)
103
+
104
+ const_node = receiver[1]
105
+ return false unless const_node.is_a?(Array) && const_node[0] == :@const
106
+
107
+ !NON_MODEL_RECEIVERS.include?(const_node[1])
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,44 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `config.action_dispatch.cookies_serializer = :marshal`. Rails
4
+ # defaults to `:json` since Rails 4.1 specifically because deserializing
5
+ # a `Marshal`-encoded cookie can be turned into remote code execution if
6
+ # the cookie's secret is ever compromised (the same class of bug
7
+ # `UnsafeDeserializationRule` flags for `Marshal.load` directly) — only
8
+ # an explicit opt back into `:marshal` is flagged, never the (safe)
9
+ # default of not setting this at all.
10
+ class InsecureCookieSerializerRule < Rule
11
+ self.rule_id = "insecure_cookie_serializer"
12
+ self.category = "security"
13
+ self.default_severity = "critical"
14
+ self.title = "Marshal cookie serializer enabled"
15
+
16
+ def scan
17
+ findings = []
18
+
19
+ Ast.each_node(sexp) do |node|
20
+ next unless Ast.tagged?(node, :assign)
21
+
22
+ target = node[1]
23
+ next unless Ast.tagged?(target, :field)
24
+ next unless Ast.ident_text(target[3]) == "cookies_serializer"
25
+ next unless Ast.literal_text(node[2]) == "marshal"
26
+
27
+ line = Ast.line_of(node)
28
+ findings << finding(
29
+ line: line,
30
+ message: "`cookies_serializer = :marshal` deserializes every cookie with " \
31
+ "`Marshal.load` — if the app's `secret_key_base` is ever leaked (or brute " \
32
+ "forced), a forged cookie deserialized this way can lead to remote code " \
33
+ "execution, not just a spoofed session.",
34
+ suggested_fix: "Use the default `:json` serializer instead (remove this line, or set " \
35
+ "`config.action_dispatch.cookies_serializer = :json` explicitly) — it " \
36
+ "can only produce plain data structures, never arbitrary objects."
37
+ )
38
+ end
39
+
40
+ findings
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,98 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `SomeJob.perform_async(...)` / `.perform_later(...)` /
4
+ # `.perform_now(...)` (or the bare form inside the job's own class) where
5
+ # an argument references `params` more broadly than a single-key
6
+ # subscript — see raw_params_reference? below for exactly what's exempt
7
+ # (`params[:id]` and friends) versus what isn't.
8
+ #
9
+ # Sidekiq stores job arguments in Redis in plaintext and displays them in
10
+ # its web UI; both Sidekiq and ActiveJob log job arguments by default.
11
+ # Passing a raw `params` hash risks leaking whatever it contains —
12
+ # passwords, tokens, full user-submitted fields — into logs, Redis, and
13
+ # the Sidekiq UI, instead of passing just the specific id/value the job
14
+ # actually needs. This is a distinct, well-documented Sidekiq/ActiveJob
15
+ # concern from mass assignment (which is about writing untrusted params
16
+ # to a model, not about where params end up once queued).
17
+ class JobRawParamsRule < Rule
18
+ self.rule_id = "job_raw_params"
19
+ self.category = "security"
20
+ self.default_severity = "warning"
21
+ self.title = "Raw params passed to a background job"
22
+
23
+ PERFORM_METHODS = %w[perform_async perform_later perform_now].freeze
24
+
25
+ def scan
26
+ findings = []
27
+
28
+ Ast.each_node(sexp) do |node|
29
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
30
+
31
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
32
+ receiver_and_name = Ast.call_name(inner)
33
+ next unless receiver_and_name
34
+
35
+ _receiver, method_name = receiver_and_name
36
+ next unless PERFORM_METHODS.include?(method_name)
37
+
38
+ args = Ast.call_arguments(node)
39
+ next unless args.any? { |a| raw_params_reference?(a) }
40
+
41
+ line = Ast.line_of(node)
42
+ findings << finding(
43
+ line: line,
44
+ message: "`#{method_name}` is called with an argument that references `params` — " \
45
+ "Sidekiq stores job arguments in Redis in plaintext (visible in its web UI), " \
46
+ "and both Sidekiq and ActiveJob log job arguments by default, so anything in " \
47
+ "the raw params hash (passwords, tokens, other sensitive fields) can end up " \
48
+ "somewhere it wasn't meant to be readable.",
49
+ suggested_fix: "Extract only the specific id/value(s) the job actually needs into " \
50
+ "local variables before enqueuing (e.g. `id = params[:id]; " \
51
+ "#{method_name}(id)`), instead of passing `params` (or a subscript of " \
52
+ "it) straight through, and have the job re-fetch/re-derive anything " \
53
+ "else it needs from that id."
54
+ )
55
+ end
56
+
57
+ findings
58
+ end
59
+
60
+ private
61
+
62
+ # True if `node`'s subtree references `params` in a way broader than a
63
+ # single-key subscript (`params[:id]`) — a bare `params` value, or
64
+ # `params` as the receiver of anything other than `[]` (`.to_json`,
65
+ # `.permit`, `.to_h`, `.dig`, ...). `params[:id]` (and chained scalar
66
+ # coercion on the result, like `params[:id].to_i`) is the extremely
67
+ # common, safe idiom of pulling one field out — flagging every such
68
+ # call would make this rule useless noise. Anything else risks the
69
+ # raw/broad params data actually reaching Sidekiq's log/Redis/UI.
70
+ def raw_params_reference?(node)
71
+ found = false
72
+
73
+ walk = lambda do |n|
74
+ next if found || !n.is_a?(Array)
75
+
76
+ if Ast.tagged?(n, :aref) && bare_params?(n[1])
77
+ walk.call(n[2]) if n[2].is_a?(Array)
78
+ next
79
+ end
80
+
81
+ if bare_params?(n)
82
+ found = true
83
+ next
84
+ end
85
+
86
+ n.each { |c| walk.call(c) if c.is_a?(Array) }
87
+ end
88
+
89
+ walk.call(node)
90
+ found
91
+ end
92
+
93
+ def bare_params?(node)
94
+ Ast.tagged?(node, :vcall, :var_ref, :fcall) && Ast.ident_text(node[1]) == "params"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,120 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags insecure usage of the `jwt` gem's `JWT.decode`/`JWT.encode`:
4
+ # - `JWT.decode(token, secret, false, ...)` — the third positional arg
5
+ # literal `false` disables signature verification entirely, so any
6
+ # caller can forge a token that decodes successfully.
7
+ # - `algorithm: 'none'` / `'alg' => 'none'` — the "none" algorithm
8
+ # means the token isn't signed at all.
9
+ # - A plain string literal passed directly as the secret/key argument
10
+ # — distinct from HardcodedSecretRule, which only matches
11
+ # `x = "literal"` assignment targets, not a literal inline in a call
12
+ # argument.
13
+ class JwtInsecureRule < Rule
14
+ self.rule_id = "jwt_insecure_usage"
15
+ self.category = "security"
16
+ self.default_severity = "critical"
17
+ self.title = "Insecure JWT.decode/JWT.encode usage"
18
+
19
+ PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(secret|key)|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
20
+
21
+ def scan
22
+ findings = []
23
+
24
+ Ast.each_node(sexp) do |node|
25
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
26
+
27
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
28
+ receiver_and_name = Ast.call_name(inner)
29
+ next unless receiver_and_name
30
+
31
+ receiver, method_name = receiver_and_name
32
+ next unless const_receiver_name(receiver) == "JWT" && %w[decode encode].include?(method_name)
33
+
34
+ args = Ast.call_arguments(node)
35
+ line = Ast.line_of(node)
36
+
37
+ findings << verify_bypass_finding(line) if method_name == "decode" && Ast.false_literal?(args[2])
38
+ findings << algorithm_none_finding(method_name, line) if algorithm_none?(node)
39
+ findings.concat(inline_secret_finding(method_name, args, line))
40
+ end
41
+
42
+ findings
43
+ end
44
+
45
+ private
46
+
47
+ def verify_bypass_finding(line)
48
+ finding(
49
+ line: line,
50
+ message: "`JWT.decode` is called with `false` for `verify` — signature verification is " \
51
+ "disabled entirely, so any caller can hand this code a forged token with " \
52
+ "arbitrary claims and it will be accepted as valid.",
53
+ suggested_fix: "Pass `true` for `verify` and supply the correct `algorithm:` option, e.g. " \
54
+ "`JWT.decode(token, secret, true, algorithm: 'HS256')`."
55
+ )
56
+ end
57
+
58
+ def algorithm_none?(node)
59
+ Ast.each_node(node).any? do |n|
60
+ next false unless Ast.tagged?(n, :assoc_new)
61
+
62
+ key = assoc_key_text(n[1])
63
+ next false unless key && %w[algorithm alg].include?(key.downcase)
64
+
65
+ value = assoc_value_text(n[2])
66
+ value&.downcase == "none"
67
+ end
68
+ end
69
+
70
+ def algorithm_none_finding(method_name, line)
71
+ finding(
72
+ line: line,
73
+ message: "`JWT.#{method_name}` is called with `algorithm: 'none'` — the \"none\" " \
74
+ "algorithm means the token carries no signature at all, so its claims can be " \
75
+ "freely modified/forged by anyone.",
76
+ suggested_fix: "Use a real signing algorithm (e.g. `algorithm: 'HS256'` or `'RS256'`) and " \
77
+ "never accept `'none'` from user/client input when choosing it."
78
+ )
79
+ end
80
+
81
+ def inline_secret_finding(method_name, args, line)
82
+ secret_arg = args[1]
83
+ secret = secret_arg && Ast.plain_string_value(secret_arg)
84
+ return [] unless secret && !secret.strip.empty? && !PLACEHOLDER_VALUES.match?(secret.strip)
85
+
86
+ [
87
+ finding(
88
+ line: line,
89
+ message: "`JWT.#{method_name}` is called with a literal string as the signing " \
90
+ "secret/key — anyone with read access to this source (including git " \
91
+ "history) can forge or verify tokens with it.",
92
+ suggested_fix: "Move the secret out of source (`ENV.fetch(\"JWT_SECRET\")` or Rails " \
93
+ "encrypted credentials) and rotate this value since it's likely " \
94
+ "already exposed in git history."
95
+ )
96
+ ]
97
+ end
98
+
99
+ def assoc_key_text(key_node)
100
+ return key_node[1].to_s.delete_suffix(":") if key_node.is_a?(Array) && key_node[0] == :@label
101
+
102
+ Ast.plain_string_value(key_node) || Ast.literal_text(key_node)
103
+ end
104
+
105
+ def assoc_value_text(value_node)
106
+ Ast.plain_string_value(value_node) || Ast.literal_text(value_node)
107
+ end
108
+
109
+ def const_receiver_name(node)
110
+ if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
111
+ node[1][1]
112
+ elsif Ast.tagged?(node, :const_path_ref)
113
+ left = const_receiver_name(node[1])
114
+ right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
115
+ [left, right].compact.join("::")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -77,16 +77,13 @@ module Scryer
77
77
  !NON_MODEL_RECEIVERS.include?(const_node[1])
78
78
  end
79
79
 
80
- # True if `node` is `params`, `params[:x]`, or contains such a reference
80
+ # True if `node` references `params` (see Ast.references_params?)
81
81
  # without a `.permit`/`.permit!` call wrapping it (permit! is itself
82
82
  # flagged as unsafe too, so it doesn't count as "safe").
83
83
  def references_raw_params?(node)
84
- return false unless node.is_a?(Array)
85
84
  return false if has_permit_call?(node)
86
85
 
87
- Ast.each_node(node).any? do |n|
88
- Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
89
- end
86
+ Ast.references_params?(node)
90
87
  end
91
88
 
92
89
  def has_permit_call?(node)
@@ -0,0 +1,89 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags a filesystem operation (`File.join/read/open/new/write/delete`,
4
+ # `Dir.glob/entries`, `send_file`) where an argument references `params`
5
+ # directly — without sanitization, `../../etc/passwd`-style path
6
+ # segments in the request let an attacker read (or write/delete) files
7
+ # outside whatever directory the code intended to restrict access to.
8
+ class PathTraversalRule < Rule
9
+ self.rule_id = "path_traversal"
10
+ self.category = "security"
11
+ self.default_severity = "critical"
12
+ self.title = "Possible path traversal"
13
+
14
+ DANGEROUS_CALLS = {
15
+ "File" => %w[join read open new write delete binread binwrite],
16
+ "Dir" => %w[glob entries]
17
+ }.freeze
18
+ BARE_METHODS = %w[send_file send_data].freeze
19
+
20
+ def scan
21
+ findings = []
22
+
23
+ Ast.each_node(sexp) do |node|
24
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
25
+
26
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
27
+ receiver_and_name = Ast.call_name(inner)
28
+ next unless receiver_and_name
29
+
30
+ receiver, method_name = receiver_and_name
31
+ next unless dangerous_call?(receiver, method_name)
32
+
33
+ args = Ast.call_arguments(node)
34
+ next unless args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
35
+
36
+ line = Ast.line_of(node)
37
+ findings << finding(
38
+ line: line,
39
+ message: "`#{describe_call(receiver, method_name)}` is called with a path/argument " \
40
+ "that references `params` — a value like `../../config/master.key` reaches " \
41
+ "the filesystem unchanged, letting an attacker read or write files outside " \
42
+ "whatever directory this was meant to be scoped to.",
43
+ suggested_fix: "Reduce the input to a safe basename before using it " \
44
+ "(`File.basename(params[:name])`), and/or verify the resolved path " \
45
+ "stays inside the intended directory (compare " \
46
+ "`File.expand_path(...)` against the allowed root) before touching " \
47
+ "the filesystem."
48
+ )
49
+ end
50
+
51
+ findings
52
+ end
53
+
54
+ private
55
+
56
+ def dangerous_call?(receiver, method_name)
57
+ return true if receiver.nil? && BARE_METHODS.include?(method_name)
58
+
59
+ DANGEROUS_CALLS[receiver_name(receiver)]&.include?(method_name) || false
60
+ end
61
+
62
+ def describe_call(receiver, method_name)
63
+ name = receiver_name(receiver)
64
+ name ? "#{name}.#{method_name}" : method_name
65
+ end
66
+
67
+ def receiver_name(node)
68
+ return nil unless Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
69
+
70
+ node[1][1]
71
+ end
72
+
73
+ # True if `File.basename(...)` appears anywhere in the argument's
74
+ # subtree — the standard way to strip directory-traversal segments
75
+ # before using a request-controlled filename. Same "does the guard
76
+ # appear anywhere in this expression" looseness as
77
+ # MassAssignmentRule#has_permit_call? — doesn't verify it wraps
78
+ # *exactly* the params reference, just that it's present.
79
+ def sanitized_via_basename?(node)
80
+ Ast.each_node(node).any? do |n|
81
+ next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
82
+
83
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
84
+ Ast.call_name(inner)&.last == "basename"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,130 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags an explicit insecure override of Rails' default security
4
+ # headers/config — NOT absence of a header, which would require knowing
5
+ # every environment file and initializer in the app (too noisy/
6
+ # environment-dependent for a per-file static rule). Only the explicit
7
+ # opt-out is a reliable, low-noise signal on its own — same reasoning as
8
+ # ForceSslRule.
9
+ #
10
+ # config.action_dispatch.default_headers['X-Frame-Options'] = 'ALLOWALL' (or false)
11
+ # config.action_dispatch.default_headers['X-Content-Type-Options'] = false
12
+ # config.action_dispatch.default_headers.merge!('X-Frame-Options' => 'ALLOWALL', ...)
13
+ # config.content_security_policy = nil
14
+ class SecurityHeadersRule < Rule
15
+ self.rule_id = "security_headers_disabled"
16
+ self.category = "security"
17
+ self.default_severity = "critical"
18
+ self.title = "Rails default security header explicitly disabled"
19
+
20
+ HEADERS = ["X-Frame-Options", "X-Content-Type-Options"].freeze
21
+
22
+ def scan
23
+ findings = []
24
+
25
+ Ast.each_node(sexp) do |node|
26
+ if Ast.tagged?(node, :assign)
27
+ scan_assign(findings, node)
28
+ elsif Ast.tagged?(node, :method_add_arg)
29
+ scan_merge_bang(findings, node)
30
+ end
31
+ end
32
+
33
+ findings
34
+ end
35
+
36
+ private
37
+
38
+ def scan_assign(findings, node)
39
+ target = node[1]
40
+ value = node[2]
41
+
42
+ if Ast.tagged?(target, :aref_field) && default_headers_receiver?(target[1])
43
+ key = Ast.unwrap_args(target[2]).first
44
+ header = Ast.plain_string_value(key)
45
+ add_header_finding(findings, header, value, Ast.line_of(node)) if header
46
+ elsif Ast.tagged?(target, :field) && Ast.ident_text(target[3]) == "content_security_policy"
47
+ return unless Ast.kw_literal?(value) == "nil"
48
+
49
+ findings << finding(
50
+ line: Ast.line_of(node),
51
+ message: "`config.content_security_policy = nil` explicitly disables Content-Security-Policy — " \
52
+ "a header that mitigates XSS/data-injection by restricting which sources scripts, " \
53
+ "styles, and other resources can load from.",
54
+ suggested_fix: "Configure an actual policy instead of nil-ing it out — see " \
55
+ "`config.content_security_policy do |policy| ... end` in " \
56
+ "config/initializers/content_security_policy.rb."
57
+ )
58
+ end
59
+ end
60
+
61
+ def scan_merge_bang(findings, node)
62
+ inner = node[1]
63
+ name_pair = Ast.call_name(inner)
64
+ return unless name_pair && name_pair[1] == "merge!"
65
+ return unless default_headers_receiver?(name_pair[0])
66
+
67
+ each_assoc_pair(node).each do |key_node, value_node|
68
+ header = Ast.plain_string_value(key_node)
69
+ add_header_finding(findings, header, value_node, Ast.line_of(node)) if header
70
+ end
71
+ end
72
+
73
+ def add_header_finding(findings, header, value_node, line)
74
+ return unless HEADERS.include?(header)
75
+ return unless disabling_value?(header, value_node)
76
+
77
+ findings << finding(
78
+ line: line,
79
+ message: header_message(header),
80
+ suggested_fix: header_fix(header)
81
+ )
82
+ end
83
+
84
+ def disabling_value?(header, value_node)
85
+ return true if Ast.false_literal?(value_node) || Ast.kw_literal?(value_node) == "nil"
86
+
87
+ header == "X-Frame-Options" && Ast.plain_string_value(value_node).to_s.upcase == "ALLOWALL"
88
+ end
89
+
90
+ def header_message(header)
91
+ case header
92
+ when "X-Frame-Options"
93
+ "The `X-Frame-Options` header is explicitly overridden to allow framing (`ALLOWALL`/falsy) — " \
94
+ "this removes Rails' default clickjacking protection, letting this app be embedded in an " \
95
+ "attacker-controlled `<iframe>`."
96
+ when "X-Content-Type-Options"
97
+ "The `X-Content-Type-Options` header is explicitly disabled — this removes Rails' default " \
98
+ "MIME-sniffing protection, letting browsers reinterpret a response's content type (e.g. " \
99
+ "executing an uploaded file as script)."
100
+ end
101
+ end
102
+
103
+ def header_fix(header)
104
+ case header
105
+ when "X-Frame-Options"
106
+ "Remove this override (Rails defaults to `SAMEORIGIN`), or set an explicit safe value if " \
107
+ "framing from a specific trusted origin is actually needed."
108
+ when "X-Content-Type-Options"
109
+ "Remove this override — leave `X-Content-Type-Options` at Rails' default (`nosniff`)."
110
+ end
111
+ end
112
+
113
+ # True if `node` is a call chain ending in `.default_headers` (any
114
+ # receiver — usually `config.action_dispatch`, but the receiver chain
115
+ # itself isn't verified, same looseness as WeakSessionCookieRule not
116
+ # verifying its receiver is really `config`).
117
+ def default_headers_receiver?(node)
118
+ Ast.call_name(node)&.last == "default_headers"
119
+ end
120
+
121
+ def each_assoc_pair(call_node)
122
+ Ast.call_arguments(call_node).each_with_object([]) do |arg, acc|
123
+ next unless Ast.tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)
124
+
125
+ arg[1].each { |p| acc << [p[1], p[2]] if Ast.tagged?(p, :assoc_new) }
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end