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.
- checksums.yaml +7 -0
- data/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- 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
|