scryer 0.1.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. metadata +106 -0
@@ -0,0 +1,89 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags a controller class named *Controller that calls
4
+ # `skip_before_action :verify_authenticity_token` without that same file
5
+ # (or, best-effort, without any `protect_from_forgery` call visible in it)
6
+ # — skipping CSRF verification on a controller that isn't clearly API-only
7
+ # (no `< ActionController::API` / `ActionController::Base` used alongside
8
+ # explicit null_session) is a common way to accidentally disable CSRF
9
+ # protection app-wide for that controller's actions.
10
+ class CsrfProtectionRule < Rule
11
+ self.rule_id = "csrf_protection_disabled"
12
+ self.category = "security"
13
+ self.default_severity = "warning"
14
+ self.title = "CSRF protection skipped without safeguards"
15
+
16
+ def scan
17
+ findings = []
18
+
19
+ Ast.each_node(sexp) do |node|
20
+ next unless Ast.tagged?(node, :class)
21
+
22
+ class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
23
+ next unless class_name.to_s.end_with?("Controller")
24
+
25
+ body = node[3]
26
+ skip_node = find_skip_verify(body)
27
+ next unless skip_node
28
+
29
+ has_null_session_pattern = each_descendant_call_names(body).any? do |name|
30
+ name == "protect_from_forgery"
31
+ end
32
+ next if has_null_session_pattern # they've explicitly configured an alternative
33
+
34
+ line = Ast.line_of(skip_node)
35
+ findings << finding(
36
+ line: line,
37
+ message: "`#{class_name}` skips CSRF token verification (`skip_before_action " \
38
+ ":verify_authenticity_token`) without declaring its own " \
39
+ "`protect_from_forgery` policy — if this controller renders any HTML forms " \
40
+ "or is reachable with a browser session cookie, this leaves it open to " \
41
+ "cross-site request forgery.",
42
+ suggested_fix: "If this is a true JSON/API-only controller, make that explicit with " \
43
+ "`protect_from_forgery with: :null_session` (or inherit from a base " \
44
+ "class that does) rather than bypassing verification silently. If it's " \
45
+ "not API-only, remove the `skip_before_action` and let the app's normal " \
46
+ "CSRF handling apply."
47
+ )
48
+ end
49
+
50
+ findings
51
+ end
52
+
53
+ private
54
+
55
+ def find_skip_verify(node)
56
+ Ast.each_node(node).find do |n|
57
+ next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
58
+
59
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
60
+ name_pair = Ast.call_name(inner)
61
+ next false unless name_pair && name_pair[1] == "skip_before_action"
62
+
63
+ args = Ast.call_arguments(n)
64
+ args.any? { |a| symbol_value(a) == "verify_authenticity_token" }
65
+ end
66
+ end
67
+
68
+ def each_descendant_call_names(node)
69
+ Ast.each_node(node).filter_map do |n|
70
+ next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
71
+
72
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
73
+ Ast.call_name(inner)&.last
74
+ end
75
+ end
76
+
77
+ def symbol_value(node)
78
+ return nil unless Ast.tagged?(node, :symbol_literal, :dyna_symbol)
79
+
80
+ # symbol_literal wraps [:symbol, [:@ident, "name", pos]] — one more
81
+ # level of unwrapping than a bare @ident node.
82
+ inner = node[1]
83
+ return nil unless Ast.tagged?(inner, :symbol)
84
+
85
+ Ast.ident_text(inner[1])
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,96 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags plain string literals assigned to a constant/variable/hash-key
4
+ # whose name looks secret-ish (API_KEY, secret, token, password, ...),
5
+ # OR that match a known cloud-provider key format (AWS access key,
6
+ # generic high-entropy-looking tokens), regardless of variable name.
7
+ class HardcodedSecretRule < Rule
8
+ self.rule_id = "hardcoded_secret"
9
+ self.category = "security"
10
+ self.default_severity = "critical"
11
+ self.title = "Hardcoded credential or API key"
12
+
13
+ NAME_PATTERN = /(api[_-]?key|secret|token|password|passwd|access[_-]?key|private[_-]?key|auth)/i.freeze
14
+
15
+ # High-precision patterns: distinctive enough prefixes/formats that a
16
+ # match alone (regardless of variable name) is worth flagging.
17
+ KNOWN_KEY_PATTERNS = {
18
+ "AWS Access Key ID" => /\bAKIA[0-9A-Z]{16}\b/,
19
+ "Stripe API Key" => /\bsk_(live|test)_[0-9a-zA-Z]{16,}\b/,
20
+ "GitHub Token" => /\bgh[pousr]_[0-9a-zA-Z]{20,}\b/,
21
+ "Slack Token" => /\bxox[baprs]-[0-9a-zA-Z-]{10,}\b/,
22
+ "Private Key block" => /-----BEGIN (RSA |EC )?PRIVATE KEY-----/
23
+ }.freeze
24
+
25
+ # Low-precision heuristics: only meaningful when ALSO paired with a
26
+ # suspicious variable/constant name (a bare 40-char base64-ish string
27
+ # is just as likely to be a git SHA, a hash, or a test fixture id).
28
+ WEAK_KEY_PATTERNS = {
29
+ "AWS Secret Access Key (heuristic)" => /\A[A-Za-z0-9\/+=]{40}\z/
30
+ }.freeze
31
+
32
+ # Placeholders/examples that should never be flagged even if they match
33
+ # a pattern above or a suspicious name — avoids noisy false positives on
34
+ # obviously-fake sample values.
35
+ PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?(api[_-]?)?key|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
36
+
37
+ def scan
38
+ findings = []
39
+
40
+ Ast.each_node(sexp) do |node|
41
+ next unless Ast.tagged?(node, :assign)
42
+
43
+ target = node[1]
44
+ value_node = node[2]
45
+ value = Ast.plain_string_value(value_node)
46
+ next unless value
47
+ next if value.strip.empty? || value.length < 6
48
+ next if PLACEHOLDER_VALUES.match?(value.strip)
49
+
50
+ name = target_name(target)
51
+ matched_known = KNOWN_KEY_PATTERNS.find { |_label, pattern| pattern.match?(value) }
52
+ matched_weak = WEAK_KEY_PATTERNS.find { |_label, pattern| pattern.match?(value.strip) }
53
+ suspicious_name = name && NAME_PATTERN.match?(name)
54
+
55
+ next unless matched_known || suspicious_name || (matched_weak && suspicious_name)
56
+
57
+ line = Ast.line_of(node)
58
+ reason =
59
+ if matched_known
60
+ "matches the format of a #{matched_known[0]}"
61
+ elsif matched_weak && suspicious_name
62
+ "matches the format of a #{matched_weak[0]} and is assigned to `#{name}`"
63
+ else
64
+ "is assigned to `#{name}`, a name that suggests a credential"
65
+ end
66
+ findings << finding(
67
+ line: line,
68
+ message: "A literal string #{reason} — secrets committed to source control end up " \
69
+ "in git history permanently, even if removed later.",
70
+ suggested_fix: "Move this value out of the codebase: use `ENV.fetch(\"#{env_name(name)}\")` " \
71
+ "or Rails encrypted credentials (`Rails.application.credentials.dig(...)`), " \
72
+ "set the real value via your deploy environment / secrets manager, and " \
73
+ "rotate this specific key since it's likely already exposed in git history."
74
+ )
75
+ end
76
+
77
+ findings
78
+ end
79
+
80
+ private
81
+
82
+ def target_name(node)
83
+ Ast.each_node(node).each do |n|
84
+ next unless n.is_a?(Array) && n[0].is_a?(Symbol)
85
+
86
+ return n[1] if %i[@const @ident @ivar @gvar @label].include?(n[0]) && n[1].is_a?(String)
87
+ end
88
+ nil
89
+ end
90
+
91
+ def env_name(name)
92
+ (name || "SECRET").to_s.gsub(/[^a-zA-Z0-9]+/, "_").upcase.sub(/\A_+|_+\z/, "")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,103 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `Model.new(params[...])` / `Model.new(params)` / `.update(params[...])`
4
+ # / `.assign_attributes(params)` where the argument is `params` (or a
5
+ # subscript of it) with no `.permit(...)` anywhere in the same argument
6
+ # expression — i.e. attributes are being mass-assigned straight from the
7
+ # request with no allow-list.
8
+ class MassAssignmentRule < Rule
9
+ self.rule_id = "mass_assignment"
10
+ self.category = "security"
11
+ self.default_severity = "critical"
12
+ self.title = "Unpermitted mass assignment from params"
13
+
14
+ ASSIGNMENT_METHODS = %w[new create create! update update! assign_attributes attributes=].freeze
15
+
16
+ # Common stdlib/gem constants with their own `.new`/`.create`-style
17
+ # factory methods that have nothing to do with ActiveRecord mass
18
+ # assignment (e.g. `BCrypt::Password.create(params[:password])` is
19
+ # hashing a single value, not setting a hash of model attributes).
20
+ # Excluding these — plus anything referenced through a namespaced
21
+ # `A::B` path, which real Rails models are less commonly called via at
22
+ # the exact call site — cuts down false positives significantly.
23
+ NON_MODEL_RECEIVERS = %w[
24
+ Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
25
+ JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
26
+ ].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 ASSIGNMENT_METHODS.include?(method_name)
40
+ next unless likely_model_receiver?(receiver)
41
+
42
+ args = Ast.call_arguments(node)
43
+ next if args.empty?
44
+
45
+ arg = args.first
46
+ next unless references_raw_params?(arg)
47
+
48
+ line = Ast.line_of(arg) || Ast.line_of(node)
49
+ findings << finding(
50
+ line: line,
51
+ message: "`#{method_name}` receives `params` (or a subscript of it) directly, " \
52
+ "with no `.permit(...)` call — every attribute in the request can be " \
53
+ "set, including ones the form/API was never meant to expose (e.g. `admin`, `role_id`).",
54
+ suggested_fix: "Wrap the params in a strong-parameters method, e.g. " \
55
+ "`#{method_name}(order_params)` with `def order_params; " \
56
+ "params.require(:order).permit(:status, :total); end` — only the " \
57
+ "explicitly permitted keys get through."
58
+ )
59
+ end
60
+
61
+ findings
62
+ end
63
+
64
+ private
65
+
66
+ # 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)
71
+ return true if receiver.nil?
72
+ return false unless Ast.tagged?(receiver, :var_ref)
73
+
74
+ const_node = receiver[1]
75
+ return false unless const_node.is_a?(Array) && const_node[0] == :@const
76
+
77
+ !NON_MODEL_RECEIVERS.include?(const_node[1])
78
+ end
79
+
80
+ # True if `node` is `params`, `params[:x]`, or contains such a reference
81
+ # without a `.permit`/`.permit!` call wrapping it (permit! is itself
82
+ # flagged as unsafe too, so it doesn't count as "safe").
83
+ def references_raw_params?(node)
84
+ return false unless node.is_a?(Array)
85
+ return false if has_permit_call?(node)
86
+
87
+ Ast.each_node(node).any? do |n|
88
+ Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
89
+ end
90
+ end
91
+
92
+ def has_permit_call?(node)
93
+ Ast.each_node(node).any? do |n|
94
+ next false unless Ast.tagged?(n, :call, :method_add_arg)
95
+
96
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
97
+ name = Ast.call_name(inner)&.last
98
+ %w[permit permit!].include?(name)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,57 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `redirect_to` called with `params[...]` (or plain `params`)
4
+ # directly as the destination — an attacker can craft a link to your own
5
+ # site that redirects the victim onward to an attacker-controlled domain
6
+ # (a classic phishing enabler), unless the value is validated against an
7
+ # allow-list first.
8
+ class OpenRedirectRule < Rule
9
+ self.rule_id = "open_redirect"
10
+ self.category = "security"
11
+ self.default_severity = "warning"
12
+ self.title = "Possible open redirect via unvalidated params"
13
+
14
+ def scan
15
+ findings = []
16
+
17
+ Ast.each_node(sexp) do |node|
18
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
19
+
20
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
21
+ name_pair = Ast.call_name(inner)
22
+ next unless name_pair && name_pair[1] == "redirect_to"
23
+
24
+ args = Ast.call_arguments(node)
25
+ next if args.empty?
26
+
27
+ target = args.first
28
+ next unless references_raw_params?(target)
29
+
30
+ line = Ast.line_of(target) || Ast.line_of(node)
31
+ findings << finding(
32
+ line: line,
33
+ message: "`redirect_to` receives `params` (or a subscript of it) directly as the " \
34
+ "destination — a crafted link can redirect users from your domain to an " \
35
+ "attacker-controlled site, which is commonly used for phishing.",
36
+ suggested_fix: "Validate the destination against an allow-list before redirecting, " \
37
+ "e.g. `redirect_to params[:next] if ALLOWED_PATHS.include?(params[:next])`, " \
38
+ "or only allow relative paths on your own host " \
39
+ "(`URI.parse(params[:next]).host.nil?`), falling back to a safe default otherwise."
40
+ )
41
+ end
42
+
43
+ findings
44
+ end
45
+
46
+ private
47
+
48
+ def references_raw_params?(node)
49
+ return false unless node.is_a?(Array)
50
+
51
+ Ast.each_node(node).any? do |n|
52
+ Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,63 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags ActiveRecord finder/query methods called with a string argument
4
+ # that contains interpolation (#{...}) — the classic Rails SQL injection
5
+ # pattern, e.g. `Order.where("status = '#{params[:status]}'")`.
6
+ # Parameterized/hash forms (`where(status: params[:status])`,
7
+ # `where("status = ?", params[:status])`) are safe and not flagged.
8
+ class SqlInjectionRule < Rule
9
+ self.rule_id = "sql_injection"
10
+ self.category = "security"
11
+ self.default_severity = "critical"
12
+ self.title = "Possible SQL injection via string interpolation"
13
+
14
+ QUERY_METHODS = %w[
15
+ where find_by find_by! order pluck select group having
16
+ find_by_sql calculate exists? count
17
+ ].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
+ receiver_and_name = Ast.call_name(inner_call_node(node))
26
+ next unless receiver_and_name
27
+
28
+ _receiver, method_name = receiver_and_name
29
+ next unless QUERY_METHODS.include?(method_name)
30
+
31
+ args = Ast.call_arguments(node)
32
+ next if args.empty?
33
+
34
+ first_arg = args.first
35
+ next unless Ast.string_literal_has_interpolation?(first_arg)
36
+
37
+ line = Ast.line_of(first_arg) || Ast.line_of(node)
38
+ findings << finding(
39
+ line: line,
40
+ message: "`#{method_name}` is called with a string built via interpolation, " \
41
+ "which lets user-controlled input change the SQL executed.",
42
+ suggested_fix: "Use a parameterized form instead, e.g. " \
43
+ "`#{method_name}(\"column = ?\", value)` or the hash form " \
44
+ "`#{method_name}(column: value)` — both let Active Record escape " \
45
+ "the value safely instead of interpolating it directly into SQL."
46
+ )
47
+ end
48
+
49
+ findings
50
+ end
51
+
52
+ private
53
+
54
+ # method_add_arg wraps [call_or_fcall_node, args_node] — call_name needs
55
+ # the inner call/fcall/vcall node, not the method_add_arg wrapper itself.
56
+ def inner_call_node(node)
57
+ return node unless Ast.tagged?(node, :method_add_arg)
58
+
59
+ node[1]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,71 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `Marshal.load`, `YAML.load` (as opposed to `YAML.safe_load`), and
4
+ # `JSON.load` (as opposed to `JSON.parse`) — all three can instantiate
5
+ # arbitrary Ruby objects from untrusted input, a known RCE vector in Rails
6
+ # apps (several real-world CVEs trace back to exactly this).
7
+ class UnsafeDeserializationRule < Rule
8
+ self.rule_id = "unsafe_deserialization"
9
+ self.category = "security"
10
+ self.default_severity = "critical"
11
+ self.title = "Unsafe deserialization of untrusted data"
12
+
13
+ UNSAFE_CALLS = {
14
+ %w[Marshal load] => "Marshal.load can instantiate arbitrary Ruby objects, including ones " \
15
+ "that execute code as a side effect of being constructed — never call " \
16
+ "it on data that came from a user, request, or external service.",
17
+ %w[YAML load] => "YAML.load (unlike YAML.safe_load) can instantiate arbitrary Ruby objects " \
18
+ "from the document, which is a known remote-code-execution vector when " \
19
+ "the YAML source isn't fully trusted.",
20
+ %w[JSON load] => "JSON.load can invoke arbitrary `create_id`-tagged object construction, " \
21
+ "unlike the safer JSON.parse."
22
+ }.freeze
23
+
24
+ def scan
25
+ findings = []
26
+
27
+ Ast.each_node(sexp) do |node|
28
+ # Only match the method_add_arg wrapper (call + its parenthesized
29
+ # args) — the :call node it wraps would otherwise also match this
30
+ # loop on its own and double-count every finding.
31
+ next unless Ast.tagged?(node, :method_add_arg)
32
+
33
+ inner = node[1]
34
+ next unless Ast.tagged?(inner, :call)
35
+
36
+ receiver = inner[1]
37
+ method_name = Ast.ident_text(inner[3])
38
+ receiver_name = Ast.ident_text(receiver.is_a?(Array) ? receiver[1] : nil) if Ast.tagged?(receiver, :var_ref)
39
+
40
+ match = UNSAFE_CALLS.keys.find { |(recv, meth)| recv == receiver_name && meth == method_name }
41
+ next unless match
42
+
43
+ line = Ast.line_of(node)
44
+ findings << finding(
45
+ line: line,
46
+ message: UNSAFE_CALLS[match],
47
+ suggested_fix: safe_alternative(match)
48
+ )
49
+ end
50
+
51
+ findings
52
+ end
53
+
54
+ private
55
+
56
+ def safe_alternative(match)
57
+ case match
58
+ when %w[Marshal load]
59
+ "Avoid deserializing untrusted data with Marshal at all. If you must, verify a " \
60
+ "signature/HMAC over the payload first, or switch to a safe format like JSON."
61
+ when %w[YAML load]
62
+ "Use `YAML.safe_load(input, permitted_classes: [...])` instead, explicitly listing " \
63
+ "which classes are allowed to be instantiated."
64
+ when %w[JSON load]
65
+ "Use `JSON.parse(input)` instead — it only produces plain Hash/Array/String/Numeric/" \
66
+ "boolean values, never arbitrary objects."
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `Digest::MD5`/`Digest::SHA1` used in a context that looks like
4
+ # password/credential hashing (method or nearby variable name contains
5
+ # "password"/"passwd") — both are cryptographically broken for that use
6
+ # case; a fast general-purpose hash lets an attacker who steals the DB
7
+ # brute-force passwords far faster than a proper password hash
8
+ # (bcrypt/scrypt/argon2, which are deliberately slow).
9
+ class WeakCryptoRule < Rule
10
+ self.rule_id = "weak_crypto"
11
+ self.category = "security"
12
+ self.default_severity = "warning"
13
+ self.title = "Weak hash algorithm used for password/credential hashing"
14
+
15
+ WEAK_DIGESTS = %w[MD5 SHA1].freeze
16
+ PASSWORD_HINT = /password|passwd|credential/i.freeze
17
+
18
+ def scan
19
+ findings = []
20
+
21
+ Ast.each_node(sexp) do |node|
22
+ next unless Ast.tagged?(node, :top_const_ref, :const_path_ref, :var_ref)
23
+
24
+ digest_name = digest_algorithm_name(node)
25
+ next unless digest_name
26
+
27
+ # Only flag when something nearby (same statement/line) mentions
28
+ # password-ish naming — Digest::MD5/SHA1 have plenty of legitimate
29
+ # non-credential uses (cache keys, ETags, checksums) that shouldn't
30
+ # be flagged as a crypto weakness.
31
+ line = Ast.line_of(node)
32
+ context_line = Ast.source_line(source, line).to_s
33
+ next unless PASSWORD_HINT.match?(context_line)
34
+
35
+ findings << finding(
36
+ line: line,
37
+ message: "`Digest::#{digest_name}` is used near what looks like password/credential " \
38
+ "handling — #{digest_name} is fast and unsalted by default, making stolen " \
39
+ "hashes practical to brute-force.",
40
+ suggested_fix: "Use `bcrypt` via Rails' `has_secure_password` for password storage " \
41
+ "instead of a general-purpose digest — it's deliberately slow and " \
42
+ "handles salting automatically. Reserve Digest::#{digest_name} for " \
43
+ "non-credential uses (cache keys, checksums)."
44
+ )
45
+ end
46
+
47
+ findings
48
+ end
49
+
50
+ private
51
+
52
+ # Matches `Digest::MD5` / `Digest::SHA1` referenced as a constant path.
53
+ def digest_algorithm_name(node)
54
+ text = Ast.each_node(node)
55
+ .filter_map { |n| n.is_a?(Array) && n[0] == :@const ? n[1] : nil }
56
+ .join("::")
57
+
58
+ WEAK_DIGESTS.find { |name| text == "Digest::#{name}" || text == name && sexp_mentions_digest?(node) }
59
+ end
60
+
61
+ def sexp_mentions_digest?(node)
62
+ Ast.each_node(node).any? { |n| n.is_a?(Array) && n[0] == :@const && n[1] == "Digest" }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `.html_safe` and `raw(...)` calls on anything that isn't an
4
+ # obviously-static string literal — both tell Rails to skip HTML-escaping,
5
+ # so calling them on user-influenced data is a stored/reflected XSS risk.
6
+ # A call on a plain string literal with no interpolation (`"<br>".html_safe`)
7
+ # is far more likely to be intentional/safe, so it's not flagged.
8
+ class XssUnsafeHtmlRule < Rule
9
+ self.rule_id = "xss_unsafe_html"
10
+ self.category = "security"
11
+ self.default_severity = "warning"
12
+ self.title = "Unescaped HTML output (possible XSS)"
13
+
14
+ def scan
15
+ findings = []
16
+
17
+ Ast.each_node(sexp) do |node|
18
+ if Ast.tagged?(node, :call)
19
+ method_name = Ast.ident_text(node[3])
20
+ next unless method_name == "html_safe"
21
+
22
+ receiver = node[1]
23
+ next if safe_literal?(receiver)
24
+
25
+ line = Ast.line_of(node)
26
+ findings << finding(
27
+ line: line,
28
+ message: "`.html_safe` is called on a value that isn't a plain static string — " \
29
+ "if it can contain user input, this disables Rails' automatic HTML escaping " \
30
+ "for it, allowing injected `<script>`/attribute-based XSS.",
31
+ suggested_fix: "Only mark content `.html_safe` after sanitizing it yourself " \
32
+ "(e.g. `sanitize(value)` or `ActionController::Base.helpers.sanitize`), " \
33
+ "or better, avoid `.html_safe` and let Rails escape the value normally, " \
34
+ "using `content_tag`/safe helpers to build any HTML that's actually needed."
35
+ )
36
+ elsif Ast.tagged?(node, :method_add_arg, :command, :fcall, :vcall)
37
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
38
+ name_pair = Ast.call_name(inner)
39
+ next unless name_pair && name_pair[1] == "raw"
40
+
41
+ args = Ast.call_arguments(node)
42
+ next if args.any? && safe_literal?(args.first)
43
+ next if args.empty? # bare `raw` with no args isn't this pattern
44
+
45
+ line = Ast.line_of(node)
46
+ findings << finding(
47
+ line: line,
48
+ message: "`raw(...)` disables HTML escaping for its argument — if that value can " \
49
+ "contain user input, this is a direct XSS vector.",
50
+ suggested_fix: "Avoid `raw()` for anything derived from user input or the database. " \
51
+ "If some HTML really needs to pass through unescaped, sanitize it first " \
52
+ "with `sanitize(value, tags: %w[b i em strong])` restricted to an explicit allow-list."
53
+ )
54
+ end
55
+ end
56
+
57
+ findings
58
+ end
59
+
60
+ private
61
+
62
+ def safe_literal?(node)
63
+ return true if node.nil?
64
+ return true if Ast.tagged?(node, :string_literal) && !Ast.string_literal_has_interpolation?(node)
65
+
66
+ false
67
+ end
68
+ end
69
+ end
70
+ end