scryer 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -10
  3. data/README.md +9 -4
  4. data/docs/architecture.md +45 -36
  5. data/docs/rails-integration.md +182 -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 +22 -0
  10. data/lib/scryer/ai_client.rb +1 -0
  11. data/lib/scryer/ai_fix_suggester.rb +1 -0
  12. data/lib/scryer/apm.rb +458 -0
  13. data/lib/scryer/ast.rb +53 -0
  14. data/lib/scryer/authorization_watcher.rb +1 -0
  15. data/lib/scryer/baseline.rb +1 -0
  16. data/lib/scryer/cache_extractor.rb +1 -0
  17. data/lib/scryer/cli.rb +19 -11
  18. data/lib/scryer/colorizer.rb +1 -0
  19. data/lib/scryer/dependency_audit.rb +1 -0
  20. data/lib/scryer/dependency_fixer.rb +1 -0
  21. data/lib/scryer/duplicate_detector.rb +1 -0
  22. data/lib/scryer/finding.rb +1 -0
  23. data/lib/scryer/fix_runner.rb +1 -0
  24. data/lib/scryer/fix_verifier.rb +1 -0
  25. data/lib/scryer/mechanical_fixer.rb +1 -0
  26. data/lib/scryer/method_extractor.rb +1 -0
  27. data/lib/scryer/minitest.rb +1 -0
  28. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
  29. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  30. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
  31. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
  32. data/lib/scryer/query_extractor.rb +1 -0
  33. data/lib/scryer/query_watcher.rb +1 -0
  34. data/lib/scryer/railtie.rb +64 -4
  35. data/lib/scryer/report_renderer.rb +161 -99
  36. data/lib/scryer/rspec.rb +1 -0
  37. data/lib/scryer/rule.rb +15 -2
  38. data/lib/scryer/rule_set.rb +1 -0
  39. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
  40. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
  41. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
  42. data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
  43. data/lib/scryer/rules/command_injection_rule.rb +1 -0
  44. data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
  45. data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
  46. data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
  47. data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
  48. data/lib/scryer/rules/force_ssl_rule.rb +1 -0
  49. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
  50. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
  51. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
  52. data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
  53. data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
  54. data/lib/scryer/rules/idor_rule.rb +6 -14
  55. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
  56. data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
  57. data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
  58. data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
  59. data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
  60. data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
  61. data/lib/scryer/rules/open_redirect_rule.rb +1 -0
  62. data/lib/scryer/rules/path_traversal_rule.rb +1 -0
  63. data/lib/scryer/rules/security_headers_rule.rb +1 -0
  64. data/lib/scryer/rules/sql_injection_rule.rb +1 -0
  65. data/lib/scryer/rules/ssrf_rule.rb +1 -0
  66. data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
  67. data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
  68. data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
  69. data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
  70. data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
  71. data/lib/scryer/scanner.rb +126 -7
  72. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  73. data/lib/scryer/version.rb +2 -1
  74. data/lib/scryer.rb +49 -0
  75. data/lib/tasks/scryer.rake +8 -2
  76. metadata +3 -1
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # Opt-in Minitest integration — require this file yourself (e.g. `require
2
3
  # "scryer/minitest"` in test_helper.rb) rather than it loading automatically
3
4
  # with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a
@@ -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,12 +1,72 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
- # This gem runs on-demand via a rake task, not as request-cycle
3
- # instrumentation — so the Railtie doesn't need to hook into the
4
- # middleware stack or boot process at all. Its only job is to make sure
5
- # requiring this gem inside a Rails app never raises, and to load the
3
+ # The static scan runs on-demand via a rake task, not as request-cycle
4
+ # instrumentation — that part of the Railtie's job is just making sure
5
+ # requiring this gem inside a Rails app never raises, and loading the
6
6
  # rake tasks into the host app's Rails console/`rake -T` listing.
7
+ #
8
+ # Scryer::APM (runtime method tracing) is the one exception: it *is*
9
+ # request-cycle instrumentation, and unlike QueryWatcher/AuthorizationWatcher
10
+ # (which the README has host apps wire up by hand — see
11
+ # docs/rails-integration.md) this one auto-wires itself when
12
+ # `Scryer.configuration.apm.enabled` is true, so setting that flag (plus
13
+ # `c.apm.include`) in an initializer is genuinely sufficient — no separate
14
+ # `require "scryer/apm"` / `Scryer::APM.enable!` / `middleware.use` calls
15
+ # needed. `after: :load_config_initializers` guarantees this runs after
16
+ # every file in config/initializers/ (including the host app's own
17
+ # scryer.rb) has already set that flag, regardless of alphabetical
18
+ # filename ordering.
7
19
  class Railtie < Rails::Railtie
8
20
  rake_tasks do
9
21
  load File.expand_path("../tasks/scryer.rake", __dir__)
10
22
  end
23
+
24
+ initializer "scryer.apm", after: :load_config_initializers do |app|
25
+ Scryer::Railtie.maybe_enable_apm(app)
26
+ end
27
+
28
+ # Plain Ruby, no Rails::Railtie API surface — pulled out of the
29
+ # `initializer` block above specifically so it's directly unit-testable
30
+ # (test/railtie_apm_test.rb) with a fake `app` (anything responding to
31
+ # `middleware.use`), without needing a real Rails app to boot. What this
32
+ # test *cannot* verify: that `after: :load_config_initializers` above
33
+ # actually fires at the right point in a real Rails boot sequence —
34
+ # that would need a real Rails app, which this gem's test suite doesn't
35
+ # depend on (see gemspec: zero runtime deps, Rails-optional).
36
+ #
37
+ # This method runs unattended during every boot of every host app that
38
+ # sets `c.apm.enabled = true` — a misconfigured `include` entry, an
39
+ # unimplemented `instrumentation` mode set by mistake, a `require`
40
+ # failure, anything — must degrade to "APM is off, logged once" rather
41
+ # than fail app boot. That's a materially different bar than the rest
42
+ # of Scryer::APM's public API (Scryer::APM.enable! called directly, in
43
+ # a console or test, DOES still raise for an unimplemented mode — fast,
44
+ # loud feedback is correct there, where a developer is watching).
45
+ # `rescue Exception` (not just StandardError) is deliberate here for
46
+ # the same reason: a boot-time LoadError from a packaging issue is a
47
+ # ScriptError, not a StandardError, and must be caught by this specific
48
+ # boundary too — re-raising SystemExit/NoMemoryError/SignalException
49
+ # keeps this from swallowing the handful of cases where propagating is
50
+ # still correct even at boot.
51
+ def self.maybe_enable_apm(app)
52
+ return unless Scryer.configuration.apm.enabled
53
+
54
+ require "scryer/apm"
55
+ Scryer::APM.enable!
56
+ app.middleware.use Scryer::APM::Middleware
57
+ rescue SystemExit, NoMemoryError, SignalException
58
+ raise
59
+ rescue Exception => e # rubocop-equivalent: intentional, see comment above
60
+ logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || begin
61
+ require "logger"
62
+ Logger.new($stderr)
63
+ end
64
+ logger.error(
65
+ "[Scryer::APM] enabling APM via c.apm.enabled failed (#{e.class}: #{e.message}) — " \
66
+ "APM is disabled for this process; the rest of the app boots normally. " \
67
+ "#{e.backtrace&.first(5)&.join("\n")}"
68
+ )
69
+ Scryer::APM.disable! if defined?(Scryer::APM)
70
+ end
11
71
  end
12
72
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "json"
2
3
  require "time"
3
4
 
@@ -56,6 +57,9 @@ module Scryer
56
57
  "duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) },
57
58
  "dependency_findings" => @dependency_findings.map(&:to_h),
58
59
  "security_score" => security_score,
60
+ "performance_score" => performance_score,
61
+ "style_score" => style_score,
62
+ "dependency_score" => dependency_score,
59
63
  "rules_clean_rate" => rules_clean_rate
60
64
  }
61
65
  end
@@ -105,71 +109,84 @@ module Scryer
105
109
  counts.sort_by { |category, count| [-count, category] }
106
110
  end
107
111
 
108
- # A single 0-100 number (plus a letter grade) summarizing this scan's
109
- # security risk exposuresecurity findings and dependency findings at
110
- # full weight, plus performance findings at a small fraction of that
111
- # weight (a slow app is a real cost, just not a *security* one, so it
112
- # nudges the score rather than driving it see SCORE_CATEGORY_WEIGHT).
113
- # Code-quality findings (duplicate-code groups, frozen_string_literal)
114
- # are never part of this on purpose: a `DuplicateDetector::DuplicateGroup`
115
- # isn't even a `Finding` (no severity/confidence to weigh in the first
116
- # place see duplicate_detector.rb), and style findings are cosmetic,
117
- # not risk. Deliberately NOT normalized by files-scanned or lines of
118
- # code: it reflects this scan's absolute finding exposure, so it's
119
- # meaningful for tracking one project's trend over time (does the next
120
- # scan score higher or lower), not for comparing two differently-sized
121
- # codebases against each other a bigger app with the same finding
122
- # *density* will naturally score lower here, and that's a documented
123
- # limitation, not a 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.
124
131
  #
125
132
  # Exponential decay rather than linear subtraction from 100: a single
126
- # critical/high finding should visibly move the score (100 -> ~86) without
127
- # a handful of findings driving a real app straight to a hard-clamped 0,
128
- # which would make the score useless for comparing "bad" against "worse."
129
- # weighted_penalty combines severity (the dominant factor) with
130
- # confidence (a low-confidence rule's finding shouldn't hurt the score as
131
- # much as a high-confidence one saying the same severity) and category —
132
- # same philosophy as SARIF's `rank` (see sarif_rank above), computed once
133
- # here for a single scan-level number instead of per-result.
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 codebase with only info findings
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
- # style_findings/duplicate_groups are never included — see this
162
- # method's doc comment above for why.
163
- findings = (@result.security_findings + @result.performance_findings + @dependency_findings).map(&:to_h)
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" => findings.size }
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, distinct from security_score (which is
187
- # finding-weighted, not rule-counted) a codebase can have a high clean
188
- # rate (few distinct rules triggered) and still a low score (the few
189
- # 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
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
- <h1>Scryer report</h1>
255
- <p class="meta">
256
- #{escape(@project_name)} &middot; #{escape(h["release_label"] || "no release label")} &middot;
257
- #{escape(h["scanned_at"])} &middot; #{h["files_scanned"]} files scanned
258
- #{h["parse_errors"].any? ? "&middot; <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
259
- </p>
260
-
261
- #{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"])}
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 badge + severity bar chart at the very top of the report —
351
- # an "is this bad or fine" answer in the first thing a reader sees,
352
- # ahead of even the table of contents. Scoped to security + dependency
353
- # findings only, same as ReportRenderer#security_score itself
354
- # (performance/code-quality findings aren't part of the security score,
355
- # so they're not part of this chart either — the Summary table further
356
- # down still shows those breakdowns).
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-badge grade-#{score["grade"]}">
388
- <span class="score-number">#{score["score"]}</span>
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">Security Score #{score["finding_count"]} security + dependency
393
- + performance finding(s) (performance weighted far lighter than security), weighted
394
- by severity and this rule's confidence. Not normalized by app
395
- 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.
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 score.</p>
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
- body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
1012
- h1 { margin-bottom: 0.25rem; }
1013
- h2 { margin-top: 2rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
1014
- .meta { color: #64748b; font-size: 0.875rem; margin-top: 0; }
1015
- .crit { color: #b91c1c; font-weight: 600; }
1016
- .score-panel { display: flex; gap: 1.5rem; align-items: center; background: #f8fafc;
1017
- border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0 1.25rem; }
1018
- .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;
1019
1076
  display: flex; flex-direction: column; align-items: center; justify-content: center;
1020
- color: #fff; }
1021
- .score-badge .score-number { font-size: 1.5rem; font-weight: 700; line-height: 1; }
1022
- .score-badge .score-grade { font-size: 0.9rem; font-weight: 600; opacity: 0.9; }
1023
- .score-badge.grade-A { background: #16a34a; }
1024
- .score-badge.grade-B { background: #0891b2; }
1025
- .score-badge.grade-C { background: #d97706; }
1026
- .score-badge.grade-D { background: #ea580c; }
1027
- .score-badge.grade-F { background: #dc2626; }
1028
- .score-details { flex: 1 1 auto; min-width: 0; }
1029
- .score-label { margin: 0 0 0.6rem; font-size: 0.82rem; color: #475569; }
1030
- .score-bar-row { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; margin: 0.25rem 0; }
1031
- .score-bar-label { flex: 0 0 4.5rem; color: #475569; }
1032
- .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; }
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: #64748b; }
1037
- .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;
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
@@ -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