scryer 1.2.1 → 1.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -10
  3. data/README.md +9 -4
  4. data/docs/architecture.md +45 -36
  5. data/docs/rails-integration.md +182 -1
  6. data/docs/rules.md +8 -1
  7. data/docs/usage.md +5 -2
  8. data/lib/generators/scryer/install_generator.rb +1 -0
  9. data/lib/generators/scryer/templates/scryer_initializer.rb +22 -0
  10. data/lib/scryer/ai_client.rb +1 -0
  11. data/lib/scryer/ai_fix_suggester.rb +1 -0
  12. data/lib/scryer/apm.rb +458 -0
  13. data/lib/scryer/ast.rb +53 -0
  14. data/lib/scryer/authorization_watcher.rb +1 -0
  15. data/lib/scryer/baseline.rb +1 -0
  16. data/lib/scryer/cache_extractor.rb +1 -0
  17. data/lib/scryer/cli.rb +19 -11
  18. data/lib/scryer/colorizer.rb +1 -0
  19. data/lib/scryer/dependency_audit.rb +1 -0
  20. data/lib/scryer/dependency_fixer.rb +1 -0
  21. data/lib/scryer/duplicate_detector.rb +1 -0
  22. data/lib/scryer/finding.rb +1 -0
  23. data/lib/scryer/fix_runner.rb +1 -0
  24. data/lib/scryer/fix_verifier.rb +1 -0
  25. data/lib/scryer/mechanical_fixer.rb +1 -0
  26. data/lib/scryer/method_extractor.rb +1 -0
  27. data/lib/scryer/minitest.rb +1 -0
  28. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
  29. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  30. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
  31. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
  32. data/lib/scryer/query_extractor.rb +1 -0
  33. data/lib/scryer/query_watcher.rb +1 -0
  34. data/lib/scryer/railtie.rb +64 -4
  35. data/lib/scryer/report_renderer.rb +161 -99
  36. data/lib/scryer/rspec.rb +1 -0
  37. data/lib/scryer/rule.rb +15 -2
  38. data/lib/scryer/rule_set.rb +1 -0
  39. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
  40. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
  41. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
  42. data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
  43. data/lib/scryer/rules/command_injection_rule.rb +1 -0
  44. data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
  45. data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
  46. data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
  47. data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
  48. data/lib/scryer/rules/force_ssl_rule.rb +1 -0
  49. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
  50. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
  51. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
  52. data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
  53. data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
  54. data/lib/scryer/rules/idor_rule.rb +6 -14
  55. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
  56. data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
  57. data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
  58. data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
  59. data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
  60. data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
  61. data/lib/scryer/rules/open_redirect_rule.rb +1 -0
  62. data/lib/scryer/rules/path_traversal_rule.rb +1 -0
  63. data/lib/scryer/rules/security_headers_rule.rb +1 -0
  64. data/lib/scryer/rules/sql_injection_rule.rb +1 -0
  65. data/lib/scryer/rules/ssrf_rule.rb +1 -0
  66. data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
  67. data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
  68. data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
  69. data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
  70. data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
  71. data/lib/scryer/scanner.rb +126 -7
  72. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  73. data/lib/scryer/version.rb +2 -1
  74. data/lib/scryer.rb +49 -0
  75. data/lib/tasks/scryer.rake +8 -2
  76. metadata +3 -1
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ module Scryer
3
+ module Rules
4
+ # Flags `eval`/`instance_eval`/`class_eval`/`module_eval` called with
5
+ # anything other than a plain string literal with no interpolation —
6
+ # the standard "Dangerous Eval" pattern every mainstream Rails security
7
+ # scanner (Brakeman included) checks for.
8
+ #
9
+ # Deliberately broader than this gem's other injection rules
10
+ # (sql_injection/mass_assignment/ssrf/path_traversal all only fire when
11
+ # the argument specifically traces back to `params` — see
12
+ # Ast.references_params?): eval-family methods execute their argument
13
+ # as arbitrary Ruby code, and there's essentially no legitimate reason
14
+ # for that string to be anything other than a literal the developer
15
+ # wrote themselves. A local variable, an instance variable, a method
16
+ # call, or an interpolated string are all flagged here regardless of
17
+ # where the value actually came from, since tracing where a local
18
+ # variable's value originated is real data-flow analysis this gem's
19
+ # per-expression heuristics don't attempt (see the README's honest
20
+ # comparison to Brakeman).
21
+ #
22
+ # `severity` is always "critical" — regardless of confidence, the
23
+ # blast radius if this argument is ever attacker-influenced is the
24
+ # same (arbitrary code execution in this process), and `eval` is never
25
+ # the *correct* way to do dynamic dispatch even when today's value
26
+ # happens to be safe (`const_get`/`safe_constantize` do the same job
27
+ # with zero code-execution risk). `confidence`, though, genuinely
28
+ # varies — this is exactly the axis Brakeman's own "Dangerous Eval"
29
+ # check varies confidence on too (High when its taint engine traces the
30
+ # argument to request data, Weak otherwise), and a real false-positive
31
+ # comparison against a real app surfaced why: the single most common
32
+ # shape here by far is `eval(controller_path.classify)` — a Rails-
33
+ # internal string identifying the *current controller class*, not
34
+ # request data at all, used as a (badly chosen) dynamic-dispatch idiom.
35
+ # That's still worth flagging at "critical" — it's needless RCE risk
36
+ # for something `const_get` does safely — but it is not the same
37
+ # confidence-of-actual-exploitability as `eval(params[:code])`. Since
38
+ # this gem has no real taint tracking, `Ast.references_params?`
39
+ # appearing anywhere in the argument is the strongest signal available
40
+ # for "this is confidently, not just plausibly, attacker-reachable" —
41
+ # confidence is "high" when it's present, and the class default
42
+ # ("medium" — real risk, but not confidently proven external input)
43
+ # otherwise.
44
+ #
45
+ # A block-only call (`obj.instance_eval { ... }`, no string argument at
46
+ # all) is never flagged — that's just running a block of code the
47
+ # developer wrote inline, nothing dynamic about it, and it doesn't even
48
+ # reach this rule's node-matching (see class comment on the shared
49
+ # `:method_add_arg, :command, :command_call` matching this gem's other
50
+ # call-shaped rules use — a block wraps the call in `:method_add_block`
51
+ # instead, a different tag entirely).
52
+ class DangerousEvalRule < Rule
53
+ self.rule_id = "dangerous_eval"
54
+ self.category = "security"
55
+ self.default_severity = "critical"
56
+ self.title = "Dynamic code evaluation (eval) with non-literal input"
57
+ self.cwe = "CWE-95"
58
+ self.owasp_category = "A03:2021-Injection"
59
+ self.confidence = "medium"
60
+
61
+ EVAL_METHODS = %w[eval instance_eval class_eval module_eval].freeze
62
+
63
+ def scan
64
+ findings = []
65
+
66
+ Ast.each_node(sexp) do |node|
67
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
68
+
69
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
70
+ receiver_and_name = Ast.call_name(inner)
71
+ next unless receiver_and_name
72
+
73
+ _receiver, method_name = receiver_and_name
74
+ next unless EVAL_METHODS.include?(method_name)
75
+
76
+ args = Ast.call_arguments(node)
77
+ next if args.empty? # block-only call — nothing dynamic being evaluated
78
+
79
+ arg = args.first
80
+ next if safe_literal?(arg)
81
+
82
+ params_tainted = Ast.references_params?(arg)
83
+ message =
84
+ if params_tainted
85
+ "`#{method_name}` executes its argument as Ruby code, and that argument directly " \
86
+ "references `params` — request data reaches this eval, which means arbitrary code " \
87
+ "execution for anyone who can influence that value."
88
+ else
89
+ "`#{method_name}` executes its argument as Ruby code, and this one isn't a plain " \
90
+ "hardcoded string — no `params` reference is directly visible here, but there's " \
91
+ "still no safe way to be sure the value can never come from outside this process, " \
92
+ "and `eval` risks arbitrary code execution the moment it does."
93
+ end
94
+
95
+ line = Ast.line_of(arg) || Ast.line_of(node)
96
+ findings << finding(
97
+ line: line,
98
+ confidence: params_tainted ? "high" : self.class.confidence,
99
+ message: message,
100
+ suggested_fix: "Avoid `#{method_name}` with dynamic input entirely — there's almost " \
101
+ "always a safer, narrower way to express what it's doing (e.g. " \
102
+ "`public_send`/`const_get`/`safe_constantize` for dynamic dispatch, a " \
103
+ "case/lookup table instead of building code as a string)."
104
+ )
105
+ end
106
+
107
+ findings
108
+ end
109
+
110
+ private
111
+
112
+ def safe_literal?(node)
113
+ Ast.tagged?(node, :string_literal) && !Ast.string_literal_has_interpolation?(node)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.force_ssl = false` — an explicit opt-out of Rails'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a `class X < GraphQL::Schema` (or a class whose superclass
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `http_basic_authenticate_with` calls whose `password:` (or
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.secret_key_base = "literal"` (or
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags plain string literals assigned to a constant/variable/hash-key
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.hosts.clear` (in any file — `Rails.application.config.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `Model.find(params[...])` / `Model.find_by(...params...)` inside
@@ -27,15 +28,6 @@ module Scryer
27
28
 
28
29
  FINDER_METHODS = %w[find find_by find_by!].freeze
29
30
 
30
- # Same reasoning as MassAssignmentRule::NON_MODEL_RECEIVERS: common
31
- # stdlib/gem constants with their own `.find`-style methods that have
32
- # nothing to do with an ActiveRecord model lookup.
33
- NON_MODEL_RECEIVERS = %w[
34
- Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
35
- JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
36
- Enumerable File Dir
37
- ].freeze
38
-
39
31
  # Pundit's `authorize`/`policy_scope`/`can?`/`cannot?` plus two more
40
32
  # well-established framework-provided safeguards, deliberately not an
41
33
  # attempt at an exhaustive list of every app's custom guard method
@@ -140,17 +132,17 @@ module Scryer
140
132
  # namespaced model's `.find(params[...])` was silently never examined
141
133
  # at all (a false negative, not a false positive — worth fixing since
142
134
  # namespacing under a module is an extremely common Rails convention).
143
- # Checked against the *last* segment (`"Post"`, not `"Admin"`), same
144
- # exclusion list either way — none of NON_MODEL_RECEIVERS are commonly
145
- # used in namespaced form for this purpose, but checking the actual
146
- # class name being looked up is the more correct match regardless.
135
+ # Checked against the *last* segment (`"Post"`, not `"Admin"`) same
136
+ # last-segment matching Scanner's known_models/known_non_models
137
+ # already use (see Ast.likely_model_name?), so a namespaced model
138
+ # resolves consistently either way.
147
139
  def likely_model_receiver?(receiver)
148
140
  return false if receiver.nil? # bare find(...) inside the model itself, not a controller lookup
149
141
 
150
142
  const_name = const_receiver_name(receiver)
151
143
  return false unless const_name
152
144
 
153
- !NON_MODEL_RECEIVERS.include?(const_name)
145
+ Ast.likely_model_name?(const_name, known_models: known_models, known_non_models: known_non_models)
154
146
  end
155
147
 
156
148
  def const_receiver_name(node)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.action_dispatch.cookies_serializer = :marshal`. Rails
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `SomeJob.perform_async(...)` / `.perform_later(...)` /
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags insecure usage of the `jwt` gem's `JWT.decode`/`JWT.encode`:
@@ -1,10 +1,16 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `Model.new(params[...])` / `Model.new(params)` / `.update(params[...])`
4
5
  # / `.assign_attributes(params)` where the argument is `params` (or a
5
6
  # subscript of it) with no `.permit(...)` anywhere in the same argument
6
7
  # expression — i.e. attributes are being mass-assigned straight from the
7
- # request with no allow-list.
8
+ # request with no allow-list. "Is the receiver actually an ActiveRecord
9
+ # model" is answered by Ast.likely_model_name? (known_models/
10
+ # known_non_models, resolved once per scan by Scanner from every
11
+ # `class X < Y` declaration it saw — see its own doc comment) — this
12
+ # rule only supplies the receiver-shape acceptance itself (a bare
13
+ # constant, an implicit receiver, or an ivar for the update-style verbs).
8
14
  class MassAssignmentRule < Rule
9
15
  self.rule_id = "mass_assignment"
10
16
  self.category = "security"
@@ -32,18 +38,6 @@ module Scryer
32
38
  # `@filters.update(params[:filters])` on a Hash.
33
39
  IVAR_RECEIVER_METHODS = %w[update update! assign_attributes attributes=].freeze
34
40
 
35
- # Common stdlib/gem constants with their own `.new`/`.create`-style
36
- # factory methods that have nothing to do with ActiveRecord mass
37
- # assignment (e.g. `BCrypt::Password.create(params[:password])` is
38
- # hashing a single value, not setting a hash of model attributes).
39
- # Excluding these — plus anything referenced through a namespaced
40
- # `A::B` path, which real Rails models are less commonly called via at
41
- # the exact call site — cuts down false positives significantly.
42
- NON_MODEL_RECEIVERS = %w[
43
- Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
44
- JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
45
- ].freeze
46
-
47
41
  def scan
48
42
  findings = []
49
43
 
@@ -83,8 +77,8 @@ module Scryer
83
77
  private
84
78
 
85
79
  # true for an implicit receiver (bare `create(...)` inside the model
86
- # itself), a plain unnamespaced constant reference (`Order`) that isn't
87
- # a known non-model stdlib/gem constant, or — for the update-style
80
+ # itself), a plain unnamespaced constant reference (`Order`) Ast.
81
+ # likely_model_name? doesn't rule out, or — for the update-style
88
82
  # verbs only, see IVAR_RECEIVER_METHODS — an instance variable
89
83
  # (`@order`); false for namespaced constant paths (`BCrypt::Password`,
90
84
  # `Admin::Order` — a real gap, see mass_assignment_rule's class comment
@@ -97,7 +91,7 @@ module Scryer
97
91
  const_node = receiver[1]
98
92
  return false unless const_node.is_a?(Array) && const_node[0] == :@const
99
93
 
100
- !NON_MODEL_RECEIVERS.include?(const_node[1])
94
+ Ast.likely_model_name?(const_node[1], known_models: known_models, known_non_models: known_non_models)
101
95
  end
102
96
 
103
97
  def ivar_receiver?(node)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a `*Controller` class that defines `create`/`update`/`destroy`
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a controller's `index` action that queries a model directly
@@ -31,14 +32,6 @@ module Scryer
31
32
  self.confidence = "medium"
32
33
 
33
34
  UNSCOPED_METHODS = %w[all where].freeze
34
- # Same list IdorRule/MassAssignmentRule use for the same reason —
35
- # stdlib/gem constants with their own `.all`/`.where`-shaped methods
36
- # that have nothing to do with an ActiveRecord model query.
37
- NON_MODEL_RECEIVERS = %w[
38
- Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
39
- JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
40
- Enumerable File Dir
41
- ].freeze
42
35
 
43
36
  def scan
44
37
  findings = []
@@ -108,7 +101,7 @@ module Scryer
108
101
  const_name = const_receiver_name(receiver)
109
102
  return false unless const_name
110
103
 
111
- !NON_MODEL_RECEIVERS.include?(const_name)
104
+ Ast.likely_model_name?(const_name, known_models: known_models, known_non_models: known_non_models)
112
105
  end
113
106
 
114
107
  def const_receiver_name(node)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `redirect_to` called with `params[...]` (or plain `params`)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a filesystem operation (`File.join/read/open/new/write/delete`,
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags an explicit insecure override of Rails' default security
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags ActiveRecord finder/query methods called with a string argument
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags an outbound HTTP call (`Net::HTTP`, `URI.open`/bare `open`
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `Marshal.load`, `YAML.load` (as opposed to `YAML.safe_load`), and
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.log_level = :debug` specifically in
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `Digest::MD5`/`Digest::SHA1` used in a context that looks like
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.session_store :cookie_store, ...` with no `secure: true`
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `.html_safe` and `raw(...)` calls on anything that isn't an
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
  require "set"
3
4
 
@@ -26,6 +27,20 @@ module Scryer
26
27
  QUERY_SIMILARITY_THRESHOLD = 0.7
27
28
  CACHE_SIMILARITY_THRESHOLD = 0.7
28
29
 
30
+ # Real ActiveRecord model base classes — a class transitively inheriting
31
+ # from one of these (see #model_via_chain?) goes into known_models.
32
+ KNOWN_MODEL_BASE_CLASSES = %w[ApplicationRecord ActiveRecord::Base].freeze
33
+
34
+ # Rails generates exactly one non-model "Application*" base class per
35
+ # concern (ApplicationController, ApplicationJob, ApplicationMailer,
36
+ # ApplicationCable::Connection/Channel) — ApplicationRecord is the only
37
+ # one of that family that models actually inherit from. A class
38
+ # inheriting from anything else matching this shape (including a
39
+ # project's own equivalent, e.g. a hand-rolled `ApplicationService`)
40
+ # can never also be an ActiveRecord model, regardless of what it's
41
+ # named — see Ast.likely_model_name?'s known_non_models.
42
+ NON_MODEL_SUPERCLASS_PATTERN = /\AApplication(?!Record\z)\w*\z/.freeze
43
+
29
44
  Result = Struct.new(:security_findings, :performance_findings, :style_findings, :duplicate_groups, :files_scanned, :parse_errors, keyword_init: true)
30
45
 
31
46
  # `skip_rules` silences specific checks by rule_id (e.g. a known false
@@ -48,14 +63,28 @@ module Scryer
48
63
 
49
64
  def call
50
65
  files = collect_files
51
- all_methods = []
52
- all_queries = []
53
- all_cache_calls = []
54
- security_findings = []
55
- performance_findings = []
56
- style_findings = []
66
+ parsed_files = []
57
67
  parse_errors = []
58
68
 
69
+ # First pass: read + parse every file exactly once (cached in
70
+ # parsed_files for the second pass below) and, while we're already
71
+ # walking each file's sexp, collect every `class X < Y` declaration
72
+ # into class_superclass/no_superclass_classes/application_family_classes
73
+ # — the raw material #resolve_known_models/#resolve_known_non_models
74
+ # turn into a real, project-wide "is this actually an ActiveRecord
75
+ # model" signal (see Ast.likely_model_name?'s doc comment for why this
76
+ # exists: a plain Ruby service/command object's `.new(params)` call
77
+ # looks identical to a real model's, and no per-file view can tell
78
+ # them apart — this can, because it's seen every class declaration in
79
+ # the project before any rule runs). Has to be a separate pass from
80
+ # rule-scanning below: a model declared in one file needs to be known
81
+ # before an *earlier-processed* file's controller referencing it is
82
+ # scanned, which a single combined pass can't guarantee regardless of
83
+ # file processing order.
84
+ class_superclass = {}
85
+ no_superclass_classes = Set.new
86
+ application_family_classes = Set.new
87
+
59
88
  files.each do |abs_path|
60
89
  rel_path = abs_path.sub(/\A#{Regexp.escape(@root)}\/?/, "")
61
90
  source = File.read(abs_path)
@@ -72,6 +101,21 @@ module Scryer
72
101
  next
73
102
  end
74
103
 
104
+ parsed_files << [rel_path, source, sexp]
105
+ collect_class_declarations(sexp, class_superclass, no_superclass_classes, application_family_classes)
106
+ end
107
+
108
+ known_models = resolve_known_models(class_superclass)
109
+ known_non_models = no_superclass_classes | application_family_classes
110
+
111
+ all_methods = []
112
+ all_queries = []
113
+ all_cache_calls = []
114
+ security_findings = []
115
+ performance_findings = []
116
+ style_findings = []
117
+
118
+ parsed_files.each do |rel_path, source, sexp|
75
119
  RuleSet.all.each do |rule_class|
76
120
  next if @skip_rules.include?(rule_class.rule_id.to_s)
77
121
 
@@ -83,7 +127,8 @@ module Scryer
83
127
  end
84
128
  next unless bucket
85
129
 
86
- bucket.concat(rule_class.new(file: rel_path, source: source, sexp: sexp).scan)
130
+ bucket.concat(rule_class.new(file: rel_path, source: source, sexp: sexp,
131
+ known_models: known_models, known_non_models: known_non_models).scan)
87
132
  end
88
133
 
89
134
  if @detect_duplicates && duplicate_detection_target?(rel_path)
@@ -121,6 +166,80 @@ module Scryer
121
166
 
122
167
  private
123
168
 
169
+ # Records one file's `class X < Y` (and `module X`) declarations into the
170
+ # three accumulators #call builds across every scanned file.
171
+ # `class_superclass` maps a class's own (last-segment) name to its
172
+ # superclass's full name (kept full, not truncated, so e.g.
173
+ # "ActiveRecord::Base" still matches KNOWN_MODEL_BASE_CLASSES exactly in
174
+ # #model_via_chain? before that method truncates it to walk the chain
175
+ # further). A class declared with literally no superclass (`class
176
+ # Server; end`) goes straight into no_superclass_classes — no real
177
+ # ActiveRecord model is ever declared that way, so this is an
178
+ # unconditional, safe "definitely not a model" signal regardless of the
179
+ # class's name. `module X` gets the same treatment for the same reason:
180
+ # a bare Ruby module (e.g. this gem's own `RuleSet`) can never be an
181
+ # ActiveRecord model either, which matters for rules like
182
+ # UnboundedTableScanRule/NPlusOneQueryRule that otherwise treat any
183
+ # `Const.all.each`-shaped call as a possible query on a model.
184
+ def collect_class_declarations(sexp, class_superclass, no_superclass_classes, application_family_classes)
185
+ Ast.each_node(sexp) do |node|
186
+ if Ast.tagged?(node, :module)
187
+ name = Ast.class_name(node[1])
188
+ no_superclass_classes << last_segment(name) if name
189
+ next
190
+ end
191
+
192
+ next unless Ast.tagged?(node, :class)
193
+
194
+ name = Ast.class_name(node[1])
195
+ next unless name
196
+
197
+ last = last_segment(name)
198
+ superclass_node = node[2]
199
+
200
+ if superclass_node.nil?
201
+ no_superclass_classes << last
202
+ next
203
+ end
204
+
205
+ superclass_name = Ast.class_name(superclass_node)
206
+ next unless superclass_name
207
+
208
+ application_family_classes << last if superclass_name.match?(NON_MODEL_SUPERCLASS_PATTERN)
209
+ class_superclass[last] = superclass_name
210
+ end
211
+ end
212
+
213
+ # Every class name in class_superclass whose superclass chain
214
+ # terminates in ApplicationRecord/ActiveRecord::Base, resolved
215
+ # transitively (`class Order < ShardedRecord` + `class ShardedRecord <
216
+ # ApplicationRecord` both scanned) — not just direct inheritance, so
217
+ # e.g. a real Rails app's own abstract per-shard base classes, or STI
218
+ # subclasses, are recognized as models too.
219
+ def resolve_known_models(class_superclass)
220
+ class_superclass.each_key.select { |name| model_via_chain?(name, class_superclass) }.to_set
221
+ end
222
+
223
+ # `seen` guards against an (invalid, but not this method's job to
224
+ # reject) inheritance cycle recursing forever.
225
+ def model_via_chain?(name, class_superclass, seen = Set.new)
226
+ return false if seen.include?(name)
227
+
228
+ seen << name
229
+ superclass = class_superclass[name]
230
+ return false unless superclass
231
+ return true if KNOWN_MODEL_BASE_CLASSES.include?(superclass)
232
+
233
+ next_name = last_segment(superclass)
234
+ return false unless class_superclass.key?(next_name)
235
+
236
+ model_via_chain?(next_name, class_superclass, seen)
237
+ end
238
+
239
+ def last_segment(name)
240
+ name.to_s.split("::").last
241
+ end
242
+
124
243
  def duplicate_detection_target?(relative_path)
125
244
  segments = relative_path.split("/")
126
245
  return true if segments.include?("concerns")
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags Ruby files with no `# frozen_string_literal: true` magic comment
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
- VERSION = "1.2.1"
3
+ VERSION = "1.3.0"
3
4
  end
data/lib/scryer.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "scryer/version"
2
3
  require "scryer/colorizer"
3
4
  require "scryer/ast"
@@ -54,6 +55,50 @@ module Scryer
54
55
  # configured default) if it's too noisy or too slow for a given project.
55
56
  attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules, :detect_duplicates
56
57
 
58
+ # Runtime method-level tracing config (see Scryer::APM) — a separate
59
+ # nested config object, not flat attrs on Configuration itself, so
60
+ # `Scryer.configure { |c| c.apm.include = [...] }` reads the same way
61
+ # section 7 of this feature's own design doc illustrates it, and so
62
+ # APM's many options don't crowd the static-scan options above into a
63
+ # single flat namespace.
64
+ class APMConfiguration
65
+ # `provider` — :new_relic (soft-detected via
66
+ # `defined?(NewRelic::Agent::Tracer)`) and :opentelemetry (soft-detected
67
+ # via `defined?(OpenTelemetry::Trace)`) are implemented; spans just
68
+ # aren't exported anywhere if the configured provider's SDK isn't
69
+ # loaded. `instrumentation` — only
70
+ # :off and :selective exist; :discovery/:deep_trace raise from
71
+ # Scryer::APM.enable! (not implemented yet, see README). `include`/
72
+ # `exclude` — arrays of "ClassName"/"Module::ClassName" strings;
73
+ # `exclude` always additionally contains Scryer's own namespace plus
74
+ # a small framework-internals floor regardless of what's set here
75
+ # (see Scryer::APM::DEFAULT_EXCLUDE). `sampling_rate` — 0.0..1.0,
76
+ # applied once per Scryer::APM.trace scope (a whole request/job is
77
+ # either sampled or not, not decided span-by-span).
78
+ # `capture_exception_messages` — false by default: only the
79
+ # exception *class* is ever recorded unless explicitly opted in,
80
+ # and even then the message passes through a best-effort redaction
81
+ # pass (see Scryer::APM#redact_exception_message).
82
+ attr_accessor :enabled, :provider, :instrumentation, :include, :exclude, :sampling_rate,
83
+ :capture_exception_messages
84
+
85
+ def initialize
86
+ @enabled = false
87
+ @provider = :new_relic
88
+ @instrumentation = :selective
89
+ @include = []
90
+ @exclude = []
91
+ @sampling_rate = 1.0
92
+ @capture_exception_messages = false
93
+ end
94
+ end
95
+
96
+ attr_writer :apm
97
+
98
+ def apm
99
+ @apm ||= APMConfiguration.new
100
+ end
101
+
57
102
  def initialize
58
103
  @dirs = Scryer::Scanner::DEFAULT_GLOB_DIRS
59
104
  @skip_rules = []
@@ -91,5 +136,9 @@ end
91
136
  # isn't required eagerly here to avoid loading active_support/notifications
92
137
  # machinery for the (default) case where nobody asked for it. Require it
93
138
  # yourself where you call .enable! (see README's "Runtime query watcher").
139
+ #
140
+ # Scryer::APM (runtime method-level tracing, see lib/scryer/apm.rb) is the
141
+ # same story — opt-in, requires it yourself where you call .enable! (see
142
+ # README's "Runtime method tracing").
94
143
 
95
144
  require "scryer/railtie" if defined?(Rails::Railtie)
@@ -440,7 +440,6 @@ module ScryerTasks
440
440
  ]
441
441
 
442
442
  divider = paint("─" * 32, :gray)
443
- score = renderer.security_score
444
443
  puts ""
445
444
  puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
446
445
  puts divider
@@ -450,7 +449,10 @@ module ScryerTasks
450
449
  puts ""
451
450
  end
452
451
  clean_rate = renderer.rules_clean_rate
453
- puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
452
+ puts score_row("Security Score", renderer.security_score)
453
+ puts score_row("Performance Score", renderer.performance_score)
454
+ puts score_row("Style Score", renderer.style_score)
455
+ puts(ran_deps ? score_row("Dependency Score", renderer.dependency_score) : "#{"Dependency Score".ljust(20)}skipped (nodeps)")
454
456
  puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
455
457
  puts ""
456
458
  rows.each { |label, count| puts summary_row(label, count) }
@@ -467,6 +469,10 @@ module ScryerTasks
467
469
  "#{label.ljust(14)}#{value.rjust(20)}"
468
470
  end
469
471
 
472
+ def score_row(label, score)
473
+ "#{label.ljust(20)}#{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
474
+ end
475
+
470
476
  # The categories above are counted separately, but nothing else ranks
471
477
  # across them — this is what actually backs "tells you what to fix
472
478
  # first" rather than just splitting findings into four buckets. Same