// ============================================================================= // Security Assessment Report — Typst template // Invoke: // typst compile --root --input data=/data.json report.typ out.pdf // Optional overrides: // --input tester= --input brand= // ============================================================================= #let data = json(sys.inputs.data) // Top-level discriminator. Schema variants in report-output-schema.ts: // exploits → ExploitsReportData (exploit=true runs, full reproduction) // findings → FindingsReportData (exploit=false runs, analysis-only) #let mode = data.at("mode", default: "exploits") #let tester-override = sys.inputs.at("tester", default: "Shannon") #let brand = sys.inputs.at("brand", default: "Shannon | AI Pentester by Keygraph") // ---------- Palette --------------------------------------------------------- // Kept distinct so Critical / High are not confused under monitor gamma. #let sev-color(level) = { if level == "Critical" { rgb("#DC2626") } // red-600 else if level == "High" { rgb("#EA580C") } // orange-600 else if level == "Medium" { rgb("#D97706") } // amber-600 else if level == "Low" { rgb("#2563EB") } // blue-600 else { rgb("#6B7280") } } #let confidence-color(c) = { if c == "High" { rgb("#15803D") } // green-700 else if c == "Medium" { rgb("#D97706") } // amber-600 else if c == "Low" { rgb("#6B7280") } // gray-500 else { rgb("#6B7280") } } // Warm, editorial, high-contrast document palette. #let ink = rgb("#141414") // warm near-black text #let muted = rgb("#5C5850") // warm gray-brown labels #let tertiary = rgb("#9A958D") // lightest muted #let rule = rgb("#E6E1D9") // warm hair rules #let rule-soft = rgb("#D9D3CA") #let code-bg = rgb("#F6F1EB") // warm eggshell #let alt-bg = rgb("#EBE6DF") #let page-bg = white // ---------- Page setup ------------------------------------------------------ #set document(title: "Security Assessment Report", author: brand) #set page( paper: "a4", margin: (top: 2.2cm, bottom: 2.2cm, left: 2.2cm, right: 2.2cm), fill: page-bg, header: context { if counter(page).get().first() > 1 [ #set text(size: 8.5pt, fill: muted) #grid(columns: (1fr, auto), [Security Assessment Report], [CONFIDENTIAL], ) #v(-4pt) #line(length: 100%, stroke: 0.3pt + rule) ] }, footer: context { if counter(page).get().first() > 1 [ #set text(size: 8.5pt, fill: muted) #line(length: 100%, stroke: 0.3pt + rule) #v(2pt) #grid(columns: (1fr, auto), [#data.meta.assessmentDate], [#counter(page).display() / #context counter(page).final().first()], ) ] }, ) #set text(size: 10.5pt, fill: ink) #set par(leading: 0.7em, justify: false) #show heading.where(level: 1): it => [ #pagebreak(weak: true) #v(4pt) #set text(size: 24pt, weight: "bold", fill: ink) #it.body #v(4pt) #line(length: 100%, stroke: 0.4pt + rule) #v(10pt) ] #show heading.where(level: 2): it => [ #v(10pt) #set text(size: 14pt, weight: "semibold", fill: ink) #it.body #v(2pt) ] #show heading.where(level: 3): it => [ #v(8pt) #set text(size: 11.5pt, weight: "semibold", fill: ink) #it.body #v(-2pt) ] #show raw: set text(size: 8.5pt) #show raw.where(block: false): it => box( fill: code-bg, inset: (x: 3pt, y: 0pt), outset: (y: 2pt), radius: 2pt, it, ) #show raw.where(block: true): it => block( fill: code-bg, stroke: (left: 2pt + rule, rest: none), inset: (x: 10pt, y: 8pt), width: 100%, breakable: true, { set par(leading: 0.5em, justify: false) it }, ) // ---------- Helpers --------------------------------------------------------- #let chip(label, color) = box( fill: color, inset: (x: 6pt, y: 2pt), radius: 2pt, text(fill: white, weight: "bold", size: 7.5pt, tracking: 0.3pt, upper(label)), ) #let categories-in-order = ( "Authentication", "Authorization", "XSS", "Injection", "SSRF", "Other", ) #let sev-chip(level) = chip(level, sev-color(level)) #let confidence-chip(c) = chip(c + " confidence", confidence-color(c)) // inline-code renders a string, turning backtick-wrapped spans into // inline raw. Safe on odd counts — a trailing unclosed backtick is // emitted as literal text so nothing gets swallowed. #let inline-code(s) = { if type(s) != str { return s } let parts = s.split("`") if parts.len() == 1 { return parts.at(0) } let out = [] for (i, p) in parts.enumerate() { if calc.even(i) { out += [#p] } else if i == parts.len() - 1 { out += [#("`" + p)] } else { out += raw(p) } } out } #let render-items(items) = { for item in items { if item.kind == "prose" [ #par(inline-code(item.text)) ] else if item.kind == "code" [ #raw(item.block.content, lang: item.block.language, block: true) ] } } // Render step items as a bulleted list; prose items become bullets, // code items break the list and render as code blocks in between. #let render-bulleted-items(items) = { 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) ] } } // Render step items as a numbered list; prose items become enumerated, // code items break the list and render as code blocks in between. #let render-numbered-items(items) = { 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) ] } } // Render an array of strings as a bulleted list with inline-code support. #let code-list(items) = list(..items.map(inline-code)) #let kv(label, value) = grid( columns: (auto, 1fr), column-gutter: 14pt, row-gutter: 4pt, text(fill: muted, size: 9.5pt)[#label], value, ) // ---------- COVER PAGE ------------------------------------------------------ #page(header: none, footer: none)[ #set align(left) #v(3.2cm) #let brand-parts = brand.split("|").map(p => p.trim()) #grid( columns: (auto, 1fr), column-gutter: 8pt, align: (horizon, horizon), image("/assets/keygraph-logo.png", width: 1.6cm), { set par(leading: 0.6em) text(size: 11pt, fill: ink, weight: "semibold", tracking: 1.2pt)[ #upper(brand-parts.at(0)) ] if brand-parts.len() > 1 { linebreak() text(size: 9pt, fill: muted, weight: "regular")[ #brand-parts.slice(1).join(" ") ] } } ) #set par(leading: 0.7em) #v(1.6cm) #set par(leading: 0.4em) #text(size: 46pt, weight: "bold", fill: ink)[ Security\ Assessment\ Report ] #set par(leading: 0.7em) #v(1fr) #line(length: 100%, stroke: 0.3pt + rule) #v(0.6cm) #grid( columns: (1fr, 1fr), column-gutter: 28pt, row-gutter: 14pt, grid( columns: (auto, 1fr), column-gutter: 18pt, row-gutter: 14pt, text(fill: muted, size: 9pt)[Target], text(size: 10pt)[#inline-code(data.meta.target)], text(fill: muted, size: 9pt)[Date], text(size: 10pt)[#data.meta.assessmentDate], ..(if "application" in data.meta and data.meta.application != none { (text(fill: muted, size: 9pt)[Application], text(size: 10pt)[#inline-code(data.meta.application)]) } else { () }), ), grid( columns: (auto, 1fr), column-gutter: 18pt, row-gutter: 14pt, text(fill: muted, size: 9pt)[Tester], text(size: 10pt)[#tester-override], text(fill: muted, size: 9pt)[Classification], text(size: 10pt, weight: "semibold")[#data.meta.classification], ), ) #v(0.8cm) #text(size: 8pt, fill: muted)[ This document contains sensitive security findings. Handle in accordance with your organization's data classification policy. ] ] // ---------- TABLE OF CONTENTS ----------------------------------------------- #outline(title: [Contents], depth: 3, indent: auto) // ---------- EXECUTIVE SUMMARY ----------------------------------------------- = Executive Summary #grid( columns: (auto, 1fr), column-gutter: 20pt, row-gutter: 12pt, text(fill: muted, size: 10pt)[Target], text(size: 10.5pt)[#inline-code(data.meta.target)], text(fill: muted, size: 10pt)[Date], text(size: 10.5pt)[#data.meta.assessmentDate], ..(if "application" in data.meta and data.meta.application != none { (text(fill: muted, size: 10pt)[Application], text(size: 10.5pt)[#inline-code(data.meta.application)]) } else { () }), text(fill: muted, size: 10pt)[Tester], text(size: 10.5pt)[#tester-override], ) == Scope #inline-code(data.scope) // ---------- BY TYPE --------------------------------------------------------- #let by-type-entries = if mode == "exploits" { data.exploitedByType } else { data.identifiedByType } #if mode == "exploits" [ = Successfully Exploited Vulnerabilities by Type ] else [ = Identified Vulnerabilities by Type ] #for entry in by-type-entries [ == #entry.category #if "narrative" in entry and entry.narrative != none [ #inline-code(entry.narrative) ] #if "bullets" in entry and entry.bullets != none [ #list( ..entry.bullets.map(b => [ #text(weight: "semibold")[#b.id] — #inline-code(b.description) ]) ) ] ] // ---------- SUMMARY --------------------------------------------------------- = Summary #let s = data.summary #let sev = data.derivedCounts.bySeverity #let severity-card(label, sev-key, n) = box( fill: sev-color(sev-key), inset: (x: 8pt, y: 12pt), radius: 4pt, width: 100%, stack( dir: ttb, spacing: 6pt, text(fill: white, weight: "bold", size: 20pt)[#n], text(fill: white, size: 8pt, tracking: 0.5pt)[#upper(label)], ), ) #grid( columns: 4, column-gutter: 8pt, severity-card("Critical", "Critical", sev.Critical), severity-card("High", "High", sev.High), severity-card("Medium", "Medium", sev.Medium), severity-card("Low", "Low", sev.Low), ) #if mode == "findings" [ #v(18pt) #let cf = data.derivedCounts.byConfidence #let confidence-card(label, c-key, n) = box( stroke: 0.6pt + confidence-color(c-key), inset: (x: 8pt, y: 12pt), radius: 4pt, width: 100%, stack( dir: ttb, spacing: 6pt, text(fill: ink, weight: "bold", size: 20pt)[#n], text(fill: confidence-color(c-key), size: 8pt, tracking: 0.5pt)[#upper(label + " confidence")], ), ) #grid( columns: 3, column-gutter: 8pt, confidence-card("High", "High", cf.High), confidence-card("Medium", "Medium", cf.Medium), confidence-card("Low", "Low", cf.Low), ) ] #v(14pt) #if mode == "exploits" [ #grid( columns: (auto, 1fr), column-gutter: 14pt, row-gutter: 4pt, text(fill: muted, size: 10pt)[Total identified], text(weight: "semibold")[#s.totalIdentified], text(fill: muted, size: 10pt)[Successfully exploited], text(weight: "semibold")[#s.successfullyExploited], ) ] else [ #grid( columns: (auto, 1fr), column-gutter: 14pt, row-gutter: 4pt, text(fill: muted, size: 10pt)[Total identified], text(weight: "semibold")[#s.totalIdentified], ) ] #v(8pt) #let breakdown = if mode == "exploits" { s.exploitedBreakdown } else { s.identifiedBreakdown } #list( ..breakdown.map(c => [ #text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)] ]) ) #if mode == "exploits" [ #if "outOfScope" in s and s.outOfScope != none [ #v(4pt) #text(weight: "semibold")[Out of Scope#if "note" in s.outOfScope and s.outOfScope.note != none [ (#s.outOfScope.note)]:] #s.outOfScope.total vulnerabilities #if "breakdown" in s.outOfScope and s.outOfScope.breakdown != none [ #list( ..s.outOfScope.breakdown.map(c => [ #text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)] ]) ) ] ] #if "blockedByConstraints" in s and s.blockedByConstraints != none [ #v(4pt) #text(weight: "semibold")[Blocked by Testing Constraints:] #s.blockedByConstraints.total#if "note" in s.blockedByConstraints and s.blockedByConstraints.note != none [ — #s.blockedByConstraints.note] ] ] == Critical Findings #enum(..s.criticalFindings.map(f => [#inline-code(f)])) // ---------- FINDINGS OVERVIEW ----------------------------------------------- = Findings Overview #let show-confidence-col = mode == "findings" #table( columns: if show-confidence-col { (auto, 1fr, auto, auto, auto) } else { (auto, 1fr, auto, auto) }, stroke: none, inset: (x: 8pt, y: 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( text(size: 9.5pt, weight: "semibold")[ID], text(size: 9.5pt, weight: "semibold")[Title], text(size: 9.5pt, weight: "semibold")[Category], text(size: 9.5pt, weight: "semibold")[Severity], ..(if show-confidence-col { (text(size: 9.5pt, weight: "semibold")[Confidence],) } else { () }), ), ..data.findings.map(f => ( text(weight: "semibold")[#f.id], inline-code(f.title), text(size: 9.5pt)[#f.category], sev-chip(f.severity), ..(if show-confidence-col { (confidence-chip(f.confidence),) } else { () }), )).flatten() ) // ---------- FINDING RENDER -------------------------------------------------- #let render-finding-summary(f) = [ #v(8pt) #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)], 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) ] #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) = [ == #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) ] #let render-finding(f) = if mode == "exploits" { render-exploit(f) } else { render-analysis(f) } // ---------- PER-CATEGORY ----------------------------------------------------- #let category-section-label(n) = if mode == "exploits" { "Exploitation Evidence" } else { "Findings" } #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 { render-finding(f) } ] }