scryer 1.0.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -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 +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- 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 +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -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 +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- 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 +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- 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 +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- metadata +52 -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),
|
|
@@ -11,6 +11,9 @@ module Scryer
|
|
|
11
11
|
self.category = "security"
|
|
12
12
|
self.default_severity = "critical"
|
|
13
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"
|
|
14
17
|
|
|
15
18
|
def scan
|
|
16
19
|
findings = []
|
|
@@ -11,6 +11,9 @@ module Scryer
|
|
|
11
11
|
self.category = "security"
|
|
12
12
|
self.default_severity = "warning"
|
|
13
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"
|
|
14
17
|
|
|
15
18
|
def scan
|
|
16
19
|
findings = []
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "warning"
|
|
12
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"
|
|
13
16
|
|
|
14
17
|
ATTACHMENT_METHODS = %w[has_one_attached has_many_attached].freeze
|
|
15
18
|
|
|
@@ -12,6 +12,9 @@ module Scryer
|
|
|
12
12
|
self.category = "security"
|
|
13
13
|
self.default_severity = "warning"
|
|
14
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"
|
|
15
18
|
|
|
16
19
|
SKIP_METHODS = %w[skip_before_action skip_action_callback skip_before_filter].freeze
|
|
17
20
|
AUTH_FILTER_NAMES = %w[
|
|
@@ -25,17 +28,37 @@ module Scryer
|
|
|
25
28
|
Ast.each_node(sexp) do |node|
|
|
26
29
|
next unless Ast.tagged?(node, :class)
|
|
27
30
|
|
|
28
|
-
class_name = Ast.
|
|
31
|
+
class_name = Ast.class_name(node[1])
|
|
29
32
|
next unless class_name.to_s.end_with?("Controller")
|
|
30
33
|
|
|
31
|
-
each_skip_call(node[3]).each do |skip_node, filter_name|
|
|
34
|
+
each_skip_call(node[3]).each do |skip_node, filter_name, args|
|
|
32
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
|
|
33
59
|
findings << finding(
|
|
34
60
|
line: line,
|
|
35
|
-
message:
|
|
36
|
-
"(`#{skip_call_method(skip_node)} :#{filter_name}`) — every action this " \
|
|
37
|
-
"applies to is reachable without logging in unless something else in " \
|
|
38
|
-
"this controller re-checks authentication.",
|
|
61
|
+
message: message,
|
|
39
62
|
suggested_fix: "If this is genuinely a public action (a webhook, a login/signup " \
|
|
40
63
|
"page), scope the skip tightly with `only: [:action_name]` rather " \
|
|
41
64
|
"than leaving it unscoped or using a broad `except:`. If it's not " \
|
|
@@ -59,7 +82,7 @@ module Scryer
|
|
|
59
82
|
|
|
60
83
|
args = Ast.call_arguments(n)
|
|
61
84
|
filter_name = args.filter_map { |a| Ast.literal_text(a) }.find { |v| AUTH_FILTER_NAMES.include?(v) }
|
|
62
|
-
[n, filter_name] if filter_name
|
|
85
|
+
[n, filter_name, args] if filter_name
|
|
63
86
|
end
|
|
64
87
|
end
|
|
65
88
|
|
|
@@ -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
|
|
@@ -7,35 +7,50 @@ module Scryer
|
|
|
7
7
|
# either half alone (wildcard origin with no credentials, or credentials
|
|
8
8
|
# with a real origin allowlist) is fine.
|
|
9
9
|
#
|
|
10
|
-
# `
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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).
|
|
16
25
|
class CorsMisconfigurationRule < Rule
|
|
17
26
|
self.rule_id = "cors_misconfiguration"
|
|
18
27
|
self.category = "security"
|
|
19
28
|
self.default_severity = "critical"
|
|
20
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"
|
|
21
33
|
|
|
22
34
|
def scan
|
|
23
35
|
findings = []
|
|
24
|
-
return findings unless wildcard_origin_anywhere?(sexp)
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
findings
|
|
@@ -43,6 +58,22 @@ module Scryer
|
|
|
43
58
|
|
|
44
59
|
private
|
|
45
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
|
+
|
|
46
77
|
def wildcard_origin_anywhere?(node)
|
|
47
78
|
Ast.each_node(node).any? do |n|
|
|
48
79
|
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
@@ -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)
|
|
@@ -11,6 +11,9 @@ module Scryer
|
|
|
11
11
|
self.category = "security"
|
|
12
12
|
self.default_severity = "critical"
|
|
13
13
|
self.title = "HTTPS enforcement explicitly disabled"
|
|
14
|
+
self.cwe = "CWE-319"
|
|
15
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
16
|
+
self.confidence = "high"
|
|
14
17
|
|
|
15
18
|
def scan
|
|
16
19
|
findings = []
|
|
@@ -9,11 +9,42 @@ module Scryer
|
|
|
9
9
|
# a single finding per class (not one per missing directive), the same
|
|
10
10
|
# "did the class do the safe thing at all" pattern as
|
|
11
11
|
# ActiveStorageMissingContentTypeValidationRule/IdorRule.
|
|
12
|
+
#
|
|
13
|
+
# Known precision limits, both around indirection this rule can't (or
|
|
14
|
+
# doesn't try to) resolve, same disclosed-gap spirit as IdorRule:
|
|
15
|
+
#
|
|
16
|
+
# - A schema that inherits from a shared custom base class (e.g.
|
|
17
|
+
# `class MySchema < BaseSchema`, where `BaseSchema` is the one that
|
|
18
|
+
# actually extends `GraphQL::Schema` and calls `max_depth`/
|
|
19
|
+
# `max_complexity`) is never even examined by this rule — the
|
|
20
|
+
# superclass-name check only matches a literal `GraphQL::Schema`
|
|
21
|
+
# superclass, so `MySchema` here isn't checked at all (a
|
|
22
|
+
# false-negative blind spot, not a false positive: verified via
|
|
23
|
+
# `Scryer::Scanner` that such a subclass produces no finding either
|
|
24
|
+
# way).
|
|
25
|
+
# - Conversely, a class that DOES extend `GraphQL::Schema` directly
|
|
26
|
+
# but gets its limits from an `include`d module (e.g. `include
|
|
27
|
+
# QueryLimits`, where the module sets `max_depth`/`max_complexity`
|
|
28
|
+
# via its own `included do ... end` block in a different file) WILL
|
|
29
|
+
# be flagged as missing them, even though it isn't — confirmed via
|
|
30
|
+
# `Scryer::Scanner` against exactly this fixture. Resolving what an
|
|
31
|
+
# `include`d module does requires following it to its own
|
|
32
|
+
# definition, potentially in another file entirely — real
|
|
33
|
+
# cross-file resolution, not a same-file AST tweak — so rather than
|
|
34
|
+
# risk a broad/wrong exemption (e.g. "any class that calls
|
|
35
|
+
# `include` at all is exempt" would silence the rule for classes
|
|
36
|
+
# that include something unrelated and still have no real limit),
|
|
37
|
+
# this gap is left as-is and disclosed here instead. Treat a
|
|
38
|
+
# finding on a schema using a shared limits module as worth a
|
|
39
|
+
# second look, not a confirmed bug.
|
|
12
40
|
class GraphqlMissingQueryLimitsRule < Rule
|
|
13
41
|
self.rule_id = "graphql_missing_query_limits"
|
|
14
42
|
self.category = "security"
|
|
15
43
|
self.default_severity = "warning"
|
|
16
44
|
self.title = "GraphQL schema without query depth/complexity limits"
|
|
45
|
+
self.cwe = "CWE-770"
|
|
46
|
+
self.owasp_category = "A04:2021-Insecure Design"
|
|
47
|
+
self.confidence = "medium"
|
|
17
48
|
|
|
18
49
|
LIMIT_METHODS = %w[max_depth max_complexity].freeze
|
|
19
50
|
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "critical"
|
|
12
12
|
self.title = "Hardcoded HTTP Basic Auth credential"
|
|
13
|
+
self.cwe = "CWE-798"
|
|
14
|
+
self.owasp_category = "A07:2021-Identification and Authentication Failures"
|
|
15
|
+
self.confidence = "high"
|
|
13
16
|
|
|
14
17
|
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?password|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
15
18
|
|
|
@@ -19,6 +19,9 @@ module Scryer
|
|
|
19
19
|
self.category = "security"
|
|
20
20
|
self.default_severity = "critical"
|
|
21
21
|
self.title = "Hardcoded secret_key_base"
|
|
22
|
+
self.cwe = "CWE-798"
|
|
23
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
24
|
+
self.confidence = "high"
|
|
22
25
|
|
|
23
26
|
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
24
27
|
|
|
@@ -9,6 +9,9 @@ module Scryer
|
|
|
9
9
|
self.category = "security"
|
|
10
10
|
self.default_severity = "critical"
|
|
11
11
|
self.title = "Hardcoded credential or API key"
|
|
12
|
+
self.cwe = "CWE-798"
|
|
13
|
+
self.owasp_category = "A07:2021-Identification and Authentication Failures"
|
|
14
|
+
self.confidence = "medium"
|
|
12
15
|
|
|
13
16
|
NAME_PATTERN = /(api[_-]?key|secret|token|password|passwd|access[_-]?key|private[_-]?key|auth)/i.freeze
|
|
14
17
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.hosts.clear` (in any file — `Rails.application.config.
|
|
4
|
+
# hosts.clear` and similar longer chains match too, since only the last
|
|
5
|
+
# two segments of the call chain matter). Rails 6+ checks the `Host`
|
|
6
|
+
# header on every request against `config.hosts` by default
|
|
7
|
+
# (ActionDispatch::HostAuthorization) specifically to block DNS-rebinding
|
|
8
|
+
# and Host-header-injection attacks; `.clear` empties that allowlist,
|
|
9
|
+
# which disables the check entirely rather than narrowing it — unlike
|
|
10
|
+
# adding a specific host to the list, there's no legitimate narrowing use
|
|
11
|
+
# of `.clear` itself (a project that wants to allow every host would
|
|
12
|
+
# still say so more precisely, e.g. a regex covering its actual domains).
|
|
13
|
+
class HostAuthorizationDisabledRule < Rule
|
|
14
|
+
self.rule_id = "host_authorization_disabled"
|
|
15
|
+
self.category = "security"
|
|
16
|
+
self.default_severity = "warning"
|
|
17
|
+
self.title = "Host header authorization allowlist cleared"
|
|
18
|
+
self.cwe = "CWE-350"
|
|
19
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
20
|
+
self.confidence = "high"
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
|
|
25
|
+
Ast.each_node(sexp) do |node|
|
|
26
|
+
next unless Ast.tagged?(node, :call)
|
|
27
|
+
next unless Ast.ident_text(node[3]) == "clear"
|
|
28
|
+
|
|
29
|
+
receiver = node[1]
|
|
30
|
+
next unless Ast.tagged?(receiver, :call)
|
|
31
|
+
next unless Ast.ident_text(receiver[3]) == "hosts"
|
|
32
|
+
|
|
33
|
+
findings << finding(
|
|
34
|
+
line: Ast.line_of(node),
|
|
35
|
+
message: "`config.hosts.clear` empties Rails' Host-header allowlist " \
|
|
36
|
+
"(ActionDispatch::HostAuthorization), disabling its check against DNS-" \
|
|
37
|
+
"rebinding and Host-header-injection attacks entirely rather than narrowing it.",
|
|
38
|
+
suggested_fix: "Add this app's actual host(s) to the allowlist instead of clearing it " \
|
|
39
|
+
"— e.g. `config.hosts << \"example.com\"` — or, if requests genuinely " \
|
|
40
|
+
"come from unpredictable hosts (a multi-tenant app resolving hosts at " \
|
|
41
|
+
"runtime), use a regex/proc that still validates against a known pattern " \
|
|
42
|
+
"rather than accepting every host."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
findings
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|