fix(worker): correct PDF finding reporting

- Render OWASP category, authentication state, and remediation
- Omit the redundant per-finding exploited status
- Preserve canonical category and field ordering across report modes
- Continue Proof of Impact numbering across embedded code blocks
- Wrap long PDF code lines without changing canonical report content
This commit is contained in:
ajmallesh
2026-08-30 14:08:32 -07:00
parent 602bc27bbc
commit ddfd026f93
3 changed files with 153 additions and 81 deletions
@@ -16,7 +16,12 @@
* pipeline knows nothing about the Typst shape.
*/
import type { AddFindingInput, AdditionalSection, StepItem, StructuredStep } from '../collectors/finding-collector.js';
import type {
AddFindingInput,
AdditionalSection,
StepItem as CollectorStepItem,
StructuredStep,
} from '../collectors/finding-collector.js';
import { orderFindings } from './finding-order.js';
import type {
ExploitsReportData,
@@ -26,6 +31,7 @@ import type {
ReportData as TypstReportData,
TypstSeverity,
TypstStatus,
StepItem as TypstStepItem,
} from './report-output-schema.js';
import type { ReportData } from './report-renderer.js';
@@ -85,14 +91,40 @@ function toTypstCategory(s: string): TypstCategory {
// STEP / ITEM TRANSFORMS
// ============================================================================
// StepItem is currently identical on both sides of the adapter boundary, so this is a no-op.
// It stays as an explicit seam rather than being inlined so a future divergence between the
// collector's StepItem and the Typst schema's StepItem has a single place to add the conversion.
function adaptStepItem(item: StepItem): StepItem {
return item;
// Typst raw blocks do not wrap long lines. Keep the exact payload in `content` and derive a separate
// display-only projection with inserted line breaks for the PDF. Canonical JSON, Markdown, SARIF,
// and the exact Typst-side payload remain byte-for-byte intact.
const PDF_CODE_LINE_COLUMNS = 84;
function wrapCodeForPdf(content: string): string {
return content
.split('\n')
.flatMap((line) => {
const characters = Array.from(line);
if (characters.length <= PDF_CODE_LINE_COLUMNS) return [line];
const wrapped: string[] = [];
for (let offset = 0; offset < characters.length; offset += PDF_CODE_LINE_COLUMNS) {
wrapped.push(characters.slice(offset, offset + PDF_CODE_LINE_COLUMNS).join(''));
}
return wrapped;
})
.join('\n');
}
function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: StepItem[] } {
function adaptStepItem(item: CollectorStepItem): TypstStepItem {
if (item.kind === 'prose') return item;
return {
kind: 'code',
block: {
language: item.block.language,
content: item.block.content,
displayContent: wrapCodeForPdf(item.block.content),
},
};
}
function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: TypstStepItem[] } {
return {
number: index + 1,
...(step.title && { title: step.title }),
@@ -100,7 +132,7 @@ function adaptStep(step: StructuredStep, index: number): { number: number; title
};
}
function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: StepItem[] } {
function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: TypstStepItem[] } {
return {
heading: section.heading,
items: section.items.map(adaptStepItem),
@@ -201,7 +233,8 @@ function adaptExploitsMode(data: ReportData): ExploitsReportData {
title: f.title,
category: toTypstCategory(f.category),
severity: toTypstSeverity(f.severity),
status: toTypstStatus(f.status ?? 'exploited'),
owaspCategory: f.owasp_category,
...(f.auth_state && { authState: f.auth_state }),
summary: {
vulnerableLocation: f.vulnerable_location,
overview: f.overview,
@@ -212,6 +245,7 @@ function adaptExploitsMode(data: ReportData): ExploitsReportData {
prerequisites: f.prerequisites ?? '',
exploitationSteps: (f.exploitation_steps ?? []).map(adaptStep),
proofOfImpact: (f.proof_of_impact ?? []).map(adaptStepItem),
remediation: f.remediation,
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
...(f.additional_sections &&
f.additional_sections.length > 0 && {
@@ -277,11 +311,13 @@ function adaptFindingsMode(data: ReportData): FindingsReportData {
category: toTypstCategory(f.category),
severity: toTypstSeverity(f.severity),
confidence: toTypstConfidence(f.confidence ?? 'medium'),
owaspCategory: f.owasp_category,
summary: {
vulnerableLocation: f.vulnerable_location,
overview: f.overview,
impact: f.impact,
},
remediation: f.remediation,
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
...(f.additional_sections &&
f.additional_sections.length > 0 && {
@@ -21,6 +21,8 @@ export type TypstCategory = 'Authentication' | 'Authorization' | 'XSS' | 'Inject
export interface CodeBlock {
readonly language: string;
readonly content: string;
/** PDF-only projection with inserted visual line breaks; `content` remains exact. */
readonly displayContent: string;
}
export type StepItem =
@@ -84,11 +86,13 @@ export interface ExploitFinding {
readonly title: string;
readonly category: TypstCategory;
readonly severity: TypstSeverity;
readonly status: TypstStatus;
readonly owaspCategory: string;
readonly authState?: string;
readonly summary: FindingSummary;
readonly prerequisites: string;
readonly exploitationSteps: readonly Step[];
readonly proofOfImpact: readonly StepItem[];
readonly remediation: string;
readonly notes?: readonly StepItem[];
readonly additionalSections?: readonly AdditionalSection[];
}
@@ -136,7 +140,9 @@ export interface AnalysisFinding {
readonly category: TypstCategory;
readonly severity: TypstSeverity;
readonly confidence: TypstConfidence;
readonly owaspCategory: string;
readonly summary: FindingSummary;
readonly remediation: string;
readonly notes?: readonly StepItem[];
readonly additionalSections?: readonly AdditionalSection[];
}
+101 -71
View File
@@ -79,8 +79,7 @@
#set text(size: 10.5pt, fill: ink)
#set par(leading: 0.7em, justify: false)
#show heading.where(level: 1): it => [
#pagebreak(weak: true)
#show heading.where(level: 1): it => block(sticky: true)[
#v(4pt)
#set text(size: 24pt, weight: "bold", fill: ink)
#it.body
@@ -88,13 +87,13 @@
#line(length: 100%, stroke: 0.4pt + rule)
#v(10pt)
]
#show heading.where(level: 2): it => [
#show heading.where(level: 2): it => block(sticky: true)[
#v(10pt)
#set text(size: 14pt, weight: "semibold", fill: ink)
#it.body
#v(2pt)
]
#show heading.where(level: 3): it => [
#show heading.where(level: 3): it => block(sticky: true)[
#v(8pt)
#set text(size: 11.5pt, weight: "semibold", fill: ink)
#it.body
@@ -129,14 +128,11 @@
text(fill: white, weight: "bold", size: 7.5pt, tracking: 0.3pt, upper(label)),
)
#let categories-in-order = (
"Authentication",
"Authorization",
"XSS",
"Injection",
"SSRF",
"Miscellaneous",
)
#let categories-in-order = if mode == "exploits" {
data.exploitedByType.map(entry => entry.category)
} else {
data.identifiedByType.map(entry => entry.category)
}
#let sev-chip(level) = chip(level, sev-color(level))
#let confidence-chip(c) = chip(c + " confidence", confidence-color(c))
@@ -175,7 +171,7 @@
if item.kind == "prose" [
#par(inline-code(item.text))
] else if item.kind == "code" [
#raw(item.block.content, lang: item.block.language, block: true)
#raw(item.block.displayContent, lang: item.block.language, block: true)
]
}
}
@@ -187,20 +183,28 @@
if item.kind == "prose" [
- #inline-code(item.text)
] else if item.kind == "code" [
#raw(item.block.content, lang: item.block.language, block: true)
#raw(item.block.displayContent, lang: item.block.language, block: true)
]
}
}
// Render step items as a numbered list; prose items become enumerated,
// code items break the list and render as code blocks in between.
// A code item interrupts the enum grouping, so each prose item carries an
// explicit number — `auto` would restart the count after every code block.
#let strip-leading-item-number(s) = s.replace(regex("^\\s*[0-9]+[.)]\\s+"), "")
#let render-numbered-items(items) = {
let index = 0
for item in items {
if item.kind == "prose" [
+ #inline-code(item.text)
] else if item.kind == "code" [
#raw(item.block.content, lang: item.block.language, block: true)
]
if item.kind == "prose" {
index += 1
enum.item(index)[#inline-code(strip-leading-item-number(item.text))]
} else if item.kind == "code" {
block(sticky: true)[
#raw(item.block.displayContent, lang: item.block.language, block: true)
]
}
}
}
@@ -290,6 +294,7 @@
#outline(title: [Contents], depth: 3, indent: auto)
// ---------- EXECUTIVE SUMMARY -----------------------------------------------
#pagebreak(weak: true)
= Executive Summary
#grid(
@@ -340,6 +345,7 @@
]
// ---------- SUMMARY ---------------------------------------------------------
#pagebreak(weak: true)
= Summary
#let s = data.summary
@@ -449,6 +455,7 @@
]
// ---------- FINDINGS OVERVIEW -----------------------------------------------
#pagebreak(weak: true)
= Findings Overview
#let show-confidence-col = mode == "findings"
@@ -456,7 +463,7 @@
#table(
columns: if show-confidence-col { (auto, 1fr, auto, auto, auto) } else { (auto, 1fr, auto, auto) },
stroke: none,
inset: (x: 8pt, y: 7pt),
inset: (x: 8pt, y: if data.findings.len() > 30 { 6pt } else { 7pt }),
align: if show-confidence-col { (left, left, left, center, center) } else { (left, left, left, center) },
fill: (_, row) => if row == 0 { none } else if calc.even(row) { code-bg } else { none },
table.header(
@@ -476,66 +483,88 @@
)
// ---------- FINDING RENDER --------------------------------------------------
#let render-finding-summary(f) = [
#v(8pt)
#let render-finding-owasp(f) = [
#grid(
columns: (auto, 1fr),
column-gutter: 18pt,
row-gutter: 12pt,
text(fill: muted, size: 9.5pt)[OWASP], text(size: 10pt)[#inline-code(f.owaspCategory)],
)
]
#let render-finding-summary-remainder(f) = [
#v(12pt)
#grid(
columns: (auto, 1fr),
column-gutter: 18pt,
row-gutter: 12pt,
text(fill: muted, size: 9.5pt)[Location], text(size: 10pt)[#inline-code(f.summary.vulnerableLocation)],
..(if "authState" in f and f.authState != none and f.authState != "" {
(text(fill: muted, size: 9.5pt)[Auth state], text(size: 10pt)[#inline-code(f.authState)])
} else { () }),
..(if "prerequisites" in f and f.prerequisites != none and f.prerequisites != "" {
(text(fill: muted, size: 9.5pt)[Prerequisites], text(size: 10pt)[#inline-code(f.prerequisites)])
} else { () }),
text(fill: muted, size: 9.5pt)[Overview], text(size: 10pt)[#inline-code(f.summary.overview)],
text(fill: muted, size: 9.5pt)[Impact], text(size: 10pt)[#inline-code(f.summary.impact)],
)
]
#let render-finding-extras(f) = [
#if "notes" in f and f.notes != none and f.notes.len() > 0 [
#heading(level: 3, outlined: false)[Notes]
#render-bulleted-items(f.notes)
#let render-remediation(f) = {
heading(level: 3, outlined: false)[Remediation]
render-paragraphs(f.remediation)
}
#let render-finding-extras(f) = {
if "notes" in f and f.notes != none and f.notes.len() > 0 {
heading(level: 3, outlined: false)[Notes]
render-bulleted-items(f.notes)
}
if "additionalSections" in f and f.additionalSections != none {
for extra in f.additionalSections {
heading(level: 3, outlined: false)[#inline-code(extra.heading)]
render-items(extra.items)
}
}
}
#let render-exploit(f) = {
block(breakable: false)[
#heading(level: 2)[#f.id: #inline-code(f.title)]
#sev-chip(f.severity)
#v(8pt)
#render-finding-owasp(f)
]
render-finding-summary-remainder(f)
#if "additionalSections" in f and f.additionalSections != none [
#for extra in f.additionalSections [
#heading(level: 3, outlined: false)[#inline-code(extra.heading)]
#render-items(extra.items)
]
heading(level: 3, outlined: false)[Exploitation Steps]
for step in f.exploitationSteps {
[#text(weight: "semibold")[Step #step.number#if "title" in step and step.title != none [ #inline-code(step.title)]]]
render-items(step.items)
}
heading(level: 3, outlined: false)[Proof of Impact]
render-numbered-items(f.proofOfImpact)
render-remediation(f)
render-finding-extras(f)
v(16pt)
}
#let render-analysis(f) = {
block(breakable: false)[
#heading(level: 2)[#f.id: #inline-code(f.title)]
#sev-chip(f.severity)
#h(4pt)
#confidence-chip(f.confidence)
#v(8pt)
#render-finding-owasp(f)
]
]
#let render-exploit(f) = [
== #f.id: #inline-code(f.title)
#sev-chip(f.severity)
#render-finding-summary(f)
=== Prerequisites
#inline-code(f.prerequisites)
=== Exploitation Steps
#for step in f.exploitationSteps [
#text(weight: "semibold")[Step #step.number#if "title" in step and step.title != none [ #inline-code(step.title)]]
#render-items(step.items)
]
=== Proof of Impact
#render-numbered-items(f.proofOfImpact)
#render-finding-extras(f)
#v(16pt)
]
#let render-analysis(f) = [
== #f.id: #inline-code(f.title)
#sev-chip(f.severity) #h(4pt) #confidence-chip(f.confidence)
#render-finding-summary(f)
#render-finding-extras(f)
#v(16pt)
]
render-finding-summary-remainder(f)
render-remediation(f)
render-finding-extras(f)
v(16pt)
}
#let render-finding(f) = if mode == "exploits" { render-exploit(f) } else { render-analysis(f) }
@@ -546,12 +575,13 @@
"Findings"
}
#pagebreak(weak: true)
#for cat in categories-in-order {
let cat-findings = data.findings.filter(f => f.category == cat)
if cat-findings.len() > 0 [
= #cat #category-section-label(cat-findings.len()) (#cat-findings.len() #if cat-findings.len() == 1 [finding] else [findings])
#for f in cat-findings {
if cat-findings.len() > 0 {
heading(level: 1)[#cat #category-section-label(cat-findings.len()) (#cat-findings.len() #if cat-findings.len() == 1 [finding] else [findings])]
for f in cat-findings {
render-finding(f)
}
]
}
}