import type { DecodedHtmlDocument, DecodedHtmlTable } from './runtime-adapters'; // Real-world scanner reports can exceed the isolated worker's time budget well // before four MiB when their markup expands into a large DOM. Route reports // above one MiB through the bounded lexical parser, which preserves the table // and source-extractor contract without constructing the complete DOM tree. const MAXIMUM_DOM_HTML_CHARACTERS = 1 * 1024 * 1024; const MAXIMUM_EXTRACTED_TABLES = 10_001; const MAXIMUM_EXTRACTED_ROWS_PER_TABLE = 250_001; const MAXIMUM_EXTRACTED_ROWS_TOTAL = 500_001; const MAXIMUM_FALLBACK_CELL_CHARACTERS = 100_000; const REMOVED_HTML_TAGS = new Set([ 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'LINK', 'META', 'BASE', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'TEMPLATE', 'CANVAS', 'AUDIO', 'VIDEO', 'SOURCE' ]); const BLOCK_HTML_TAGS = new Set([ 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BR', 'DIV', 'DL', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL' ]); const DANGEROUS_HTML_ATTRIBUTE = /^(?:on.*|srcdoc|src|action|formaction|xlink:href)$/i; /** * Verweisziele aus importierten Berichten. * * `href` wurde bisher pauschal entfernt. Damit gingen auch die Belegstellen * verloren, die Werkzeuge wie Purple Knight und PingCastle mitliefern - der * eigentliche Zweck des Attributs. Erhalten bleiben deshalb ausschliesslich * externe Web-Adressen; jedes andere Schema (`javascript:`, `data:`, `file:`, * `vbscript:` und alles Weitere) wird weiterhin verworfen, ebenso `xlink:href`. * * Dieselbe Pruefung wendet die Darstellung ueber `safeHref` in RichTextValue * ein zweites Mal an, sodass ein durchgerutschter Wert dort erneut scheitert. */ const SAFE_LINK_TARGET = /^https?:\/\/[^\s"'<>]+$/i; export function isSafeLinkTarget(value: string): boolean { return SAFE_LINK_TARGET.test(value.trim()); } export function escapeHtmlText(value: string): string { return value.replace(/[&<>"]/g, character => ( character === '&' ? '&' : character === '<' ? '<' : character === '>' ? '>' : '"' )); } export function shouldUseBoundedHtmlExtraction(text: string): boolean { return text.length > MAXIMUM_DOM_HTML_CHARACTERS; } export function isHtmlStackOverflow(error: unknown): boolean { return error instanceof RangeError || error instanceof Error && /maximum call stack|stack overflow/i.test(error.message); } function normalizeInlineText(value: string): string { return value.replace(/\s+/g, ' ').trim(); } export function decodeHtmlEntities(value: string): string { const named: Record = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' }; return value.replace(/&(?:#(\d+)|#x([\da-f]+)|([a-z][\da-z]+));/gi, (match, decimal, hexadecimal, name) => { if (decimal || hexadecimal) { const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16); if (Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff) { try { return String.fromCodePoint(codePoint); } catch { return match; } } return match; } return named[String(name).toLocaleLowerCase('en-US')] ?? match; }); } /** * `preserveLinks` behaelt Anker mit sicherem Ziel als Markup im Ergebnis. * Nur so erreicht eine Belegstelle den Feldwert - der Rest der Extraktion * liefert reinen Text. Ist die Option aktiv, werden Textknoten maskiert, damit * spitze Klammern aus der Quelle kein zusaetzliches Markup erzeugen koennen. */ function textFromNode(root: Node, preserveBlocks = false, preserveLinks = false): string { const output: string[] = []; const stack: Array<{ node: Node; closing: boolean }> = [{ node: root, closing: false }]; while (stack.length) { const frame = stack.pop()!; const node = frame.node; if (node.nodeType === 3 || node.nodeType === 4) { const value = node.nodeValue ?? ''; output.push(preserveLinks ? escapeHtmlText(value) : value); continue; } if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) continue; const element = node.nodeType === 1 ? node as Element : null; const isBlock = preserveBlocks && Boolean(element && BLOCK_HTML_TAGS.has(element.tagName.toLocaleUpperCase('en-US'))); const linkTarget = preserveLinks && element && element.tagName.toLocaleUpperCase('en-US') === 'A' ? (element.getAttribute('href') ?? '') : ''; const isLink = Boolean(linkTarget) && isSafeLinkTarget(linkTarget); if (frame.closing) { if (isLink) output.push(''); if (isBlock) output.push('\n'); continue; } if (isBlock) output.push('\n'); if (isLink) output.push(``); stack.push({ node, closing: true }); for (let child = node.lastChild; child; child = child.previousSibling) { stack.push({ node: child, closing: false }); } } return preserveBlocks ? output.join('') : normalizeInlineText(output.join(' ')); } export function sanitizeHtmlDocument(documentNode: Document): number { const root = documentNode.documentElement; if (!root) return 0; let removedContentCount = 0; const stack: Element[] = [root]; while (stack.length) { const element = stack.pop()!; if (REMOVED_HTML_TAGS.has(element.tagName.toLocaleUpperCase('en-US'))) { element.remove(); removedContentCount += 1; continue; } for (let index = element.attributes.length - 1; index >= 0; index -= 1) { const attribute = element.attributes.item(index); if (!attribute) continue; const isHref = attribute.name.toLocaleLowerCase('en-US') === 'href'; // Ein Verweisziel bleibt nur an einem Anker und nur als Web-Adresse // stehen; alles andere wird wie bisher entfernt. const keepsLink = isHref && element.tagName.toLocaleUpperCase('en-US') === 'A' && isSafeLinkTarget(attribute.value ?? ''); if (!keepsLink && (isHref || DANGEROUS_HTML_ATTRIBUTE.test(attribute.name))) { element.removeAttribute(attribute.name); removedContentCount += 1; } } for (let child = element.lastElementChild; child; child = child.previousElementSibling) { stack.push(child); } } return removedContentCount; } /** Obergrenze fuer rowspan/colspan, damit eine boeswillige Angabe (z. B. rowspan="999999") keine uferlose Tabelle erzeugt. */ const MAXIMUM_CELL_SPAN = 1000; interface SpannableCell { text: string; colSpan: number; rowSpan: number; selector: string; } interface PendingSpan { remaining: number; text: string; selector: string; } /** * Richtet Tabellenzeilen an einer gemeinsamen Spaltenachse aus, indem eine * `rowspan`-Zelle in die Zeilen darunter uebernommen wird. * * Ohne diese Ausrichtung hat nur die erste Zeile eines Blocks die gespannte * Zelle - jede weitere Zeile hat dadurch eine Spalte weniger. Die * Kopfzeilenerkennung liest die erste Zeile dann als Kopf (sie hat als * einzige die volle Spaltenzahl), und ihr Inhalt - z. B. eine "Criterion:"- * Zeile in einer CIS-CAT-Belegtabelle - verschwindet als Datenzeile. */ function expandCellSpans(rawRows: SpannableCell[][]): { values: string[]; selectors: string[] }[] { const pending = new Map(); return rawRows.map(row => { const values: string[] = []; const selectors: string[] = []; let column = 0; for (const cell of row) { while (pending.has(column) && pending.get(column)!.remaining > 0) { const span = pending.get(column)!; values[column] = span.text; selectors[column] = span.selector; span.remaining -= 1; if (span.remaining <= 0) pending.delete(column); column += 1; } const colSpan = Math.min(Math.max(cell.colSpan, 1), MAXIMUM_CELL_SPAN); const rowSpan = Math.min(Math.max(cell.rowSpan, 1), MAXIMUM_CELL_SPAN); for (let offset = 0; offset < colSpan; offset += 1) { values[column] = cell.text; selectors[column] = cell.selector; if (rowSpan > 1) pending.set(column, { remaining: rowSpan - 1, text: cell.text, selector: cell.selector }); column += 1; } } while (pending.has(column) && pending.get(column)!.remaining > 0) { const span = pending.get(column)!; values[column] = span.text; selectors[column] = span.selector; span.remaining -= 1; if (span.remaining <= 0) pending.delete(column); column += 1; } return { values, selectors }; }); } function fallbackRowsFromText(value: string): string[][] { const rows: string[][] = []; for (const rawLine of value.split(/\r\n|\r|\n/)) { const line = normalizeInlineText(rawLine); if (!line) continue; for (let offset = 0; offset < line.length && rows.length < MAXIMUM_EXTRACTED_ROWS_PER_TABLE; offset += MAXIMUM_FALLBACK_CELL_CHARACTERS) { rows.push([line.slice(offset, offset + MAXIMUM_FALLBACK_CELL_CHARACTERS)]); } if (rows.length >= MAXIMUM_EXTRACTED_ROWS_PER_TABLE) break; } return rows; } export function decodeHtmlDocument(documentNode: Document): DecodedHtmlDocument { const removedContentCount = sanitizeHtmlDocument(documentNode); const tables: DecodedHtmlTable[] = []; const tableNodes = documentNode.getElementsByTagName('table'); const tableCount = Math.min(tableNodes.length, MAXIMUM_EXTRACTED_TABLES); let totalRows = 0; for (let tableIndex = 0; tableIndex < tableCount && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; tableIndex += 1) { const table = tableNodes.item(tableIndex) as HTMLTableElement | null; if (!table) continue; const caption = table.caption ? textFromNode(table.caption) : ''; const ariaLabel = table.getAttribute('aria-label')?.trim() ?? ''; const name = caption || table.id || ariaLabel || `Tabelle ${tableIndex + 1}`; const maximumRows = Math.min(table.rows.length, MAXIMUM_EXTRACTED_ROWS_PER_TABLE); const rawRows: SpannableCell[][] = []; const highlightedRows: boolean[] = []; for (let rowIndex = 0; rowIndex < maximumRows && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; rowIndex += 1) { const row = table.rows.item(rowIndex); if (!row || !row.cells.length) continue; const cells: SpannableCell[] = []; for (let columnIndex = 0; columnIndex < row.cells.length; columnIndex += 1) { const cell = row.cells.item(columnIndex); cells.push({ text: cell ? textFromNode(cell, false, true) : '', colSpan: cell?.colSpan ?? 1, rowSpan: cell?.rowSpan ?? 1, selector: `table:nth-of-type(${tableIndex + 1}) tr:nth-of-type(${rowIndex + 1}) > :nth-child(${columnIndex + 1})` }); } rawRows.push(cells); highlightedRows.push(isEvaluatedRowClass(row.className ?? '')); totalRows += 1; } const expanded = expandCellSpans(rawRows); const rows = expanded.map(row => row.values); const selectors = expanded.map(row => row.selectors); const attributes: Record = { kind: 'html-table' }; if (caption) attributes.caption = caption; if (table.id) attributes.elementId = table.id; if (ariaLabel) attributes.ariaLabel = ariaLabel; tables.push({ name, rows, cellSelectors: selectors, attributes, highlightedRows }); } const documentText = textFromNode(documentNode.body ?? documentNode.documentElement, true); const fallbackRows = tables.length ? [] : fallbackRowsFromText(documentText); return { tables, fallbackRows, documentText, removedContentCount, extractionMode: 'dom' }; } export function sanitizeHtmlText(text: string): { text: string; removedContentCount: number } { let removedContentCount = 0; let sanitized = text.replace(/<(script|style|iframe|object|embed|svg|form|template|canvas|audio|video)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, () => { removedContentCount += 1; return ' '; }); sanitized = sanitized.replace(/<(?:script|style|iframe|object|embed|svg|link|meta|base|form|input|button|textarea|select|template|canvas|audio|video|source)\b[^>]*\/?>/gi, () => { removedContentCount += 1; return ' '; }); sanitized = sanitized.replace(/\s(?:on[\w:-]*|srcdoc|src|action|formaction|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, () => { removedContentCount += 1; return ''; }); // Verweisziele bleiben als Web-Adresse erhalten, jedes andere Schema faellt // weiterhin heraus. Der Wert wird normalisiert neu geschrieben, damit keine // Anfuehrungszeichen aus der Quelle das Attribut verlassen koennen. sanitized = sanitized.replace(/\shref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, quoted, single, bare) => { const target = decodeHtmlEntities(quoted ?? single ?? bare ?? '').trim(); if (isSafeLinkTarget(target)) return ` href="${escapeHtmlText(target)}"`; removedContentCount += 1; return ''; }); sanitized = sanitized.replace(//g, ' '); return { text: sanitized, removedContentCount }; } function attributeValue(attributes: string, name: string): string { const pattern = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; let match: RegExpExecArray | null; while ((match = pattern.exec(attributes))) { if (match[1]?.toLocaleLowerCase('en-US') === name.toLocaleLowerCase('en-US')) { return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? '').trim(); } } return ''; } /** * Markiert der Bericht selbst diese Zeile als ausgewertet? * * CIS-CAT zeichnet in einer Belegtabelle (z. B. "Registry Item", * "Auditeventpolicysubcategories Item") genau die Zeile(n) mit * `` aus, gegen die ein Criterion tatsaechlich prueft - * im Bericht selbst als Hintergrundfarbe sichtbar. Ohne diese Markierung * verschwindet im Ergebnisfenster, welche der oft Dutzenden Zeilen einer * Belegtabelle ueberhaupt zum Ergebnis gefuehrt hat. */ function isEvaluatedRowClass(classAttribute: string): boolean { return /(?:^|\s)evaluated(?:$|\s)/i.test(classAttribute); } export function markupText(value: string): string { return normalizeInlineText(decodeHtmlEntities( value.replace(//g, ' ').replace(/<[^>]*>/g, ' ') )); } const MAXIMUM_TABLE_SPANS = MAXIMUM_EXTRACTED_TABLES * 4; export interface HtmlTableSpan { start: number; attributesText: string; bodyStart: number; bodyEnd: number; end: number; children: HtmlTableSpan[]; } /** * Findet jede `` im Text - eingeschlossen verschachtelte - und * ordnet ihr per Klammerstapel ihr eigenes schliessendes Tag zu. * * CIS-CAT bettet die Belegtabelle eines OR-verknuepften Complex Checks in * eine `
` der aeusseren "Complex Check"-Tabelle ein. Eine einfache, * nicht verschachtelungsbewusste Suche nach `...
` haelt beim * ERSTEN `
` an - das schliesst dann die innere, nicht die aeussere * Tabelle. Die aeussere Tabelle "endet" dadurch mitten im ersten Kriterium, * dessen Zeilen (samt "Criterion:") als Kopfzeile der aeusseren Tabelle * fehlinterpretiert werden und aus dem Ergebnis verschwinden. */ export function findHtmlTableSpans(text: string): HtmlTableSpan[] { const tagPattern = /]*)>|<\/table\s*>/gi; const stack: HtmlTableSpan[] = []; const spans: HtmlTableSpan[] = []; let match: RegExpExecArray | null; while (spans.length < MAXIMUM_TABLE_SPANS && (match = tagPattern.exec(text))) { const isClosingTag = match[0].charCodeAt(1) === 47; if (!isClosingTag) { const span: HtmlTableSpan = { start: match.index, attributesText: match[1] ?? '', bodyStart: tagPattern.lastIndex, bodyEnd: -1, end: -1, children: [] }; if (stack.length) stack[stack.length - 1].children.push(span); stack.push(span); spans.push(span); continue; } const span = stack.pop(); if (!span) continue; span.bodyEnd = match.index; span.end = tagPattern.lastIndex; } return spans.filter(span => span.end >= 0); } /** * Entfernt jede direkt verschachtelte Tabelle aus dem eigenen Textbereich * einer Tabelle, damit die Zeilen- und Zellensuche einer Tabelle nicht in * ihre eigenen verschachtelten Tabellen hineinliest - genau wie * `HTMLTableElement.rows` im Browser nur Zeilen der naechstgelegenen * Elterntabelle liefert. */ export function maskNestedTables(text: string, start: number, end: number, children: HtmlTableSpan[]): string { let body = text.slice(start, end); for (let index = children.length - 1; index >= 0; index -= 1) { const child = children[index]; if (child.end < 0) continue; body = body.slice(0, child.start - start) + body.slice(child.end - start); } return body; } function fallbackRowsFromMarkup(value: string): string[][] { const withLines = value .replace(//gi, '\n') .replace(/<\/?(?:address|article|aside|blockquote|div|dl|fieldset|figcaption|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>/gi, '\n') .replace(/<[^>]*>/g, ' '); return fallbackRowsFromText(decodeHtmlEntities(withLines)); } export function decodeHtmlLexically(text: string): DecodedHtmlDocument { const sanitized = sanitizeHtmlText(text); const tables: DecodedHtmlTable[] = []; const spans = findHtmlTableSpans(sanitized.text); let totalRows = 0; for (let tableIndex = 0; tableIndex < spans.length && tables.length < MAXIMUM_EXTRACTED_TABLES && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; tableIndex += 1) { const span = spans[tableIndex]; const attributesText = span.attributesText; const body = maskNestedTables(sanitized.text, span.bodyStart, span.bodyEnd, span.children); const caption = markupText(/]*>([\s\S]*?)<\/caption\s*>/i.exec(body)?.[1] ?? ''); const elementId = attributeValue(attributesText, 'id'); const ariaLabel = attributeValue(attributesText, 'aria-label'); const rawRows: SpannableCell[][] = []; const highlightedRows: boolean[] = []; const rowPattern = /]*)>([\s\S]*?)<\/tr\s*>/gi; let rowMatch: RegExpExecArray | null; while (rawRows.length < MAXIMUM_EXTRACTED_ROWS_PER_TABLE && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL && (rowMatch = rowPattern.exec(body))) { const cells: SpannableCell[] = []; const cellPattern = /]*)>([\s\S]*?)<\/t[hd]\s*>/gi; let cellMatch: RegExpExecArray | null; while ((cellMatch = cellPattern.exec(rowMatch[2] ?? ''))) { const columnIndex = cells.length; const cellAttributes = cellMatch[1] ?? ''; cells.push({ text: markupText(cellMatch[2] ?? ''), colSpan: Number.parseInt(attributeValue(cellAttributes, 'colspan'), 10) || 1, rowSpan: Number.parseInt(attributeValue(cellAttributes, 'rowspan'), 10) || 1, selector: `table:nth-of-type(${tableIndex + 1}) tr:nth-of-type(${rawRows.length + 1}) > :nth-child(${columnIndex + 1})` }); } if (cells.length) { rawRows.push(cells); highlightedRows.push(isEvaluatedRowClass(attributeValue(rowMatch[1] ?? '', 'class'))); totalRows += 1; } } const expandedRows = expandCellSpans(rawRows); const rows = expandedRows.map(row => row.values); const selectors = expandedRows.map(row => row.selectors); const attributes: Record = { kind: 'html-table', extractionMode: 'bounded-lexical' }; if (caption) attributes.caption = caption; if (elementId) attributes.elementId = elementId; if (ariaLabel) attributes.ariaLabel = ariaLabel; tables.push({ name: caption || elementId || ariaLabel || `Tabelle ${tableIndex + 1}`, rows, cellSelectors: selectors, attributes, highlightedRows }); } const documentText = fallbackRowsFromMarkup(sanitized.text).map(row => row[0] ?? '').filter(Boolean).join('\n'); return { tables, fallbackRows: tables.length ? [] : fallbackRowsFromText(documentText), documentText, removedContentCount: sanitized.removedContentCount, extractionMode: 'bounded-lexical' }; }