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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -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),
@@ -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.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
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: "`#{class_name}` skips the `#{filter_name}` authentication filter " \
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
- # `config/initializers/cors.rb` is small and self-contained, so this
11
- # checks "does `origins(...)` with a literal `'*'` appear anywhere in the
12
- # file AND does a `resource` call with `credentials: true` appear
13
- # anywhere in the file" rather than requiring both inside the exact same
14
- # `allow do ... end` block — the same file-wide-evidence looseness
15
- # IdorRule uses for its class-wide authorization check.
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
- each_credentialed_resource(sexp).each do |node|
27
- findings << finding(
28
- line: Ast.line_of(node),
29
- message: "This file allows a wildcard origin (`origins '*'`) somewhere and this " \
30
- "`resource` call sets `credentials: true` — browsers won't honor that " \
31
- "combination for actual credentialed requests, and Rack::Cors handling it " \
32
- "inconsistently is the standard CORS misconfiguration flagged in reviews. " \
33
- "Either restrict origins to a real allowlist, or drop `credentials: true`.",
34
- suggested_fix: "Replace the wildcard with an explicit origin allowlist wherever " \
35
- "`credentials: true` is set: `origins 'https://app.example.com'` " \
36
- "instead of `origins '*'` — a wildcard origin should only be paired " \
37
- "with `credentials: false` (the default)."
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.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)
@@ -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