mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-13 21:49:04 +02:00
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:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user