Assessor_2.0/src/importers/html-extraction.ts

469 lines
20 KiB
TypeScript

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 === '&' ? '&amp;' : character === '<' ? '&lt;' : character === '>' ? '&gt;' : '&quot;'
));
}
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<string, string> = {
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('</a>');
if (isBlock) output.push('\n');
continue;
}
if (isBlock) output.push('\n');
if (isLink) output.push(`<a href="${escapeHtmlText(linkTarget.trim())}">`);
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<number, PendingSpan>();
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<string, string> = { 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(/<!--[\s\S]*?-->/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
* `<tr class="evaluated">` 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(/<!--[\s\S]*?-->/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 `<table>` 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 `<td>` der aeusseren "Complex Check"-Tabelle ein. Eine einfache,
* nicht verschachtelungsbewusste Suche nach `<table>...</table>` haelt beim
* ERSTEN `</table>` 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\b([^>]*)>|<\/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(/<br\s*\/?>/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(/<caption\b[^>]*>([\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 = /<tr\b([^>]*)>([\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 = /<t[hd]\b([^>]*)>([\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<string, string> = { 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'
};
}