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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -10
- data/README.md +9 -4
- data/docs/architecture.md +45 -36
- data/docs/rails-integration.md +182 -1
- data/docs/rules.md +8 -1
- data/docs/usage.md +5 -2
- data/lib/generators/scryer/install_generator.rb +1 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +22 -0
- data/lib/scryer/ai_client.rb +1 -0
- data/lib/scryer/ai_fix_suggester.rb +1 -0
- data/lib/scryer/apm.rb +458 -0
- data/lib/scryer/ast.rb +53 -0
- data/lib/scryer/authorization_watcher.rb +1 -0
- data/lib/scryer/baseline.rb +1 -0
- data/lib/scryer/cache_extractor.rb +1 -0
- data/lib/scryer/cli.rb +19 -11
- data/lib/scryer/colorizer.rb +1 -0
- data/lib/scryer/dependency_audit.rb +1 -0
- data/lib/scryer/dependency_fixer.rb +1 -0
- data/lib/scryer/duplicate_detector.rb +1 -0
- data/lib/scryer/finding.rb +1 -0
- data/lib/scryer/fix_runner.rb +1 -0
- data/lib/scryer/fix_verifier.rb +1 -0
- data/lib/scryer/mechanical_fixer.rb +1 -0
- data/lib/scryer/method_extractor.rb +1 -0
- data/lib/scryer/minitest.rb +1 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
- data/lib/scryer/query_extractor.rb +1 -0
- data/lib/scryer/query_watcher.rb +1 -0
- data/lib/scryer/railtie.rb +64 -4
- data/lib/scryer/report_renderer.rb +161 -99
- data/lib/scryer/rspec.rb +1 -0
- data/lib/scryer/rule.rb +15 -2
- data/lib/scryer/rule_set.rb +1 -0
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
- data/lib/scryer/rules/command_injection_rule.rb +1 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
- data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
- data/lib/scryer/rules/force_ssl_rule.rb +1 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
- data/lib/scryer/rules/idor_rule.rb +6 -14
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
- data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
- data/lib/scryer/rules/open_redirect_rule.rb +1 -0
- data/lib/scryer/rules/path_traversal_rule.rb +1 -0
- data/lib/scryer/rules/security_headers_rule.rb +1 -0
- data/lib/scryer/rules/sql_injection_rule.rb +1 -0
- data/lib/scryer/rules/ssrf_rule.rb +1 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
- data/lib/scryer/scanner.rb +126 -7
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +2 -1
- data/lib/scryer.rb +49 -0
- data/lib/tasks/scryer.rake +8 -2
- 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 `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"`)
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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
|
-
|
|
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,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`)
|
|
87
|
-
#
|
|
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
|
-
|
|
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'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
|
-
|
|
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)
|
data/lib/scryer/scanner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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")
|
data/lib/scryer/version.rb
CHANGED
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)
|
data/lib/tasks/scryer.rake
CHANGED
|
@@ -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
|
|
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
|