Assessor_2.0/server/compliance/iso-pdf-corpus.ts

335 lines
14 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { getDocument, PermissionFlag } from 'pdfjs-dist/legacy/build/pdf.mjs';
import type {
RegulatoryControlDocument,
RegulatoryControlTheme,
RegulatoryCorpusV2,
RegulatorySectionKind
} from '../../src/compliance/regulatory-rag';
export const ISO_PDF_PARSER_VERSION = '1.0.0';
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
const EXPECTED_THEME_COUNTS: Record<RegulatoryControlTheme, number> = {
organizational: 37,
people: 8,
physical: 14,
technological: 34
};
export interface PdfSpan {
text: string;
page: number;
x: number;
y: number;
width: number;
height: number;
}
interface PdfJsTextItem {
str: string;
transform: unknown[];
width: number;
height: number;
}
export interface PdfLine {
text: string;
page: number;
y: number;
spans: PdfSpan[];
}
export interface ParsedPdf {
sha256: string;
pageCount: number;
pages: Array<{ page: number; lines: PdfLine[] }>;
}
export interface ParsedSection {
kind: RegulatorySectionKind;
text: string;
pageStart: number;
pageEnd: number;
}
export interface ParsedControl {
controlId: string;
title: string;
theme: RegulatoryControlTheme;
sections: ParsedSection[];
}
export interface IsoPdfCorpusOptions {
iso27001Path: string;
iso27002Path: string;
corpusId?: string;
corpusVersion?: string;
generatedAt?: string;
}
export async function buildIsoPdfCorpus(options: IsoPdfCorpusOptions): Promise<RegulatoryCorpusV2> {
const [iso27001, iso27002] = await Promise.all([
extractPdf(options.iso27001Path, 'ISO/IEC 27001'),
extractPdf(options.iso27002Path, 'ISO/IEC 27002')
]);
const references = parseIso27001AnnexA(iso27001);
const semanticControls = parseIso27002Controls(iso27002);
validateIsoControlSets(references, semanticControls);
const corpusId = options.corpusId?.trim() || 'iso-27001-27002-de';
const combinedHash = sha256(`${iso27001.sha256}\n${iso27002.sha256}`);
const corpusVersion = options.corpusVersion?.trim() || `din-2024-de-${combinedHash.slice(0, 12)}`;
const common = { corpusId, corpusVersion, language: 'de', parserVersion: ISO_PDF_PARSER_VERSION } as const;
const documents: RegulatoryControlDocument[] = [];
for (const control of semanticControls) {
const reference = references.find(item => item.controlId === control.controlId)!;
documents.push(documentForSection(common, control, reference.sections[0], {
sourceStandard: 'ISO/IEC 27001', sourceEdition: '2022', nationalEdition: 'DIN EN ISO/IEC 27001:2024-01',
sourceSha256: iso27001.sha256, retrievalRole: 'reference'
}));
for (const section of control.sections) {
documents.push(documentForSection(common, control, section, {
sourceStandard: 'ISO/IEC 27002', sourceEdition: '2022', nationalEdition: 'DIN EN ISO/IEC 27002:2024-01',
sourceSha256: iso27002.sha256, retrievalRole: 'semantic'
}));
}
}
return {
schemaVersion: 2,
corpusId,
corpusVersion,
language: 'de',
parserVersion: ISO_PDF_PARSER_VERSION,
generatedAt: options.generatedAt ?? new Date().toISOString(),
sources: [
{ standard: 'ISO/IEC 27001', sourceEdition: '2022', nationalEdition: 'DIN EN ISO/IEC 27001:2024-01', sha256: iso27001.sha256, pageCount: iso27001.pageCount },
{ standard: 'ISO/IEC 27002', sourceEdition: '2022', nationalEdition: 'DIN EN ISO/IEC 27002:2024-01', sha256: iso27002.sha256, pageCount: iso27002.pageCount }
],
documents
};
}
function documentForSection(
common: { corpusId: string; corpusVersion: string; language: string; parserVersion: string },
control: ParsedControl,
section: ParsedSection,
source: Pick<RegulatoryControlDocument, 'sourceStandard' | 'sourceEdition' | 'nationalEdition' | 'sourceSha256' | 'retrievalRole'>
): RegulatoryControlDocument {
const sourceToken = source.sourceStandard === 'ISO/IEC 27001' ? '27001' : '27002';
return {
schemaVersion: 2,
...common,
documentId: `${common.corpusId}:${control.controlId}:${sourceToken}:${section.kind}`,
framework: 'ISO/IEC 27001 Annex A',
frameworkVersion: '2022',
controlId: control.controlId,
annexAControlId: `A.${control.controlId}`,
controlTheme: control.theme,
title: control.title,
text: section.text,
sectionKind: section.kind,
sourcePageStart: section.pageStart,
sourcePageEnd: section.pageEnd,
...source
};
}
async function extractPdf(path: string, expectedStandard: string): Promise<ParsedPdf> {
const file = await stat(path);
if (!file.isFile() || file.size < 1 || file.size > MAXIMUM_PDF_BYTES) {
throw new Error(`${expectedStandard} PDF must be a non-empty file no larger than 32 MiB.`);
}
const bytes = await readFile(path);
let document;
try {
document = await getDocument({ data: new Uint8Array(bytes), password: '', isEvalSupported: false, useSystemFonts: true }).promise;
} catch {
throw new Error(`${expectedStandard} PDF could not be opened without a password.`);
}
try {
const permissions = await document.getPermissions();
if (permissions && !permissions.includes(PermissionFlag.COPY)) throw new Error(`${expectedStandard} PDF does not permit text extraction.`);
if (document.numPages < 1 || document.numPages > 1_000) throw new Error(`${expectedStandard} PDF page count is outside the supported range.`);
const pages: ParsedPdf['pages'] = [];
let characterCount = 0;
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1 });
const content = await page.getTextContent({ disableNormalization: false });
const spans = content.items.flatMap(item => toSpan(item, pageNumber, viewport.height));
const lines = groupLines(spans);
characterCount += lines.reduce((sum, line) => sum + line.text.length, 0);
pages.push({ page: pageNumber, lines });
page.cleanup();
}
if (characterCount < document.numPages * 100) throw new Error(`${expectedStandard} PDF does not contain a usable text layer.`);
return { sha256: createHash('sha256').update(bytes).digest('hex'), pageCount: document.numPages, pages };
} finally {
await document.destroy();
}
}
function toSpan(item: unknown, page: number, pageHeight: number): PdfSpan[] {
if (!isTextItem(item)) return [];
const [a, b, c, d, x, y] = item.transform.map(Number);
if (![a, b, c, d, x, y].every(Number.isFinite) || Math.abs(b) > 0.2 || Math.abs(c) > 0.2) return [];
if (y < 70 || y > pageHeight - 55) return [];
const text = normalizeInline(item.str);
return text ? [{ text, page, x, y, width: Number(item.width) || 0, height: Number(item.height) || Math.abs(d) }] : [];
}
function groupLines(spans: PdfSpan[]): PdfLine[] {
const rows: PdfSpan[][] = [];
for (const span of spans.slice().sort((left, right) => right.y - left.y || left.x - right.x)) {
const row = rows.find(candidate => Math.abs(candidate[0].y - span.y) <= Math.max(1.5, span.height * 0.12));
if (row) row.push(span); else rows.push([span]);
}
return rows.map(row => {
const ordered = row.sort((left, right) => left.x - right.x);
return { page: ordered[0].page, y: ordered[0].y, spans: ordered, text: joinSpans(ordered) };
}).filter(line => line.text);
}
function joinSpans(spans: PdfSpan[]): string {
let result = '';
let previous: PdfSpan | undefined;
for (const span of spans) {
const gap = previous ? span.x - (previous.x + previous.width) : 0;
const separator = result && !/\s$/.test(result) && !/^\s/.test(span.text) && gap > Math.max(1.2, span.height * 0.12) ? ' ' : '';
result += separator + span.text;
previous = span;
}
return normalizeInline(result);
}
export function parseIso27002Controls(pdf: ParsedPdf): ParsedControl[] {
const lines = pdf.pages.flatMap(page => page.lines);
const starts: number[] = [];
for (let index = 0; index < lines.length; index += 1) {
if (!/^([5-8]\.\d{1,2})\s+\S/.test(lines[index].text)) continue;
const nearby = new Set(lines.slice(index + 1, index + 35).map(line => line.text));
if (nearby.has('Maßnahme') && nearby.has('Zweck') && (nearby.has('Anleitung') || nearby.has('Leitfaden'))) starts.push(index);
}
const uniqueStarts = starts.filter((start, index) => !starts.slice(0, index).some(other => controlId(lines[other].text) === controlId(lines[start].text)));
return uniqueStarts.map((start, index) => parseIso27002Block(lines.slice(start, uniqueStarts[index + 1] ?? lines.length)));
}
function parseIso27002Block(block: PdfLine[]): ParsedControl {
const match = /^([5-8]\.\d{1,2})\s+(.+)$/.exec(block[0].text);
if (!match) throw new Error('ISO/IEC 27002 control heading is malformed.');
const labels: Array<[RegulatorySectionKind, readonly string[]]> = [
['control', ['Maßnahme']], ['purpose', ['Zweck']], ['guidance', ['Anleitung', 'Leitfaden']], ['other-information', ['Weitere Informationen']]
];
const positions = labels.map(([kind, aliases]) => ({ kind, index: block.findIndex(line => aliases.includes(line.text)) }));
if (positions.some(position => position.index < 0)) throw new Error(`ISO/IEC 27002 control ${match[1]} is missing a required section.`);
const attributesAt = block.slice(1, positions[0].index).findIndex(line => /Maßnahmenart/.test(line.text));
if (attributesAt < 0) throw new Error(`ISO/IEC 27002 control ${match[1]} is missing its attribute table.`);
const titleLines = [match[2], ...block.slice(1, 1 + attributesAt).map(line => line.text)];
const attributeLines = block.slice(1 + attributesAt, positions[0].index);
const sections: ParsedSection[] = [sectionFromLines('attributes', attributeLines)];
positions.forEach((position, positionIndex) => {
const next = positions[positionIndex + 1]?.index ?? block.length;
sections.push(sectionFromLines(position.kind, block.slice(position.index + 1, next)));
});
return { controlId: match[1], title: linesToText(titleLines), theme: themeFor(match[1]), sections };
}
export function parseIso27001AnnexA(pdf: ParsedPdf): ParsedControl[] {
const startPageIndex = pdf.pages.findIndex(page => page.lines.some(line => /Tabelle A\.1/.test(line.text))
&& page.lines.flatMap(line => line.spans).filter(span => span.text === 'Maßnahme').length >= 3);
if (startPageIndex < 0) throw new Error('ISO/IEC 27001 Annex A table was not found.');
const controls: Array<{ controlId: string; pageStart: number; pageEnd: number; title: string[]; statement: string[] }> = [];
let current: (typeof controls)[number] | undefined;
for (const page of pdf.pages.slice(startPageIndex)) {
const statementX = Math.min(...page.lines.flatMap(line => line.spans)
.filter(span => span.text === 'Maßnahme').map(span => span.x));
if (!Number.isFinite(statementX)) continue;
for (const line of page.lines) {
const idSpan = line.spans.find(span => span.x < 125 && /^[5-8]\.\d{1,2}$/.test(span.text));
if (idSpan) {
current = { controlId: idSpan.text, pageStart: page.page, pageEnd: page.page, title: [], statement: [] };
controls.push(current);
}
if (!current) continue;
current.pageEnd = page.page;
for (const span of line.spans) {
if (span === idSpan || span.text === 'Maßnahme' || /Tabelle A\.1|Informationssicherheitsmaßnahmen/.test(span.text)) continue;
if (span.x >= 65 && span.x < statementX) current.title.push(span.text);
else if (span.x >= statementX) current.statement.push(span.text);
}
}
}
return controls.map(control => ({
controlId: control.controlId,
title: linesToText(control.title),
theme: themeFor(control.controlId),
sections: [{
kind: 'control', text: linesToText(control.statement),
pageStart: control.pageStart, pageEnd: control.pageEnd
}]
}));
}
function sectionFromLines(kind: RegulatorySectionKind, lines: PdfLine[]): ParsedSection {
const meaningful = lines.filter(line => !isFurniture(line.text));
const text = linesToText(meaningful.map(line => line.text));
if (!text) throw new Error(`ISO/IEC 27002 ${kind} section is empty.`);
return { kind, text, pageStart: meaningful[0].page, pageEnd: meaningful.at(-1)!.page };
}
export function validateIsoControlSets(references: ParsedControl[], semantic: ParsedControl[]): void {
const referenceIds = references.map(item => item.controlId);
const semanticIds = semantic.map(item => item.controlId);
for (const [label, ids] of [['ISO/IEC 27001', referenceIds], ['ISO/IEC 27002', semanticIds]] as const) {
if (ids.length !== 93 || new Set(ids).size !== ids.length) {
throw new Error(`${label} must contain exactly 93 unique controls; parsed ${ids.length} entries and ${new Set(ids).size} unique identifiers.`);
}
const counts = Object.fromEntries(Object.keys(EXPECTED_THEME_COUNTS).map(theme => [theme, ids.filter(id => themeFor(id) === theme).length]));
for (const [theme, expected] of Object.entries(EXPECTED_THEME_COUNTS)) {
if (counts[theme] !== expected) throw new Error(`${label} ${theme} control count must be ${expected}.`);
}
}
if (referenceIds.slice().sort().join('\n') !== semanticIds.slice().sort().join('\n')) {
throw new Error('ISO/IEC 27001 Annex A and ISO/IEC 27002 control identifiers do not match.');
}
}
function themeFor(controlIdValue: string): RegulatoryControlTheme {
if (controlIdValue.startsWith('5.')) return 'organizational';
if (controlIdValue.startsWith('6.')) return 'people';
if (controlIdValue.startsWith('7.')) return 'physical';
if (controlIdValue.startsWith('8.')) return 'technological';
throw new Error(`Unsupported ISO control identifier ${controlIdValue}.`);
}
function controlId(value: string): string | undefined {
return /^([5-8]\.\d{1,2})\s+/.exec(value)?.[1];
}
function linesToText(lines: string[]): string {
return lines.map(normalizeInline).filter(Boolean).join('\n')
.replace(/(\p{L})-\n(?=\p{Ll})/gu, '$1')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeInline(value: string): string {
return value.normalize('NFKC').replace(/\u00ad/g, '').replace(/[\u00a0\u2007\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
}
function isFurniture(value: string): boolean {
return /^(?:DIN EN ISO\/IEC 2700[12]:|EN ISO\/IEC 2700[12]:|Normen-Download-|\d{1,3})/.test(value);
}
function isTextItem(value: unknown): value is PdfJsTextItem {
return Boolean(value) && typeof value === 'object' && typeof (value as PdfJsTextItem).str === 'string'
&& Array.isArray((value as PdfJsTextItem).transform) && (value as PdfJsTextItem).transform.length === 6;
}
function sha256(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}