scryer 1.2.0 → 1.2.2
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 +107 -9
- data/README.md +6 -3
- data/docs/architecture.md +45 -31
- data/docs/rails-integration.md +1 -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 +1 -0
- data/lib/scryer/ai_client.rb +1 -0
- data/lib/scryer/ai_fix_suggester.rb +1 -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 +17 -1
- 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 +1 -0
- data/lib/scryer/report_renderer.rb +185 -84
- 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 +1 -0
- data/lib/tasks/scryer.rake +8 -2
- metadata +4 -7
|
@@ -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,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,49 +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
|
-
#
|
|
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.
|
|
119
131
|
#
|
|
120
132
|
# Exponential decay rather than linear subtraction from 100: a single
|
|
121
|
-
# critical/high finding should visibly move
|
|
122
|
-
# a handful of findings driving a real app straight to a
|
|
123
|
-
# which would make the score useless for comparing
|
|
124
|
-
# weighted_penalty combines severity (the
|
|
125
|
-
# confidence (a low-confidence rule's finding
|
|
126
|
-
# much as a high-confidence one saying the
|
|
127
|
-
# philosophy as SARIF's `rank` (see sarif_rank
|
|
128
|
-
#
|
|
129
|
-
|
|
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.
|
|
141
|
+
# "info" is deliberately far below critical/warning, not just a step
|
|
142
|
+
# down from them — an info-severity finding is closer to a cosmetic
|
|
143
|
+
# note than a real risk (see e.g. csrf_protection_disabled's
|
|
144
|
+
# narrowly-scoped-skip case), so a category with only info findings
|
|
145
|
+
# should barely move off 100 even with a fair number of them, rather
|
|
146
|
+
# than accumulating like a smaller warning would.
|
|
147
|
+
SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 0.25 }.freeze
|
|
130
148
|
SCORE_CONFIDENCE_WEIGHT = { "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
|
|
131
149
|
SCORE_DECAY_CONSTANT = 100.0
|
|
132
150
|
|
|
133
|
-
# Deliberately reads @result/@dependency_findings directly rather than
|
|
134
|
-
# going through as_hash — as_hash includes this method's own output (so
|
|
135
|
-
# JSON consumers get the score without a separate call), and as_hash
|
|
136
|
-
# calling security_score while security_score called as_hash would
|
|
137
|
-
# recurse forever.
|
|
138
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)
|
|
139
178
|
# .to_h (not the full as_hash) so this works uniformly across Finding
|
|
140
179
|
# (has "confidence") and DependencyAudit::Finding (doesn't — a plain
|
|
141
180
|
# Hash returns nil for a missing key rather than raising, unlike
|
|
142
181
|
# calling #confidence directly on a struct that has no such member).
|
|
143
|
-
|
|
182
|
+
hashes = findings.map(&:to_h)
|
|
144
183
|
|
|
145
|
-
weighted_penalty =
|
|
184
|
+
weighted_penalty = hashes.sum do |f|
|
|
146
185
|
(SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) * (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7)
|
|
147
186
|
end
|
|
148
187
|
|
|
149
188
|
score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round
|
|
150
|
-
{ "score" => score, "grade" => score_grade(score), "finding_count" =>
|
|
189
|
+
{ "score" => score, "grade" => score_grade(score), "finding_count" => hashes.size }
|
|
151
190
|
end
|
|
152
191
|
|
|
153
192
|
SCORE_GRADE_BANDS = [[90, "A"], [80, "B"], [70, "C"], [60, "D"]].freeze
|
|
@@ -161,10 +200,12 @@ module Scryer
|
|
|
161
200
|
# (security + performance + style; NOT the dependency checks, which
|
|
162
201
|
# aren't backed by a Rule subclass at all — see DEPENDENCY_SARIF_RULES)
|
|
163
202
|
# fired zero findings in this scan, out of every rule that exists to
|
|
164
|
-
# fire. A rule-level pass rate
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
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
|
|
168
209
|
# different rules each firing once, none of them serious). Report both;
|
|
169
210
|
# neither alone tells the whole story.
|
|
170
211
|
def rules_clean_rate
|
|
@@ -229,14 +270,18 @@ module Scryer
|
|
|
229
270
|
<style>#{CSS}</style>
|
|
230
271
|
</head>
|
|
231
272
|
<body>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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"])}
|
|
240
285
|
|
|
241
286
|
#{render_toc(h)}
|
|
242
287
|
|
|
@@ -325,14 +370,13 @@ module Scryer
|
|
|
325
370
|
HTML
|
|
326
371
|
end
|
|
327
372
|
|
|
328
|
-
# The score
|
|
329
|
-
# an "is this bad or fine" answer in the first thing a reader
|
|
330
|
-
# ahead of even the table of contents.
|
|
331
|
-
# findings
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
|
|
335
|
-
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)
|
|
336
380
|
sec_and_deps = h["security_findings"] + h["dependency_findings"]
|
|
337
381
|
by_severity = Hash.new(0)
|
|
338
382
|
sec_and_deps.each { |f| by_severity[f["severity"]] += 1 }
|
|
@@ -360,19 +404,41 @@ module Scryer
|
|
|
360
404
|
BAR
|
|
361
405
|
end.join
|
|
362
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
|
+
|
|
363
424
|
<<~HTML
|
|
364
425
|
<div class="score-panel">
|
|
365
|
-
<div class="score-
|
|
366
|
-
|
|
367
|
-
<span class="score-grade">#{score["grade"]}</span>
|
|
426
|
+
<div class="score-badges">
|
|
427
|
+
#{score_badges}
|
|
368
428
|
</div>
|
|
429
|
+
<div class="score-panel-divider"></div>
|
|
369
430
|
<div class="score-details">
|
|
370
|
-
<p class="score-label">
|
|
371
|
-
finding(s),
|
|
372
|
-
|
|
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.
|
|
373
439
|
<strong>#{clean_rate["clean"]}/#{clean_rate["total"]}</strong> rules clean
|
|
374
440
|
(#{clean_rate["percent"]}%) — a rule-level pass rate, a different (and not always
|
|
375
|
-
matching) signal from the finding-weighted
|
|
441
|
+
matching) signal from the finding-weighted scores.</p>
|
|
376
442
|
#{bars}
|
|
377
443
|
</div>
|
|
378
444
|
</div>
|
|
@@ -399,7 +465,16 @@ module Scryer
|
|
|
399
465
|
sec_counts = count_by_severity(security)
|
|
400
466
|
perf_counts = count_by_severity(performance)
|
|
401
467
|
style_counts = count_by_severity(style)
|
|
402
|
-
|
|
468
|
+
# Dependency findings carry a real severity (see DependencyAudit::
|
|
469
|
+
# Finding#severity) same as any other finding — previously left out of
|
|
470
|
+
# this row (rendered as a "—" placeholder) and out of the Total row's
|
|
471
|
+
# sum below, which made the Total row silently undercount relative to
|
|
472
|
+
# what a reader would expect from a row literally labeled "Total" at
|
|
473
|
+
# the bottom of this same table, and inconsistent with the executive
|
|
474
|
+
# summary's severity bars above (which do include dependency findings
|
|
475
|
+
# in their own count).
|
|
476
|
+
deps_counts = count_by_severity(dependency_findings)
|
|
477
|
+
total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] + deps_counts[s] }
|
|
403
478
|
|
|
404
479
|
header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
|
|
405
480
|
# Category rows link via the same search-filter mechanism as the OWASP
|
|
@@ -411,13 +486,20 @@ module Scryer
|
|
|
411
486
|
perf_row = summary_row("Performance", perf_counts, filter_term: "performance")
|
|
412
487
|
style_row = summary_row("Style", style_counts, filter_term: "style")
|
|
413
488
|
# The Total row has no single category tag to filter by (it's the sum
|
|
414
|
-
# across all
|
|
489
|
+
# across all four), so its per-severity cells link straight to the
|
|
415
490
|
# matching #sev-* heading instead — same anchors, same precision, as
|
|
416
|
-
# the severity distribution chart at the top of the report.
|
|
491
|
+
# the severity distribution chart at the top of the report. Same
|
|
492
|
+
# caveat that chart's own comment already notes: #sev-* only groups
|
|
493
|
+
# security/performance/style findings, so a reader following this link
|
|
494
|
+
# for a severity that's only present via a dependency finding won't
|
|
495
|
+
# see it highlighted there — still the right destination for the bulk
|
|
496
|
+
# of what's counted, and dependency findings are one section away via
|
|
497
|
+
# the Dependency audit row's own link just below.
|
|
417
498
|
total_row = summary_row("Total", total_counts, css_class: "total", severity_anchors: true)
|
|
418
499
|
dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
|
|
419
500
|
"<td><a class=\"jump-link\" href=\"#duplicates\">#{duplicate_groups.size} group(s)</a></td></tr>"
|
|
420
|
-
|
|
501
|
+
deps_cells = SEVERITY_ORDER.map { |s| "<td>#{deps_counts[s]}</td>" }.join
|
|
502
|
+
deps_row = "<tr><th>Dependency audit</th>#{deps_cells}" \
|
|
421
503
|
"<td><a class=\"jump-link\" href=\"#dependency-audit\">#{dependency_findings.size} finding(s)</a></td></tr>"
|
|
422
504
|
|
|
423
505
|
"<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
|
|
@@ -969,35 +1051,54 @@ module Scryer
|
|
|
969
1051
|
end
|
|
970
1052
|
|
|
971
1053
|
CSS = <<~CSS
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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;
|
|
980
1076
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
981
|
-
color: #fff; }
|
|
982
|
-
.score-badge .score-number { font-size: 1.
|
|
983
|
-
.score-badge .score-grade { font-size: 0.
|
|
984
|
-
.score-badge.grade-A { background: #
|
|
985
|
-
.score-badge.grade-B { background: #
|
|
986
|
-
.score-badge.grade-C { background: #
|
|
987
|
-
.score-badge.grade-D { background: #
|
|
988
|
-
.score-badge.grade-F { background: #
|
|
989
|
-
.score-details { flex: 1 1
|
|
990
|
-
.score-label { margin: 0 0 0.
|
|
991
|
-
.score-bar-row { display:
|
|
992
|
-
|
|
993
|
-
.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; }
|
|
994
1091
|
.score-bar-fill { height: 100%; border-radius: 999px; }
|
|
995
|
-
.score-bar-fill.critical { background: #dc2626; }
|
|
996
|
-
.score-bar-fill.warning { background: #d97706; }
|
|
997
|
-
.score-bar-fill.info { background: #
|
|
998
|
-
.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;
|
|
999
1096
|
text-decoration: none; }
|
|
1000
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
|
+
}
|
|
1001
1102
|
.findings-search { display: block; width: 100%; max-width: 28rem; margin: 0.75rem 0 1.25rem;
|
|
1002
1103
|
padding: 0.5rem 0.75rem; font: inherit; font-size: 0.85rem; border: 1px solid #cbd5e1;
|
|
1003
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