mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-05 07:46:49 +02:00
294 lines
9.3 KiB
TypeScript
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);
|
|
}
|