mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-16 08:20:39 +02:00
feat(worker): render PDF security reports via Typst (#421)
This commit is contained in:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user