diff --git a/apps/worker/src/services/report-json-adapter.ts b/apps/worker/src/services/report-json-adapter.ts index cd92c42c..f829be22 100644 --- a/apps/worker/src/services/report-json-adapter.ts +++ b/apps/worker/src/services/report-json-adapter.ts @@ -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 && { diff --git a/apps/worker/src/services/report-output-schema.ts b/apps/worker/src/services/report-output-schema.ts index 9c778607..12b76e0c 100644 --- a/apps/worker/src/services/report-output-schema.ts +++ b/apps/worker/src/services/report-output-schema.ts @@ -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[]; } diff --git a/apps/worker/templates/typst/report.typ b/apps/worker/templates/typst/report.typ index 87ab88ae..9a31e1fa 100644 --- a/apps/worker/templates/typst/report.typ +++ b/apps/worker/templates/typst/report.typ @@ -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) } - ] + } }