scryer 0.3.0 → 1.1.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +322 -0
  3. data/README.md +546 -43
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +95 -6
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +209 -11
  11. data/lib/scryer/dependency_audit.rb +108 -5
  12. data/lib/scryer/finding.rb +6 -0
  13. data/lib/scryer/fix_verifier.rb +82 -0
  14. data/lib/scryer/minitest.rb +48 -0
  15. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  16. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  18. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  19. data/lib/scryer/report_renderer.rb +637 -44
  20. data/lib/scryer/rspec.rb +55 -0
  21. data/lib/scryer/rule.rb +22 -2
  22. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
  23. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
  24. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
  25. data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  27. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  28. data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
  29. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  30. data/lib/scryer/rules/force_ssl_rule.rb +48 -0
  31. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
  32. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
  33. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
  34. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  35. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  36. data/lib/scryer/rules/idor_rule.rb +165 -0
  37. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
  38. data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
  39. data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
  40. data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
  41. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  42. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  43. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  44. data/lib/scryer/rules/path_traversal_rule.rb +110 -0
  45. data/lib/scryer/rules/security_headers_rule.rb +133 -0
  46. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  47. data/lib/scryer/rules/ssrf_rule.rb +139 -0
  48. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  49. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  50. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  51. data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
  52. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  53. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  54. data/lib/scryer/version.rb +1 -1
  55. data/lib/scryer.rb +15 -0
  56. data/lib/tasks/scryer.rake +138 -13
  57. metadata +41 -12
@@ -12,7 +12,15 @@ module Scryer
12
12
  class ReportRenderer
13
13
  SEVERITY_ORDER = %w[critical warning info].freeze
14
14
  SEVERITY_LABELS = { "critical" => "Critical", "warning" => "Warning", "info" => "Info" }.freeze
15
- CSV_HEADERS = %w[kind identifier severity location message suggested_fix code_snippet url].freeze
15
+ CSV_HEADERS = %w[kind identifier severity confidence cwe owasp_category location message
16
+ suggested_fix code_snippet url].freeze
17
+
18
+ # Tiebreak within the same severity for top_risks — a critical finding
19
+ # is a critical finding regardless of category, but when severity is
20
+ # equal this is the order that best matches "what's actually riskiest":
21
+ # a security hole, then a known-vulnerable dependency, ahead of a
22
+ # performance or style issue at the same nominal severity.
23
+ CATEGORY_RISK_PRIORITY = { "security" => 0, "dependency" => 1, "performance" => 2, "code quality" => 3 }.freeze
16
24
 
17
25
  # `dependency_findings` is an optional array of Scryer::DependencyAudit::
18
26
  # Finding (insecure_sources + vulnerable_gems) — pass it to fold a
@@ -46,7 +54,9 @@ module Scryer
46
54
  "performance_findings" => @result.performance_findings.map(&:to_h),
47
55
  "style_findings" => @result.style_findings.map(&:to_h),
48
56
  "duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) },
49
- "dependency_findings" => @dependency_findings.map(&:to_h)
57
+ "dependency_findings" => @dependency_findings.map(&:to_h),
58
+ "security_score" => security_score,
59
+ "rules_clean_rate" => rules_clean_rate
50
60
  }
51
61
  end
52
62
 
@@ -54,6 +64,121 @@ module Scryer
54
64
  JSON.pretty_generate(as_hash)
55
65
  end
56
66
 
67
+ DEFAULT_TOP_RISKS_LIMIT = 5
68
+
69
+ # This is what actually backs "tells you what to fix first" — Scryer's
70
+ # categories (security, dependencies, performance, code quality) each
71
+ # already carry a severity ("critical"/"warning"/"info"), but they're
72
+ # scanned and reported separately; nothing ranks across them. top_risks
73
+ # merges every severity-bearing finding (rule-based + dependency) into
74
+ # one list, sorted by severity first and then by category (a security
75
+ # hole outranks a stylistic one at the same severity) — pure aggregation
76
+ # of data every format already has, no new detection logic. Used by the
77
+ # console summary (CLI + rake) and the top of the HTML report; JSON/CSV/
78
+ # SARIF are consumed by other tools that do their own sorting/filtering,
79
+ # so this stays a display-only convenience rather than a new field there.
80
+ def top_risks(limit: DEFAULT_TOP_RISKS_LIMIT)
81
+ h = as_hash
82
+ entries = []
83
+ h["security_findings"].each { |f| entries << finding_risk_entry("security", f) }
84
+ h["performance_findings"].each { |f| entries << finding_risk_entry("performance", f) }
85
+ h["style_findings"].each { |f| entries << finding_risk_entry("code quality", f) }
86
+ h["dependency_findings"].each { |f| entries << dependency_risk_entry(f) }
87
+
88
+ entries.sort_by { |e| [SEVERITY_ORDER.index(e[:severity]) || SEVERITY_ORDER.size, CATEGORY_RISK_PRIORITY[e[:category]] || 99] }
89
+ .first(limit)
90
+ end
91
+
92
+ # How many of this scan's security findings fall into each OWASP Top 10
93
+ # (2021) category — [[category, count], ...] sorted by count descending.
94
+ # A byproduct of every security rule already carrying an owasp_category
95
+ # (see Rule.owasp_category) rather than new detection logic: this is
96
+ # purely "count what's already tagged," not a separate audit against
97
+ # OWASP's own benchmark suite or certification of any kind. Scryer's
98
+ # CWE/OWASP tags are its own best-effort categorization for practitioner
99
+ # convenience and compliance-reporting purposes (e.g. "does our tooling
100
+ # catch OWASP category X" conversations) — not an OWASP-endorsed mapping
101
+ # and not independently audited; see the README for the caveat in full.
102
+ def owasp_coverage
103
+ counts = Hash.new(0)
104
+ as_hash["security_findings"].each { |f| counts[f["owasp_category"]] += 1 if f["owasp_category"] }
105
+ counts.sort_by { |category, count| [-count, category] }
106
+ end
107
+
108
+ # A single 0-100 number (plus a letter grade) summarizing this scan's
109
+ # security risk exposure — security 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.
119
+ #
120
+ # 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
130
+ SCORE_CONFIDENCE_WEIGHT = { "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
131
+ SCORE_DECAY_CONSTANT = 100.0
132
+
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
+ def security_score
139
+ # .to_h (not the full as_hash) so this works uniformly across Finding
140
+ # (has "confidence") and DependencyAudit::Finding (doesn't — a plain
141
+ # Hash returns nil for a missing key rather than raising, unlike
142
+ # calling #confidence directly on a struct that has no such member).
143
+ findings = (@result.security_findings + @dependency_findings).map(&:to_h)
144
+
145
+ weighted_penalty = findings.sum do |f|
146
+ (SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) * (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7)
147
+ end
148
+
149
+ score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round
150
+ { "score" => score, "grade" => score_grade(score), "finding_count" => findings.size }
151
+ end
152
+
153
+ SCORE_GRADE_BANDS = [[90, "A"], [80, "B"], [70, "C"], [60, "D"]].freeze
154
+
155
+ def score_grade(score)
156
+ SCORE_GRADE_BANDS.each { |threshold, grade| return grade if score >= threshold }
157
+ "F"
158
+ end
159
+
160
+ # "N/M rules clean" — how many of the registered Scryer::Rule checks
161
+ # (security + performance + style; NOT the dependency checks, which
162
+ # aren't backed by a Rule subclass at all — see DEPENDENCY_SARIF_RULES)
163
+ # 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
168
+ # different rules each firing once, none of them serious). Report both;
169
+ # neither alone tells the whole story.
170
+ def rules_clean_rate
171
+ all_rule_ids = Scryer::RuleSet.all.map(&:rule_id)
172
+ fired_rule_ids = (@result.security_findings + @result.performance_findings + @result.style_findings)
173
+ .map(&:rule_id).uniq
174
+
175
+ total = all_rule_ids.size
176
+ clean = total - (all_rule_ids & fired_rule_ids).size
177
+ percent = total.zero? ? 100.0 : (clean.to_f / total * 100).round(1)
178
+
179
+ { "clean" => clean, "total" => total, "percent" => percent }
180
+ end
181
+
57
182
  # Flat, one-row-per-finding CSV — security + performance findings plus
58
183
  # any dependency findings, in that order — for dropping into a
59
184
  # spreadsheet or importing into a ticketing tool. Deliberately excludes
@@ -73,6 +198,18 @@ module Scryer
73
198
  rows.map { |row| row.map { |field| csv_field(field) }.join(",") }.join("\n")
74
199
  end
75
200
 
201
+ SARIF_LEVEL_BY_SEVERITY = { "critical" => "error", "warning" => "warning", "info" => "note" }.freeze
202
+
203
+ # SARIF 2.1.0 (docs.oasis-open.org/sarif/sarif/v2.1.0) — the format
204
+ # GitHub Code Scanning (and other CI security dashboards) natively
205
+ # ingest, turning findings into inline PR annotations and Security-tab
206
+ # entries instead of a report file nobody opens. Pure data mapping of
207
+ # what's already in as_hash — no new detection logic, and every finding
208
+ # behaves identically to how it does in the other formats.
209
+ def as_sarif
210
+ JSON.pretty_generate(sarif_hash)
211
+ end
212
+
76
213
  def as_html
77
214
  h = as_hash
78
215
  security = h["security_findings"]
@@ -99,6 +236,8 @@ module Scryer
99
236
  #{h["parse_errors"].any? ? "&middot; <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
100
237
  </p>
101
238
 
239
+ #{render_executive_summary(h, h["security_score"], h["rules_clean_rate"])}
240
+
102
241
  #{render_toc(h)}
103
242
 
104
243
  <section id="overview">
@@ -111,8 +250,13 @@ module Scryer
111
250
  #{render_summary_table(security, performance, style, duplicate_groups, dependency_findings)}
112
251
  </section>
113
252
 
253
+ <section id="owasp-coverage">
254
+ <h2>OWASP Top 10 (2021) coverage</h2>
255
+ #{render_owasp_coverage(owasp_coverage)}
256
+ </section>
257
+
114
258
  <section id="checks-performed">
115
- <h2>Checks performed</h2>
259
+ <h2>Checks performed #{expand_collapse_controls("#checks-performed")}</h2>
116
260
  #{render_checks_performed}
117
261
  </section>
118
262
 
@@ -123,16 +267,20 @@ module Scryer
123
267
 
124
268
  <section id="findings">
125
269
  <h2>Findings (#{all_findings.size}) #{expand_collapse_controls("#findings")}</h2>
270
+
271
+ <input type="search" id="findings-search" class="findings-search"
272
+ placeholder="Filter by rule, file, or message text&hellip;" aria-label="Filter findings">
273
+
274
+ <div id="top-priorities">
275
+ <h3>Top priorities — fix these first</h3>
276
+ #{render_top_priorities(top_risks)}
277
+ </div>
278
+
126
279
  #{render_severity_section("critical", by_severity["critical"] || [])}
127
280
  #{render_severity_section("warning", by_severity["warning"] || [])}
128
281
  #{render_severity_section("info", by_severity["info"] || [])}
129
282
  </section>
130
283
 
131
- <section id="duplicates">
132
- <h2>Duplicate code groups (#{duplicate_groups.size}) #{expand_collapse_controls("#duplicates")}</h2>
133
- #{render_duplicate_groups(duplicate_groups)}
134
- </section>
135
-
136
284
  <section id="dependency-audit">
137
285
  <h2>Dependency audit (#{dependency_findings.size}) #{expand_collapse_controls("#dependency-audit")}</h2>
138
286
  #{render_dependency_findings(dependency_findings)}
@@ -143,10 +291,13 @@ module Scryer
143
291
  #{render_parse_errors(h["parse_errors"])}
144
292
  </section>
145
293
 
294
+ <section id="duplicates">
295
+ <h2>Duplicate code groups (#{duplicate_groups.size}) #{expand_collapse_controls("#duplicates")}</h2>
296
+ #{render_duplicate_groups(duplicate_groups)}
297
+ </section>
298
+
146
299
  <p class="footer">
147
- Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])}) &middot;
148
- heuristic static analysis, not full data-flow/taint analysis — review every finding in
149
- its surrounding context before acting on it.
300
+ Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])})
150
301
  </p>
151
302
 
152
303
  <script>#{JS}</script>
@@ -162,16 +313,72 @@ module Scryer
162
313
  <nav class="toc">
163
314
  <a href="#overview">Overview</a>
164
315
  <a href="#summary">Summary</a>
316
+ <a href="#owasp-coverage">OWASP Top 10 coverage</a>
165
317
  <a href="#checks-performed">Checks performed</a>
166
318
  <a href="#warnings-by-type">Warnings by type</a>
167
319
  <a href="#findings">Findings</a>
168
- <a href="#duplicates">Duplicate code (#{h["duplicate_groups"].size})</a>
320
+ <a href="#top-priorities">Top priorities</a>
169
321
  <a href="#dependency-audit">Dependency audit (#{h["dependency_findings"].size})</a>
170
322
  <a href="#errors">Parse errors (#{h["parse_errors"].size})</a>
323
+ <a href="#duplicates">Duplicate code (#{h["duplicate_groups"].size})</a>
171
324
  </nav>
172
325
  HTML
173
326
  end
174
327
 
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)
336
+ sec_and_deps = h["security_findings"] + h["dependency_findings"]
337
+ by_severity = Hash.new(0)
338
+ sec_and_deps.each { |f| by_severity[f["severity"]] += 1 }
339
+ total = by_severity.values.sum
340
+
341
+ # Links to the matching #sev-critical/#sev-warning/#sev-info heading
342
+ # already rendered under Findings (render_severity_section) — a plain
343
+ # anchor jump, no JS needed (the existing hashchange handler already
344
+ # scrolls to it). Slightly imprecise for one reason worth being
345
+ # explicit about: this bar's count includes dependency findings (see
346
+ # sec_and_deps above), but #sev-* only groups security/performance/
347
+ # style findings — dependency findings live in the separate
348
+ # "Dependency audit" section with no severity-grouped anchors of their
349
+ # own. Still the right destination for the bulk of what's counted;
350
+ # dependency findings are one section away via the TOC.
351
+ bars = SEVERITY_ORDER.map do |sev|
352
+ count = by_severity[sev]
353
+ pct = total.zero? ? 0 : (count.to_f / total * 100).round(1)
354
+ <<~BAR
355
+ <div class="score-bar-row">
356
+ <span class="score-bar-label">#{SEVERITY_LABELS[sev]}</span>
357
+ <div class="score-bar-track"><div class="score-bar-fill #{sev}" style="width: #{pct}%"></div></div>
358
+ <a href="##{"sev-#{sev}"}" class="score-bar-count">#{count}</a>
359
+ </div>
360
+ BAR
361
+ end.join
362
+
363
+ <<~HTML
364
+ <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>
368
+ </div>
369
+ <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.
373
+ <strong>#{clean_rate["clean"]}/#{clean_rate["total"]}</strong> rules clean
374
+ (#{clean_rate["percent"]}%) — a rule-level pass rate, a different (and not always
375
+ matching) signal from the finding-weighted score.</p>
376
+ #{bars}
377
+ </div>
378
+ </div>
379
+ HTML
380
+ end
381
+
175
382
  def render_overview_table(h)
176
383
  rows = {
177
384
  "Project" => h["project_name"],
@@ -195,26 +402,66 @@ module Scryer
195
402
  total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] }
196
403
 
197
404
  header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
198
- sec_row = summary_row("Security", sec_counts)
199
- perf_row = summary_row("Performance", perf_counts)
200
- style_row = summary_row("Style", style_counts)
201
- total_row = summary_row("Total", total_counts, css_class: "total")
202
- dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{duplicate_groups.size} group(s)</td></tr>"
203
- deps_row = "<tr><th>Dependency audit</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{dependency_findings.size} finding(s)</td></tr>"
405
+ # Category rows link via the same search-filter mechanism as the OWASP
406
+ # coverage table (a "security"/"performance"/"style" tag was added to
407
+ # every finding specifically so this can match precisely — see
408
+ # render_finding_tags) — a cell like "Security × Critical" has no
409
+ # existing anchor of its own to jump to, unlike a plain severity total.
410
+ sec_row = summary_row("Security", sec_counts, filter_term: "security")
411
+ perf_row = summary_row("Performance", perf_counts, filter_term: "performance")
412
+ style_row = summary_row("Style", style_counts, filter_term: "style")
413
+ # 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
415
+ # matching #sev-* heading instead — same anchors, same precision, as
416
+ # the severity distribution chart at the top of the report.
417
+ total_row = summary_row("Total", total_counts, css_class: "total", severity_anchors: true)
418
+ dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
419
+ "<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>" \
421
+ "<td><a class=\"jump-link\" href=\"#dependency-audit\">#{dependency_findings.size} finding(s)</a></td></tr>"
204
422
 
205
423
  "<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
206
424
  end
207
425
 
208
- def summary_row(label, counts, css_class: nil)
209
- cells = SEVERITY_ORDER.map { |s| "<td>#{counts[s]}</td>" }.join
426
+ def summary_row(label, counts, css_class: nil, filter_term: nil, severity_anchors: false)
427
+ cells = SEVERITY_ORDER.map do |s|
428
+ if severity_anchors
429
+ "<td><a class=\"jump-link\" href=\"#sev-#{s}\">#{counts[s]}</a></td>"
430
+ elsif filter_term
431
+ "<td><a href=\"#findings\" class=\"filter-link\" data-filter=\"#{filter_term} #{s}\">#{counts[s]}</a></td>"
432
+ else
433
+ "<td>#{counts[s]}</td>"
434
+ end
435
+ end.join
210
436
  total = counts.values.sum
211
- "<tr#{css_class ? " class=\"#{css_class}\"" : ""}><th>#{escape(label)}</th>#{cells}<td>#{total}</td></tr>"
437
+ total_cell = filter_term ? "<a href=\"#findings\" class=\"filter-link\" data-filter=\"#{filter_term}\">#{total}</a>" : total
438
+ "<tr#{css_class ? " class=\"#{css_class}\"" : ""}><th>#{escape(label)}</th>#{cells}<td>#{total_cell}</td></tr>"
212
439
  end
213
440
 
214
441
  def count_by_severity(findings)
215
442
  SEVERITY_ORDER.each_with_object(Hash.new(0)) { |s, acc| acc[s] = findings.count { |f| f["severity"] == s } }
216
443
  end
217
444
 
445
+ def render_owasp_coverage(coverage)
446
+ return "<p class=\"muted\">No security findings — nothing to categorize.</p>" if coverage.empty?
447
+
448
+ # The count links into the same Findings search box render_finding_tags
449
+ # feeds (see that method's comment) — clicking jumps to #findings and
450
+ # fills/triggers the search with this exact category string, so
451
+ # "detailed information" for a category is "the matching findings
452
+ # themselves," not a second, separately-maintained breakdown view.
453
+ rows = coverage.map do |category, count|
454
+ "<tr><td>#{escape(category)}</td>" \
455
+ "<td><a href=\"#findings\" class=\"filter-link\" data-filter=\"#{escape(category)}\">#{count}</a></td></tr>"
456
+ end.join
457
+
458
+ "<table class=\"summary\"><tr><th>Category</th><th>Findings</th></tr>#{rows}</table>" \
459
+ "<p class=\"muted\">Scryer's own best-effort categorization of each rule's findings, for a " \
460
+ "quick answer to \"does this cover OWASP category X\" — not an OWASP-endorsed or " \
461
+ "independently audited mapping. See the README for details. Click a count to see those " \
462
+ "findings.</p>"
463
+ end
464
+
218
465
  def rules_by_category
219
466
  Scryer::RuleSet.all.group_by(&:category)
220
467
  end
@@ -223,14 +470,31 @@ module Scryer
223
470
  Scryer::RuleSet.all.each_with_object({}) { |r, acc| acc[r.rule_id] = r.title }
224
471
  end
225
472
 
473
+ # Collapsed by default, same as render_rule_group — this is reference
474
+ # material (every rule that *can* fire, not what actually did), and with
475
+ # 26 security rules alone, showing all three category tables expanded by
476
+ # default buried the sections a reader actually came for (Findings,
477
+ # Top priorities) under a wall of rows nobody needed to see up front.
226
478
  def render_checks_performed
227
- security_rules = rules_by_category["security"] || []
228
- performance_rules = rules_by_category["performance"] || []
229
- style_rules = rules_by_category["style"] || []
479
+ [
480
+ checks_accordion("checks-security", "Security", rules_by_category["security"] || []),
481
+ checks_accordion("checks-performance", "Performance", rules_by_category["performance"] || []),
482
+ checks_accordion("checks-style", "Style", rules_by_category["style"] || [])
483
+ ].join
484
+ end
230
485
 
231
- "<h3>Security (#{security_rules.size})</h3>#{checks_table(security_rules)}" \
232
- "<h3>Performance (#{performance_rules.size})</h3>#{checks_table(performance_rules)}" \
233
- "<h3>Style (#{style_rules.size})</h3>#{checks_table(style_rules)}"
486
+ def checks_accordion(anchor, label, rules)
487
+ <<~HTML
488
+ <div class="accordion" id="#{anchor}">
489
+ <button type="button" class="accordion-header">
490
+ <span>#{escape(label)}</span>
491
+ <span class="accordion-meta">#{rules.size} rule#{"s" unless rules.size == 1}<span class="chevron">&#9656;</span></span>
492
+ </button>
493
+ <div class="accordion-body">
494
+ #{checks_table(rules)}
495
+ </div>
496
+ </div>
497
+ HTML
234
498
  end
235
499
 
236
500
  def checks_table(rules)
@@ -257,7 +521,7 @@ module Scryer
257
521
  "<td>#{escape(titles[rule_id] || rule_id)}</td>" \
258
522
  "<td>#{escape(findings.first["category"])}</td>" \
259
523
  "<td><span class=\"badge #{worst}\">#{SEVERITY_LABELS[worst]}</span></td>" \
260
- "<td>#{findings.size}</td></tr>"
524
+ "<td><a class=\"jump-link\" href=\"##{anchor}\">#{findings.size}</a></td></tr>"
261
525
  end.join
262
526
 
263
527
  "<table class=\"checks\"><tr><th>Rule ID</th><th>Description</th><th>Category</th>" \
@@ -351,13 +615,47 @@ module Scryer
351
615
  <code>#{escape(f["rule_id"])}</code>
352
616
  <span class="loc">#{escape(f["file"])}#{f["line"] ? ":#{f["line"]}" : ""}</span>
353
617
  </div>
618
+ #{render_finding_tags(f)}
354
619
  <p>#{escape(f["message"])}</p>
355
620
  #{f["code_snippet"] ? "<pre>#{escape(f["code_snippet"])}</pre>" : ""}
356
621
  <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
622
+ #{render_fix_verified_badge(f["fix_verified"])}
357
623
  </div>
358
624
  ROW
359
625
  end
360
626
 
627
+ # fix_verified is only ever non-nil when an ai_client is configured (see
628
+ # AiFixSuggester/FixVerifier) — nil (the default for every finding
629
+ # otherwise) renders nothing at all, so a report from a scan with no AI
630
+ # client configured looks exactly like it did before this existed.
631
+ def render_fix_verified_badge(fix_verified)
632
+ case fix_verified
633
+ when true
634
+ "<p class=\"fix-verified verified\">&#10003; AI fix verified — re-scanning with this " \
635
+ "fix applied, the rule no longer fires here.</p>"
636
+ when false
637
+ "<p class=\"fix-verified unverified\">&#10007; AI fix NOT verified — re-scanning with " \
638
+ "this fix applied, the rule still fires (or the rewritten line didn't parse). Review " \
639
+ "before using it.</p>"
640
+ else
641
+ ""
642
+ end
643
+ end
644
+
645
+ # CWE/owasp_category are security-only (nil for performance/style
646
+ # findings — see Rule.cwe/owasp_category); confidence applies to every
647
+ # category. Rendered as plain text tags specifically so the existing
648
+ # Findings search box (which matches on each .finding's textContent)
649
+ # can filter by any of them for free — typing "A01" or "CWE-89" into
650
+ # that box is the actual answer to "how do I see which findings are in
651
+ # OWASP category X," not a separate feature.
652
+ def render_finding_tags(f)
653
+ tags = [f["category"], f["cwe"], f["owasp_category"], f["confidence"] && "#{f["confidence"]} confidence"].compact
654
+ return "" if tags.empty?
655
+
656
+ "<p class=\"finding-tags\">#{tags.map { |t| "<span class=\"tag\">#{escape(t)}</span>" }.join}</p>"
657
+ end
658
+
361
659
  KIND_LABELS = {
362
660
  "method_duplicate" => "Method duplicate",
363
661
  "query_duplicate" => "Query duplicate",
@@ -395,35 +693,89 @@ module Scryer
395
693
  # patched_versions/message/suggested_fix) — a different shape from the
396
694
  # rule-based Finding hashes rendered by render_finding, so this has its
397
695
  # own layout rather than reusing render_severity_section.
696
+ # One accordion per finding, collapsed by default — same pattern as
697
+ # render_rule_group under Findings and the Checks-performed tables.
698
+ # Previously these rendered as always-expanded `.finding` divs with no
699
+ # `.accordion` wrapper at all, so the "Expand all"/"Collapse all"
700
+ # buttons on this section's heading (added when every other section got
701
+ # the same controls) had nothing to actually expand or collapse.
398
702
  def render_dependency_findings(dependency_findings)
399
703
  return "<p class=\"muted\">None detected (run with dependency auditing enabled to check " \
400
704
  "Gemfile.lock against OSV.dev and for insecure git/http sources).</p>" if dependency_findings.empty?
401
705
 
402
706
  sorted = dependency_findings.sort_by { |f| [SEVERITY_ORDER.index(f["severity"]) || 99, f["gem_name"].to_s] }
403
- rows = sorted.map { |f| render_dependency_finding(f) }.join
707
+ rows = sorted.each_with_index.map { |f, index| render_dependency_finding(f, index) }.join
404
708
 
405
709
  "<div class=\"dep-list\">#{rows}</div>"
406
710
  end
407
711
 
408
- def render_dependency_finding(f)
712
+ def render_dependency_finding(f, index)
409
713
  severity = f["severity"]
410
714
  heading = f["kind"] == "insecure_source" ? "Insecure gem source" : "#{escape(f["gem_name"])} #{escape(f["installed_version"])}"
411
- advisory = f["advisory_id"] ? "<span class=\"loc\">#{escape(f["advisory_id"])}#{f["title"] ? " — #{escape(f["title"])}" : ""}</span>" : ""
715
+ advisory_text = [f["advisory_id"], f["title"]].compact.map { |t| escape(t) }.join(" — ")
716
+ advisory = advisory_text.empty? ? "" : "<span class=\"loc\">#{advisory_text}</span>"
412
717
  link = f["url"] ? " &middot; <a href=\"#{escape(f["url"])}\" target=\"_blank\" rel=\"noopener\">advisory</a>" : ""
413
718
  patched = Array(f["patched_versions"])
414
719
 
415
- <<~ROW
416
- <div class="finding #{severity}">
417
- <div class="finding-head">
418
- <span class="badge #{severity}">#{severity.upcase}</span>
419
- <strong>#{heading}</strong>
420
- #{advisory}#{link}
720
+ <<~HTML
721
+ <div class="accordion" id="dep-#{index}">
722
+ <button type="button" class="accordion-header">
723
+ <span><span class="badge #{severity}">#{severity.upcase}</span> <strong>#{heading}</strong> #{advisory}</span>
724
+ <span class="accordion-meta"><span class="chevron">&#9656;</span></span>
725
+ </button>
726
+ <div class="accordion-body">
727
+ <p>#{escape(f["message"])}#{link}</p>
728
+ #{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
729
+ <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
421
730
  </div>
422
- <p>#{escape(f["message"])}</p>
423
- #{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
424
- <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
425
731
  </div>
426
- ROW
732
+ HTML
733
+ end
734
+
735
+ def render_top_priorities(risks)
736
+ return "<p class=\"muted\">Nothing critical or above — the highest-priority findings, " \
737
+ "if any, are in the sections below.</p>" if risks.empty?
738
+
739
+ items = risks.map do |r|
740
+ <<~ITEM
741
+ <li class="finding #{r[:severity]}">
742
+ <div class="finding-head">
743
+ <span class="badge #{r[:severity]}">#{r[:severity].upcase}</span>
744
+ <span class="loc">#{escape(r[:category])} &middot; <code>#{escape(r[:label].to_s)}</code> &middot; #{escape(r[:location].to_s)}</span>
745
+ </div>
746
+ <p>#{escape(r[:message])}</p>
747
+ </li>
748
+ ITEM
749
+ end.join
750
+
751
+ "<ol class=\"top-risks\">#{items}</ol>"
752
+ end
753
+
754
+ def finding_risk_entry(category, f)
755
+ {
756
+ severity: f["severity"],
757
+ category: category,
758
+ label: f["rule_id"],
759
+ location: f["line"] ? "#{f["file"]}:#{f["line"]}" : f["file"],
760
+ message: f["message"]
761
+ }
762
+ end
763
+
764
+ def dependency_risk_entry(f)
765
+ location =
766
+ if f["gem_name"]
767
+ f["installed_version"] ? "#{f["gem_name"]} #{f["installed_version"]}" : f["gem_name"]
768
+ else
769
+ "Gemfile.lock"
770
+ end
771
+
772
+ {
773
+ severity: f["severity"],
774
+ category: "dependency",
775
+ label: f["kind"],
776
+ location: location,
777
+ message: f["message"]
778
+ }
427
779
  end
428
780
 
429
781
  def escape(text)
@@ -474,15 +826,22 @@ module Scryer
474
826
 
475
827
  def static_csv_row(f)
476
828
  [
477
- f["category"], f["rule_id"], f["severity"],
829
+ f["category"], f["rule_id"], f["severity"], f["confidence"], f["cwe"], f["owasp_category"],
478
830
  "#{f["file"]}#{f["line"] ? ":#{f["line"]}" : ""}",
479
831
  f["message"], f["suggested_fix"], f["code_snippet"], nil
480
832
  ]
481
833
  end
482
834
 
483
835
  def dependency_csv_row(f)
484
- identifier = f["kind"] == "insecure_source" ? "insecure_source" : "#{f["gem_name"]} #{f["installed_version"]} (#{f["advisory_id"]})"
485
- [f["kind"], identifier, f["severity"], "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
836
+ identifier =
837
+ if f["kind"] == "insecure_source"
838
+ "insecure_source"
839
+ elsif f["advisory_id"]
840
+ "#{f["gem_name"]} #{f["installed_version"]} (#{f["advisory_id"]})"
841
+ else
842
+ "#{f["gem_name"]} #{f["installed_version"]}"
843
+ end
844
+ [f["kind"], identifier, f["severity"], nil, nil, nil, "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
486
845
  end
487
846
 
488
847
  def csv_field(value)
@@ -490,12 +849,159 @@ module Scryer
490
849
  s.match?(/[",\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
491
850
  end
492
851
 
852
+ # kind/title/severity for the three DependencyAudit finding kinds, which
853
+ # (unlike security/performance/style findings) aren't backed by a
854
+ # Scryer::Rule — described here just so SARIF's tool.driver.rules[]
855
+ # taxonomy has an entry for them too.
856
+ DEPENDENCY_SARIF_RULES = [
857
+ { "id" => "vulnerable_dependency", "title" => "Known-vulnerable gem version (OSV.dev)", "severity" => "critical" },
858
+ { "id" => "insecure_source", "title" => "Insecure (unencrypted) Gemfile.lock source", "severity" => "warning" },
859
+ { "id" => "ruby_eol", "title" => "Ruby version is end-of-life", "severity" => "critical" },
860
+ { "id" => "credentials_exposure", "title" => "config/master.key present and not gitignored", "severity" => "critical" }
861
+ ].freeze
862
+
863
+ def sarif_hash
864
+ h = as_hash
865
+ findings = h["security_findings"] + h["performance_findings"] + h["style_findings"]
866
+
867
+ {
868
+ "$schema" => "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
869
+ "version" => "2.1.0",
870
+ "runs" => [
871
+ {
872
+ "tool" => {
873
+ "driver" => {
874
+ "name" => "Scryer",
875
+ "version" => Scryer::VERSION,
876
+ "informationUri" => "https://ramlaxmanyadav.github.io/scryer/",
877
+ "rules" => sarif_rules
878
+ }
879
+ },
880
+ "results" => findings.map { |f| sarif_result(f) } + h["dependency_findings"].map { |f| sarif_dependency_result(f) }
881
+ }
882
+ ]
883
+ }
884
+ end
885
+
886
+ def sarif_rules
887
+ rule_entries = Scryer::RuleSet.all.map do |rule|
888
+ {
889
+ "id" => rule.rule_id,
890
+ "name" => rule.rule_id,
891
+ "shortDescription" => { "text" => rule.title },
892
+ "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[rule.default_severity] || "warning" },
893
+ "properties" => sarif_rule_properties(rule.cwe, rule.owasp_category)
894
+ }.compact
895
+ end
896
+
897
+ dependency_entries = DEPENDENCY_SARIF_RULES.map do |r|
898
+ {
899
+ "id" => r["id"],
900
+ "name" => r["id"],
901
+ "shortDescription" => { "text" => r["title"] },
902
+ "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[r["severity"]] || "warning" }
903
+ }
904
+ end
905
+
906
+ rule_entries + dependency_entries
907
+ end
908
+
909
+ # `external/cwe/cwe-NN` is the tag GitHub Code Scanning specifically
910
+ # looks for to show a "CWE-NN" badge and link on a Security tab alert —
911
+ # see docs.github.com/code-security/code-scanning (SARIF properties.tags
912
+ # for rules). The OWASP category has no equivalent standardized SARIF
913
+ # tag convention, so it's included as a plain readable tag instead —
914
+ # still valid SARIF (tags are freeform strings), just not something
915
+ # GitHub's UI specifically recognizes the way it does CWE tags.
916
+ def sarif_rule_properties(cwe, owasp_category)
917
+ return nil unless cwe || owasp_category
918
+
919
+ tags = []
920
+ tags << "external/cwe/#{cwe.downcase}" if cwe
921
+ tags << owasp_category if owasp_category
922
+ { "tags" => tags }
923
+ end
924
+
925
+ # SARIF's own "how important is this result" field (0-100, higher =
926
+ # more urgent) — the per-result equivalent of what ReportRenderer#
927
+ # top_risks does across a whole scan, so a SARIF consumer (GitHub Code
928
+ # Scanning, a CI dashboard) can sort/prioritize without recomputing it.
929
+ # Combines severity (the bulk of the signal) with confidence (this
930
+ # rule's own precision estimate) rather than either alone — a
931
+ # high-severity, low-confidence finding like idor should rank below a
932
+ # high-severity, high-confidence one like sql_injection, not tie with it.
933
+ SEVERITY_RANK_BASE = { "critical" => 100, "warning" => 60, "info" => 20 }.freeze
934
+ CONFIDENCE_RANK_MULTIPLIER = { "high" => 1.0, "medium" => 0.75, "low" => 0.5 }.freeze
935
+
936
+ def sarif_rank(severity, confidence)
937
+ base = SEVERITY_RANK_BASE[severity] || 50
938
+ multiplier = CONFIDENCE_RANK_MULTIPLIER[confidence] || 0.75
939
+ (base * multiplier).round
940
+ end
941
+
942
+ def sarif_result(f)
943
+ physical_location = { "artifactLocation" => { "uri" => f["file"] } }
944
+ physical_location["region"] = { "startLine" => f["line"] } if f["line"]
945
+
946
+ {
947
+ "ruleId" => f["rule_id"],
948
+ "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
949
+ "rank" => sarif_rank(f["severity"], f["confidence"]),
950
+ "message" => { "text" => f["message"] },
951
+ "locations" => [{ "physicalLocation" => physical_location }],
952
+ "properties" => { "confidence" => f["confidence"], "cwe" => f["cwe"], "owasp_category" => f["owasp_category"] }.compact
953
+ }
954
+ end
955
+
956
+ # Dependency findings don't point at a line in app source — Gemfile.lock
957
+ # itself is the meaningful "location" (no region: nothing to underline
958
+ # inside it the way a code finding underlines a specific line). No
959
+ # confidence/CWE/OWASP metadata — these aren't backed by a Scryer::Rule,
960
+ # see DEPENDENCY_SARIF_RULES above.
961
+ def sarif_dependency_result(f)
962
+ {
963
+ "ruleId" => f["kind"],
964
+ "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
965
+ "rank" => SEVERITY_RANK_BASE[f["severity"]] || 50,
966
+ "message" => { "text" => f["message"] },
967
+ "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => "Gemfile.lock" } } }]
968
+ }
969
+ end
970
+
493
971
  CSS = <<~CSS
494
972
  body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
495
973
  h1 { margin-bottom: 0.25rem; }
496
974
  h2 { margin-top: 2rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
497
975
  .meta { color: #64748b; font-size: 0.875rem; margin-top: 0; }
498
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%;
980
+ 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; }
994
+ .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;
999
+ text-decoration: none; }
1000
+ .score-bar-count:hover { text-decoration: underline; }
1001
+ .findings-search { display: block; width: 100%; max-width: 28rem; margin: 0.75rem 0 1.25rem;
1002
+ padding: 0.5rem 0.75rem; font: inherit; font-size: 0.85rem; border: 1px solid #cbd5e1;
1003
+ border-radius: 8px; box-sizing: border-box; }
1004
+ .findings-search:focus { outline: 2px solid #6366f1; outline-offset: 1px; }
499
1005
  .toc { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; background: #f8fafc; border: 1px solid #e2e8f0;
500
1006
  border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.85rem; }
501
1007
  .toc a { color: #3730a3; text-decoration: none; }
@@ -507,8 +1013,16 @@ module Scryer
507
1013
  table.kv th { width: 12rem; background: #f8fafc; color: #475569; font-weight: 600; }
508
1014
  table.summary th, table.checks th { background: #f8fafc; color: #475569; }
509
1015
  table.summary tr.total { font-weight: 700; }
1016
+ #top-priorities { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0; }
1017
+ .top-risks { list-style: none; padding: 0; margin: 0; }
510
1018
  .finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
1019
+ .finding.highlight { border-color: #6366f1; box-shadow: 0 0 0 2px #6366f1; background: #eef2ff; }
511
1020
  .finding-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
1021
+ .finding-tags { margin: -0.15rem 0 0.5rem; }
1022
+ .finding-tags .tag { display: inline-block; font-size: 0.72rem; color: #475569; background: #f1f5f9;
1023
+ border-radius: 4px; padding: 0.1rem 0.45rem; margin: 0.15rem 0.3rem 0.15rem 0; }
1024
+ .filter-link { color: #3730a3; text-decoration: none; font-weight: 600; }
1025
+ .filter-link:hover { text-decoration: underline; }
512
1026
  .badge { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 4px; }
513
1027
  .badge.critical { background: #fee2e2; color: #991b1b; }
514
1028
  .badge.warning { background: #fef3c7; color: #92400e; }
@@ -521,6 +1035,9 @@ module Scryer
521
1035
  .fix > strong { display: block; margin-bottom: 0.35rem; }
522
1036
  .fix p { margin: 0 0 0.5rem; }
523
1037
  .fix p:last-child { margin-bottom: 0; }
1038
+ .fix-verified { margin: 0.4rem 0 0; font-size: 0.8rem; font-weight: 600; }
1039
+ .fix-verified.verified { color: #166534; }
1040
+ .fix-verified.unverified { color: #991b1b; }
524
1041
  .fix pre { margin: 0.5rem 0 0; }
525
1042
  .fix pre:last-child { margin-bottom: 0; }
526
1043
  .dup-member { margin-top: 0.5rem; }
@@ -595,6 +1112,82 @@ module Scryer
595
1112
 
596
1113
  window.addEventListener("hashchange", openHashTarget);
597
1114
  openHashTarget();
1115
+
1116
+ // Filters every .finding — both the accordion entries under
1117
+ // #findings and the fixed top-5 list items under #top-priorities —
1118
+ // by rule id, file, or message text, all already part of each
1119
+ // .finding's rendered text content, so no separate index is needed.
1120
+ // A non-matching finding is hidden entirely (including a
1121
+ // now-empty Top priorities list item, same as a now-empty
1122
+ // accordion); a matching one gets the `.highlight` treatment
1123
+ // (visually distinct, not just "still visible") specifically so a
1124
+ // filter applied via typing OR via one of the click-through links
1125
+ // (OWASP coverage counts, severity bar counts) makes it obvious
1126
+ // which finding(s) are "the" target, not just that unrelated ones
1127
+ // got hidden. Matching accordions (rule groups) auto-open so
1128
+ // results are visible without an extra click; a group with zero
1129
+ // matches hides entirely. Clearing the search restores everything
1130
+ // to its pre-search open/closed state isn't attempted — simplest
1131
+ // correct behavior is "show everything, closed," same as a fresh
1132
+ // page load.
1133
+ var searchBox = document.getElementById("findings-search");
1134
+
1135
+ // `terms` (plural) rather than one substring: a Summary-table cell
1136
+ // like "Security × Critical" links with a two-word query
1137
+ // ("security critical") and needs BOTH words present, not the
1138
+ // literal two-word phrase — a finding's category tag and severity
1139
+ // badge aren't adjacent text. Every existing single-term or
1140
+ // multi-word-phrase query (an OWASP category like "A03:2021-
1141
+ // Injection" splits into ["a03:2021-injection"] with no spaces, so
1142
+ // it's unaffected either way) still works the same: requiring every
1143
+ // term to appear somewhere is a strict superset of "the exact
1144
+ // phrase appears," never stricter.
1145
+ function matchAndMarkFinding(finding, terms) {
1146
+ var text = finding.textContent.toLowerCase();
1147
+ var match = terms.length === 0 || terms.every(function (term) { return text.indexOf(term) !== -1; });
1148
+ finding.style.display = match ? "" : "none";
1149
+ finding.classList.toggle("highlight", terms.length > 0 && match);
1150
+ return match;
1151
+ }
1152
+
1153
+ function applyFindingsFilter() {
1154
+ var terms = searchBox.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
1155
+
1156
+ var findingsSection = document.getElementById("findings");
1157
+ findingsSection.querySelectorAll(".accordion").forEach(function (acc) {
1158
+ var anyVisible = false;
1159
+ acc.querySelectorAll(".finding").forEach(function (finding) {
1160
+ if (matchAndMarkFinding(finding, terms)) anyVisible = true;
1161
+ });
1162
+ acc.style.display = anyVisible ? "" : "none";
1163
+ if (terms.length) setOpen(acc, anyVisible);
1164
+ });
1165
+
1166
+ var topPriorities = document.getElementById("top-priorities");
1167
+ if (topPriorities) {
1168
+ topPriorities.querySelectorAll(".finding").forEach(function (finding) {
1169
+ matchAndMarkFinding(finding, terms);
1170
+ });
1171
+ }
1172
+ }
1173
+
1174
+ if (searchBox) {
1175
+ searchBox.addEventListener("input", applyFindingsFilter);
1176
+ }
1177
+
1178
+ // OWASP coverage table counts (and, by the same mechanism, any
1179
+ // future "count that means a specific set of findings") link into
1180
+ // the Findings search box rather than a second breakdown view —
1181
+ // set the search value to this row's exact category string, run
1182
+ // the same filter the search box itself uses, then let the link's
1183
+ // own #findings href do the scrolling.
1184
+ document.addEventListener("click", function (e) {
1185
+ var link = e.target.closest(".filter-link");
1186
+ if (link && searchBox) {
1187
+ searchBox.value = link.dataset.filter;
1188
+ applyFindingsFilter();
1189
+ }
1190
+ });
598
1191
  })();
599
1192
  JS
600
1193
  end