Files
shannon/apps/worker/src/services/report-json-adapter.ts
T

294 lines
9.3 KiB
TypeScript

// 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);
}