import type { JsonValue } from '../domain/canonical-model'; import type { DecodedSourceDocument, ImportTable, SourceLocator } from '../domain/import-intermediate'; import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction'; import { clean } from '../shared/text'; import { cleanLineBreaks } from '../shared/markdown'; import { cisCatEnvironmentContext } from './cis-cat-environment-context'; import { cisCatResultCategory } from './cis-cat-summary'; import { decodeHtmlEntities, findHtmlTableSpans, isSafeLinkTarget, markupText, sanitizeHtmlText } from './html-extraction'; import { htmlAttributeValue } from './html-attributes'; interface HtmlDivBlock { start: number; end: number; id: string; classes: string[]; html: string; } interface CisCatTableReference { tableIndex: number; label: string; } /** Woertliche Abschnittsmarke einer Regel samt ihrer Position im Regelblock. */ interface RuleSectionMarker { position: number; label: string; } interface CisCatRuleDetails { fragmentId: string; description: string; rationale: string; remediation: string; impact: string; ruleResultXml: string; references: string; controls: string; tables: CisCatTableReference[]; } const RULE_TITLE_PREFIX = /^(\d+(?:\.\d+)+)\s+/; const RESULT_TABLE_NAME = /^assessment\s*results?$|^assessmentresulttable$/i; const MAXIMUM_RULE_RESULT_XML_CHARACTERS = 2_000_000; /** Aufklappverweis eines versteckten Blocks ("Show Assessment Evidence"). */ const TOGGLE_PREFIX = /^(?:show|hide|display|view|anzeigen|einblenden|ausblenden|zeigen?)\b[\s:]*/i; /** Aufklappverweis eines eingebetteten Steuerungsblocks ("... - More"). */ const TOGGLE_SUFFIX = /[\s–—>-]*\b(?:more|less|mehr|weniger)\b[\s:>]*$/i; const MAXIMUM_SECTION_HEADING_CHARACTERS = 160; /** Fenster, in dem die schliessende Marke einer Ueberschrift gesucht wird. */ const MAXIMUM_SECTION_HEADING_SCAN = 640; const MAXIMUM_DERIVED_TABLE_NAME_CHARACTERS = 80; const DEFAULT_SECTION = 'Result Details'; const RULE_RESULT_XML_SECTION = /^rule\s*result\s*xml$/i; const REFERENCES_SECTION = /^references?$/i; const RULE_RESULT_XML_BLOCK_ID = /_xml_result$/i; const REFERENCE_LIST_CLASS = /(?:^|\s)referenceList(?:$|\s)/i; const BOLD_CLASS = /(?:^|\s)bold(?:$|\s)/i; /** * Beschriftung eines aufklappbaren Blocks. Der Bericht baut den Schalter als * `ShowAssessment * Evidence` - das Bedienwort und die Bezeichnung stehen also in zwei * getrennten Elementen, und die Bezeichnung traegt keinen Doppelpunkt. */ const CAPTION_CLASS = /(?:^|\s)caption(?:$|\s)/i; function attributeValue(attributes: string, name: string): string { return markupText(htmlAttributeValue(attributes, name)); } function scanDivBlocks(html: string): HtmlDivBlock[] { const blocks: HtmlDivBlock[] = []; const stack: Array<{ start: number; id: string; classes: string[] }> = []; const tags = /<\/?div\b[^>]*>/gi; let match: RegExpExecArray | null; while ((match = tags.exec(html))) { if (/^<\s*\/div/i.test(match[0])) { const open = stack.pop(); if (!open) continue; blocks.push({ ...open, end: tags.lastIndex, html: html.slice(open.start, tags.lastIndex) }); continue; } if (/\/\s*>$/.test(match[0])) continue; const attributes = match[0].replace(/^$/, ''); stack.push({ start: match.index, id: attributeValue(attributes, 'id'), classes: attributeValue(attributes, 'class').split(/\s+/).filter(Boolean) }); } return blocks; } function normalizedTitle(value: string): string { return clean(markupText(value)).replace(/\s+/g, ' ').toLocaleLowerCase('en-US'); } function detailTargetByTitle(html: string): Map { const targets = new Map(); const links = /]*)>([\s\S]*?)<\/a\s*>/gi; let match: RegExpExecArray | null; while ((match = links.exec(html))) { const href = attributeValue(match[1] ?? '', 'href'); if (!/^#detail-[\w:.-]+$/i.test(href)) continue; const title = normalizedTitle(match[2] ?? ''); if (title && !targets.has(title)) targets.set(title, href.slice(1)); } return targets; } function blockByClass(rule: HtmlDivBlock, className: string, idSuffix?: string): HtmlDivBlock | undefined { return scanDivBlocks(rule.html) .filter(block => block.classes.some(value => value.toLocaleLowerCase('en-US') === className)) .find(block => !idSuffix || block.id.endsWith(idSuffix)); } function markdownLinkTargets(markup: string): string { return markup.replace(/]*)>([\s\S]*?)<\/a\s*>/gi, (_match, attributes: string, body: string) => { const href = attributeValue(attributes, 'href'); const label = markupText(body); return isSafeLinkTarget(href) ? `[${label}](${href})` : label; }); } function markdownText(markup: string, removeTables = true): string { let value = markup .replace(//g, ' ') .replace(/<(?:script|style|iframe|object|embed|svg|form|template|canvas|audio|video)\b[^>]*>[\s\S]*?<\/(?:script|style|iframe|object|embed|svg|form|template|canvas|audio|video)\s*>/gi, ' '); if (removeTables) value = value.replace(/]*>[\s\S]*?<\/table\s*>/gi, '\n'); value = markdownLinkTargets(value); value = value.replace(/]*>([\s\S]*?)<\/code\s*>/gi, (_match, body: string) => { const code = markupText(body); return code ? `\n\`\`\`\n${code}\n\`\`\`\n` : ''; }); value = value.replace( /<(?:p|div)\b([^>]*)>([\s\S]*?)<\/(?:p|div)\s*>/gi, (match, attributes: string, body: string) => /\bbold\b/i.test(attributeValue(attributes, 'class')) ? `\n### ${markupText(body).replace(/:\s*$/, '')}\n` : match ); value = value .replace(//gi, '\n') .replace(/]*>/gi, '\n- ') .replace(/<\/(?:p|div|li|ul|ol|h[1-6]|pre)\s*>/gi, '\n') .replace(/<(?:p|div|ul|ol|h[1-6]|pre)\b[^>]*>/gi, '\n') .replace(/<[^>]*>/g, ' '); return cleanLineBreaks( value .split(/\r\n|\r|\n/) .map(line => markupText(line)) .join('\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') ); } function withoutLeadingLabel(value: string, label: RegExp): string { return value.replace(label, '').trim(); } /** Bereiche der Rahmenwerk-Zuordnungslisten innerhalb einer Regel. */ function referenceListRanges(ruleHtml: string): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; const lists = /]*)>([\s\S]*?)<\/ul\s*>/gi; let match: RegExpExecArray | null; while ((match = lists.exec(ruleHtml))) { if (REFERENCE_LIST_CLASS.test(attributeValue(match[1] ?? '', 'class'))) { ranges.push({ start: match.index, end: lists.lastIndex }); } } return ranges; } function pushMarker(markers: RuleSectionMarker[], position: number, label: string): void { const value = clean(label); if (!value || value.length > MAXIMUM_SECTION_HEADING_CHARACTERS) return; markers.push({ position, label: value }); } /** * Abschnittsmarken einer Regel - woertlich so, wie der Bericht sie schreibt. * * CIS-CAT gliedert einen Regelblock auf zwei Arten: ueber fette Ueberschriften * mit Doppelpunkt ("Description:", "CIS Controls V7.0:") und ueber * Aufklappverweise auf versteckte Bloecke ("Show Assessment Evidence", * "Show Rule Result XML"). Beide werden hier unveraendert uebernommen. * * Entscheidend ist, dass keine Liste bekannter Ueberschriften gepflegt wird: * zuvor kannte die Zuordnung nur "CIS Controls V7" und "CIS Critical Security * Controls V8" als feste Zeichenketten. Ein Bericht mit einer neuen Fassung * ("CIS Controls V8") oder einem anderen Rahmenwerk fiel damit auf die * vorherige Marke zurueck - im Zweifel auf "Rule Result XML", womit die * zugehoerige Tabelle vollstaendig aus dem Results-Fenster verschwand. Mit der * woertlichen Marke traegt jeder kuenftige Abschnitt automatisch seinen * richtigen Namen. */ function sectionMarkers(ruleHtml: string): RuleSectionMarker[] { const markers: RuleSectionMarker[] = []; // Ueberschriften innerhalb einer Zuordnungsliste benennen ihren Listenpunkt // ("Control 6: Maintenance, Monitoring and Analysis of Audit Logs:"), nicht // den Abschnitt. Ohne diese Grenze trug die eingebettete Steuerungstabelle // den Namen der Steuerung statt den des Rahmenwerks. const listRanges = referenceListRanges(ruleHtml); const insideList = (position: number) => listRanges.some(range => position >= range.start && position < range.end); const links = /]*>([\s\S]*?)<\/a\s*>/gi; let match: RegExpExecArray | null; while ((match = links.exec(ruleHtml))) { if (insideList(match.index)) continue; const text = markupText(match[1] ?? ''); if (!TOGGLE_PREFIX.test(text)) continue; // Der Bericht setzt haeufig nur das Bedienwort in den Verweis ("Show") und // schreibt die Bezeichnung des Blocks als Text daneben. Ohne diesen // Nachlauf blieb die Marke leer und die Belegtabellen erbten die // vorhergehende Ueberschrift - etwa "Impact" statt "Assessment Evidence". const label = clean(text.replace(TOGGLE_PREFIX, '')) || markupText((/^[^<]*/.exec(ruleHtml.slice(links.lastIndex)) ?? [''])[0].slice(0, MAXIMUM_SECTION_HEADING_CHARACTERS)); pushMarker(markers, match.index, label); } // Ueber die oeffnenden Marken statt ueber ganze Elemente: CIS-CAT schachtelt // seine Ueberschriften (

). Ein Ausdruck // ueber das gesamte Element haette den aeusseren Absatz verbraucht und die // fette Ueberschrift darin nie gesehen. const openingTags = /<(b|strong|h[1-6]|span|p|div)\b([^>]*)>/gi; while ((match = openingTags.exec(ruleHtml))) { if (insideList(match.index)) continue; const tag = match[1].toLocaleLowerCase('en-US'); const classes = attributeValue(match[2] ?? '', 'class'); const caption = CAPTION_CLASS.test(classes); const emphasized = /^(?:b|strong|h[1-6])$/.test(tag) || BOLD_CLASS.test(classes); if (!caption && !emphasized) continue; const body = ruleHtml.slice(openingTags.lastIndex, openingTags.lastIndex + MAXIMUM_SECTION_HEADING_SCAN); const close = new RegExp(``, 'i').exec(body); const text = markupText(close ? body.slice(0, close.index) : body); // Die Beschriftung eines Aufklappblocks benennt den Block bereits und // traegt keinen Doppelpunkt; eine Ueberschrift erkennt man nur an ihm. if (caption) pushMarker(markers, match.index, text.replace(/:$/, '')); else if (text.endsWith(':')) pushMarker(markers, match.index, text.slice(0, -1)); } return markers.sort((left, right) => left.position - right.position); } /** Abschnitt, unter dem eine Stelle im Regelblock steht. */ function sectionAt(markers: RuleSectionMarker[], position: number): string { let label = DEFAULT_SECTION; for (const marker of markers) { if (marker.position >= position) break; label = marker.label; } return label; } /** * Aufzaehlungstext der Rahmenwerk-Zuordnungen ("Control 6: ... - More"). * * Der Text stand bisher in keinem Feld: die Zuordnung wurde zwar erzeugt, aber * nie als Kandidatenfeld ausgegeben. Sichtbar waren dadurch nur die hinter * "More" versteckten Tabellen, nicht die Steuerung, zu der sie gehoeren. * "More" selbst ist Bedienelement des Berichts und kein Inhalt; es faellt * zusammen mit dem uebrig bleibenden Trennzeichen weg. */ function controlListMarkdown(listHtml: string): string { return markdownText(listHtml) .split('\n') .map(line => clean(line).replace(TOGGLE_SUFFIX, '').replace(/[\s:>]+$/, '')) .filter(line => line && line !== '-') .join('\n'); } /** * Bereiche des Regelblocks, die den References-Abschnitt bilden. * * Der Bericht verwendet fuer die Belegstellen dieselbe Listenklasse wie fuer * die Rahmenwerk-Zuordnungen. Ohne diese Abgrenzung stand die * References-Liste ein zweites Mal in den Control Mappings, obwohl sie bereits * ihr eigenes Feld hat. */ function referenceBlockRanges(rule: HtmlDivBlock): Array<{ start: number; end: number }> { return scanDivBlocks(rule.html) .filter(block => block.classes.some(value => /^references?$/i.test(value))) .map(block => ({ start: block.start, end: block.end })); } function controlsMarkdown(rule: HtmlDivBlock, markers: RuleSectionMarker[]): string { const sections: string[] = []; const referenceRanges = referenceBlockRanges(rule); const controls = /]*)>([\s\S]*?)<\/ul\s*>/gi; let match: RegExpExecArray | null; while ((match = controls.exec(rule.html))) { if (!REFERENCE_LIST_CLASS.test(attributeValue(match[1] ?? '', 'class'))) continue; if (referenceRanges.some(range => match!.index >= range.start && match!.index < range.end)) continue; const heading = sectionAt(markers, match.index); if (REFERENCES_SECTION.test(heading)) continue; const body = controlListMarkdown(match[2] ?? ''); if (body) sections.push(heading === DEFAULT_SECTION ? body : `### ${heading}\n\n${body}`); } return sections.join('\n\n'); } function tableStarts(html: string, tables: ImportTable[]): number[] { // Der bounded-lexical Parser ordnet verschachtelte Tabellen per // Klammerstapel ihrem eigenen schliessenden Tag zu (siehe // `findHtmlTableSpans`) und liefert seine Tabellen in genau dieser // Reihenfolge - dieselbe Zuordnung wird hier fuer die Positionen // gebraucht, sonst laufen Regel und Tabelle bei verschachtelten Berichten // (z. B. einem OR-verknuepften Complex Check) auseinander. const lexical = tables.some(table => table.attributes.extractionMode === 'bounded-lexical'); if (lexical) return findHtmlTableSpans(html).map(span => span.start); const starts: number[] = []; const pattern = / { return scanDivBlocks(rule.html) .filter(block => block.classes.some(value => value.toLocaleLowerCase('en-US') === 'xml') && RULE_RESULT_XML_BLOCK_ID.test(block.id)) .map(block => ({ start: block.start, end: block.end })); } function tableNameForRule(table: ImportTable): string { const rawName = clean(table.name); if (rawName && !/^tabelle\s+\d+$/i.test(rawName)) return rawName; const firstRow = table.rows[0]?.cells ?? []; const firstCell = clean(firstRow[0]?.decodedValue); if (/^criterion:?$/i.test(firstCell)) { return 'Complex Check'; } // Ohne Caption fuehren CIS-CAT-Detailtabellen ihre Bezeichnung in einer // einzelnen Kopfzelle ("CIS Control Information"). Sie benennt die Tabelle // im Results-Fenster besser als der laufende Zaehler des Parsers. // // Eine ueber `colspan` gespannte Kopfzelle traegt nach der Spannweiten- // Aufloesung denselben Text in jeder ueberspannten Spalte - gezaehlt wird // deshalb die Anzahl unterschiedlicher Werte, nicht die Anzahl gefuellter // Zellen. const filled = firstRow.filter(cell => clean(cell.decodedValue)); const distinctFilled = new Set(filled.map(cell => clean(cell.decodedValue))); if (distinctFilled.size === 1 && table.rows.length > 1 && firstCell.length <= MAXIMUM_DERIVED_TABLE_NAME_CHARACTERS) { return firstCell; } return rawName || `Tabelle ${table.index + 1}`; } /** Zeileninhalt einer Tabelle als Vergleichsschluessel fuer Duplikaterkennung. */ function tableContentSignature(table: ImportTable): string { return table.rows.map(row => row.cells.map(cell => cell.decodedValue).join('')).join(''); } function tableReferencesForRule( rule: HtmlDivBlock, starts: number[], tables: ImportTable[], markers: RuleSectionMarker[] ): CisCatTableReference[] { const xmlRanges = ruleResultXmlRanges(rule); const references = starts .map((position, tableIndex) => ({ position, table: tables[tableIndex] })) .filter(item => item.table && item.position >= rule.start && item.position < rule.end) .map(item => { const position = item.position - rule.start; return { tableIndex: item.table.index, insideRuleResultXml: xmlRanges.some(range => position >= range.start && position < range.end), section: sectionAt(markers, position), name: tableNameForRule(item.table), contentSignature: tableContentSignature(item.table) }; }) .filter(reference => !reference.insideRuleResultXml && !RULE_RESULT_XML_SECTION.test(reference.section)); // CIS-CAT bettet die Belegtabelle eines Complex Checks im Bericht mitunter // wortgleich ein zweites Mal in denselben Regelblock ein - gleicher Name, // identischer Zeileninhalt, vermutlich fuer eine zweite, im gerenderten // Bericht selbst nicht sichtbare Darstellung. Ohne diese Erkennung erschien // die Tabelle im Popup faelschlich als "Item 1" / "Item 2", obwohl der // Originalbericht sie nur einmal zeigt. const seenContent = new Set(); const deduplicated = references.filter(reference => { const key = `${reference.section}\u0000${reference.name}\u0000${reference.contentSignature}`; if (seenContent.has(key)) return false; seenContent.add(key); return true; }); const repetitions = new Map(); return deduplicated.map(reference => { const key = `${reference.section}\u0000${reference.name}`; const ordinal = (repetitions.get(key) ?? 0) + 1; repetitions.set(key, ordinal); const repeated = deduplicated.filter(item => item.section === reference.section && item.name === reference.name).length > 1; return { tableIndex: reference.tableIndex, label: `${reference.section} · ${reference.name}${repeated ? ` ${ordinal}` : ''}` }; }); } function emptyDetails(fragmentId: string): CisCatRuleDetails { return { fragmentId, description: '', rationale: '', remediation: '', impact: '', ruleResultXml: '', references: '', controls: '', tables: [] }; } function extractCisCatXml(html: string): string { if (!html) return ''; const preMatch = /]*>([\s\S]*?)<\/pre\s*>/i.exec(html); let raw = preMatch ? preMatch[1] : html; raw = raw.replace(/^[\s\S]*?Rule Result XML:\s*/i, ''); const tableIdx = raw.indexOf('= 0 && !/ row.index === rowIndex)?.cells[columnIndex]?.rawValue ?? ''; } function headers(table: ImportTable): { rowIndex: number; values: string[] } | null { const candidate = table.headerCandidates[0]; return candidate ? { rowIndex: candidate.rowIndex, values: candidate.headers.map(clean) } : null; } function candidateFields( document: DecodedSourceDocument, table: ImportTable, rowIndex: number, headerValues: string[], targetId: string, details: CisCatRuleDetails ): SourceFieldCandidate[] { const row = table.rows.find(item => item.index === rowIndex)!; const titleIndex = headerValues.findIndex(value => /^(?:benchmark\s*item|title|recommendation)$/i.test(value)); const resultIndex = headerValues.findIndex(value => /^result$/i.test(value)); const weightIndex = headerValues.findIndex(value => /^(?:w|weight)$/i.test(value)); const title = row.cells[titleIndex]?.decodedValue ?? ''; const result = row.cells[resultIndex]?.decodedValue ?? ''; const ruleId = RULE_TITLE_PREFIX.exec(clean(title))?.[1] ?? ''; const baseSource: SourceLocator = { fileName: document.file.name, tableIndex: table.index, rowIndex, elementSelector: `#${targetId}` }; const fields: SourceFieldCandidate[] = []; if (ruleId) fields.push(sourceField('rule-id', 'Rule ID', ruleId, ruleId, { ...baseSource, columnIndex: titleIndex })); fields.push(sourceField('benchmark-item', 'Benchmark Item', cellValue(table, rowIndex, titleIndex), title, { ...baseSource, columnIndex: titleIndex })); fields.push(sourceField('result', 'Result', cellValue(table, rowIndex, resultIndex), result, { ...baseSource, columnIndex: resultIndex })); if (weightIndex >= 0 && row.cells[weightIndex]?.decodedValue) { fields.push(sourceField('weight', 'Weight', cellValue(table, rowIndex, weightIndex), row.cells[weightIndex].decodedValue, { ...baseSource, columnIndex: weightIndex })); } if (details.description) fields.push(sourceField('description', 'Description', details.description, details.description, baseSource)); if (details.rationale) fields.push(sourceField('rationale', 'Rationale', details.rationale, details.rationale, baseSource)); if (details.remediation) fields.push(sourceField('remediation', 'Remediation', details.remediation, details.remediation, baseSource)); if (details.impact) fields.push(sourceField('impact', 'Impact', details.impact, details.impact, baseSource)); if (details.references) fields.push(sourceField('references', 'References', details.references, details.references, baseSource)); // Bewusst rahmenwerkneutral benannt: der Bericht fuehrt heute CIS Controls // V7/V8, die Ueberschriften im Wert stammen aber woertlich aus der Quelle und // koennen kuenftig jede andere Zuordnung tragen. if (details.controls) { fields.push(sourceField('control-mappings', 'Control Mappings', details.controls, details.controls, baseSource)); } const tableReferences = details.tables .map(reference => `source-table:${reference.tableIndex} "${reference.label.replace(/"/g, "'")}"`) .join('\n'); if (tableReferences) fields.push(sourceField('results', 'Results', { fragmentId: details.fragmentId, tables: details.tables.map(reference => ({ tableIndex: reference.tableIndex, label: reference.label })) }, tableReferences, baseSource)); const ruleResultXml = ruleResultXmlMarkdown(details); if (ruleResultXml) fields.push(sourceField('rule-result-xml', 'Rule Result XML', { fragmentId: details.fragmentId, ruleResultXml: details.ruleResultXml }, ruleResultXml, baseSource)); return fields; } export const cisCatSourceExtractor: SourceExtractor = { id: 'cis-cat-html-source-extractor', version: '1.3.1', detect(document) { if (document.format !== 'HTML') return { score: 0, evidence: [] }; const resultTable = document.tables.find(table => table.attributes.elementId === 'assessmentResultTable' || RESULT_TABLE_NAME.test(clean(table.name)) ); const hasRuleDetails = /]*class\s*=\s*(?:"[^"]*\bRule\b[^"]*"|'[^']*\bRule\b[^']*')/i.test(document.rawText); const hasDetailLinks = /href\s*=\s*(?:"|')#detail-/i.test(document.rawText); if (!resultTable || !hasRuleDetails || !hasDetailLinks) return { score: 0, evidence: [] }; return { score: 1, evidence: [ 'CIS-CAT Assessment-Result-Tabelle vorhanden.', 'Interne Detailverweise und regelgebundene Detailblöcke vorhanden.' ] }; }, extract(document) { // Der Umgebungskontext haengt nicht an der Ergebnistabelle: auch ein // Bericht ohne auswertbare Regelzeilen nennt System und Benchmark. const context = cisCatEnvironmentContext(document); const resultTable = document.tables.find(table => table.attributes.elementId === 'assessmentResultTable' || RESULT_TABLE_NAME.test(clean(table.name)) ); if (!resultTable) return { version: 1, candidates: [], ...context, diagnostics: [] }; const tableHeader = headers(resultTable); if (!tableHeader) { return { version: 1, candidates: [], ...context, diagnostics: [{ severity: 'warning', code: 'cis-cat-result-header-missing', message: 'Die CIS-CAT-Ergebnistabelle besitzt keine sicher erkennbare Kopfzeile.' }] }; } const titleIndex = tableHeader.values.findIndex(value => /^(?:benchmark\s*item|title|recommendation)$/i.test(value)); const resultIndex = tableHeader.values.findIndex(value => /^result$/i.test(value)); if (titleIndex < 0 || resultIndex < 0) { return { version: 1, candidates: [], ...context, diagnostics: [{ severity: 'warning', code: 'cis-cat-result-columns-missing', message: 'Benchmark Item und Result konnten in der CIS-CAT-Ergebnistabelle nicht gemeinsam gefunden werden.' }] }; } const targets = detailTargetByTitle(document.rawText); const sanitized = sanitizeHtmlText(document.rawText).text; const rules = scanDivBlocks(sanitized) .filter(block => block.id && block.classes.some(value => value.toLocaleLowerCase('en-US') === 'rule')); const rulesById = new Map(rules.map(rule => [rule.id, rule])); const starts = tableStarts(sanitized, document.tables); const candidates: FindingCandidate[] = []; let missingDetails = 0; for (const row of resultTable.rows) { if (row.index === tableHeader.rowIndex) continue; const title = row.cells[titleIndex]?.decodedValue ?? ''; const cellRaw = String(row.cells[titleIndex]?.rawValue ?? ''); const result = row.cells[resultIndex]?.decodedValue ?? ''; if (!title || !cisCatResultCategory(result)) continue; const hrefMatch = /href\s*=\s*["']#(detail-[\w:.-]+)["']/i.exec(cellRaw); const targetId = hrefMatch ? hrefMatch[1] : targets.get(normalizedTitle(title)); const rule = targetId ? rulesById.get(targetId) : undefined; if (!targetId || !rule) missingDetails += 1; const resolvedTargetId = targetId ?? 'assessmentResultTable'; const details = rule ? ruleDetails(rule, starts, document.tables) : emptyDetails(resolvedTargetId); candidates.push({ version: 1, id: `candidate-cis-cat-${resultTable.index}-${row.index}`, source: { fileName: document.file.name, tableIndex: resultTable.index, rowIndex: row.index, elementSelector: `#${resolvedTargetId}` }, fields: candidateFields(document, resultTable, row.index, tableHeader.values, resolvedTargetId, details) }); } const diagnostics = missingDetails ? [{ severity: 'warning' as const, code: 'cis-cat-linked-detail-missing', message: `${missingDetails} CIS-CAT-Ergebniszeile(n) konnten keinem internen Detailblock zugeordnet werden.`, details: { missingDetails } }] : []; return { version: 1, candidates, ...context, diagnostics }; } };