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
data/lib/scryer/minitest.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Scryer
|
|
2
3
|
module PerformanceRules
|
|
3
4
|
# Flags likely N+1 queries: inside a `.each`/`.map` block whose receiver
|
|
@@ -111,6 +112,7 @@ module Scryer
|
|
|
111
112
|
|
|
112
113
|
const = root[1]
|
|
113
114
|
return false unless const.is_a?(Array) && const[0] == :@const
|
|
115
|
+
return false unless Ast.likely_model_name?(const[1], known_models: known_models, known_non_models: known_non_models)
|
|
114
116
|
|
|
115
117
|
Ast.each_node(value).any? do |n|
|
|
116
118
|
next false unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Scryer
|
|
2
3
|
module PerformanceRules
|
|
3
4
|
# Flags `Model.all.each`/`Model.where(...).each` (or `.order(...).each`) —
|
|
@@ -9,6 +10,11 @@ module Scryer
|
|
|
9
10
|
# assigned from the query and iterated later (that pattern is out of scope
|
|
10
11
|
# here; see `NPlusOneQueryRule`, which does track simple local
|
|
11
12
|
# assignments, for a related check on what happens *inside* such a loop).
|
|
13
|
+
# The receiver constant is checked against known_models/known_non_models
|
|
14
|
+
# (see Ast.likely_model_name?) rather than accepted unconditionally — a
|
|
15
|
+
# bare Ruby module/class with no AR ancestry (e.g. this gem's own
|
|
16
|
+
# `RuleSet.all.each`) can coincidentally match the same `Const.all.each`
|
|
17
|
+
# shape without being a query at all.
|
|
12
18
|
class UnboundedTableScanRule < Rule
|
|
13
19
|
self.rule_id = "unbounded_table_scan"
|
|
14
20
|
self.category = "performance"
|
|
@@ -61,7 +67,9 @@ module Scryer
|
|
|
61
67
|
return false unless Ast.tagged?(root, :var_ref, :vcall)
|
|
62
68
|
|
|
63
69
|
const = root[1]
|
|
64
|
-
const.is_a?(Array) && const[0] == :@const
|
|
70
|
+
return false unless const.is_a?(Array) && const[0] == :@const
|
|
71
|
+
|
|
72
|
+
Ast.likely_model_name?(const[1], known_models: known_models, known_non_models: known_non_models)
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
def root_of(node)
|
data/lib/scryer/query_watcher.rb
CHANGED
data/lib/scryer/railtie.rb
CHANGED
|
@@ -1,12 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Scryer
|
|
2
|
-
#
|
|
3
|
-
# instrumentation —
|
|
4
|
-
#
|
|
5
|
-
# requiring this gem inside a Rails app never raises, and to load the
|
|
3
|
+
# The static scan runs on-demand via a rake task, not as request-cycle
|
|
4
|
+
# instrumentation — that part of the Railtie's job is just making sure
|
|
5
|
+
# requiring this gem inside a Rails app never raises, and loading the
|
|
6
6
|
# rake tasks into the host app's Rails console/`rake -T` listing.
|
|
7
|
+
#
|
|
8
|
+
# Scryer::APM (runtime method tracing) is the one exception: it *is*
|
|
9
|
+
# request-cycle instrumentation, and unlike QueryWatcher/AuthorizationWatcher
|
|
10
|
+
# (which the README has host apps wire up by hand — see
|
|
11
|
+
# docs/rails-integration.md) this one auto-wires itself when
|
|
12
|
+
# `Scryer.configuration.apm.enabled` is true, so setting that flag (plus
|
|
13
|
+
# `c.apm.include`) in an initializer is genuinely sufficient — no separate
|
|
14
|
+
# `require "scryer/apm"` / `Scryer::APM.enable!` / `middleware.use` calls
|
|
15
|
+
# needed. `after: :load_config_initializers` guarantees this runs after
|
|
16
|
+
# every file in config/initializers/ (including the host app's own
|
|
17
|
+
# scryer.rb) has already set that flag, regardless of alphabetical
|
|
18
|
+
# filename ordering.
|
|
7
19
|
class Railtie < Rails::Railtie
|
|
8
20
|
rake_tasks do
|
|
9
21
|
load File.expand_path("../tasks/scryer.rake", __dir__)
|
|
10
22
|
end
|
|
23
|
+
|
|
24
|
+
initializer "scryer.apm", after: :load_config_initializers do |app|
|
|
25
|
+
Scryer::Railtie.maybe_enable_apm(app)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Plain Ruby, no Rails::Railtie API surface — pulled out of the
|
|
29
|
+
# `initializer` block above specifically so it's directly unit-testable
|
|
30
|
+
# (test/railtie_apm_test.rb) with a fake `app` (anything responding to
|
|
31
|
+
# `middleware.use`), without needing a real Rails app to boot. What this
|
|
32
|
+
# test *cannot* verify: that `after: :load_config_initializers` above
|
|
33
|
+
# actually fires at the right point in a real Rails boot sequence —
|
|
34
|
+
# that would need a real Rails app, which this gem's test suite doesn't
|
|
35
|
+
# depend on (see gemspec: zero runtime deps, Rails-optional).
|
|
36
|
+
#
|
|
37
|
+
# This method runs unattended during every boot of every host app that
|
|
38
|
+
# sets `c.apm.enabled = true` — a misconfigured `include` entry, an
|
|
39
|
+
# unimplemented `instrumentation` mode set by mistake, a `require`
|
|
40
|
+
# failure, anything — must degrade to "APM is off, logged once" rather
|
|
41
|
+
# than fail app boot. That's a materially different bar than the rest
|
|
42
|
+
# of Scryer::APM's public API (Scryer::APM.enable! called directly, in
|
|
43
|
+
# a console or test, DOES still raise for an unimplemented mode — fast,
|
|
44
|
+
# loud feedback is correct there, where a developer is watching).
|
|
45
|
+
# `rescue Exception` (not just StandardError) is deliberate here for
|
|
46
|
+
# the same reason: a boot-time LoadError from a packaging issue is a
|
|
47
|
+
# ScriptError, not a StandardError, and must be caught by this specific
|
|
48
|
+
# boundary too — re-raising SystemExit/NoMemoryError/SignalException
|
|
49
|
+
# keeps this from swallowing the handful of cases where propagating is
|
|
50
|
+
# still correct even at boot.
|
|
51
|
+
def self.maybe_enable_apm(app)
|
|
52
|
+
return unless Scryer.configuration.apm.enabled
|
|
53
|
+
|
|
54
|
+
require "scryer/apm"
|
|
55
|
+
Scryer::APM.enable!
|
|
56
|
+
app.middleware.use Scryer::APM::Middleware
|
|
57
|
+
rescue SystemExit, NoMemoryError, SignalException
|
|
58
|
+
raise
|
|
59
|
+
rescue Exception => e # rubocop-equivalent: intentional, see comment above
|
|
60
|
+
logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || begin
|
|
61
|
+
require "logger"
|
|
62
|
+
Logger.new($stderr)
|
|
63
|
+
end
|
|
64
|
+
logger.error(
|
|
65
|
+
"[Scryer::APM] enabling APM via c.apm.enabled failed (#{e.class}: #{e.message}) — " \
|
|
66
|
+
"APM is disabled for this process; the rest of the app boots normally. " \
|
|
67
|
+
"#{e.backtrace&.first(5)&.join("\n")}"
|
|
68
|
+
)
|
|
69
|
+
Scryer::APM.disable! if defined?(Scryer::APM)
|
|
70
|
+
end
|
|
11
71
|
end
|
|
12
72
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
require "json"
|
|
2
3
|
require "time"
|
|
3
4
|
|
|
@@ -56,6 +57,9 @@ module Scryer
|
|
|
56
57
|
"duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) },
|
|
57
58
|
"dependency_findings" => @dependency_findings.map(&:to_h),
|
|
58
59
|
"security_score" => security_score,
|
|
60
|
+
"performance_score" => performance_score,
|
|
61
|
+
"style_score" => style_score,
|
|
62
|
+
"dependency_score" => dependency_score,
|
|
59
63
|
"rules_clean_rate" => rules_clean_rate
|
|
60
64
|
}
|
|
61
65
|
end
|
|
@@ -105,71 +109,84 @@ module Scryer
|
|
|
105
109
|
counts.sort_by { |category, count| [-count, category] }
|
|
106
110
|
end
|
|
107
111
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
112
|
+
# Four independent 0-100 scores (each with its own letter grade) — one
|
|
113
|
+
# per category (security/performance/style/dependency) — rather than
|
|
114
|
+
# one blended number. A single combined score used to fold performance
|
|
115
|
+
# findings in at a diluted weight and drop style findings entirely,
|
|
116
|
+
# which meant a bad performance problem or a wall of style noise could
|
|
117
|
+
# never really move "the" score, and there was no way to see "how bad
|
|
118
|
+
# is *just* the security picture" without mentally subtracting the
|
|
119
|
+
# other categories back out. Each of the four methods below runs the
|
|
120
|
+
# exact same severity+confidence-weighted formula (see
|
|
121
|
+
# #category_score) against only that one category's own findings —
|
|
122
|
+
# nothing here dilutes or borrows weight from another category anymore.
|
|
123
|
+
#
|
|
124
|
+
# Deliberately NOT normalized by files-scanned or lines of code: each
|
|
125
|
+
# score reflects that category's absolute finding exposure in this
|
|
126
|
+
# scan, so it's meaningful for tracking one project's own trend over
|
|
127
|
+
# time (does the next scan score higher or lower), not for comparing
|
|
128
|
+
# two differently-sized codebases against each other — a bigger app
|
|
129
|
+
# with the same finding *density* will naturally score lower here, and
|
|
130
|
+
# that's a documented limitation, not a bug.
|
|
124
131
|
#
|
|
125
132
|
# Exponential decay rather than linear subtraction from 100: a single
|
|
126
|
-
# critical/high finding should visibly move
|
|
127
|
-
# a handful of findings driving a real app straight to a
|
|
128
|
-
# which would make the score useless for comparing
|
|
129
|
-
# weighted_penalty combines severity (the
|
|
130
|
-
# confidence (a low-confidence rule's finding
|
|
131
|
-
# much as a high-confidence one saying the
|
|
132
|
-
# same philosophy as SARIF's `rank` (see sarif_rank
|
|
133
|
-
#
|
|
133
|
+
# critical/high finding should visibly move a score (100 -> ~86)
|
|
134
|
+
# without a handful of findings driving a real app straight to a
|
|
135
|
+
# hard-clamped 0, which would make the score useless for comparing
|
|
136
|
+
# "bad" against "worse." weighted_penalty combines severity (the
|
|
137
|
+
# dominant factor) with confidence (a low-confidence rule's finding
|
|
138
|
+
# shouldn't hurt the score as much as a high-confidence one saying the
|
|
139
|
+
# same severity) — same philosophy as SARIF's `rank` (see sarif_rank
|
|
140
|
+
# above), computed once per category instead of per-result.
|
|
134
141
|
# "info" is deliberately far below critical/warning, not just a step
|
|
135
142
|
# down from them — an info-severity finding is closer to a cosmetic
|
|
136
143
|
# note than a real risk (see e.g. csrf_protection_disabled's
|
|
137
|
-
# narrowly-scoped-skip case), so a
|
|
144
|
+
# narrowly-scoped-skip case), so a category with only info findings
|
|
138
145
|
# should barely move off 100 even with a fair number of them, rather
|
|
139
146
|
# than accumulating like a smaller warning would.
|
|
140
147
|
SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 0.25 }.freeze
|
|
141
148
|
SCORE_CONFIDENCE_WEIGHT = { "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
|
|
142
|
-
# `category` is "security" for every Scryer::Finding in security_findings
|
|
143
|
-
# and every DependencyAudit::Finding (which has no `category` field at
|
|
144
|
-
# all — `f["category"]` is nil for those, so they fall through to the
|
|
145
|
-
# 1.0 default below, same full weight as security). "performance" is the
|
|
146
|
-
# only other category ever passed into this method's `findings` — style
|
|
147
|
-
# findings and duplicate-code groups never reach here at all (see above).
|
|
148
|
-
SCORE_CATEGORY_WEIGHT = { "security" => 1.0, "performance" => 0.2 }.freeze
|
|
149
149
|
SCORE_DECAY_CONSTANT = 100.0
|
|
150
150
|
|
|
151
|
-
# Deliberately reads @result/@dependency_findings directly rather than
|
|
152
|
-
# going through as_hash — as_hash includes this method's own output (so
|
|
153
|
-
# JSON consumers get the score without a separate call), and as_hash
|
|
154
|
-
# calling security_score while security_score called as_hash would
|
|
155
|
-
# recurse forever.
|
|
156
151
|
def security_score
|
|
152
|
+
category_score(@result.security_findings)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def performance_score
|
|
156
|
+
category_score(@result.performance_findings)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Duplicate-code groups are deliberately excluded from this (and so
|
|
160
|
+
# from every score) — a `DuplicateDetector::DuplicateGroup` isn't even
|
|
161
|
+
# a `Finding` (no severity/confidence to weigh in the first place; see
|
|
162
|
+
# duplicate_detector.rb) — so this is really just `frozen_string_literal`
|
|
163
|
+
# today, the one style check this gem has.
|
|
164
|
+
def style_score
|
|
165
|
+
category_score(@result.style_findings)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def dependency_score
|
|
169
|
+
category_score(@dependency_findings)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Shared by all four *_score methods above. Deliberately takes the raw
|
|
173
|
+
# findings array rather than going through as_hash — as_hash includes
|
|
174
|
+
# every score's own output (so JSON consumers get all four without a
|
|
175
|
+
# separate call), and as_hash calling a *_score method while that
|
|
176
|
+
# method called as_hash would recurse forever.
|
|
177
|
+
def category_score(findings)
|
|
157
178
|
# .to_h (not the full as_hash) so this works uniformly across Finding
|
|
158
179
|
# (has "confidence") and DependencyAudit::Finding (doesn't — a plain
|
|
159
180
|
# Hash returns nil for a missing key rather than raising, unlike
|
|
160
181
|
# calling #confidence directly on a struct that has no such member).
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
weighted_penalty = findings.sum do |f|
|
|
166
|
-
(SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) *
|
|
167
|
-
(SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7) *
|
|
168
|
-
(SCORE_CATEGORY_WEIGHT[f["category"]] || 1.0)
|
|
182
|
+
hashes = findings.map(&:to_h)
|
|
183
|
+
|
|
184
|
+
weighted_penalty = hashes.sum do |f|
|
|
185
|
+
(SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) * (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7)
|
|
169
186
|
end
|
|
170
187
|
|
|
171
188
|
score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round
|
|
172
|
-
{ "score" => score, "grade" => score_grade(score), "finding_count" =>
|
|
189
|
+
{ "score" => score, "grade" => score_grade(score), "finding_count" => hashes.size }
|
|
173
190
|
end
|
|
174
191
|
|
|
175
192
|
SCORE_GRADE_BANDS = [[90, "A"], [80, "B"], [70, "C"], [60, "D"]].freeze
|
|
@@ -183,10 +200,12 @@ module Scryer
|
|
|
183
200
|
# (security + performance + style; NOT the dependency checks, which
|
|
184
201
|
# aren't backed by a Rule subclass at all — see DEPENDENCY_SARIF_RULES)
|
|
185
202
|
# fired zero findings in this scan, out of every rule that exists to
|
|
186
|
-
# fire. A rule-level pass rate
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
203
|
+
# fire. A single rule-level pass rate spanning all three rule-backed
|
|
204
|
+
# categories together, distinct from the four *_score methods above
|
|
205
|
+
# (each of which is finding-weighted, not rule-counted, and scoped to
|
|
206
|
+
# one category) — a codebase can have a high clean rate (few distinct
|
|
207
|
+
# rules triggered) and still a low score in one category (the few that
|
|
208
|
+
# did trigger there were severe/high-confidence), or the reverse (many
|
|
190
209
|
# different rules each firing once, none of them serious). Report both;
|
|
191
210
|
# neither alone tells the whole story.
|
|
192
211
|
def rules_clean_rate
|
|
@@ -251,14 +270,18 @@ module Scryer
|
|
|
251
270
|
<style>#{CSS}</style>
|
|
252
271
|
</head>
|
|
253
272
|
<body>
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
273
|
+
<header class="hero">
|
|
274
|
+
<h1>Scryer report</h1>
|
|
275
|
+
<p class="meta">
|
|
276
|
+
<span class="meta-item meta-project">#{escape(@project_name)}</span>
|
|
277
|
+
<span class="meta-item">#{escape(h["release_label"] || "no release label")}</span>
|
|
278
|
+
<span class="meta-item">#{escape(h["scanned_at"])}</span>
|
|
279
|
+
<span class="meta-item">#{h["files_scanned"]} files scanned</span>
|
|
280
|
+
#{h["parse_errors"].any? ? "<span class=\"meta-item crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
|
|
281
|
+
</p>
|
|
282
|
+
</header>
|
|
283
|
+
|
|
284
|
+
#{render_executive_summary(h, h["rules_clean_rate"])}
|
|
262
285
|
|
|
263
286
|
#{render_toc(h)}
|
|
264
287
|
|
|
@@ -347,14 +370,13 @@ module Scryer
|
|
|
347
370
|
HTML
|
|
348
371
|
end
|
|
349
372
|
|
|
350
|
-
# The score
|
|
351
|
-
# an "is this bad or fine" answer in the first thing a reader
|
|
352
|
-
# ahead of even the table of contents.
|
|
353
|
-
# findings
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
|
|
357
|
-
def render_executive_summary(h, score, clean_rate)
|
|
373
|
+
# The four score badges + severity bar chart at the very top of the
|
|
374
|
+
# report — an "is this bad or fine" answer in the first thing a reader
|
|
375
|
+
# sees, ahead of even the table of contents. The bar chart itself stays
|
|
376
|
+
# scoped to security + dependency findings (the two categories a
|
|
377
|
+
# "should I be worried" skim cares about most) — the Summary table
|
|
378
|
+
# further down shows the performance/style breakdown too.
|
|
379
|
+
def render_executive_summary(h, clean_rate)
|
|
358
380
|
sec_and_deps = h["security_findings"] + h["dependency_findings"]
|
|
359
381
|
by_severity = Hash.new(0)
|
|
360
382
|
sec_and_deps.each { |f| by_severity[f["severity"]] += 1 }
|
|
@@ -382,20 +404,41 @@ module Scryer
|
|
|
382
404
|
BAR
|
|
383
405
|
end.join
|
|
384
406
|
|
|
407
|
+
score_badges = [
|
|
408
|
+
["Security", h["security_score"]],
|
|
409
|
+
["Performance", h["performance_score"]],
|
|
410
|
+
["Style", h["style_score"]],
|
|
411
|
+
["Dependency", h["dependency_score"]]
|
|
412
|
+
].map do |label, score|
|
|
413
|
+
<<~BADGE
|
|
414
|
+
<div class="score-badge-item">
|
|
415
|
+
<div class="score-badge grade-#{score["grade"]}">
|
|
416
|
+
<span class="score-number">#{score["score"]}</span>
|
|
417
|
+
<span class="score-grade">#{score["grade"]}</span>
|
|
418
|
+
</div>
|
|
419
|
+
<span class="score-category">#{label}</span>
|
|
420
|
+
</div>
|
|
421
|
+
BADGE
|
|
422
|
+
end.join
|
|
423
|
+
|
|
385
424
|
<<~HTML
|
|
386
425
|
<div class="score-panel">
|
|
387
|
-
<div class="score-
|
|
388
|
-
|
|
389
|
-
<span class="score-grade">#{score["grade"]}</span>
|
|
426
|
+
<div class="score-badges">
|
|
427
|
+
#{score_badges}
|
|
390
428
|
</div>
|
|
429
|
+
<div class="score-panel-divider"></div>
|
|
391
430
|
<div class="score-details">
|
|
392
|
-
<p class="score-label">
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
431
|
+
<p class="score-label">Four independent scores, one per category — Security
|
|
432
|
+
(#{h["security_score"]["finding_count"]} finding(s)), Performance
|
|
433
|
+
(#{h["performance_score"]["finding_count"]}), Style
|
|
434
|
+
(#{h["style_score"]["finding_count"]}), and Dependency audit
|
|
435
|
+
(#{h["dependency_score"]["finding_count"]}) — each weighted by severity and
|
|
436
|
+
confidence within its own category only, so a bad performance problem or a wall of
|
|
437
|
+
style findings can't hide inside (or get diluted by) a security number. Not
|
|
438
|
+
normalized by app size — see the README for what these numbers do and don't mean.
|
|
396
439
|
<strong>#{clean_rate["clean"]}/#{clean_rate["total"]}</strong> rules clean
|
|
397
440
|
(#{clean_rate["percent"]}%) — a rule-level pass rate, a different (and not always
|
|
398
|
-
matching) signal from the finding-weighted
|
|
441
|
+
matching) signal from the finding-weighted scores.</p>
|
|
399
442
|
#{bars}
|
|
400
443
|
</div>
|
|
401
444
|
</div>
|
|
@@ -1008,35 +1051,54 @@ module Scryer
|
|
|
1008
1051
|
end
|
|
1009
1052
|
|
|
1010
1053
|
CSS = <<~CSS
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1054
|
+
:root {
|
|
1055
|
+
--ink: #0f172a; --muted: #64748b; --border: #e2e8f0; --surface: #ffffff; --canvas: #f6f8fb;
|
|
1056
|
+
}
|
|
1057
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
1058
|
+
margin: 0; padding: 2.5rem clamp(1.25rem, 4vw, 3rem) 3rem; color: var(--ink); background: var(--canvas); }
|
|
1059
|
+
h1 { margin: 0 0 0.4rem; font-size: 1.9rem; font-weight: 800; letter-spacing: -0.02em; }
|
|
1060
|
+
h2 { margin-top: 2.75rem; font-size: 1.05rem; font-weight: 700; border-bottom: 1px solid var(--border);
|
|
1061
|
+
padding-bottom: 0.5rem; }
|
|
1062
|
+
.hero { margin-bottom: 1.5rem; }
|
|
1063
|
+
.meta { color: var(--muted); font-size: 0.85rem; margin: 0; display: flex; flex-wrap: wrap;
|
|
1064
|
+
gap: 0.35rem 0; }
|
|
1065
|
+
.meta-item + .meta-item::before { content: "\\00b7"; margin: 0 0.55rem; color: #cbd5e1; }
|
|
1066
|
+
.crit { color: #b91c1c; font-weight: 700; }
|
|
1067
|
+
.score-panel { display: flex; gap: 2rem; align-items: center; flex-wrap: wrap; background: var(--surface);
|
|
1068
|
+
border: 1px solid var(--border); border-radius: 16px; padding: 1.75rem 2rem; margin: 1.25rem 0 1.75rem;
|
|
1069
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 28px -16px rgba(15, 23, 42, 0.12); }
|
|
1070
|
+
.score-badges { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
|
1071
|
+
.score-panel-divider { align-self: stretch; width: 1px; background: var(--border); }
|
|
1072
|
+
.score-badge-item { display: flex; flex-direction: column; align-items: center; gap: 0.55rem; }
|
|
1073
|
+
.score-category { font-size: 0.68rem; font-weight: 700; color: var(--muted); text-transform: uppercase;
|
|
1074
|
+
letter-spacing: 0.06em; }
|
|
1075
|
+
.score-badge { flex: 0 0 auto; width: 5rem; height: 5rem; border-radius: 20px;
|
|
1019
1076
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
1020
|
-
color: #fff; }
|
|
1021
|
-
.score-badge .score-number { font-size: 1.
|
|
1022
|
-
.score-badge .score-grade { font-size: 0.
|
|
1023
|
-
.score-badge.grade-A { background: #
|
|
1024
|
-
.score-badge.grade-B { background: #
|
|
1025
|
-
.score-badge.grade-C { background: #
|
|
1026
|
-
.score-badge.grade-D { background: #
|
|
1027
|
-
.score-badge.grade-F { background: #
|
|
1028
|
-
.score-details { flex: 1 1
|
|
1029
|
-
.score-label { margin: 0 0 0.
|
|
1030
|
-
.score-bar-row { display:
|
|
1031
|
-
|
|
1032
|
-
.score-bar-
|
|
1077
|
+
color: #fff; box-shadow: 0 6px 14px -6px rgba(15, 23, 42, 0.35); }
|
|
1078
|
+
.score-badge .score-number { font-size: 1.45rem; font-weight: 800; line-height: 1; }
|
|
1079
|
+
.score-badge .score-grade { font-size: 0.7rem; font-weight: 700; opacity: 0.9; margin-top: 0.15rem; }
|
|
1080
|
+
.score-badge.grade-A { background: linear-gradient(155deg, #4ade80, #15803d); }
|
|
1081
|
+
.score-badge.grade-B { background: linear-gradient(155deg, #22d3ee, #0e7490); }
|
|
1082
|
+
.score-badge.grade-C { background: linear-gradient(155deg, #fbbf24, #b45309); }
|
|
1083
|
+
.score-badge.grade-D { background: linear-gradient(155deg, #fb923c, #c2410c); }
|
|
1084
|
+
.score-badge.grade-F { background: linear-gradient(155deg, #f87171, #b91c1c); }
|
|
1085
|
+
.score-details { flex: 1 1 18rem; min-width: 0; }
|
|
1086
|
+
.score-label { margin: 0 0 0.85rem; font-size: 0.82rem; color: var(--muted); line-height: 1.5; }
|
|
1087
|
+
.score-bar-row { display: grid; grid-template-columns: 5rem 1fr 2.25rem; align-items: center; gap: 0.75rem;
|
|
1088
|
+
font-size: 0.8rem; margin: 0.4rem 0; }
|
|
1089
|
+
.score-bar-label { color: var(--muted); font-weight: 600; }
|
|
1090
|
+
.score-bar-track { background: #eef1f6; border-radius: 999px; height: 0.55rem; overflow: hidden; }
|
|
1033
1091
|
.score-bar-fill { height: 100%; border-radius: 999px; }
|
|
1034
|
-
.score-bar-fill.critical { background: #dc2626; }
|
|
1035
|
-
.score-bar-fill.warning { background: #d97706; }
|
|
1036
|
-
.score-bar-fill.info { background: #
|
|
1037
|
-
.score-bar-count {
|
|
1092
|
+
.score-bar-fill.critical { background: linear-gradient(90deg, #f87171, #dc2626); }
|
|
1093
|
+
.score-bar-fill.warning { background: linear-gradient(90deg, #fbbf24, #d97706); }
|
|
1094
|
+
.score-bar-fill.info { background: #94a3b8; }
|
|
1095
|
+
.score-bar-count { text-align: right; color: var(--ink); font-weight: 700;
|
|
1038
1096
|
text-decoration: none; }
|
|
1039
1097
|
.score-bar-count:hover { text-decoration: underline; }
|
|
1098
|
+
@media (max-width: 640px) {
|
|
1099
|
+
.score-panel { flex-direction: column; align-items: stretch; }
|
|
1100
|
+
.score-panel-divider { display: none; }
|
|
1101
|
+
}
|
|
1040
1102
|
.findings-search { display: block; width: 100%; max-width: 28rem; margin: 0.75rem 0 1.25rem;
|
|
1041
1103
|
padding: 0.5rem 0.75rem; font: inherit; font-size: 0.85rem; border: 1px solid #cbd5e1;
|
|
1042
1104
|
border-radius: 8px; box-sizing: border-box; }
|
data/lib/scryer/rspec.rb
CHANGED
data/lib/scryer/rule.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Scryer
|
|
2
3
|
# Base class for a single detection rule. Subclasses implement `#scan` and
|
|
3
4
|
# return an Array of Finding. Every rule gets the parsed sexp tree (so it
|
|
@@ -30,13 +31,25 @@ module Scryer
|
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
# `known_models`/`known_non_models` — Sets of unqualified, last-segment
|
|
35
|
+
# class names Scanner#call resolved once per scan by walking every
|
|
36
|
+
# `class X < Y` declaration across every scanned file (see its own
|
|
37
|
+
# comment, and Ast.likely_model_name?, for what these mean and why they
|
|
38
|
+
# exist) — a real, project-wide signal for "is this class actually an
|
|
39
|
+
# ActiveRecord model," instead of the pure name-guessing
|
|
40
|
+
# MassAssignmentRule/IdorRule/MissingPolicyScopeRule used to each do
|
|
41
|
+
# independently. Optional and empty by default: only those three rules
|
|
42
|
+
# read them; every other rule (and every existing direct `Rule.new` call
|
|
43
|
+
# outside Scanner, e.g. in tests) is unaffected.
|
|
44
|
+
def initialize(file:, source:, sexp:, known_models: Ast::EMPTY_SET, known_non_models: Ast::EMPTY_SET)
|
|
34
45
|
@file = file
|
|
35
46
|
@source = source
|
|
36
47
|
@sexp = sexp
|
|
48
|
+
@known_models = known_models
|
|
49
|
+
@known_non_models = known_non_models
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
attr_reader :file, :source, :sexp
|
|
52
|
+
attr_reader :file, :source, :sexp, :known_models, :known_non_models
|
|
40
53
|
|
|
41
54
|
def scan
|
|
42
55
|
raise NotImplementedError, "#{self.class} must implement #scan"
|
data/lib/scryer/rule_set.rb
CHANGED