mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-14 15:30:25 +02:00
feat(worker): render PDF security reports via Typst (#421)
This commit is contained in:
+18
@@ -52,6 +52,8 @@ RUN apk update && apk add --no-cache \
|
||||
curl \
|
||||
ca-certificates \
|
||||
shadow \
|
||||
# Typst tarball decompression
|
||||
xz \
|
||||
# Language runtimes (minimal)
|
||||
nodejs-22 \
|
||||
npm \
|
||||
@@ -73,6 +75,22 @@ RUN apk update && apk add --no-cache \
|
||||
# Font rendering
|
||||
fontconfig
|
||||
|
||||
# Install Typst (report PDF compilation)
|
||||
ARG TYPST_VERSION=0.14.2
|
||||
RUN case "$(uname -m)" in \
|
||||
x86_64) TYPST_ARCH=x86_64-unknown-linux-musl ;; \
|
||||
aarch64) TYPST_ARCH=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported arch $(uname -m)" && exit 1 ;; \
|
||||
esac && \
|
||||
mkdir -p /tmp/typst-dl /usr/local/bin && cd /tmp/typst-dl && \
|
||||
curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" -o typst.tar.xz && \
|
||||
xz -d typst.tar.xz && \
|
||||
tar -xf typst.tar && \
|
||||
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
|
||||
chmod +x /usr/local/bin/typst && \
|
||||
cd / && rm -rf /tmp/typst-dl && \
|
||||
typst --version
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 pentest && \
|
||||
adduser -u 1001 -G pentest -s /bin/bash -D pentest
|
||||
|
||||
@@ -13,7 +13,7 @@ import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validat
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
@@ -249,7 +249,7 @@ function printInfo(
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_FILENAME);
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
|
||||
console.log(' Scan started — it runs in the background, so you can close this terminal.');
|
||||
console.log('');
|
||||
|
||||
@@ -23,10 +23,10 @@ export interface MountPair {
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/**
|
||||
* Filename of the human-facing final report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_FILENAME in the worker package.
|
||||
* Filename of the human-facing PDF report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_PDF_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
|
||||
@@ -9,6 +9,9 @@ const WORKER_ROOT = path.resolve(import.meta.dirname, '..');
|
||||
export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts');
|
||||
export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs');
|
||||
|
||||
/** Bundled Typst template that renders report.json into the PDF report. */
|
||||
export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'report.typ');
|
||||
|
||||
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
|
||||
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');
|
||||
|
||||
@@ -28,8 +31,11 @@ export const INTERNAL_DIR = '.shannon';
|
||||
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
|
||||
|
||||
/** Filename of the human-facing final report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
/** Filename of the compiled PDF report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_report.pdf';
|
||||
|
||||
/** Filename of the human-facing PDF report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/** Structured findings the report agent emits; the markdown report is rendered from it. */
|
||||
export const REPORT_JSON_FILENAME = 'report.json';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Typst PDF renderer.
|
||||
*
|
||||
* Adapts the structured report.json into the Typst-shaped schema and compiles
|
||||
* it to a PDF with the bundled report.typ template. Compilation runs in an
|
||||
* isolated temp dir: the template is copied in and the adapted JSON is written
|
||||
* beside it so `--root` can scope every file read to that dir, matching how the
|
||||
* template resolves `--input data=/data.json`.
|
||||
*
|
||||
* The `typst` binary is installed in the worker image and resolved from PATH.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { adaptReportToTypst } from './report-json-adapter.js';
|
||||
import type { ReportData } from './report-renderer.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TESTER = 'Shannon';
|
||||
const DEFAULT_BRAND = 'Shannon | AI Pentester by Keygraph';
|
||||
|
||||
const DATA_FILENAME = 'data.json';
|
||||
const TEMPLATE_FILENAME = 'report.typ';
|
||||
const OUTPUT_FILENAME = 'report.pdf';
|
||||
|
||||
export interface RenderReportPdfOptions {
|
||||
/** Structured report data (report.json contents), pre-assembly. */
|
||||
readonly reportData: ReportData;
|
||||
/** Absolute path to the bundled report.typ template. */
|
||||
readonly templatePath: string;
|
||||
/** Absolute path where the compiled PDF should be written. */
|
||||
readonly outputPath: string;
|
||||
/** Name shown on the cover/footer. Defaults to "Shannon". */
|
||||
readonly tester?: string;
|
||||
/** Wordmark shown on the cover. Defaults to "Shannon | AI Pentester by Keygraph". */
|
||||
readonly brand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the report to a PDF at `outputPath`.
|
||||
*
|
||||
* Throws if adaptation or `typst compile` fails; callers treat the PDF as a
|
||||
* secondary artifact and should not let a failure here fail the run.
|
||||
*/
|
||||
export async function renderReportPdf(options: RenderReportPdfOptions): Promise<void> {
|
||||
const { reportData, templatePath, outputPath } = options;
|
||||
const tester = options.tester ?? DEFAULT_TESTER;
|
||||
const brand = options.brand ?? DEFAULT_BRAND;
|
||||
|
||||
const typstData = adaptReportToTypst(reportData);
|
||||
|
||||
const workDir = await mkdtemp(path.join(tmpdir(), 'shannon-typst-'));
|
||||
try {
|
||||
const templateInWorkDir = path.join(workDir, TEMPLATE_FILENAME);
|
||||
const dataInWorkDir = path.join(workDir, DATA_FILENAME);
|
||||
const pdfInWorkDir = path.join(workDir, OUTPUT_FILENAME);
|
||||
|
||||
await copyFile(templatePath, templateInWorkDir);
|
||||
|
||||
// Ship the template's assets (e.g. the cover logo) so `--root`-scoped image reads resolve.
|
||||
const assetsDir = path.join(path.dirname(templatePath), 'assets');
|
||||
if (existsSync(assetsDir)) {
|
||||
await cp(assetsDir, path.join(workDir, 'assets'), { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(dataInWorkDir, JSON.stringify(typstData), 'utf-8');
|
||||
|
||||
await execFileAsync('typst', [
|
||||
'compile',
|
||||
'--root',
|
||||
workDir,
|
||||
'--input',
|
||||
`data=/${DATA_FILENAME}`,
|
||||
'--input',
|
||||
`tester=${tester}`,
|
||||
'--input',
|
||||
`brand=${brand}`,
|
||||
templateInWorkDir,
|
||||
pdfInWorkDir,
|
||||
]);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await copyFile(pdfInWorkDir, outputPath);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Programmatic adapter: report.json → Typst ReportData JSON.
|
||||
*
|
||||
* Converts the renderer-neutral structured report output (produced by the
|
||||
* finding-collector + set-report-meta CLI) into the Typst-specific schema that
|
||||
* report.typ consumes.
|
||||
*
|
||||
* All Typst-specific concepts (PascalCase enums, computed aggregations,
|
||||
* exploitedByType grouping) are confined to this file. The rest of the
|
||||
* pipeline knows nothing about the Typst shape.
|
||||
*/
|
||||
|
||||
import type { AddFindingInput, AdditionalSection, StepItem, StructuredStep } from '../collectors/finding-collector.js';
|
||||
import type {
|
||||
ExploitsReportData,
|
||||
FindingsReportData,
|
||||
TypstCategory,
|
||||
TypstConfidence,
|
||||
ReportData as TypstReportData,
|
||||
TypstSeverity,
|
||||
TypstStatus,
|
||||
} from './report-output-schema.js';
|
||||
import type { ReportData } from './report-renderer.js';
|
||||
|
||||
// ============================================================================
|
||||
// CASING TRANSFORMS
|
||||
// ============================================================================
|
||||
|
||||
const SEVERITY_MAP: Record<string, TypstSeverity> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, TypstStatus> = {
|
||||
exploited: 'Exploited',
|
||||
out_of_scope: 'OutOfScope',
|
||||
blocked_by_constraints: 'BlockedByConstraints',
|
||||
false_positive: 'FalsePositive',
|
||||
};
|
||||
|
||||
const CONFIDENCE_MAP: Record<string, TypstConfidence> = {
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
const VALID_CATEGORIES = new Set<TypstCategory>([
|
||||
'Authentication',
|
||||
'Authorization',
|
||||
'XSS',
|
||||
'Injection',
|
||||
'SSRF',
|
||||
'Other',
|
||||
]);
|
||||
|
||||
function toTypstSeverity(s: string): TypstSeverity {
|
||||
return SEVERITY_MAP[s] ?? 'Low';
|
||||
}
|
||||
|
||||
function toTypstStatus(s: string): TypstStatus {
|
||||
return STATUS_MAP[s] ?? 'Exploited';
|
||||
}
|
||||
|
||||
function toTypstConfidence(s: string): TypstConfidence {
|
||||
return CONFIDENCE_MAP[s] ?? 'Medium';
|
||||
}
|
||||
|
||||
function toTypstCategory(s: string): TypstCategory {
|
||||
if (VALID_CATEGORIES.has(s as TypstCategory)) return s as TypstCategory;
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STEP / ITEM TRANSFORMS
|
||||
// ============================================================================
|
||||
|
||||
function adaptStepItem(item: StepItem): StepItem {
|
||||
return item;
|
||||
}
|
||||
|
||||
function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: StepItem[] } {
|
||||
return {
|
||||
number: index + 1,
|
||||
...(step.title && { title: step.title }),
|
||||
items: step.items.map(adaptStepItem),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: StepItem[] } {
|
||||
return {
|
||||
heading: section.heading,
|
||||
items: section.items.map(adaptStepItem),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGGREGATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
interface CategoryGroup {
|
||||
category: TypstCategory;
|
||||
findings: AddFindingInput[];
|
||||
}
|
||||
|
||||
function groupByCategory(findings: readonly AddFindingInput[]): CategoryGroup[] {
|
||||
const map = new Map<TypstCategory, AddFindingInput[]>();
|
||||
for (const f of findings) {
|
||||
const cat = toTypstCategory(f.category);
|
||||
const list = map.get(cat) ?? [];
|
||||
list.push(f);
|
||||
map.set(cat, list);
|
||||
}
|
||||
return Array.from(map.entries()).map(([category, fs]) => ({ category, findings: fs }));
|
||||
}
|
||||
|
||||
function countBySeverity(findings: readonly AddFindingInput[]): Record<TypstSeverity, number> {
|
||||
const counts: Record<string, number> = {
|
||||
Critical: 0,
|
||||
High: 0,
|
||||
Medium: 0,
|
||||
Low: 0,
|
||||
};
|
||||
for (const f of findings) {
|
||||
const sev = toTypstSeverity(f.severity);
|
||||
counts[sev] = (counts[sev] ?? 0) + 1;
|
||||
}
|
||||
return counts as Record<TypstSeverity, number>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPLOIT MODE ADAPTER
|
||||
// ============================================================================
|
||||
|
||||
function adaptExploitsMode(data: ReportData): ExploitsReportData {
|
||||
const { report_meta, findings } = data;
|
||||
const groups = groupByCategory(findings);
|
||||
const sevCounts = countBySeverity(findings);
|
||||
|
||||
const statusCounts = { Exploited: 0, OutOfScope: 0, BlockedByConstraints: 0, FalsePositive: 0 };
|
||||
for (const f of findings) {
|
||||
const s = toTypstStatus(f.status ?? 'exploited');
|
||||
statusCounts[s]++;
|
||||
}
|
||||
|
||||
const exploitedFindings = findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
|
||||
|
||||
return {
|
||||
mode: 'exploits' as const,
|
||||
meta: {
|
||||
target: report_meta.target,
|
||||
assessmentDate: report_meta.assessment_date,
|
||||
classification: 'CONFIDENTIAL',
|
||||
},
|
||||
scope: report_meta.scope,
|
||||
exploitedByType: groups.map((g) => {
|
||||
const exploited = g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
|
||||
if (exploited.length === 0) {
|
||||
return {
|
||||
category: g.category,
|
||||
narrative: `No ${g.category.toLowerCase()} vulnerabilities were successfully exploited during this assessment.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: g.category,
|
||||
bullets: exploited.map((f) => ({ id: f.finding_id, description: f.title })),
|
||||
};
|
||||
}),
|
||||
summary: {
|
||||
totalIdentified: findings.length,
|
||||
successfullyExploited: exploitedFindings.length,
|
||||
exploitedBreakdown: groups
|
||||
.map((g) => ({
|
||||
category: g.category,
|
||||
count: g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited').length,
|
||||
}))
|
||||
.filter((e) => e.count > 0),
|
||||
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
|
||||
},
|
||||
findings: findings.map((f) => ({
|
||||
id: f.finding_id,
|
||||
title: f.title,
|
||||
category: toTypstCategory(f.category),
|
||||
severity: toTypstSeverity(f.severity),
|
||||
status: toTypstStatus(f.status ?? 'exploited'),
|
||||
summary: {
|
||||
vulnerableLocation: f.vulnerable_location,
|
||||
overview: f.overview,
|
||||
impact: f.impact,
|
||||
},
|
||||
// This branch only runs for an exploitative report, where the schema made these
|
||||
// required. The fallbacks keep the superset type honest rather than assuming.
|
||||
prerequisites: f.prerequisites ?? '',
|
||||
exploitationSteps: (f.exploitation_steps ?? []).map(adaptStep),
|
||||
proofOfImpact: (f.proof_of_impact ?? []).map(adaptStepItem),
|
||||
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
|
||||
...(f.additional_sections &&
|
||||
f.additional_sections.length > 0 && {
|
||||
additionalSections: f.additional_sections.map(adaptAdditionalSection),
|
||||
}),
|
||||
})),
|
||||
derivedCounts: {
|
||||
bySeverity: sevCounts,
|
||||
byStatus: statusCounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FINDINGS MODE ADAPTER
|
||||
// ============================================================================
|
||||
|
||||
function adaptFindingsMode(data: ReportData): FindingsReportData {
|
||||
const { report_meta, findings } = data;
|
||||
const groups = groupByCategory(findings);
|
||||
const sevCounts = countBySeverity(findings);
|
||||
|
||||
const confidenceCounts = { High: 0, Medium: 0, Low: 0 };
|
||||
for (const f of findings) {
|
||||
const c = toTypstConfidence(f.confidence ?? 'medium');
|
||||
confidenceCounts[c]++;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'findings' as const,
|
||||
meta: {
|
||||
target: report_meta.target,
|
||||
assessmentDate: report_meta.assessment_date,
|
||||
classification: 'CONFIDENTIAL',
|
||||
},
|
||||
scope: report_meta.scope,
|
||||
identifiedByType: groups.map((g) => {
|
||||
if (g.findings.length === 0) {
|
||||
return {
|
||||
category: g.category,
|
||||
narrative: `No ${g.category.toLowerCase()} vulnerabilities were identified during this assessment.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: g.category,
|
||||
bullets: g.findings.map((f) => ({ id: f.finding_id, description: f.title })),
|
||||
};
|
||||
}),
|
||||
summary: {
|
||||
totalIdentified: findings.length,
|
||||
identifiedBreakdown: groups.map((g) => ({
|
||||
category: g.category,
|
||||
count: g.findings.length,
|
||||
})),
|
||||
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
|
||||
},
|
||||
findings: findings.map((f) => ({
|
||||
id: f.finding_id,
|
||||
title: f.title,
|
||||
category: toTypstCategory(f.category),
|
||||
severity: toTypstSeverity(f.severity),
|
||||
confidence: toTypstConfidence(f.confidence ?? 'medium'),
|
||||
summary: {
|
||||
vulnerableLocation: f.vulnerable_location,
|
||||
overview: f.overview,
|
||||
impact: f.impact,
|
||||
},
|
||||
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
|
||||
...(f.additional_sections &&
|
||||
f.additional_sections.length > 0 && {
|
||||
additionalSections: f.additional_sections.map(adaptAdditionalSection),
|
||||
}),
|
||||
})),
|
||||
derivedCounts: {
|
||||
bySeverity: sevCounts,
|
||||
byConfidence: confidenceCounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API
|
||||
// ============================================================================
|
||||
|
||||
export function adaptReportToTypst(data: ReportData): TypstReportData {
|
||||
const exploitEnabled = data.report_meta.exploit ?? true;
|
||||
if (exploitEnabled) {
|
||||
return adaptExploitsMode(data);
|
||||
}
|
||||
return adaptFindingsMode(data);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* TypeScript types for the structured report the Typst template consumes, in two
|
||||
* shapes keyed by a `mode` discriminator: `exploits` (exploit=true) and `findings`
|
||||
* (exploit=false, analysis-only). Types only — the object is built programmatically
|
||||
* in report-json-adapter.ts, so these exist to keep the adapter and report.typ in sync.
|
||||
*/
|
||||
|
||||
// === Shared primitives ===
|
||||
|
||||
export type TypstSeverity = 'Critical' | 'High' | 'Medium' | 'Low';
|
||||
export type TypstStatus = 'Exploited' | 'OutOfScope' | 'BlockedByConstraints' | 'FalsePositive';
|
||||
export type TypstConfidence = 'High' | 'Medium' | 'Low';
|
||||
export type TypstCategory = 'Authentication' | 'Authorization' | 'XSS' | 'Injection' | 'SSRF' | 'Other';
|
||||
|
||||
export interface CodeBlock {
|
||||
readonly language: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export type StepItem =
|
||||
| { readonly kind: 'prose'; readonly text: string }
|
||||
| { readonly kind: 'code'; readonly block: CodeBlock };
|
||||
|
||||
export interface Step {
|
||||
readonly number: number;
|
||||
readonly title?: string;
|
||||
readonly items: readonly StepItem[];
|
||||
}
|
||||
|
||||
export interface AdditionalSection {
|
||||
readonly heading: string;
|
||||
readonly items: readonly StepItem[];
|
||||
}
|
||||
|
||||
export interface FindingSummary {
|
||||
readonly vulnerableLocation: string;
|
||||
readonly overview: string;
|
||||
readonly impact: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
readonly target: string;
|
||||
readonly assessmentDate: string;
|
||||
readonly tester?: string;
|
||||
readonly application?: string;
|
||||
readonly classification: string;
|
||||
}
|
||||
|
||||
export interface CategoryCount {
|
||||
readonly category: TypstCategory;
|
||||
readonly count: number;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
export type SeverityCounts = Record<TypstSeverity, number>;
|
||||
export type StatusCounts = Record<TypstStatus, number>;
|
||||
export type ConfidenceCounts = Record<TypstConfidence, number>;
|
||||
|
||||
export interface TypeEntryBullet {
|
||||
readonly id: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
// === Exploits-mode schema ===
|
||||
|
||||
export interface ExploitFinding {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly category: TypstCategory;
|
||||
readonly severity: TypstSeverity;
|
||||
readonly status: TypstStatus;
|
||||
readonly summary: FindingSummary;
|
||||
readonly prerequisites: string;
|
||||
readonly exploitationSteps: readonly Step[];
|
||||
readonly proofOfImpact: readonly StepItem[];
|
||||
readonly notes?: readonly StepItem[];
|
||||
readonly additionalSections?: readonly AdditionalSection[];
|
||||
}
|
||||
|
||||
export interface ExploitedByTypeEntry {
|
||||
readonly category: TypstCategory;
|
||||
readonly bullets?: readonly TypeEntryBullet[];
|
||||
readonly narrative?: string;
|
||||
}
|
||||
|
||||
export interface ExploitsReportData {
|
||||
readonly mode: 'exploits';
|
||||
readonly meta: Meta;
|
||||
readonly scope: string;
|
||||
readonly exploitedByType: readonly ExploitedByTypeEntry[];
|
||||
readonly summary: {
|
||||
readonly totalIdentified: number;
|
||||
readonly successfullyExploited: number;
|
||||
readonly exploitedBreakdown: readonly CategoryCount[];
|
||||
readonly outOfScope?: {
|
||||
readonly total: number;
|
||||
readonly breakdown?: readonly CategoryCount[];
|
||||
readonly note?: string;
|
||||
};
|
||||
readonly blockedByConstraints?: {
|
||||
readonly total: number;
|
||||
readonly note?: string;
|
||||
};
|
||||
readonly criticalFindings: readonly string[];
|
||||
};
|
||||
readonly findings: readonly ExploitFinding[];
|
||||
readonly derivedCounts: {
|
||||
readonly bySeverity: SeverityCounts;
|
||||
readonly byStatus: StatusCounts;
|
||||
};
|
||||
}
|
||||
|
||||
// === Findings-mode schema (analysis-only, exploit=false runs) ===
|
||||
|
||||
export interface AnalysisFinding {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly category: TypstCategory;
|
||||
readonly severity: TypstSeverity;
|
||||
readonly confidence: TypstConfidence;
|
||||
readonly summary: FindingSummary;
|
||||
readonly notes?: readonly StepItem[];
|
||||
readonly additionalSections?: readonly AdditionalSection[];
|
||||
}
|
||||
|
||||
export interface IdentifiedByTypeEntry {
|
||||
readonly category: TypstCategory;
|
||||
readonly bullets?: readonly TypeEntryBullet[];
|
||||
readonly narrative?: string;
|
||||
}
|
||||
|
||||
export interface FindingsReportData {
|
||||
readonly mode: 'findings';
|
||||
readonly meta: Meta;
|
||||
readonly scope: string;
|
||||
readonly identifiedByType: readonly IdentifiedByTypeEntry[];
|
||||
readonly summary: {
|
||||
readonly totalIdentified: number;
|
||||
readonly identifiedBreakdown: readonly CategoryCount[];
|
||||
readonly criticalFindings: readonly string[];
|
||||
};
|
||||
readonly findings: readonly AnalysisFinding[];
|
||||
readonly derivedCounts: {
|
||||
readonly bySeverity: SeverityCounts;
|
||||
readonly byConfidence: ConfidenceCounts;
|
||||
};
|
||||
}
|
||||
|
||||
// === Discriminated union for downstream consumers that handle both ===
|
||||
|
||||
export type ReportData = ExploitsReportData | FindingsReportData;
|
||||
@@ -7,8 +7,9 @@
|
||||
import { fs, path } from 'zx';
|
||||
import {
|
||||
ASSEMBLED_REPORT_FILENAME,
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
deliverablesDir,
|
||||
FINAL_REPORT_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
SARIF_FILENAME,
|
||||
} from '../paths.js';
|
||||
@@ -174,7 +175,8 @@ export async function injectModelIntoReport(
|
||||
/**
|
||||
* Surface the run's deliverables at the run directory's top level, so a customer opening the run
|
||||
* folder sees the report without digging through internals. Sources stay in the deliverables dir
|
||||
* (git-checkpointed, used by resume).
|
||||
* (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the
|
||||
* markdown remains in the deliverables dir but is not surfaced.
|
||||
*
|
||||
* The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable
|
||||
* path and cannot be expected to reach into the internals directory. It is absent whenever the
|
||||
@@ -188,13 +190,13 @@ export async function copyReportToRunRoot(
|
||||
): Promise<void> {
|
||||
const dir = deliverablesDir(repoPath, deliverablesSubdir);
|
||||
|
||||
const source = path.join(dir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (await fs.pathExists(source)) {
|
||||
const destination = path.join(runDir, FINAL_REPORT_FILENAME);
|
||||
await fs.copy(source, destination, { overwrite: true });
|
||||
logger.info(`Surfaced report at ${destination}`);
|
||||
const pdfSource = path.join(dir, ASSEMBLED_REPORT_PDF_FILENAME);
|
||||
if (await fs.pathExists(pdfSource)) {
|
||||
const destination = path.join(runDir, FINAL_REPORT_PDF_FILENAME);
|
||||
await fs.copy(pdfSource, destination, { overwrite: true });
|
||||
logger.info(`Surfaced PDF report at ${destination}`);
|
||||
} else {
|
||||
logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`);
|
||||
logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
}
|
||||
|
||||
const sarifSource = path.join(dir, SARIF_FILENAME);
|
||||
|
||||
@@ -154,7 +154,7 @@ function buildMessageMarkdown(finding: AddFindingInput): string {
|
||||
parts.push('', '**Remediation**', '', finding.remediation);
|
||||
// Exploitation steps and proof of impact are deliberately absent: SARIF has no structural home
|
||||
// for them, and flattening them into prose would imply this file carries the evidence.
|
||||
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.md`');
|
||||
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.pdf`');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,13 @@ import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||
import {
|
||||
ASSEMBLED_REPORT_FILENAME,
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
DEFAULT_DELIVERABLES_SUBDIR,
|
||||
deliverablesDir,
|
||||
REPORT_JSON_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
SARIF_FILENAME,
|
||||
TYPST_TEMPLATE,
|
||||
} from '../paths.js';
|
||||
import { getAgentGitPaths } from '../services/agent-git-paths.js';
|
||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||
@@ -477,6 +479,30 @@ async function writeSarifIfEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the PDF report from the assembled report data.
|
||||
*
|
||||
* Failures are logged and swallowed — the PDF is a secondary artifact and must not fail a run
|
||||
* whose report is already written.
|
||||
*/
|
||||
async function writePdfReport(
|
||||
reportData: ReportData,
|
||||
deliverablesPath: string,
|
||||
logger: ReturnType<typeof createActivityLogger>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { renderReportPdf } = await import('../services/pdf-renderer.js');
|
||||
await renderReportPdf({
|
||||
reportData,
|
||||
templatePath: TYPST_TEMPLATE,
|
||||
outputPath: path.join(deliverablesPath, ASSEMBLED_REPORT_PDF_FILENAME),
|
||||
});
|
||||
logger.info(`Wrote ${ASSEMBLED_REPORT_PDF_FILENAME}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to write ${ASSEMBLED_REPORT_PDF_FILENAME}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runReportAgent(input: ActivityInput, exploit: boolean): Promise<AgentMetrics> {
|
||||
const { createFindingCollector } = await import('../collectors/finding-collector.js');
|
||||
const { renderReport } = await import('../services/report-renderer.js');
|
||||
@@ -532,6 +558,7 @@ export async function runReportAgent(input: ActivityInput, exploit: boolean): Pr
|
||||
await atomicWrite(path.join(deliverablesPath, ASSEMBLED_REPORT_FILENAME), renderReport(reportData));
|
||||
logger.info(`Wrote ${ASSEMBLED_REPORT_FILENAME} from structured data`);
|
||||
|
||||
await writePdfReport(reportData, deliverablesPath, logger);
|
||||
await writeSarifIfEnabled(input, exploit, reportData, deliverablesPath, logger);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker
|
||||
import dotenv from 'dotenv';
|
||||
import { sanitizeHostname } from '../audit/utils.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
|
||||
import {
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
deliverablesDir,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
} from '../paths.js';
|
||||
import type { VulnClass } from '../types/config.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import * as activities from './activities.js';
|
||||
@@ -389,9 +394,9 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
|
||||
}
|
||||
|
||||
// Surface the report under its human-facing name alongside the raw deliverables
|
||||
const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (fs.existsSync(assembledReport)) {
|
||||
fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME));
|
||||
const assembledPdf = path.join(outputDir, ASSEMBLED_REPORT_PDF_FILENAME);
|
||||
if (fs.existsSync(assembledPdf)) {
|
||||
fs.copyFileSync(assembledPdf, path.join(outputPath, FINAL_REPORT_PDF_FILENAME));
|
||||
}
|
||||
|
||||
console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,535 @@
|
||||
// =============================================================================
|
||||
// Security Assessment Report — Typst template
|
||||
// Invoke:
|
||||
// typst compile --root <root> --input data=/data.json report.typ out.pdf
|
||||
// Optional overrides:
|
||||
// --input tester=<name> --input brand=<name>
|
||||
// =============================================================================
|
||||
|
||||
#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)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -115,7 +115,7 @@ A finding carries one rating or the other, never both: an exploited finding is r
|
||||
|
||||
### SARIF Output
|
||||
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
|
||||
```yaml
|
||||
exploit: "true"
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ Output structure — the run directory's top level holds only the final report;
|
||||
|
||||
```text
|
||||
workspaces/{hostname}_{sessionId}/
|
||||
|-- Security-Assessment-Report.md # the final report (the deliverable)
|
||||
|-- Security-Assessment-Report.pdf # the final report (the deliverable)
|
||||
`-- .shannon/ # internals
|
||||
|-- deliverables/ # report source, per-phase analysis, queues
|
||||
|-- agents/ # per-agent logs
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo
|
||||
- Use `-w <name>` to give a run a custom name.
|
||||
- To resume a run, pass the same workspace name with `-w`.
|
||||
- Each agent's progress is checkpointed so resumed runs can skip completed work.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
|
||||
> [!NOTE]
|
||||
> The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination.
|
||||
|
||||
+3
-3
@@ -401,7 +401,7 @@ Output structure — the run directory's top level holds only the final report;
|
||||
|
||||
```text
|
||||
workspaces/{hostname}_{sessionId}/
|
||||
|-- Security-Assessment-Report.md # the final report (the deliverable)
|
||||
|-- Security-Assessment-Report.pdf # the final report (the deliverable)
|
||||
`-- .shannon/ # internals
|
||||
|-- deliverables/ # report source, per-phase analysis, queues
|
||||
|-- agents/ # per-agent logs
|
||||
@@ -532,7 +532,7 @@ A finding carries one rating or the other, never both: an exploited finding is r
|
||||
|
||||
### SARIF Output
|
||||
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
|
||||
```yaml
|
||||
exploit: "true"
|
||||
@@ -861,7 +861,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo
|
||||
- Use `-w <name>` to give a run a custom name.
|
||||
- To resume a run, pass the same workspace name with `-w`.
|
||||
- Each agent's progress is checkpointed so resumed runs can skip completed work.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
|
||||
> [!NOTE]
|
||||
> The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination.
|
||||
|
||||
Reference in New Issue
Block a user