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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -9
  3. data/README.md +6 -3
  4. data/docs/architecture.md +45 -31
  5. data/docs/rails-integration.md +1 -1
  6. data/docs/rules.md +8 -1
  7. data/docs/usage.md +5 -2
  8. data/lib/generators/scryer/install_generator.rb +1 -0
  9. data/lib/generators/scryer/templates/scryer_initializer.rb +1 -0
  10. data/lib/scryer/ai_client.rb +1 -0
  11. data/lib/scryer/ai_fix_suggester.rb +1 -0
  12. data/lib/scryer/ast.rb +53 -0
  13. data/lib/scryer/authorization_watcher.rb +1 -0
  14. data/lib/scryer/baseline.rb +1 -0
  15. data/lib/scryer/cache_extractor.rb +1 -0
  16. data/lib/scryer/cli.rb +19 -11
  17. data/lib/scryer/colorizer.rb +1 -0
  18. data/lib/scryer/dependency_audit.rb +17 -1
  19. data/lib/scryer/dependency_fixer.rb +1 -0
  20. data/lib/scryer/duplicate_detector.rb +1 -0
  21. data/lib/scryer/finding.rb +1 -0
  22. data/lib/scryer/fix_runner.rb +1 -0
  23. data/lib/scryer/fix_verifier.rb +1 -0
  24. data/lib/scryer/mechanical_fixer.rb +1 -0
  25. data/lib/scryer/method_extractor.rb +1 -0
  26. data/lib/scryer/minitest.rb +1 -0
  27. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
  28. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  29. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
  30. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
  31. data/lib/scryer/query_extractor.rb +1 -0
  32. data/lib/scryer/query_watcher.rb +1 -0
  33. data/lib/scryer/railtie.rb +1 -0
  34. data/lib/scryer/report_renderer.rb +185 -84
  35. data/lib/scryer/rspec.rb +1 -0
  36. data/lib/scryer/rule.rb +15 -2
  37. data/lib/scryer/rule_set.rb +1 -0
  38. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
  39. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
  40. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
  41. data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
  42. data/lib/scryer/rules/command_injection_rule.rb +1 -0
  43. data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
  44. data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
  45. data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
  46. data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
  47. data/lib/scryer/rules/force_ssl_rule.rb +1 -0
  48. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
  49. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
  50. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
  51. data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
  52. data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
  53. data/lib/scryer/rules/idor_rule.rb +6 -14
  54. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
  55. data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
  56. data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
  57. data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
  58. data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
  59. data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
  60. data/lib/scryer/rules/open_redirect_rule.rb +1 -0
  61. data/lib/scryer/rules/path_traversal_rule.rb +1 -0
  62. data/lib/scryer/rules/security_headers_rule.rb +1 -0
  63. data/lib/scryer/rules/sql_injection_rule.rb +1 -0
  64. data/lib/scryer/rules/ssrf_rule.rb +1 -0
  65. data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
  66. data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
  67. data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
  68. data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
  69. data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
  70. data/lib/scryer/scanner.rb +126 -7
  71. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  72. data/lib/scryer/version.rb +2 -1
  73. data/lib/scryer.rb +1 -0
  74. data/lib/tasks/scryer.rake +8 -2
  75. metadata +4 -7
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module PerformanceRules
3
4
  # Flags `.save`/`.save!`/`.update`/`.update!`/`.update_attribute(s)` called
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module PerformanceRules
3
4
  # Flags a controller `index` action that loads `Model.all` or a bare
@@ -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)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # This gem runs on-demand via a rake task, not as request-cycle
3
4
  # instrumentation — so the Railtie doesn't need to hook into the
@@ -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
- # A single 0-100 number (plus a letter grade) summarizing this scan's
109
- # security risk exposuresecurity findings and dependency findings
110
- # only (performance/code-quality findings aren't security risk, so
111
- # they're deliberately excluded; a slow app doesn't lower a *security*
112
- # score). Deliberately NOT normalized by files-scanned or lines of code:
113
- # it reflects this scan's absolute finding exposure, so it's meaningful
114
- # for tracking one project's trend over time (does the next scan score
115
- # higher or lower), not for comparing two differently-sized codebases
116
- # against each other — a bigger app with the same finding *density* will
117
- # naturally score lower here, and that's a documented limitation, not a
118
- # bug.
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 the score (100 -> ~86) without
122
- # a handful of findings driving a real app straight to a hard-clamped 0,
123
- # which would make the score useless for comparing "bad" against "worse."
124
- # weighted_penalty combines severity (the dominant factor) with
125
- # confidence (a low-confidence rule's finding shouldn't hurt the score as
126
- # much as a high-confidence one saying the same severity) — same
127
- # philosophy as SARIF's `rank` (see sarif_rank above), computed once here
128
- # for a single scan-level number instead of per-result.
129
- SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 1 }.freeze
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
- findings = (@result.security_findings + @dependency_findings).map(&:to_h)
182
+ hashes = findings.map(&:to_h)
144
183
 
145
- weighted_penalty = findings.sum do |f|
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" => findings.size }
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, distinct from security_score (which is
165
- # finding-weighted, not rule-counted) a codebase can have a high clean
166
- # rate (few distinct rules triggered) and still a low score (the few
167
- # that did trigger were severe/high-confidence), or the reverse (many
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
- <h1>Scryer report</h1>
233
- <p class="meta">
234
- #{escape(@project_name)} &middot; #{escape(h["release_label"] || "no release label")} &middot;
235
- #{escape(h["scanned_at"])} &middot; #{h["files_scanned"]} files scanned
236
- #{h["parse_errors"].any? ? "&middot; <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
237
- </p>
238
-
239
- #{render_executive_summary(h, h["security_score"], h["rules_clean_rate"])}
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 badge + severity bar chart at the very top of the report —
329
- # an "is this bad or fine" answer in the first thing a reader sees,
330
- # ahead of even the table of contents. Scoped to security + dependency
331
- # findings only, same as ReportRenderer#security_score itself
332
- # (performance/code-quality findings aren't part of the security score,
333
- # so they're not part of this chart either — the Summary table further
334
- # down still shows those breakdowns).
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-badge grade-#{score["grade"]}">
366
- <span class="score-number">#{score["score"]}</span>
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">Security Score #{score["finding_count"]} security + dependency
371
- finding(s), weighted by severity and this rule's confidence. Not normalized by app
372
- size — see the README for what this number does and doesn't mean.
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 score.</p>
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
- total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] }
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 three), so its per-severity cells link straight to the
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
- deps_row = "<tr><th>Dependency audit</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
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
- body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
973
- h1 { margin-bottom: 0.25rem; }
974
- h2 { margin-top: 2rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
975
- .meta { color: #64748b; font-size: 0.875rem; margin-top: 0; }
976
- .crit { color: #b91c1c; font-weight: 600; }
977
- .score-panel { display: flex; gap: 1.5rem; align-items: center; background: #f8fafc;
978
- border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0 1.25rem; }
979
- .score-badge { flex: 0 0 auto; width: 5.5rem; height: 5.5rem; border-radius: 50%;
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.5rem; font-weight: 700; line-height: 1; }
983
- .score-badge .score-grade { font-size: 0.9rem; font-weight: 600; opacity: 0.9; }
984
- .score-badge.grade-A { background: #16a34a; }
985
- .score-badge.grade-B { background: #0891b2; }
986
- .score-badge.grade-C { background: #d97706; }
987
- .score-badge.grade-D { background: #ea580c; }
988
- .score-badge.grade-F { background: #dc2626; }
989
- .score-details { flex: 1 1 auto; min-width: 0; }
990
- .score-label { margin: 0 0 0.6rem; font-size: 0.82rem; color: #475569; }
991
- .score-bar-row { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; margin: 0.25rem 0; }
992
- .score-bar-label { flex: 0 0 4.5rem; color: #475569; }
993
- .score-bar-track { flex: 1 1 auto; background: #e2e8f0; border-radius: 999px; height: 0.6rem; overflow: hidden; }
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: #64748b; }
998
- .score-bar-count { flex: 0 0 1.75rem; text-align: right; color: #1e293b; font-weight: 600;
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
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # Opt-in RSpec integration — require this file yourself (e.g. `require
2
3
  # "scryer/rspec"` in spec_helper.rb) rather than it loading automatically
3
4
  # with the gem, since RSpec itself is never a Scryer runtime dependency (see
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
- def initialize(file:, source:, sexp:)
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"
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Registry of every Rule subclass (rules self-register via Rule.inherited).
3
4
  # Scanner uses .all to run every registered rule against each parsed file.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.action_cable.disable_request_forgery_protection = true`
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags an explicit `disposition: "inline"`/`:inline` on a blob/variant
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `has_one_attached`/`has_many_attached :name` with no
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a controller class that calls `skip_before_action`/
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags shell-executing calls (`system`, `` ` ` `` backticks, `%x{}`,
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags `config.consider_all_requests_local = true` specifically in
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags the well-known Rack::Cors antipattern: a wildcard origin
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  module Rules
3
4
  # Flags a controller class named *Controller that calls