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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- 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
|
|
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? ? "· <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…" 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"])})
|
|
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="#
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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">▸</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\">✓ 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\">✗ 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"] ? " · <a href=\"#{escape(f["url"])}\" target=\"_blank\" rel=\"noopener\">advisory</a>" : ""
|
|
426
718
|
patched = Array(f["patched_versions"])
|
|
427
719
|
|
|
428
|
-
<<~
|
|
429
|
-
<div class="
|
|
430
|
-
<
|
|
431
|
-
<span class="badge #{severity}">#{severity.upcase}</span>
|
|
432
|
-
<
|
|
433
|
-
|
|
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">▸</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
|
-
|
|
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])} · <code>#{escape(r[:label].to_s)}</code> · #{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
|