645 lines
28 KiB
TypeScript
645 lines
28 KiB
TypeScript
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 };
|
||
}
|
||
};
|