scryer 1.0.0 → 1.2.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -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
@@ -111,6 +236,8 @@ module Scryer
111
236
  #{h["parse_errors"].any? ? "&middot; <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
112
237
  </p>
113
238
 
239
+ #{render_executive_summary(h, h["security_score"], h["rules_clean_rate"])}
240
+
114
241
  #{render_toc(h)}
115
242
 
116
243
  <section id="overview">
@@ -123,8 +250,13 @@ module Scryer
123
250
  #{render_summary_table(security, performance, style, duplicate_groups, dependency_findings)}
124
251
  </section>
125
252
 
253
+ <section id="owasp-coverage">
254
+ <h2>OWASP Top 10 (2021) coverage</h2>
255
+ #{render_owasp_coverage(owasp_coverage)}
256
+ </section>
257
+
126
258
  <section id="checks-performed">
127
- <h2>Checks performed</h2>
259
+ <h2>Checks performed #{expand_collapse_controls("#checks-performed")}</h2>
128
260
  #{render_checks_performed}
129
261
  </section>
130
262
 
@@ -135,16 +267,20 @@ module Scryer
135
267
 
136
268
  <section id="findings">
137
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
+
138
279
  #{render_severity_section("critical", by_severity["critical"] || [])}
139
280
  #{render_severity_section("warning", by_severity["warning"] || [])}
140
281
  #{render_severity_section("info", by_severity["info"] || [])}
141
282
  </section>
142
283
 
143
- <section id="duplicates">
144
- <h2>Duplicate code groups (#{duplicate_groups.size}) #{expand_collapse_controls("#duplicates")}</h2>
145
- #{render_duplicate_groups(duplicate_groups)}
146
- </section>
147
-
148
284
  <section id="dependency-audit">
149
285
  <h2>Dependency audit (#{dependency_findings.size}) #{expand_collapse_controls("#dependency-audit")}</h2>
150
286
  #{render_dependency_findings(dependency_findings)}
@@ -155,10 +291,13 @@ module Scryer
155
291
  #{render_parse_errors(h["parse_errors"])}
156
292
  </section>
157
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
+
158
299
  <p class="footer">
159
- Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])}) &middot;
160
- heuristic static analysis, not full data-flow/taint analysis — review every finding in
161
- its surrounding context before acting on it.
300
+ Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])})
162
301
  </p>
163
302
 
164
303
  <script>#{JS}</script>
@@ -174,16 +313,72 @@ module Scryer
174
313
  <nav class="toc">
175
314
  <a href="#overview">Overview</a>
176
315
  <a href="#summary">Summary</a>
316
+ <a href="#owasp-coverage">OWASP Top 10 coverage</a>
177
317
  <a href="#checks-performed">Checks performed</a>
178
318
  <a href="#warnings-by-type">Warnings by type</a>
179
319
  <a href="#findings">Findings</a>
180
- <a href="#duplicates">Duplicate code (#{h["duplicate_groups"].size})</a>
320
+ <a href="#top-priorities">Top priorities</a>
181
321
  <a href="#dependency-audit">Dependency audit (#{h["dependency_findings"].size})</a>
182
322
  <a href="#errors">Parse errors (#{h["parse_errors"].size})</a>
323
+ <a href="#duplicates">Duplicate code (#{h["duplicate_groups"].size})</a>
183
324
  </nav>
184
325
  HTML
185
326
  end
186
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
+
187
382
  def render_overview_table(h)
188
383
  rows = {
189
384
  "Project" => h["project_name"],
@@ -207,26 +402,66 @@ module Scryer
207
402
  total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] }
208
403
 
209
404
  header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
210
- sec_row = summary_row("Security", sec_counts)
211
- perf_row = summary_row("Performance", perf_counts)
212
- style_row = summary_row("Style", style_counts)
213
- total_row = summary_row("Total", total_counts, css_class: "total")
214
- dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{duplicate_groups.size} group(s)</td></tr>"
215
- 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>"
216
422
 
217
423
  "<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
218
424
  end
219
425
 
220
- def summary_row(label, counts, css_class: nil)
221
- 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
222
436
  total = counts.values.sum
223
- "<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>"
224
439
  end
225
440
 
226
441
  def count_by_severity(findings)
227
442
  SEVERITY_ORDER.each_with_object(Hash.new(0)) { |s, acc| acc[s] = findings.count { |f| f["severity"] == s } }
228
443
  end
229
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
+
230
465
  def rules_by_category
231
466
  Scryer::RuleSet.all.group_by(&:category)
232
467
  end
@@ -235,14 +470,31 @@ module Scryer
235
470
  Scryer::RuleSet.all.each_with_object({}) { |r, acc| acc[r.rule_id] = r.title }
236
471
  end
237
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.
238
478
  def render_checks_performed
239
- security_rules = rules_by_category["security"] || []
240
- performance_rules = rules_by_category["performance"] || []
241
- 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
242
485
 
243
- "<h3>Security (#{security_rules.size})</h3>#{checks_table(security_rules)}" \
244
- "<h3>Performance (#{performance_rules.size})</h3>#{checks_table(performance_rules)}" \
245
- "<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
246
498
  end
247
499
 
248
500
  def checks_table(rules)
@@ -269,7 +521,7 @@ module Scryer
269
521
  "<td>#{escape(titles[rule_id] || rule_id)}</td>" \
270
522
  "<td>#{escape(findings.first["category"])}</td>" \
271
523
  "<td><span class=\"badge #{worst}\">#{SEVERITY_LABELS[worst]}</span></td>" \
272
- "<td>#{findings.size}</td></tr>"
524
+ "<td><a class=\"jump-link\" href=\"##{anchor}\">#{findings.size}</a></td></tr>"
273
525
  end.join
274
526
 
275
527
  "<table class=\"checks\"><tr><th>Rule ID</th><th>Description</th><th>Category</th>" \
@@ -363,13 +615,47 @@ module Scryer
363
615
  <code>#{escape(f["rule_id"])}</code>
364
616
  <span class="loc">#{escape(f["file"])}#{f["line"] ? ":#{f["line"]}" : ""}</span>
365
617
  </div>
618
+ #{render_finding_tags(f)}
366
619
  <p>#{escape(f["message"])}</p>
367
620
  #{f["code_snippet"] ? "<pre>#{escape(f["code_snippet"])}</pre>" : ""}
368
621
  <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
622
+ #{render_fix_verified_badge(f["fix_verified"])}
369
623
  </div>
370
624
  ROW
371
625
  end
372
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
+
373
659
  KIND_LABELS = {
374
660
  "method_duplicate" => "Method duplicate",
375
661
  "query_duplicate" => "Query duplicate",
@@ -407,17 +693,23 @@ module Scryer
407
693
  # patched_versions/message/suggested_fix) — a different shape from the
408
694
  # rule-based Finding hashes rendered by render_finding, so this has its
409
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.
410
702
  def render_dependency_findings(dependency_findings)
411
703
  return "<p class=\"muted\">None detected (run with dependency auditing enabled to check " \
412
704
  "Gemfile.lock against OSV.dev and for insecure git/http sources).</p>" if dependency_findings.empty?
413
705
 
414
706
  sorted = dependency_findings.sort_by { |f| [SEVERITY_ORDER.index(f["severity"]) || 99, f["gem_name"].to_s] }
415
- rows = sorted.map { |f| render_dependency_finding(f) }.join
707
+ rows = sorted.each_with_index.map { |f, index| render_dependency_finding(f, index) }.join
416
708
 
417
709
  "<div class=\"dep-list\">#{rows}</div>"
418
710
  end
419
711
 
420
- def render_dependency_finding(f)
712
+ def render_dependency_finding(f, index)
421
713
  severity = f["severity"]
422
714
  heading = f["kind"] == "insecure_source" ? "Insecure gem source" : "#{escape(f["gem_name"])} #{escape(f["installed_version"])}"
423
715
  advisory_text = [f["advisory_id"], f["title"]].compact.map { |t| escape(t) }.join(" — ")
@@ -425,18 +717,65 @@ module Scryer
425
717
  link = f["url"] ? " &middot; <a href=\"#{escape(f["url"])}\" target=\"_blank\" rel=\"noopener\">advisory</a>" : ""
426
718
  patched = Array(f["patched_versions"])
427
719
 
428
- <<~ROW
429
- <div class="finding #{severity}">
430
- <div class="finding-head">
431
- <span class="badge #{severity}">#{severity.upcase}</span>
432
- <strong>#{heading}</strong>
433
- #{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>
434
730
  </div>
435
- <p>#{escape(f["message"])}</p>
436
- #{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
437
- <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
438
731
  </div>
439
- 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
+ }
440
779
  end
441
780
 
442
781
  def escape(text)
@@ -487,7 +826,7 @@ module Scryer
487
826
 
488
827
  def static_csv_row(f)
489
828
  [
490
- f["category"], f["rule_id"], f["severity"],
829
+ f["category"], f["rule_id"], f["severity"], f["confidence"], f["cwe"], f["owasp_category"],
491
830
  "#{f["file"]}#{f["line"] ? ":#{f["line"]}" : ""}",
492
831
  f["message"], f["suggested_fix"], f["code_snippet"], nil
493
832
  ]
@@ -502,7 +841,7 @@ module Scryer
502
841
  else
503
842
  "#{f["gem_name"]} #{f["installed_version"]}"
504
843
  end
505
- [f["kind"], identifier, f["severity"], "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
844
+ [f["kind"], identifier, f["severity"], nil, nil, nil, "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
506
845
  end
507
846
 
508
847
  def csv_field(value)
@@ -550,8 +889,9 @@ module Scryer
550
889
  "id" => rule.rule_id,
551
890
  "name" => rule.rule_id,
552
891
  "shortDescription" => { "text" => rule.title },
553
- "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[rule.default_severity] || "warning" }
554
- }
892
+ "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[rule.default_severity] || "warning" },
893
+ "properties" => sarif_rule_properties(rule.cwe, rule.owasp_category)
894
+ }.compact
555
895
  end
556
896
 
557
897
  dependency_entries = DEPENDENCY_SARIF_RULES.map do |r|
@@ -566,6 +906,39 @@ module Scryer
566
906
  rule_entries + dependency_entries
567
907
  end
568
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
+
569
942
  def sarif_result(f)
570
943
  physical_location = { "artifactLocation" => { "uri" => f["file"] } }
571
944
  physical_location["region"] = { "startLine" => f["line"] } if f["line"]
@@ -573,18 +946,23 @@ module Scryer
573
946
  {
574
947
  "ruleId" => f["rule_id"],
575
948
  "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
949
+ "rank" => sarif_rank(f["severity"], f["confidence"]),
576
950
  "message" => { "text" => f["message"] },
577
- "locations" => [{ "physicalLocation" => physical_location }]
951
+ "locations" => [{ "physicalLocation" => physical_location }],
952
+ "properties" => { "confidence" => f["confidence"], "cwe" => f["cwe"], "owasp_category" => f["owasp_category"] }.compact
578
953
  }
579
954
  end
580
955
 
581
956
  # Dependency findings don't point at a line in app source — Gemfile.lock
582
957
  # itself is the meaningful "location" (no region: nothing to underline
583
- # inside it the way a code finding underlines a specific line).
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.
584
961
  def sarif_dependency_result(f)
585
962
  {
586
963
  "ruleId" => f["kind"],
587
964
  "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
965
+ "rank" => SEVERITY_RANK_BASE[f["severity"]] || 50,
588
966
  "message" => { "text" => f["message"] },
589
967
  "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => "Gemfile.lock" } } }]
590
968
  }
@@ -596,6 +974,34 @@ module Scryer
596
974
  h2 { margin-top: 2rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
597
975
  .meta { color: #64748b; font-size: 0.875rem; margin-top: 0; }
598
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; }
599
1005
  .toc { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; background: #f8fafc; border: 1px solid #e2e8f0;
600
1006
  border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.85rem; }
601
1007
  .toc a { color: #3730a3; text-decoration: none; }
@@ -607,8 +1013,16 @@ module Scryer
607
1013
  table.kv th { width: 12rem; background: #f8fafc; color: #475569; font-weight: 600; }
608
1014
  table.summary th, table.checks th { background: #f8fafc; color: #475569; }
609
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; }
610
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; }
611
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; }
612
1026
  .badge { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 4px; }
613
1027
  .badge.critical { background: #fee2e2; color: #991b1b; }
614
1028
  .badge.warning { background: #fef3c7; color: #92400e; }
@@ -621,6 +1035,9 @@ module Scryer
621
1035
  .fix > strong { display: block; margin-bottom: 0.35rem; }
622
1036
  .fix p { margin: 0 0 0.5rem; }
623
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; }
624
1041
  .fix pre { margin: 0.5rem 0 0; }
625
1042
  .fix pre:last-child { margin-bottom: 0; }
626
1043
  .dup-member { margin-top: 0.5rem; }
@@ -695,6 +1112,82 @@ module Scryer
695
1112
 
696
1113
  window.addEventListener("hashchange", openHashTarget);
697
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
+ });
698
1191
  })();
699
1192
  JS
700
1193
  end