]*>([\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(`${tag}\\s*>`, '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 };
}
};