Class: Scryer::ReportRenderer
- Inherits:
-
Object
- Object
- Scryer::ReportRenderer
- Defined in:
- lib/scryer/report_renderer.rb
Overview
Turns a Scanner::Result into the exact JSON shape documented in CONTRACT2.md (also the ingest POST body shape) and a self-contained HTML report (inline CSS, no external assets except optionally linking out — here, none at all, so it works offline too). The HTML report is laid out similarly to a Brakeman report: an overview, a summary of counts, the full list of checks that ran, a breakdown of warnings by type, and then every finding in detail.
Constant Summary collapse
- SEVERITY_ORDER =
%w[critical warning info].freeze
- SEVERITY_LABELS =
{ "critical" => "Critical", "warning" => "Warning", "info" => "Info" }.freeze
- CSV_HEADERS =
%w[kind identifier severity confidence cwe owasp_category location message suggested_fix code_snippet url].freeze
- CATEGORY_RISK_PRIORITY =
Tiebreak within the same severity for top_risks — a critical finding is a critical finding regardless of category, but when severity is equal this is the order that best matches "what's actually riskiest": a security hole, then a known-vulnerable dependency, ahead of a performance or style issue at the same nominal severity.
{ "security" => 0, "dependency" => 1, "performance" => 2, "code quality" => 3 }.freeze
- DEFAULT_TOP_RISKS_LIMIT =
5- SCORE_SEVERITY_WEIGHT =
A single 0-100 number (plus a letter grade) summarizing this scan's security risk exposure — security findings and dependency findings only (performance/code-quality findings aren't security risk, so they're deliberately excluded; a slow app doesn't lower a security score). Deliberately NOT normalized by files-scanned or lines of code: it reflects this scan's absolute finding exposure, so it's meaningful for tracking one project's trend over time (does the next scan score higher or lower), not for comparing two differently-sized codebases against each other — a bigger app with the same finding density will naturally score lower here, and that's a documented limitation, not a bug.
Exponential decay rather than linear subtraction from 100: a single critical/high finding should visibly move the score (100 -> ~86) without a handful of findings driving a real app straight to a hard-clamped 0, which would make the score useless for comparing "bad" against "worse." weighted_penalty combines severity (the dominant factor) with confidence (a low-confidence rule's finding shouldn't hurt the score as much as a high-confidence one saying the same severity) — same philosophy as SARIF's
rank(see sarif_rank above), computed once here for a single scan-level number instead of per-result. { "critical" => 15, "warning" => 6, "info" => 1 }.freeze
- SCORE_CONFIDENCE_WEIGHT =
{ "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
- SCORE_DECAY_CONSTANT =
100.0- SCORE_GRADE_BANDS =
[[90, "A"], [80, "B"], [70, "C"], [60, "D"]].freeze
- SARIF_LEVEL_BY_SEVERITY =
{ "critical" => "error", "warning" => "warning", "info" => "note" }.freeze
Instance Method Summary collapse
-
#as_csv ⇒ Object
Flat, one-row-per-finding CSV — security + performance findings plus any dependency findings, in that order — for dropping into a spreadsheet or importing into a ticketing tool.
- #as_hash ⇒ Object
- #as_html ⇒ Object
- #as_json ⇒ Object
-
#as_sarif ⇒ Object
SARIF 2.1.0 (docs.oasis-open.org/sarif/sarif/v2.1.0) — the format GitHub Code Scanning (and other CI security dashboards) natively ingest, turning findings into inline PR annotations and Security-tab entries instead of a report file nobody opens.
-
#initialize(result:, project_name:, release_label: nil, git_commit_sha: nil, git_branch: nil, dependency_findings: [], scanned_at: Time.now) ⇒ ReportRenderer
constructor
dependency_findingsis an optional array of Scryer::DependencyAudit:: Finding (insecure_sources + vulnerable_gems) — pass it to fold a bundler-audit-like dependency audit into the same report as the static scan, instead of the audit living in separate--audit-depsoutput. -
#owasp_coverage ⇒ Object
How many of this scan's security findings fall into each OWASP Top 10 (2021) category — [[category, count], ...] sorted by count descending.
-
#rules_clean_rate ⇒ Object
"N/M rules clean" — how many of the registered Scryer::Rule checks (security + performance + style; NOT the dependency checks, which aren't backed by a Rule subclass at all — see DEPENDENCY_SARIF_RULES) fired zero findings in this scan, out of every rule that exists to fire.
- #score_grade(score) ⇒ Object
-
#security_score ⇒ Object
Deliberately reads @result/@dependency_findings directly rather than going through as_hash — as_hash includes this method's own output (so JSON consumers get the score without a separate call), and as_hash calling security_score while security_score called as_hash would recurse forever.
-
#top_risks(limit: DEFAULT_TOP_RISKS_LIMIT) ⇒ Object
This is what actually backs "tells you what to fix first" — Scryer's categories (security, dependencies, performance, code quality) each already carry a severity ("critical"/"warning"/"info"), but they're scanned and reported separately; nothing ranks across them.
Constructor Details
#initialize(result:, project_name:, release_label: nil, git_commit_sha: nil, git_branch: nil, dependency_findings: [], scanned_at: Time.now) ⇒ ReportRenderer
dependency_findings is an optional array of Scryer::DependencyAudit::
Finding (insecure_sources + vulnerable_gems) — pass it to fold a
bundler-audit-like dependency audit into the same report as the static
scan, instead of the audit living in separate --audit-deps output.
Defaults to empty so existing callers that only run the static scan are
unaffected.
31 32 33 34 35 36 37 38 39 40 |
# File 'lib/scryer/report_renderer.rb', line 31 def initialize(result:, project_name:, release_label: nil, git_commit_sha: nil, git_branch: nil, dependency_findings: [], scanned_at: Time.now) @result = result @project_name = project_name @release_label = release_label @git_commit_sha = git_commit_sha @git_branch = git_branch @dependency_findings = dependency_findings @scanned_at = scanned_at end |
Instance Method Details
#as_csv ⇒ Object
Flat, one-row-per-finding CSV — security + performance findings plus
any dependency findings, in that order — for dropping into a
spreadsheet or importing into a ticketing tool. Deliberately excludes
duplicate-code groups: they're nested member lists, not a single
actionable item, so they don't fit a flat "one row = one thing to
fix" table (see as_json for the full nested data). No csv stdlib
dependency — RFC4180-style quoting is small enough to hand-roll, same
reasoning as this gem's other hand-rolled parsers/writers.
190 191 192 193 194 195 196 197 198 199 |
# File 'lib/scryer/report_renderer.rb', line 190 def as_csv h = as_hash rows = [CSV_HEADERS] h["security_findings"].each { |f| rows << static_csv_row(f) } h["performance_findings"].each { |f| rows << static_csv_row(f) } h["style_findings"].each { |f| rows << static_csv_row(f) } h["dependency_findings"].each { |f| rows << dependency_csv_row(f) } rows.map { |row| row.map { |field| csv_field(field) }.join(",") }.join("\n") end |
#as_hash ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/scryer/report_renderer.rb', line 42 def as_hash { "project_name" => @project_name, "scryer_version" => Scryer::VERSION, "ruby_version" => RUBY_VERSION, "scanned_at" => @scanned_at.utc.iso8601, "release_label" => @release_label, "git_commit_sha" => @git_commit_sha, "git_branch" => @git_branch, "files_scanned" => @result.files_scanned, "parse_errors" => @result.parse_errors.map { |pe| { "file" => pe[:file], "error" => pe[:error] } }, "security_findings" => @result.security_findings.map(&:to_h), "performance_findings" => @result.performance_findings.map(&:to_h), "style_findings" => @result.style_findings.map(&:to_h), "duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) }, "dependency_findings" => @dependency_findings.map(&:to_h), "security_score" => security_score, "rules_clean_rate" => rules_clean_rate } end |
#as_html ⇒ Object
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/scryer/report_renderer.rb', line 213 def as_html h = as_hash security = h["security_findings"] performance = h["performance_findings"] style = h["style_findings"] all_findings = security + performance + style by_severity = all_findings.group_by { |f| f["severity"] } duplicate_groups = h["duplicate_groups"] dependency_findings = h["dependency_findings"] <<~HTML <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Scryer report — #{escape(@project_name)}</title> <style>#{CSS}</style> </head> <body> <h1>Scryer report</h1> <p class="meta"> #{escape(@project_name)} · #{escape(h["release_label"] || "no release label")} · #{escape(h["scanned_at"])} · #{h["files_scanned"]} files scanned #{h["parse_errors"].any? ? "· <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""} </p> #{render_executive_summary(h, h["security_score"], h["rules_clean_rate"])} #{render_toc(h)} <section id="overview"> <h2>Overview</h2> #{render_overview_table(h)} </section> <section id="summary"> <h2>Summary</h2> #{render_summary_table(security, performance, style, duplicate_groups, dependency_findings)} </section> <section id="owasp-coverage"> <h2>OWASP Top 10 (2021) coverage</h2> #{render_owasp_coverage(owasp_coverage)} </section> <section id="checks-performed"> <h2>Checks performed #{("#checks-performed")}</h2> #{render_checks_performed} </section> <section id="warnings-by-type"> <h2>Warnings by type</h2> #{render_warnings_by_type(all_findings)} </section> <section id="findings"> <h2>Findings (#{all_findings.size}) #{("#findings")}</h2> <input type="search" id="findings-search" class="findings-search" placeholder="Filter by rule, file, or message text…" aria-label="Filter findings"> <div id="top-priorities"> <h3>Top priorities — fix these first</h3> #{render_top_priorities(top_risks)} </div> #{render_severity_section("critical", by_severity["critical"] || [])} #{render_severity_section("warning", by_severity["warning"] || [])} #{render_severity_section("info", by_severity["info"] || [])} </section> <section id="dependency-audit"> <h2>Dependency audit (#{dependency_findings.size}) #{("#dependency-audit")}</h2> #{render_dependency_findings(dependency_findings)} </section> <section id="errors"> <h2>Files that couldn't be parsed (#{h["parse_errors"].size})</h2> #{render_parse_errors(h["parse_errors"])} </section> <section id="duplicates"> <h2>Duplicate code groups (#{duplicate_groups.size}) #{("#duplicates")}</h2> #{render_duplicate_groups(duplicate_groups)} </section> <p class="footer"> Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])}) </p> <script>#{JS}</script> </body> </html> HTML end |
#as_json ⇒ Object
63 64 65 |
# File 'lib/scryer/report_renderer.rb', line 63 def as_json JSON.pretty_generate(as_hash) end |
#as_sarif ⇒ Object
SARIF 2.1.0 (docs.oasis-open.org/sarif/sarif/v2.1.0) — the format GitHub Code Scanning (and other CI security dashboards) natively ingest, turning findings into inline PR annotations and Security-tab entries instead of a report file nobody opens. Pure data mapping of what's already in as_hash — no new detection logic, and every finding behaves identically to how it does in the other formats.
209 210 211 |
# File 'lib/scryer/report_renderer.rb', line 209 def as_sarif JSON.pretty_generate(sarif_hash) end |
#owasp_coverage ⇒ Object
How many of this scan's security findings fall into each OWASP Top 10 (2021) category — [[category, count], ...] sorted by count descending. A byproduct of every security rule already carrying an owasp_category (see Rule.owasp_category) rather than new detection logic: this is purely "count what's already tagged," not a separate audit against OWASP's own benchmark suite or certification of any kind. Scryer's CWE/OWASP tags are its own best-effort categorization for practitioner convenience and compliance-reporting purposes (e.g. "does our tooling catch OWASP category X" conversations) — not an OWASP-endorsed mapping and not independently audited; see the README for the caveat in full.
102 103 104 105 106 |
# File 'lib/scryer/report_renderer.rb', line 102 def owasp_coverage counts = Hash.new(0) as_hash["security_findings"].each { |f| counts[f["owasp_category"]] += 1 if f["owasp_category"] } counts.sort_by { |category, count| [-count, category] } end |
#rules_clean_rate ⇒ Object
"N/M rules clean" — how many of the registered Scryer::Rule checks (security + performance + style; NOT the dependency checks, which aren't backed by a Rule subclass at all — see DEPENDENCY_SARIF_RULES) fired zero findings in this scan, out of every rule that exists to fire. A rule-level pass rate, distinct from security_score (which is finding-weighted, not rule-counted) — a codebase can have a high clean rate (few distinct rules triggered) and still a low score (the few that did trigger were severe/high-confidence), or the reverse (many different rules each firing once, none of them serious). Report both; neither alone tells the whole story.
170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/scryer/report_renderer.rb', line 170 def rules_clean_rate all_rule_ids = Scryer::RuleSet.all.map(&:rule_id) fired_rule_ids = (@result.security_findings + @result.performance_findings + @result.style_findings) .map(&:rule_id).uniq total = all_rule_ids.size clean = total - (all_rule_ids & fired_rule_ids).size percent = total.zero? ? 100.0 : (clean.to_f / total * 100).round(1) { "clean" => clean, "total" => total, "percent" => percent } end |
#score_grade(score) ⇒ Object
155 156 157 158 |
# File 'lib/scryer/report_renderer.rb', line 155 def score_grade(score) SCORE_GRADE_BANDS.each { |threshold, grade| return grade if score >= threshold } "F" end |
#security_score ⇒ Object
Deliberately reads @result/@dependency_findings directly rather than going through as_hash — as_hash includes this method's own output (so JSON consumers get the score without a separate call), and as_hash calling security_score while security_score called as_hash would recurse forever.
138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/scryer/report_renderer.rb', line 138 def security_score # .to_h (not the full as_hash) so this works uniformly across Finding # (has "confidence") and DependencyAudit::Finding (doesn't — a plain # Hash returns nil for a missing key rather than raising, unlike # calling #confidence directly on a struct that has no such member). findings = (@result.security_findings + @dependency_findings).map(&:to_h) weighted_penalty = findings.sum do |f| (SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) * (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7) end score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round { "score" => score, "grade" => score_grade(score), "finding_count" => findings.size } end |
#top_risks(limit: DEFAULT_TOP_RISKS_LIMIT) ⇒ Object
This is what actually backs "tells you what to fix first" — Scryer's categories (security, dependencies, performance, code quality) each already carry a severity ("critical"/"warning"/"info"), but they're scanned and reported separately; nothing ranks across them. top_risks merges every severity-bearing finding (rule-based + dependency) into one list, sorted by severity first and then by category (a security hole outranks a stylistic one at the same severity) — pure aggregation of data every format already has, no new detection logic. Used by the console summary (CLI + rake) and the top of the HTML report; JSON/CSV/ SARIF are consumed by other tools that do their own sorting/filtering, so this stays a display-only convenience rather than a new field there.
80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/scryer/report_renderer.rb', line 80 def top_risks(limit: DEFAULT_TOP_RISKS_LIMIT) h = as_hash entries = [] h["security_findings"].each { |f| entries << finding_risk_entry("security", f) } h["performance_findings"].each { |f| entries << finding_risk_entry("performance", f) } h["style_findings"].each { |f| entries << finding_risk_entry("code quality", f) } h["dependency_findings"].each { |f| entries << dependency_risk_entry(f) } entries.sort_by { |e| [SEVERITY_ORDER.index(e[:severity]) || SEVERITY_ORDER.size, CATEGORY_RISK_PRIORITY[e[:category]] || 99] } .first(limit) end |