Class: Scryer::ReportRenderer

Inherits:
Object
  • Object
show all
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

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_csvObject

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_hashObject



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_htmlObject



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)} &middot; #{escape(h["release_label"] || "no release label")} &middot;
        #{escape(h["scanned_at"])} &middot; #{h["files_scanned"]} files scanned
        #{h["parse_errors"].any? ? "&middot; <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 #{expand_collapse_controls("#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}) #{expand_collapse_controls("#findings")}</h2>

        <input type="search" id="findings-search" class="findings-search"
               placeholder="Filter by rule, file, or message text&hellip;" 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}) #{expand_collapse_controls("#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}) #{expand_collapse_controls("#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_jsonObject



63
64
65
# File 'lib/scryer/report_renderer.rb', line 63

def as_json
  JSON.pretty_generate(as_hash)
end

#as_sarifObject

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_coverageObject

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_rateObject

"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_scoreObject

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