import type { DecodedHierarchyNode, DecodedHierarchyView, DecodedSourceDocument, ImportDiagnostic, SourceLocator } from '../domain/import-intermediate'; import type { EnvironmentObservationCandidate, FindingCandidate, ScannerReportSectionCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction'; import type { ImportTable } from '../domain/import-intermediate'; import { findHtmlTableSpans, markupText, sanitizeHtmlText } from './html-extraction'; import { protectHtmlAttributeMarkup } from './html-attribute-markup'; import { htmlAttributeValue as attributeValue } from './html-attributes'; import { indexHtmlElements, type HtmlElementIndex } from './html-element-index'; import { acceptPingCastleRuleId, pingCastleHtmlRuleCoverage } from './pingcastle-rule-validation'; import { derivePingCastleEnvironmentAggregates } from './pingcastle-environment-aggregates'; const maximumRuleCandidates = 5_000; const maximumFieldMarkupCharacters = 1_000_000; interface HtmlLabel { index: number; label: string; value: string; } interface ResolvedAnchorSection { sectionId: string; sectionLabel: string; text: string; prose: string; headers: string[]; rows: string[][]; } interface DetailsExtras { embedded?: string[][]; linked?: ResolvedAnchorSection[]; } interface InternalReference { index: number; targetId: string; relation: 'anchor' | 'collapse' | 'modal'; label: string; } interface ElementSpan { start: number; bodyStart: number; bodyEnd: number; end: number; } interface NamedSectionSpan extends ElementSpan { sectionKey: string; parentSectionKey?: string; } interface LinkResolutionIssue { code: 'ambiguous' | 'depth-limit' | 'target-limit' | 'table-limit' | 'unresolved'; targetId: string; } interface XmlRuleGroup { path: string; root?: DecodedHierarchyNode; direct: Map; details: DecodedHierarchyNode[]; } interface PingCastleScores { global?: { value: string; source: SourceLocator }; staleObjects?: { value: string; source: SourceLocator }; privilegedAccounts?: { value: string; source: SourceLocator }; trusts?: { value: string; source: SourceLocator }; anomalies?: { value: string; source: SourceLocator }; } function safeId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, '-'); } function normalizedLabel(value: string): string { return value.replace(/\s+/g, ' ').replace(/:$/, '').trim().toLocaleLowerCase('en-US'); } function sourceField( key: string, label: string, value: string, source: SourceLocator ): SourceFieldCandidate { return { key, label, originalValue: value, decodedValue: value, source }; } // Traegt die vollstaendig aufgeloesten Detailquellen strukturiert (Header/Zeilen) // zusaetzlich zum Fliesstext im Details-Feld, damit die Work-Ansicht daraus // eigene Ergebnis-Tabellenblaetter fuer den Results-Popup bauen kann, ohne den // Text erneut parsen zu muessen. Eine Regel kann mehrere Quellen tragen (die // direkt eingebettete Beweistabelle UND ein Ankerziel), daher eine Liste. // decodedValue traegt zur Anzeige nur die kurzen Abschnittsnamen. function linkedSectionField(sections: ResolvedAnchorSection[], source: SourceLocator): SourceFieldCandidate { return { key: 'linked-section', label: 'Verwandte Daten', originalValue: { sections: sections.map(section => ({ sectionId: section.sectionId, sectionLabel: section.sectionLabel, prose: section.prose, headers: section.headers, rows: section.rows })) }, decodedValue: sections.map(section => section.sectionLabel).join(', '), source }; } function paragraphMatch(markup: string, start: number): { value: string; rawValue: string; end: number } { const boundedEnd = Math.min(markup.length, start + maximumFieldMarkupCharacters); const fragment = markup.slice(start, boundedEnd); const paragraph = /^\s*]*>([\s\S]*?)<\/p\s*>/i.exec(fragment); if (paragraph) return { value: markupText(paragraph[1] ?? ''), rawValue: paragraph[1] ?? '', end: start + paragraph.index + paragraph[0].length }; const inlineRemainder = /^\s*([^<]{1,10000})\s*<\/p\s*>/i.exec(fragment); if (inlineRemainder) return { value: markupText(inlineRemainder[1] ?? ''), rawValue: inlineRemainder[1] ?? '', end: start + inlineRemainder.index + inlineRemainder[0].length }; return { value: '', rawValue: '', end: start }; } function tableRowsFromMarkup(tableBody: string): string[][] { const rows: string[][] = []; const rowPattern = /]*>([\s\S]*?)<\/tr\s*>/gi; let rowMatch: RegExpExecArray | null; while ((rowMatch = rowPattern.exec(tableBody))) { const cells: string[] = []; const cellPattern = /]*>([\s\S]*?)<\/t[hd]\s*>/gi; let cellMatch: RegExpExecArray | null; while ((cellMatch = cellPattern.exec(rowMatch[1] ?? ''))) { cells.push(markupText(cellMatch[1] ?? '')); } if (cells.some(Boolean)) rows.push(cells); } return rows; } // PingCastle rendert nach "Details:" häufig direkt die Beweistabelle (z. B. betroffene // DNs). Wird nur unmittelbar (bis auf umschließende
) folgend erfasst, // um keine spätere, unabhängige Tabelle fälschlich anzuhängen. function embeddedTableRowsAfter(markup: string, start: number): string[][] { const boundedEnd = Math.min(markup.length, start + maximumFieldMarkupCharacters); const fragment = markup.slice(start, boundedEnd); const directTable = /^(?:\s|]*>|<\/div\s*>)*]*>([\s\S]*?)<\/table\s*>/i.exec(fragment); if (!directTable) return []; return tableRowsFromMarkup(directTable[1] ?? ''); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } const maximumAnchorSectionCharacters = 200_000; const maximumLinkedSectionDepth = 8; const maximumLinkedSectionTargets = 128; const maximumLinkedSectionTables = 256; function allSectionTables(markup: string): string[][][] { const tables: string[][][] = []; const tablePattern = /]*>([\s\S]*?)<\/table\s*>/gi; let match: RegExpExecArray | null; while ((match = tablePattern.exec(markup))) { const rows = tableRowsFromMarkup(match[1] ?? ''); if (rows.length) tables.push(rows); } return tables; } function proseText(markup: string): string { const withoutTables = markup.replace(/]*>[\s\S]*?<\/table\s*>/gi, ' '); return markupText(withoutTables); } // PingCastles "Details:" verweist bei vielen Regeln nicht auf eine eingebettete // Tabelle, sondern per internem Anker (Label) auf einen // eigenstaendigen Datenabschnitt (z. B. "LAPS", "Admin Groups", "Domain // controllers") an anderer Stelle im selben Dokument. Der Abschnitt ist per // " ... // " strukturell begrenzt. Ohne // Aufloesung bliebe nur der einleitende Verweissatz sichtbar, die eigentliche // Information ("bis zum Endpunkt der Verlinkung") ginge verloren. const subSectionEndMarker = ''; const subSectionStartMarker = ''; function subsectionMarkup(markup: string, anchorId: string, index: HtmlElementIndex): string | null { const anchor = index.namedAnchors.get(anchorId); if (!anchor) return null; const closing = /^\s*<\/a\s*>/i.exec(markup.slice(anchor.bodyStart, anchor.bodyStart + 256)); const afterAnchor = anchor.bodyStart + (closing?.[0].length ?? 0); const boundedEnd = Math.min(markup.length, afterAnchor + maximumAnchorSectionCharacters); const fragment = markup.slice(afterAnchor, boundedEnd); const ownEndMarkerIndex = fragment.indexOf(subSectionEndMarker); const contentStart = ownEndMarkerIndex >= 0 ? ownEndMarkerIndex + subSectionEndMarker.length : 0; const rest = fragment.slice(contentStart); const nextStart = rest.indexOf(subSectionStartMarker); const nextEnd = rest.indexOf(subSectionEndMarker); const nextSectionIndex = [nextStart, nextEnd].filter(index => index >= 0).sort((left, right) => left - right)[0] ?? -1; return nextSectionIndex >= 0 ? rest.slice(0, nextSectionIndex) : rest; } function elementSpanById(markup: string, targetId: string, index: HtmlElementIndex): ElementSpan | null { const opening = index.byId.get(targetId); if (!opening) return null; const { tagName, start, bodyStart, selfClosing } = opening; if (selfClosing) return { start, bodyStart, bodyEnd: bodyStart, end: bodyStart }; const tagPattern = new RegExp('<' + escapeRegExp(tagName) + '\\b[^>]*>|<\\/' + escapeRegExp(tagName) + '\\s*>', 'gi'); tagPattern.lastIndex = bodyStart; let depth = 1; let tag: RegExpExecArray | null; while ((tag = tagPattern.exec(markup))) { if (/^<\//.test(tag[0])) depth -= 1; else if (!/\/\s*>$/.test(tag[0])) depth += 1; if (depth === 0) return { start, bodyStart, bodyEnd: tag.index, end: tagPattern.lastIndex }; } return null; } function referenceLabelAt(markup: string, markerEnd: number, fallback: string): string { const bounded = markup.slice(markerEnd, Math.min(markup.length, markerEnd + 4_096)); const closing = /<\/(?:a|button)\s*>/i.exec(bounded); const label = markupText(closing ? bounded.slice(0, closing.index) : bounded.slice(0, 256)); return label || fallback; } function internalReferences(markup: string): InternalReference[] { const references: InternalReference[] = []; const seen = new Set(); const pattern = /\s*<\/i\s*>/gi; let match: RegExpExecArray | null; while ((match = pattern.exec(markup))) { const targetId = match[1]; const relation = match[2].toLocaleLowerCase('en-US') as InternalReference['relation']; const identity = relation + '|' + targetId; if (seen.has(identity)) continue; seen.add(identity); references.push({ index: match.index, targetId, relation, label: referenceLabelAt(markup, pattern.lastIndex, targetId) }); } return references; } function targetMarkup(markup: string, targetId: string, index: HtmlElementIndex): { markup: string; label: string } | null { const subsection = subsectionMarkup(markup, targetId, index); if (subsection !== null) return { markup: subsection, label: targetId }; const span = elementSpanById(markup, targetId, index); if (!span) return null; const body = markup.slice(span.bodyStart, span.bodyEnd); const title = /]*class\s*=\s*(?:"[^"]*\bmodal-title\b[^"]*"|'[^']*\bmodal-title\b[^']*')[^>]*>([\s\S]*?)<\/h[1-6]\s*>/i.exec(body); return { markup: body, label: markupText(title?.[1] ?? '') || targetId }; } function maskRanges(value: string, ranges: ElementSpan[]): string { if (!ranges.length) return value; const ordered = ranges .map(range => ({ start: Math.max(0, range.start), end: Math.min(value.length, range.end) })) .filter(range => range.end > range.start) .sort((left, right) => left.start - right.start); let output = ''; let cursor = 0; ordered.forEach(range => { if (range.end <= cursor) return; const start = Math.max(cursor, range.start); output += value.slice(cursor, start) + ' '.repeat(range.end - start); cursor = range.end; }); return output + value.slice(cursor); } function resolveLinkedSections( markup: string, roots: InternalReference[], elementIndex: HtmlElementIndex ): { sections: ResolvedAnchorSection[]; text: string; issues: LinkResolutionIssue[] } { const sections: ResolvedAnchorSection[] = []; const textBlocks: string[] = []; const visited = new Set(); const issues: LinkResolutionIssue[] = []; const issueKeys = new Set(); const addIssue = (code: LinkResolutionIssue['code'], targetId: string) => { const key = code + '|' + targetId; if (issueKeys.has(key)) return; issueKeys.add(key); issues.push({ code, targetId }); }; const visit = (reference: InternalReference, parentLabel: string, depth: number) => { if (depth > maximumLinkedSectionDepth) { addIssue('depth-limit', reference.targetId); return; } if (visited.size >= maximumLinkedSectionTargets) { addIssue('target-limit', reference.targetId); return; } if (visited.has(reference.targetId)) return; visited.add(reference.targetId); const definitionCount = elementIndex.definitions.get(reference.targetId)?.count ?? 0; if (definitionCount === 0) { addIssue('unresolved', reference.targetId); return; } if (definitionCount > 1) { addIssue('ambiguous', reference.targetId); return; } const target = targetMarkup(markup, reference.targetId, elementIndex); if (!target) { addIssue('unresolved', reference.targetId); return; } const label = target.label === reference.targetId ? reference.label : target.label; const sectionLabel = parentLabel ? parentLabel + ' → ' + label : label; const allReferences = internalReferences(target.markup); const targetIndex = indexHtmlElements(target.markup); const referencedSpans = allReferences .map(child => ({ child, span: elementSpanById(target.markup, child.targetId, targetIndex) })) .filter((entry): entry is { child: InternalReference; span: ElementSpan } => Boolean(entry.span)); const directReferences = allReferences.filter(candidate => !referencedSpans.some(entry => entry.child.targetId !== candidate.targetId && candidate.index >= entry.span.start && candidate.index < entry.span.end )); // Zielcontainer koennen als Geschwister im Subsection-Markup liegen, // obwohl ihr Verweis erst innerhalb eines anderen Zielcontainers steht. // Auch diese Definitionen gehoeren nicht zum lokalen Elterninhalt; die // Rekursion erreicht sie ueber den tatsaechlichen Verweispfad. const childSpans = referencedSpans.map(entry => entry.span); const ownMarkup = maskRanges(target.markup, childSpans); const prose = proseText(ownMarkup).trim(); const tables = allSectionTables(ownMarkup); const tablesText = tables.map(rows => rows.map(row => row.join(' | ')).join('\n')).join('\n\n'); const text = [prose, tablesText].filter(Boolean).join('\n\n'); if (text) textBlocks.push(text); if (tables.length) { const remainingTables = maximumLinkedSectionTables - sections.length; if (tables.length > remainingTables) addIssue('table-limit', reference.targetId); tables.slice(0, Math.max(0, remainingTables)).forEach((table, index) => { const hasMultipleTables = tables.length > 1; sections.push({ sectionId: hasMultipleTables ? reference.targetId + '--table-' + (index + 1) : reference.targetId, sectionLabel: hasMultipleTables ? sectionLabel + ' · Tabelle ' + (index + 1) : sectionLabel, text: table.map(row => row.join(' | ')).join('\n'), prose, headers: table[0] ?? [], rows: table.slice(1) }); }); } else if (prose) { sections.push({ sectionId: reference.targetId, sectionLabel, text: prose, prose, headers: ['Inhalt'], rows: [[prose]] }); } // PingCastle kann einen Link frueher anzeigen als den zugehoerigen Modal- // oder Collapse-Container. Fuer den vollstaendigen Detailspfad gilt die // Reihenfolge der tatsaechlichen Zielabschnitte im Bericht, nicht die // zufaellige Position ihrer Ausloeser. directReferences .sort((left, right) => (elementIndex.definitions.get(left.targetId)?.start ?? Number.MAX_SAFE_INTEGER) - (elementIndex.definitions.get(right.targetId)?.start ?? Number.MAX_SAFE_INTEGER) || left.index - right.index) .forEach(child => visit(child, sectionLabel, depth + 1)); }; roots.forEach(root => visit(root, '', 0)); return { sections, text: textBlocks.join('\n\n'), issues }; } function htmlLabels(markup: string): { labels: HtmlLabel[]; detailsExtras: Map; linkIssues: LinkResolutionIssue[]; } { const labels: HtmlLabel[] = []; const detailsExtras = new Map(); const linkIssues: LinkResolutionIssue[] = []; const elementIndex = indexHtmlElements(markup); const pattern = /]*>([\s\S]{0,2048}?)<\/strong\s*>/gi; let match: RegExpExecArray | null; while ((match = pattern.exec(markup)) && labels.length < maximumRuleCandidates * 16) { const label = normalizedLabel(markupText(match[1] ?? '')); if (!label) continue; const paragraph = paragraphMatch(markup, pattern.lastIndex); let value = paragraph.value; if (label === 'details') { // Eine Regel kann beides tragen: die direkt eingebettete Beweistabelle // UND einen Ankerverweis auf einen weiteren Datenabschnitt (z. B. // A-DC-Spooler: betroffene DC-Liste inline plus "Domain controllers"). // Beide Quellen werden erfasst; keine ersetzt die andere. const embedded = embeddedTableRowsAfter(markup, paragraph.end); if (embedded.length) { const embeddedText = embedded.map(row => row.join(' | ')).join('\n'); value = value ? `${value}\n${embeddedText}` : embeddedText; } const resolved = resolveLinkedSections(markup, internalReferences(paragraph.rawValue), elementIndex); linkIssues.push(...resolved.issues); if (resolved.text) value = value ? `${value}\n${resolved.text}` : resolved.text; if (embedded.length > 1 || resolved.sections.length) { detailsExtras.set(match.index, { embedded: embedded.length > 1 ? embedded : undefined, linked: resolved.sections.length ? resolved.sections : undefined }); } } labels.push({ index: match.index, label, value }); } return { labels, detailsExtras, linkIssues }; } function precedingHeading(markup: string, start: number, tag: 'h2' | 'h3', className?: string): string { const lowerBound = Math.max(0, start - 250_000); const fragment = markup.slice(lowerBound, start); const pattern = new RegExp('<' + tag + '\\b([^>]*)>([\\s\\S]*?)<\\/' + tag + '\\s*>', 'gi'); let selected = ''; let match: RegExpExecArray | null; while ((match = pattern.exec(fragment))) { if (className) { const classMatch = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(match[1] ?? ''); const classes = classMatch?.[1] ?? classMatch?.[2] ?? ''; if (!new RegExp('(?:^|\\s)' + className + '(?:\\s|$)', 'i').test(classes)) continue; } selected = markupText(match[2] ?? ''); } return selected; } // PingCastle benennt einen Fund im Card-Header (identisch zur Rationale der // XML-Exportvariante, z. B. "The spooler service is remotely accessible from // 1 DC"), waehrend die h3-Ueberschrift im Regelkoerper die generische // Pruefbeschreibung des Regelkatalogs traegt ("Ensure that ..."). Der Titel // folgt dem Card-Header, damit HTML- und XML-Importe denselben Fundnamen // erzeugen; die Pruefbeschreibung bleibt als eigenes Feld erhalten. floor // begrenzt die Suche auf den Bereich nach der vorherigen Regel, damit ein // regelloser Block nie den Header einer fremden Regel erbt. function precedingCardTitle(markup: string, start: number, floor: number): string { const lowerBound = Math.max(0, floor, start - 4_000); const fragment = markup.slice(lowerBound, start); const pattern = /]*>([\s\S]*?)<\/span\s*>/gi; let selected = ''; let match: RegExpExecArray | null; while ((match = pattern.exec(fragment))) selected = markupText(match[1] ?? ''); return selected; } // Die "Points:"-Beschriftung im Regelkoerper enthaelt bei vielen Regeln die // statische Punkteschema-Beschreibung (z. B. "50 points if the occurence is // greater than or equals than 50 then 45 ... then 15 points if present"), nicht // die tatsaechlich vergebene Punktzahl. Diese steht separat im vorangehenden // Card-Header als "+ N Point(s)" beziehungsweise als "Informative rule" bei // Regeln mit 0 Punkten. Ohne diesen Wert bleibt score/severity unbestimmt statt // erfunden. function precedingAwardedPoints(markup: string, start: number): string { const lowerBound = Math.max(0, start - 4_000); const fragment = markup.slice(lowerBound, start); const awarded = [...fragment.matchAll(/\+\s*([\d.]+)\s*Point\(s\)/gi)]; const informative = [...fragment.matchAll(/\bInformative rule\b/gi)]; const lastAwarded = awarded[awarded.length - 1]; const lastInformative = informative[informative.length - 1]; if (!lastAwarded && !lastInformative) return ''; if (!lastInformative || (lastAwarded && lastAwarded.index! > lastInformative.index!)) return lastAwarded![1]; return '0'; } // sanitizeHtmlText entfernt href als potenziell gefaehrliches Attribut - richtig // fuer echte Navigationsziele, wuerde hier aber auch harmlose interne // Dokumentanker (#id) unlesbar machen, bevor "Details:" sie zum Aufloesen // braucht. Nur ein eng begrenztes #-Fragment wird deshalb // vor der Sanitisierung in ein inertes data-Attribut ueberfuehrt; jeder andere // href-Wert (externe URLs, javascript:, data: etc.) bleibt unveraendert der // regulaeren Entfernung unterworfen. function preserveInternalAnchorHrefs(rawText: string): string { return rawText.replace(/\bhref\s*=\s*"#([a-zA-Z0-9_.:-]+)"/gi, 'data-anchor-target="$1"'); } // Interne PingCastle-Navigation wird als inerte Markierung vor der allgemeinen // HTML-Bereinigung gesichert. Das ist insbesondere fuer Collapse-Schaltflaechen // notwendig: