Assessor_2.0/src/importers/cis-cat-source-extractor.ts

645 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { cisCatResultCategory } from './cis-cat-summary';
import { decodeHtmlEntities, findHtmlTableSpans, isSafeLinkTarget, markupText, sanitizeHtmlText } from './html-extraction';
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
* `<span class="action">Show</span><span class="caption">Assessment
* Evidence</span>` - 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 {
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 markupText(match[2] ?? match[3] ?? match[4] ?? '');
}
}
return '';
}
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(/^<div\b/i, '').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<string, string> {
const targets = new Map<string, string>();
const links = /<a\b([^>]*)>([\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(/<a\b([^>]*)>([\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(/<!--[\s\S]*?-->/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(/<table\b[^>]*>[\s\S]*?<\/table\s*>/gi, '\n');
value = markdownLinkTargets(value);
value = value.replace(/<code\b[^>]*>([\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(/<br\s*\/?>/gi, '\n')
.replace(/<li\b[^>]*>/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 = /<ul\b([^>]*)>([\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 = /<a\b[^>]*>([\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 (<p><span title><span class="bold">). 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 = /<ul\b([^>]*)>([\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 = /<table\b/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(html))) starts.push(match.index);
return starts;
}
/**
* Bereiche des Regelblocks, die den Rule-Result-XML-Block bilden.
*
* Die darin enthaltenen Tabellen wiederholen nur, was das XML ohnehin als Text
* traegt; sie werden deshalb nicht ein zweites Mal verknuepft. Massgeblich ist
* der Behaelter selbst, nicht mehr eine Zeichenkette im Text davor: eine
* unbekannte Ueberschrift dazwischen kann so keine fremde Tabelle mehr faelsch-
* lich als XML-Doppel aussortieren.
*/
function ruleResultXmlRanges(rule: HtmlDivBlock): Array<{ start: number; end: number }> {
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<string>();
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<string, number>();
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 = /<pre\b[^>]*>([\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('<table');
if (tableIdx >= 0 && !/<xccdf:rule-result[\s\S]*?<table/i.test(raw)) {
raw = raw.slice(0, tableIdx);
}
let decoded = decodeHtmlEntities(raw).trim();
decoded = decoded.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
return decoded;
}
function ruleDetails(rule: HtmlDivBlock, starts: number[], tables: ImportTable[]): CisCatRuleDetails {
const details = emptyDetails(rule.id);
const markers = sectionMarkers(rule.html);
const description = markdownText(blockByClass(rule, 'description')?.html ?? '');
const rationale = markdownText(blockByClass(rule, 'rationale')?.html ?? '');
const remediation = markdownText(blockByClass(rule, 'fixtext')?.html ?? '');
const impact = markdownText(blockByClass(rule, 'impact')?.html ?? '');
const xml = extractCisCatXml(blockByClass(rule, 'xml', '_xml_result')?.html ?? '');
const references = markdownText(
blockByClass(rule, 'reference')?.html ?? blockByClass(rule, 'references')?.html ?? ''
);
return {
...details,
description: withoutLeadingLabel(description, /^description:\s*/i),
rationale: withoutLeadingLabel(rationale, /^rationale:\s*/i),
remediation: withoutLeadingLabel(remediation, /^remediation:\s*/i),
impact: withoutLeadingLabel(impact, /^impact:\s*/i),
ruleResultXml: xml,
references: withoutLeadingLabel(references, /^references:\s*/i),
controls: controlsMarkdown(rule, markers),
tables: tableReferencesForRule(rule, starts, tables, markers)
};
}
function ruleResultXmlMarkdown(details: CisCatRuleDetails): string {
if (!details.ruleResultXml) return '';
return `\`\`\`xml\n${details.ruleResultXml.slice(0, MAXIMUM_RULE_RESULT_XML_CHARACTERS)}\n\`\`\``;
}
function sourceField(
key: string,
label: string,
originalValue: JsonValue,
decodedValue: string,
source: SourceLocator
): SourceFieldCandidate {
return { key, label, originalValue, decodedValue, source };
}
function cellValue(table: ImportTable, rowIndex: number, columnIndex: number): JsonValue {
return table.rows.find(row => 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.2.0',
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 = /<div\b[^>]*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) {
const resultTable = document.tables.find(table =>
table.attributes.elementId === 'assessmentResultTable' || RESULT_TABLE_NAME.test(clean(table.name))
);
if (!resultTable) return { version: 1, candidates: [], diagnostics: [] };
const tableHeader = headers(resultTable);
if (!tableHeader) {
return {
version: 1,
candidates: [],
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: [],
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, diagnostics };
}
};