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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +322 -0
- data/README.md +546 -43
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +29 -9
- data/lib/scryer/ast.rb +95 -6
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +209 -11
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_verifier.rb +82 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +637 -44
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +48 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +165 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +110 -0
- data/lib/scryer/rules/security_headers_rule.rb +133 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +139 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +15 -0
- data/lib/tasks/scryer.rake +138 -13
- metadata +41 -12
data/lib/scryer/rspec.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Opt-in RSpec integration — require this file yourself (e.g. `require
|
|
2
|
+
# "scryer/rspec"` in spec_helper.rb) rather than it loading automatically
|
|
3
|
+
# with the gem, since RSpec itself is never a Scryer runtime dependency (see
|
|
4
|
+
# scryer.gemspec's zero-runtime-dependency design) and this file references
|
|
5
|
+
# RSpec::Matchers, which only exists once RSpec has already been loaded by
|
|
6
|
+
# the host app. Requiring "scryer" alone never pulls this in.
|
|
7
|
+
#
|
|
8
|
+
# Turns "did this app's own security scan stay clean" into a normal RSpec
|
|
9
|
+
# expectation, so a regression (a new critical finding, or a specific
|
|
10
|
+
# previously-fixed rule firing again) fails the test suite the same way any
|
|
11
|
+
# other regression would, instead of only showing up the next time someone
|
|
12
|
+
# remembers to run `scryer` by hand or a separate CI step notices it.
|
|
13
|
+
#
|
|
14
|
+
# RSpec.describe "security" do
|
|
15
|
+
# it "has no critical findings" do
|
|
16
|
+
# expect(Scryer.scan(root: Rails.root.to_s)).to have_no_critical_findings
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# it "never reintroduces the mass-assignment bug fixed in PR #123" do
|
|
20
|
+
# expect(Scryer.scan(root: Rails.root.to_s)).to have_no_findings_for("mass_assignment")
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Both matchers work against a Scryer::Scanner::Result (what Scryer.scan/
|
|
25
|
+
# Scanner#call returns) — style/performance findings are deliberately
|
|
26
|
+
# excluded from have_no_critical_findings (only security findings carry
|
|
27
|
+
# real security risk; a slow app isn't a "critical" security finding), but
|
|
28
|
+
# have_no_findings_for checks all three categories, since a rule regressing
|
|
29
|
+
# is worth catching regardless of which category it's filed under.
|
|
30
|
+
require "rspec/expectations"
|
|
31
|
+
|
|
32
|
+
RSpec::Matchers.define :have_no_critical_findings do
|
|
33
|
+
match do |result|
|
|
34
|
+
@criticals = result.security_findings.select { |f| f.severity == "critical" }
|
|
35
|
+
@criticals.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
failure_message do
|
|
39
|
+
lines = @criticals.map { |f| " - #{f.rule_id} at #{f.file}:#{f.line} — #{f.message}" }
|
|
40
|
+
"expected no critical security findings, but got #{@criticals.size}:\n#{lines.join("\n")}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
RSpec::Matchers.define :have_no_findings_for do |rule_id|
|
|
45
|
+
match do |result|
|
|
46
|
+
@matches = (result.security_findings + result.performance_findings + result.style_findings)
|
|
47
|
+
.select { |f| f.rule_id == rule_id.to_s }
|
|
48
|
+
@matches.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
failure_message do
|
|
52
|
+
lines = @matches.map { |f| " - #{f.file}:#{f.line} — #{f.message}" }
|
|
53
|
+
"expected no findings for rule #{rule_id.inspect}, but got #{@matches.size}:\n#{lines.join("\n")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/scryer/rule.rb
CHANGED
|
@@ -5,7 +5,24 @@ module Scryer
|
|
|
5
5
|
# the relative file path (for reporting).
|
|
6
6
|
class Rule
|
|
7
7
|
class << self
|
|
8
|
-
attr_accessor :rule_id, :category, :default_severity, :title
|
|
8
|
+
attr_accessor :rule_id, :category, :default_severity, :title, :cwe, :owasp_category
|
|
9
|
+
|
|
10
|
+
# "high"/"medium"/"low" — Scryer's own best-effort estimate of how
|
|
11
|
+
# often *this specific rule's* pattern-match actually reflects a real
|
|
12
|
+
# issue, independent of `severity` (how bad it is *if* real). A rule
|
|
13
|
+
# can be both high-severity and low-confidence at once (idor is the
|
|
14
|
+
# clearest example: a real IDOR is serious, but this rule's heuristic
|
|
15
|
+
# — no visible authorization call anywhere in the controller class —
|
|
16
|
+
# is the least precise in the gem). Defaults to "medium" so every
|
|
17
|
+
# rule doesn't have to set it explicitly; only rules with a clearly
|
|
18
|
+
# different precision (idor's documented false-positive risk, or a
|
|
19
|
+
# narrow literal-match rule with very little room for ambiguity) set
|
|
20
|
+
# this themselves. Not derived from anything measured at runtime —
|
|
21
|
+
# this is a static per-rule estimate, same as `default_severity`.
|
|
22
|
+
def confidence
|
|
23
|
+
@confidence || "medium"
|
|
24
|
+
end
|
|
25
|
+
attr_writer :confidence
|
|
9
26
|
|
|
10
27
|
def inherited(subclass)
|
|
11
28
|
super
|
|
@@ -27,11 +44,14 @@ module Scryer
|
|
|
27
44
|
|
|
28
45
|
private
|
|
29
46
|
|
|
30
|
-
def finding(line:, message:, suggested_fix:, severity: self.class.default_severity)
|
|
47
|
+
def finding(line:, message:, suggested_fix:, severity: self.class.default_severity, confidence: self.class.confidence)
|
|
31
48
|
Finding.new(
|
|
32
49
|
rule_id: self.class.rule_id,
|
|
33
50
|
category: self.class.category,
|
|
34
51
|
severity: severity,
|
|
52
|
+
confidence: confidence,
|
|
53
|
+
cwe: self.class.cwe,
|
|
54
|
+
owasp_category: self.class.owasp_category,
|
|
35
55
|
file: file,
|
|
36
56
|
line: line,
|
|
37
57
|
code_snippet: Ast.source_line(source, line),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.action_cable.disable_request_forgery_protection = true`
|
|
4
|
+
# — an explicit opt-out of Action Cable's default check that a
|
|
5
|
+
# WebSocket connection's request `Origin` header matches the app's own
|
|
6
|
+
# allowed origins, which otherwise blocks cross-site WebSocket hijacking.
|
|
7
|
+
# Same shape/reasoning as ForceSslRule: only the explicit opt-in to the
|
|
8
|
+
# insecure behavior is flagged, not its absence.
|
|
9
|
+
class ActionCableForgeryProtectionRule < Rule
|
|
10
|
+
self.rule_id = "action_cable_forgery_protection_disabled"
|
|
11
|
+
self.category = "security"
|
|
12
|
+
self.default_severity = "critical"
|
|
13
|
+
self.title = "Action Cable request forgery protection explicitly disabled"
|
|
14
|
+
self.cwe = "CWE-352"
|
|
15
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
16
|
+
self.confidence = "high"
|
|
17
|
+
|
|
18
|
+
def scan
|
|
19
|
+
findings = []
|
|
20
|
+
|
|
21
|
+
Ast.each_node(sexp) do |node|
|
|
22
|
+
next unless Ast.tagged?(node, :assign)
|
|
23
|
+
|
|
24
|
+
target = node[1]
|
|
25
|
+
next unless Ast.tagged?(target, :field)
|
|
26
|
+
next unless Ast.ident_text(target[3]) == "disable_request_forgery_protection"
|
|
27
|
+
|
|
28
|
+
value = node[2]
|
|
29
|
+
next unless Ast.true_literal?(value)
|
|
30
|
+
|
|
31
|
+
line = Ast.line_of(node)
|
|
32
|
+
findings << finding(
|
|
33
|
+
line: line,
|
|
34
|
+
message: "`config.action_cable.disable_request_forgery_protection = true` explicitly " \
|
|
35
|
+
"disables Action Cable's default check that a WebSocket connection's " \
|
|
36
|
+
"`Origin` header matches an allowed origin — without it, another site can " \
|
|
37
|
+
"open a WebSocket connection to this app in a visitor's browser and act as " \
|
|
38
|
+
"that visitor (cross-site WebSocket hijacking).",
|
|
39
|
+
suggested_fix: "Remove this override and set `config.action_cable.allowed_request_origins` " \
|
|
40
|
+
"to the app's real origin(s) instead, unless request forgery protection is " \
|
|
41
|
+
"deliberately being handled some other way — if so, leave a comment " \
|
|
42
|
+
"explaining that."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
findings
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags an explicit `disposition: "inline"`/`:inline` on a blob/variant
|
|
4
|
+
# URL helper (`rails_blob_path`, `rails_blob_url`, `url_for`, a
|
|
5
|
+
# `.variant(...)` chain). Serving user-uploaded content inline (rendered
|
|
6
|
+
# directly in the browser, rather than downloaded) can lead to stored
|
|
7
|
+
# XSS if the uploaded file's content-type isn't tightly restricted — an
|
|
8
|
+
# uploaded SVG or HTML file executes in the page's own origin.
|
|
9
|
+
class ActiveStorageInlineDispositionRule < Rule
|
|
10
|
+
self.rule_id = "active_storage_inline_disposition"
|
|
11
|
+
self.category = "security"
|
|
12
|
+
self.default_severity = "warning"
|
|
13
|
+
self.title = "Active Storage content served with inline disposition"
|
|
14
|
+
self.cwe = "CWE-79"
|
|
15
|
+
self.owasp_category = "A03:2021-Injection"
|
|
16
|
+
self.confidence = "medium"
|
|
17
|
+
|
|
18
|
+
def scan
|
|
19
|
+
findings = []
|
|
20
|
+
|
|
21
|
+
Ast.each_node(sexp) do |node|
|
|
22
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
23
|
+
|
|
24
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
25
|
+
name_pair = Ast.call_name(inner)
|
|
26
|
+
next unless name_pair
|
|
27
|
+
|
|
28
|
+
args = Ast.call_arguments(node)
|
|
29
|
+
disposition = Ast.keyword_arg(args, "disposition")
|
|
30
|
+
next unless disposition && Ast.literal_text(disposition) == "inline"
|
|
31
|
+
|
|
32
|
+
line = Ast.line_of(node)
|
|
33
|
+
findings << finding(
|
|
34
|
+
line: line,
|
|
35
|
+
message: "`disposition: \"inline\"` renders this attachment's content directly in " \
|
|
36
|
+
"the browser instead of downloading it — if the attachment's content-type " \
|
|
37
|
+
"isn't tightly restricted, a user-uploaded SVG or HTML file served this way " \
|
|
38
|
+
"executes as if it were part of the site.",
|
|
39
|
+
suggested_fix: "Prefer the default `disposition: \"attachment\"` (or drop the option " \
|
|
40
|
+
"entirely) unless inline rendering is genuinely required — and if it " \
|
|
41
|
+
"is, make sure the attachment has a strict `content_type:` allowlist " \
|
|
42
|
+
"(e.g. image types only) so nothing executable can reach this code path."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
findings
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `has_one_attached`/`has_many_attached :name` with no
|
|
4
|
+
# `validates :name, content_type: [...]` anywhere in the same class —
|
|
5
|
+
# without a content-type allowlist, a user can upload anything (an SVG
|
|
6
|
+
# or HTML file that executes script when served, an executable, ...),
|
|
7
|
+
# not just the file type the feature was built for.
|
|
8
|
+
class ActiveStorageMissingContentTypeValidationRule < Rule
|
|
9
|
+
self.rule_id = "active_storage_missing_content_type_validation"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "warning"
|
|
12
|
+
self.title = "Active Storage attachment without a content-type validation"
|
|
13
|
+
self.cwe = "CWE-434"
|
|
14
|
+
self.owasp_category = "A04:2021-Insecure Design"
|
|
15
|
+
self.confidence = "medium"
|
|
16
|
+
|
|
17
|
+
ATTACHMENT_METHODS = %w[has_one_attached has_many_attached].freeze
|
|
18
|
+
|
|
19
|
+
def scan
|
|
20
|
+
findings = []
|
|
21
|
+
|
|
22
|
+
Ast.each_node(sexp) do |node|
|
|
23
|
+
next unless Ast.tagged?(node, :class)
|
|
24
|
+
|
|
25
|
+
body = node[3]
|
|
26
|
+
validated_names = each_content_type_validated_names(body)
|
|
27
|
+
|
|
28
|
+
each_attachment(body).each do |call_node, name|
|
|
29
|
+
next if validated_names.include?(name)
|
|
30
|
+
|
|
31
|
+
line = Ast.line_of(call_node)
|
|
32
|
+
findings << finding(
|
|
33
|
+
line: line,
|
|
34
|
+
message: "`:#{name}` is attached via Active Storage with no `content_type:` " \
|
|
35
|
+
"validation anywhere in this class — any file type can be uploaded, " \
|
|
36
|
+
"including ones that execute in a browser if ever served back (e.g. SVG, " \
|
|
37
|
+
"HTML) or aren't safe to store at all.",
|
|
38
|
+
suggested_fix: "Add an explicit allowlist: `validates :#{name}, content_type: " \
|
|
39
|
+
"['image/png', 'image/jpeg']` (whatever types this feature actually " \
|
|
40
|
+
"needs) so anything else is rejected at upload time."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def each_attachment(body)
|
|
51
|
+
Ast.each_node(body).filter_map do |n|
|
|
52
|
+
next unless Ast.tagged?(n, :command, :command_call, :method_add_arg)
|
|
53
|
+
|
|
54
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
55
|
+
name_pair = Ast.call_name(inner)
|
|
56
|
+
next unless name_pair && ATTACHMENT_METHODS.include?(name_pair[1])
|
|
57
|
+
|
|
58
|
+
name = Ast.call_arguments(n).filter_map { |a| Ast.literal_text(a) }.first
|
|
59
|
+
[n, name] if name
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_content_type_validated_names(body)
|
|
64
|
+
Ast.each_node(body).filter_map do |n|
|
|
65
|
+
next unless Ast.tagged?(n, :command, :command_call, :method_add_arg)
|
|
66
|
+
|
|
67
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
68
|
+
name_pair = Ast.call_name(inner)
|
|
69
|
+
next unless name_pair && name_pair[1] == "validates"
|
|
70
|
+
|
|
71
|
+
args = Ast.call_arguments(n)
|
|
72
|
+
next unless Ast.keyword_arg(args, "content_type")
|
|
73
|
+
|
|
74
|
+
Ast.literal_text(args.first)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a controller class that calls `skip_before_action`/
|
|
4
|
+
# `skip_action_callback` naming a common authentication filter
|
|
5
|
+
# (`authenticate_user!`, `authenticate!`, ...) — same shape and reasoning
|
|
6
|
+
# as CsrfProtectionRule, just for auth filters instead of CSRF: skipping
|
|
7
|
+
# one is sometimes correct (a public endpoint, a webhook) but is also a
|
|
8
|
+
# common way to accidentally leave an action reachable without login,
|
|
9
|
+
# especially with a broad `except:`/no scoping at all.
|
|
10
|
+
class AuthenticationBypassRule < Rule
|
|
11
|
+
self.rule_id = "authentication_bypass"
|
|
12
|
+
self.category = "security"
|
|
13
|
+
self.default_severity = "warning"
|
|
14
|
+
self.title = "Authentication filter explicitly skipped"
|
|
15
|
+
self.cwe = "CWE-287"
|
|
16
|
+
self.owasp_category = "A07:2021-Identification and Authentication Failures"
|
|
17
|
+
self.confidence = "medium"
|
|
18
|
+
|
|
19
|
+
SKIP_METHODS = %w[skip_before_action skip_action_callback skip_before_filter].freeze
|
|
20
|
+
AUTH_FILTER_NAMES = %w[
|
|
21
|
+
authenticate_user! authenticate! authenticate_admin! authenticate_account!
|
|
22
|
+
require_login require_authentication authorize_request
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def scan
|
|
26
|
+
findings = []
|
|
27
|
+
|
|
28
|
+
Ast.each_node(sexp) do |node|
|
|
29
|
+
next unless Ast.tagged?(node, :class)
|
|
30
|
+
|
|
31
|
+
class_name = Ast.class_name(node[1])
|
|
32
|
+
next unless class_name.to_s.end_with?("Controller")
|
|
33
|
+
|
|
34
|
+
each_skip_call(node[3]).each do |skip_node, filter_name, args|
|
|
35
|
+
line = Ast.line_of(skip_node)
|
|
36
|
+
# A skip already scoped with `only: [...]` is the exact mitigation this
|
|
37
|
+
# rule's own suggested_fix recommends (see below) — that's the common,
|
|
38
|
+
# often entirely legitimate "public read-only actions on an otherwise
|
|
39
|
+
# authenticated controller" pattern (an index/show page, a webhook
|
|
40
|
+
# receiver), not evidence of a mistake. We still surface it (whether
|
|
41
|
+
# each named action is *actually* meant to be public is app-specific
|
|
42
|
+
# judgment this per-file rule can't verify), but the wording shouldn't
|
|
43
|
+
# read as "this is wrong" the way the unscoped/`except:` case does.
|
|
44
|
+
scoped = !Ast.keyword_arg(args, "only").nil?
|
|
45
|
+
message =
|
|
46
|
+
if scoped
|
|
47
|
+
"`#{class_name}` skips the `#{filter_name}` authentication filter, scoped with " \
|
|
48
|
+
"`only:` (`#{skip_call_method(skip_node)} :#{filter_name}`) — this is a common, " \
|
|
49
|
+
"often legitimate pattern for public-facing read actions (an index/show page, a " \
|
|
50
|
+
"webhook) on an otherwise authenticated controller. Worth a quick human check " \
|
|
51
|
+
"that every named action is genuinely meant to be public, not a signal that " \
|
|
52
|
+
"this is a bug on its own."
|
|
53
|
+
else
|
|
54
|
+
"`#{class_name}` skips the `#{filter_name}` authentication filter " \
|
|
55
|
+
"(`#{skip_call_method(skip_node)} :#{filter_name}`) with no `only:` scoping — " \
|
|
56
|
+
"every action this applies to is reachable without logging in unless something " \
|
|
57
|
+
"else in this controller re-checks authentication."
|
|
58
|
+
end
|
|
59
|
+
findings << finding(
|
|
60
|
+
line: line,
|
|
61
|
+
message: message,
|
|
62
|
+
suggested_fix: "If this is genuinely a public action (a webhook, a login/signup " \
|
|
63
|
+
"page), scope the skip tightly with `only: [:action_name]` rather " \
|
|
64
|
+
"than leaving it unscoped or using a broad `except:`. If it's not " \
|
|
65
|
+
"meant to be public, remove the skip."
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
findings
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def each_skip_call(body)
|
|
76
|
+
Ast.each_node(body).filter_map do |n|
|
|
77
|
+
next unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
78
|
+
|
|
79
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
80
|
+
name_pair = Ast.call_name(inner)
|
|
81
|
+
next unless name_pair && SKIP_METHODS.include?(name_pair[1])
|
|
82
|
+
|
|
83
|
+
args = Ast.call_arguments(n)
|
|
84
|
+
filter_name = args.filter_map { |a| Ast.literal_text(a) }.find { |v| AUTH_FILTER_NAMES.include?(v) }
|
|
85
|
+
[n, filter_name, args] if filter_name
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def skip_call_method(node)
|
|
90
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
91
|
+
Ast.call_name(inner)&.last
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -9,6 +9,9 @@ module Scryer
|
|
|
9
9
|
self.category = "security"
|
|
10
10
|
self.default_severity = "critical"
|
|
11
11
|
self.title = "Possible command injection via shell call"
|
|
12
|
+
self.cwe = "CWE-78"
|
|
13
|
+
self.owasp_category = "A03:2021-Injection"
|
|
14
|
+
self.confidence = "high"
|
|
12
15
|
|
|
13
16
|
SHELL_METHODS = %w[system exec popen spawn].freeze
|
|
14
17
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.consider_all_requests_local = true` specifically in
|
|
4
|
+
# config/environments/production.rb. This setting is Rails' own default
|
|
5
|
+
# in development.rb and test.rb (it's what shows the full backtrace/
|
|
6
|
+
# debug page on an unhandled exception instead of a generic error page)
|
|
7
|
+
# — completely normal there, and NOT flagged there; only an explicit
|
|
8
|
+
# `true` in the production environment file is a real information-
|
|
9
|
+
# disclosure risk (stack traces, local variable values, and request
|
|
10
|
+
# params rendered straight to whoever triggered the error).
|
|
11
|
+
class ConsiderAllRequestsLocalRule < Rule
|
|
12
|
+
self.rule_id = "consider_all_requests_local_production"
|
|
13
|
+
self.category = "security"
|
|
14
|
+
self.default_severity = "critical"
|
|
15
|
+
self.title = "Debug error pages enabled in production"
|
|
16
|
+
self.cwe = "CWE-209"
|
|
17
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
18
|
+
self.confidence = "high"
|
|
19
|
+
|
|
20
|
+
PRODUCTION_ENV_FILE = "config/environments/production.rb"
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
return [] unless file.to_s.end_with?(PRODUCTION_ENV_FILE)
|
|
24
|
+
|
|
25
|
+
findings = []
|
|
26
|
+
|
|
27
|
+
Ast.each_node(sexp) do |node|
|
|
28
|
+
next unless Ast.tagged?(node, :assign)
|
|
29
|
+
|
|
30
|
+
target = node[1]
|
|
31
|
+
next unless Ast.tagged?(target, :field)
|
|
32
|
+
next unless Ast.ident_text(target[3]) == "consider_all_requests_local"
|
|
33
|
+
next unless Ast.true_literal?(node[2])
|
|
34
|
+
|
|
35
|
+
findings << finding(
|
|
36
|
+
line: Ast.line_of(node),
|
|
37
|
+
message: "`config.consider_all_requests_local = true` in #{PRODUCTION_ENV_FILE} shows " \
|
|
38
|
+
"the full Rails debug error page (backtrace, local variables, request " \
|
|
39
|
+
"params) to anyone who triggers an unhandled exception in production.",
|
|
40
|
+
suggested_fix: "Remove this line or set it to `false` in production — let " \
|
|
41
|
+
"`config.consider_all_requests_local` stay at Rails' own default " \
|
|
42
|
+
"there (only true in development/test) and rely on " \
|
|
43
|
+
"`public/500.html` (or an exception-tracking service) for production errors."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
findings
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags the well-known Rack::Cors antipattern: a wildcard origin
|
|
4
|
+
# (`origins '*'`) combined with `credentials: true` on a `resource` call.
|
|
5
|
+
# Per the CORS spec, browsers reject this combination in practice, but
|
|
6
|
+
# it's still the standard misconfiguration flagged in security reviews —
|
|
7
|
+
# either half alone (wildcard origin with no credentials, or credentials
|
|
8
|
+
# with a real origin allowlist) is fine.
|
|
9
|
+
#
|
|
10
|
+
# Scoped per `allow do ... end` block (Rack::Cors' own grouping
|
|
11
|
+
# construct — each `allow` block gets its own `origins`/`resource`
|
|
12
|
+
# pairing), not file-wide: a common, legitimate pattern is a public,
|
|
13
|
+
# unauthenticated API in one `allow` block (`origins '*'`, no
|
|
14
|
+
# credentials) and a separate authenticated partner API in another
|
|
15
|
+
# `allow` block in the same initializer (a real origin allowlist +
|
|
16
|
+
# `credentials: true`) — neither block alone is a misconfiguration, but
|
|
17
|
+
# an earlier file-wide "does '*' appear ANYWHERE AND does credentials:
|
|
18
|
+
# true appear ANYWHERE" check would flag the second block just because
|
|
19
|
+
# the first one happens to use a wildcard. Requiring both signals to
|
|
20
|
+
# come from the same `allow` block's own subtree fixes that without
|
|
21
|
+
# losing detection of the real antipattern (both signals still just
|
|
22
|
+
# need to appear somewhere *within* one block, not literally on the same
|
|
23
|
+
# `resource` call, since `origins` and `resource` are usually sibling
|
|
24
|
+
# statements in the same block rather than one call).
|
|
25
|
+
class CorsMisconfigurationRule < Rule
|
|
26
|
+
self.rule_id = "cors_misconfiguration"
|
|
27
|
+
self.category = "security"
|
|
28
|
+
self.default_severity = "critical"
|
|
29
|
+
self.title = "CORS wildcard origin combined with credentials"
|
|
30
|
+
self.cwe = "CWE-942"
|
|
31
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
32
|
+
self.confidence = "high"
|
|
33
|
+
|
|
34
|
+
def scan
|
|
35
|
+
findings = []
|
|
36
|
+
|
|
37
|
+
allow_blocks(sexp).each do |block|
|
|
38
|
+
next unless wildcard_origin_anywhere?(block)
|
|
39
|
+
|
|
40
|
+
each_credentialed_resource(block).each do |node|
|
|
41
|
+
findings << finding(
|
|
42
|
+
line: Ast.line_of(node),
|
|
43
|
+
message: "This `allow` block sets a wildcard origin (`origins '*'`) and this " \
|
|
44
|
+
"`resource` call sets `credentials: true` — browsers won't honor that " \
|
|
45
|
+
"combination for actual credentialed requests, and Rack::Cors handling it " \
|
|
46
|
+
"inconsistently is the standard CORS misconfiguration flagged in reviews. " \
|
|
47
|
+
"Either restrict origins to a real allowlist, or drop `credentials: true`.",
|
|
48
|
+
suggested_fix: "Replace the wildcard with an explicit origin allowlist wherever " \
|
|
49
|
+
"`credentials: true` is set: `origins 'https://app.example.com'` " \
|
|
50
|
+
"instead of `origins '*'` — a wildcard origin should only be paired " \
|
|
51
|
+
"with `credentials: false` (the default)."
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
findings
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Every `allow do ... end` block in the file — Ripper parses it as a
|
|
62
|
+
# `method_add_block` wrapping an `fcall`/`vcall` named "allow" plus its
|
|
63
|
+
# `do_block` body. Rack::Cors configs are always structured this way
|
|
64
|
+
# (`Rack::Cors do allow do ... end end`), so scoping to these blocks
|
|
65
|
+
# rather than falling back to file-wide when none are found doesn't
|
|
66
|
+
# lose real detections in practice.
|
|
67
|
+
def allow_blocks(node)
|
|
68
|
+
Ast.each_node(node).select do |n|
|
|
69
|
+
next false unless Ast.tagged?(n, :method_add_block)
|
|
70
|
+
|
|
71
|
+
call_node = n[1]
|
|
72
|
+
inner = Ast.tagged?(call_node, :method_add_arg) ? call_node[1] : call_node
|
|
73
|
+
Ast.call_name(inner)&.last == "allow"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wildcard_origin_anywhere?(node)
|
|
78
|
+
Ast.each_node(node).any? do |n|
|
|
79
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
80
|
+
|
|
81
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
82
|
+
next false unless Ast.call_name(inner)&.last == "origins"
|
|
83
|
+
|
|
84
|
+
Ast.call_arguments(n).any? { |a| Ast.plain_string_value(a) == "*" }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def each_credentialed_resource(node)
|
|
89
|
+
Ast.each_node(node).select do |n|
|
|
90
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
91
|
+
|
|
92
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
93
|
+
next false unless Ast.call_name(inner)&.last == "resource"
|
|
94
|
+
|
|
95
|
+
Ast.true_literal?(Ast.keyword_arg(Ast.call_arguments(n), "credentials"))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -12,6 +12,9 @@ module Scryer
|
|
|
12
12
|
self.category = "security"
|
|
13
13
|
self.default_severity = "warning"
|
|
14
14
|
self.title = "CSRF protection skipped without safeguards"
|
|
15
|
+
self.cwe = "CWE-352"
|
|
16
|
+
self.owasp_category = "A01:2021-Broken Access Control"
|
|
17
|
+
self.confidence = "medium"
|
|
15
18
|
|
|
16
19
|
def scan
|
|
17
20
|
findings = []
|
|
@@ -19,7 +22,7 @@ module Scryer
|
|
|
19
22
|
Ast.each_node(sexp) do |node|
|
|
20
23
|
next unless Ast.tagged?(node, :class)
|
|
21
24
|
|
|
22
|
-
class_name = Ast.
|
|
25
|
+
class_name = Ast.class_name(node[1])
|
|
23
26
|
next unless class_name.to_s.end_with?("Controller")
|
|
24
27
|
|
|
25
28
|
body = node[3]
|
|
@@ -34,16 +37,9 @@ module Scryer
|
|
|
34
37
|
line = Ast.line_of(skip_node)
|
|
35
38
|
findings << finding(
|
|
36
39
|
line: line,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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."
|
|
40
|
+
severity: scoped_to_specific_actions?(skip_node) ? "info" : self.class.default_severity,
|
|
41
|
+
message: scoped_message(class_name, skip_node),
|
|
42
|
+
suggested_fix: scoped_suggested_fix(skip_node)
|
|
47
43
|
)
|
|
48
44
|
end
|
|
49
45
|
|
|
@@ -52,6 +48,59 @@ module Scryer
|
|
|
52
48
|
|
|
53
49
|
private
|
|
54
50
|
|
|
51
|
+
# `skip_before_action :verify_authenticity_token, only: [:webhook]` —
|
|
52
|
+
# narrowed with an `only:` list to specific actions — is a very common,
|
|
53
|
+
# often entirely legitimate pattern: external callback endpoints
|
|
54
|
+
# (Stripe/PayPal/other payment or webhook providers) can't carry a
|
|
55
|
+
# session-cookie CSRF token because the request never originates from
|
|
56
|
+
# a browser on this site, so verify_authenticity_token would just
|
|
57
|
+
# reject every legitimate call. That's a materially different, lower
|
|
58
|
+
# risk shape than skipping it for the *whole* controller (no `only:`
|
|
59
|
+
# at all), which is the actual dangerous case this rule targets (e.g.
|
|
60
|
+
# a controller that also renders normal HTML forms). We still report
|
|
61
|
+
# the `only:`-scoped case — skipping CSRF is still worth a second look
|
|
62
|
+
# to confirm the endpoint verifies the caller some other way (a
|
|
63
|
+
# provider signature header, a shared secret) rather than not at
|
|
64
|
+
# all — just at "info" severity with wording that doesn't read as
|
|
65
|
+
# "this is unconditionally wrong," instead of the same "warning" we
|
|
66
|
+
# give an unscoped, controller-wide skip.
|
|
67
|
+
def scoped_to_specific_actions?(skip_node)
|
|
68
|
+
args = Ast.call_arguments(skip_node)
|
|
69
|
+
!Ast.keyword_arg(args, "only").nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def scoped_message(class_name, skip_node)
|
|
73
|
+
if scoped_to_specific_actions?(skip_node)
|
|
74
|
+
"`#{class_name}` skips CSRF token verification (`skip_before_action " \
|
|
75
|
+
":verify_authenticity_token`) scoped to specific actions via `only:` — a common, " \
|
|
76
|
+
"often legitimate pattern for endpoints that can't carry a browser session token " \
|
|
77
|
+
"(e.g. external webhook callbacks from a payment provider). Worth double-checking " \
|
|
78
|
+
"those actions verify the caller some other way (a provider signature header, a " \
|
|
79
|
+
"shared secret) rather than skipping verification with nothing in its place."
|
|
80
|
+
else
|
|
81
|
+
"`#{class_name}` skips CSRF token verification (`skip_before_action " \
|
|
82
|
+
":verify_authenticity_token`) without declaring its own " \
|
|
83
|
+
"`protect_from_forgery` policy — if this controller renders any HTML forms " \
|
|
84
|
+
"or is reachable with a browser session cookie, this leaves it open to " \
|
|
85
|
+
"cross-site request forgery."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scoped_suggested_fix(skip_node)
|
|
90
|
+
if scoped_to_specific_actions?(skip_node)
|
|
91
|
+
"If the scoped action(s) are external provider callbacks, verify the request some " \
|
|
92
|
+
"other way instead of CSRF (e.g. `Stripe::Webhook.construct_event` and its signature " \
|
|
93
|
+
"check, or comparing a shared secret/header the provider sends). If they're not " \
|
|
94
|
+
"callback endpoints, reconsider whether skipping CSRF here is actually needed."
|
|
95
|
+
else
|
|
96
|
+
"If this is a true JSON/API-only controller, make that explicit with " \
|
|
97
|
+
"`protect_from_forgery with: :null_session` (or inherit from a base " \
|
|
98
|
+
"class that does) rather than bypassing verification silently. If it's " \
|
|
99
|
+
"not API-only, remove the `skip_before_action` and let the app's normal " \
|
|
100
|
+
"CSRF handling apply."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
55
104
|
def find_skip_verify(node)
|
|
56
105
|
Ast.each_node(node).find do |n|
|
|
57
106
|
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|