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