scryer 1.2.1 → 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 +76 -9
- data/README.md +6 -3
- data/docs/architecture.md +45 -36
- 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 +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 +1 -0
- 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 +1 -0
- data/lib/tasks/scryer.rake +8 -2
- metadata +2 -1
|
@@ -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,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
|
@@ -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
|