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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +322 -0
  3. data/README.md +546 -43
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +95 -6
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +209 -11
  11. data/lib/scryer/dependency_audit.rb +108 -5
  12. data/lib/scryer/finding.rb +6 -0
  13. data/lib/scryer/fix_verifier.rb +82 -0
  14. data/lib/scryer/minitest.rb +48 -0
  15. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  16. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  18. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  19. data/lib/scryer/report_renderer.rb +637 -44
  20. data/lib/scryer/rspec.rb +55 -0
  21. data/lib/scryer/rule.rb +22 -2
  22. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
  23. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
  24. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
  25. data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  27. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  28. data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
  29. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  30. data/lib/scryer/rules/force_ssl_rule.rb +48 -0
  31. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
  32. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
  33. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
  34. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  35. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  36. data/lib/scryer/rules/idor_rule.rb +165 -0
  37. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
  38. data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
  39. data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
  40. data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
  41. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  42. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  43. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  44. data/lib/scryer/rules/path_traversal_rule.rb +110 -0
  45. data/lib/scryer/rules/security_headers_rule.rb +133 -0
  46. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  47. data/lib/scryer/rules/ssrf_rule.rb +139 -0
  48. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  49. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  50. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  51. data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
  52. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  53. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  54. data/lib/scryer/version.rb +1 -1
  55. data/lib/scryer.rb +15 -0
  56. data/lib/tasks/scryer.rake +138 -13
  57. metadata +41 -12
@@ -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.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
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
- 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."
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)