/** * Umgebungskontext eines CIS-CAT-Berichts. * * Der Bericht traegt neben den Regelergebnissen drei Angaben, die kein * einzelnes Ergebnis kennt: welches System bewertet wurde, gegen welchen * Benchmark, und wie der Bericht sein Gesamtergebnis selbst beziffert * (Gesamtscore, Ergebniszahlen je Zustand, Punktwert je Benchmarkabschnitt). * Bisher blieben diese Angaben ausschliesslich im Dateinamen und in * Quelltabellen liegen. * * Bewusste Grenzen dieser Auswertung: * * - Es wird nur gelesen, was der Bericht selbst beschriftet. Systemangaben * entstehen aus beschrifteten Wertepaaren ("Hostname: ..."), nicht aus einer * angenommenen Stelle im Aufbau. Fehlt die Beschriftung, entsteht keine * Beobachtung - die Ansicht zeigt dann einen Gedankenstrich statt eines * geratenen Wertes. * - Der Abschnittsscore stammt aus der Uebersichtstabelle des Berichts. Nur * deren oberste Gliederungsebene ("1 Account Policies") wird als Beobachtung * gefuehrt; die vollstaendige Tabelle bleibt als Berichtsabschnitt erhalten. * - Native CIS-CAT-Zahlen bleiben unter `benchmark.*` und werden nie in eine * werkzeuguebergreifende Kennzahl gemischt. */ import type { JsonValue } from '../domain/canonical-model'; import type { EnvironmentSubject } from '../domain/environment-context'; import type { DecodedSourceDocument, ImportTable } from '../domain/import-intermediate'; import type { EnvironmentObservationCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction'; import { clean, normalizeNumericValue } from '../shared/text'; import { markupText } from './html-extraction'; import { CIS_CAT_RESULT_CATEGORIES, extractCisCatSummary } from './cis-cat-summary'; /** Titelzeile des Berichts: "Benchmark Result - ". */ const REPORT_TITLE = /]*>([\s\S]{0,400}?)<\/title\s*>/i; const BENCHMARK_TITLE_PREFIX = /^benchmark\s+result\s*[-–—:]?\s*/i; /** * Ersatzweise steht in der Titelzeile die XCCDF-Ergebniskennung statt eines * lesbaren Namens: "xccdf_org.cisecurity.benchmarks_testresult_2.0.0_CIS_...". */ const XCCDF_TESTRESULT = /^xccdf[_.][\w.]*testresult[_.](\d+(?:\.\d+)*)[_.](.+)$/i; /** Titelseite: Berichtskopf mit bewertetem System und Zielnetzadresse. */ const COVER_TITLE_BLOCK = /]*\bid\s*=\s*["']coverPageTitle["'][^>]*>([\s\S]{0,4000}?)<\/div\s*>/i; /** Titelseite: Benchmarkzeile, Profil, Erhebungszeitpunkt und Dauer. */ const COVER_SUBTITLE_BLOCK = /]*\bid\s*=\s*["']coverPageSubTitle["'][^>]*>([\s\S]{0,4000}?)<\/div\s*>/i; const COVER_HOST = /^for\s+(.+)$/i; const COVER_IP = /^(?:target\s+)?ip\s*address\s*:\s*(.+)$/i; const COVER_BENCHMARK = /^(.*?)\s+v(\d+(?:\.\d+)*)$/; const COVER_DURATION = /^assessment\s+duration\s*:\s*(.+)$/i; const ASSESSOR_ENGINE = /CIS-CAT\s+Pro\s+Assessor\)?\s*v(\d+(?:\.\d+)*)/i; const LIST_ITEM = /]*>([\s\S]{0,400}?)<\/li\s*>/gi; const HEADING = /]*>([\s\S]{0,400}?)<\/h\1\s*>/gi; /** Punktwert der Bewertungsanzeige im Summary-Block. */ const GAUGE_SCORE = /data-score\s*=\s*["']?(-?\d+(?:[.,]\d+)?)["']?/i; /** Oberste Gliederungsebene der Abschnittsuebersicht ("1 Account Policies"). */ const TOP_LEVEL_SECTION = /^(\d+)\s+(.+)$/; const MAXIMUM_LABEL_SCAN_CHARACTERS = 200_000; /** * Beschriftete Systemangaben. Der Bericht fuehrt sie als Wertepaar - entweder * in einer zweispaltigen Tabelle oder als "Beschriftung: Wert" im Kopfbereich. */ const SYSTEM_FACTS: Array<[RegExp, string, string]> = [ [/^host\s*name$|^hostname$|^system\s*name$/i, 'host.name', 'Assessed host'], [/^operating\s*system$|^os\s*name$|^os$/i, 'operating_system.name', 'Operating system'], [/^ip\s*address(?:es)?$/i, 'host.ip_address', 'IP address'], [/^mac\s*address(?:es)?$/i, 'host.mac_address', 'MAC address'], [/^domain$|^domain\s*name$/i, 'host.domain', 'Host domain'], [/^assessment\s*(?:date|time|timestamp)$|^scan\s*(?:date|time)$/i, 'report.generated_at', 'Report generation date'], [/^benchmark\s*version$/i, 'benchmark.version', 'Benchmark version'], [/^profile$|^selected\s*profile$/i, 'benchmark.profile', 'Benchmark profile'] ]; function propertyKey(value: string): string { return value.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'value'; } function assessedSystem(document: DecodedSourceDocument): EnvironmentSubject { return { type: 'assessed-system', identity: document.file.name, label: 'CIS-CAT assessed system' }; } function benchmarkSubject(document: DecodedSourceDocument): EnvironmentSubject { return { type: 'benchmark-assessment', identity: document.file.name, label: 'CIS-CAT benchmark assessment' }; } function observation( document: DecodedSourceDocument, subject: EnvironmentSubject, property: string, label: string, value: string | number, objectPath: string ): EnvironmentObservationCandidate { const numeric = typeof value === 'number' ? value : normalizeNumericValue(value); return { version: 1, subject, property, label, originalValue: value as JsonValue, normalizedValue: (numeric !== null ? numeric : value) as JsonValue, valueType: numeric !== null ? 'number' : 'string', state: 'observed', sectionKey: 'cis-cat-assessment-summary', source: { fileName: document.file.name, objectPath }, attributes: {} }; } function benchmarkName(document: DecodedSourceDocument): string { const title = clean(markupText(REPORT_TITLE.exec(document.rawText)?.[1] ?? '')).replace(BENCHMARK_TITLE_PREFIX, ''); const identifier = XCCDF_TESTRESULT.exec(title); return identifier ? clean(identifier[2].replace(/[_.]+/g, ' ')) : title; } function blockItems(block: string, pattern: RegExp): string[] { pattern.lastIndex = 0; const items: string[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(block))) items.push(clean(markupText(match[match.length - 1] ?? ''))); return items.filter(Boolean); } /** * Angaben der Berichtstitelseite. * * CIS-CAT Pro fuehrt bewertetes System, Zielnetzadresse, Benchmark samt * Version, gewaehltes Profil, Erhebungszeitpunkt und Dauer ausschliesslich hier * - nicht als beschriftete Tabelle. Fehlt der Block, entsteht keine Beobachtung. */ function coverPageObservations(document: DecodedSourceDocument): EnvironmentObservationCandidate[] { const observations: EnvironmentObservationCandidate[] = []; const add = (subject: EnvironmentSubject, property: string, label: string, value: string, objectPath: string) => { if (value) observations.push(observation(document, subject, property, label, value, objectPath)); }; const title = COVER_TITLE_BLOCK.exec(document.rawText)?.[1]; if (title) { const host = blockItems(title, HEADING).flatMap(heading => COVER_HOST.exec(heading)?.[1] ?? []); add(assessedSystem(document), 'host.name', 'Assessed host', clean(host[0] ?? ''), 'html/cover/host'); const address = blockItems(title, LIST_ITEM).flatMap(item => COVER_IP.exec(item)?.[1] ?? []); add(assessedSystem(document), 'host.ip_address', 'IP address', clean(address[0] ?? ''), 'html/cover/ip-address'); } const subtitle = COVER_SUBTITLE_BLOCK.exec(document.rawText)?.[1]; if (subtitle) { const heading = blockItems(subtitle, HEADING)[0] ?? ''; const benchmark = COVER_BENCHMARK.exec(heading); add(benchmarkSubject(document), 'benchmark.name', 'Benchmark', clean(benchmark?.[1] ?? heading), 'html/cover/benchmark'); add(benchmarkSubject(document), 'benchmark.version', 'Benchmark version', clean(benchmark?.[2] ?? ''), 'html/cover/benchmark-version'); const items = blockItems(subtitle, LIST_ITEM); const duration = items.flatMap(item => COVER_DURATION.exec(item)?.[1] ?? []); const remaining = items.filter(item => !COVER_DURATION.test(item)); add(benchmarkSubject(document), 'benchmark.profile', 'Benchmark profile', remaining[0] ?? '', 'html/cover/profile'); add(benchmarkSubject(document), 'report.generated_at', 'Report generation date', remaining[1] ?? '', 'html/cover/generated-at'); add(benchmarkSubject(document), 'benchmark.assessment_duration', 'Assessment duration', clean(duration[0] ?? ''), 'html/cover/duration'); } const engine = ASSESSOR_ENGINE.exec(document.rawText)?.[1]; add(benchmarkSubject(document), 'report.engine_version', 'CIS-CAT engine version', clean(engine ?? ''), 'html/cover/engine'); return observations; } /** Wertepaare aus zweispaltigen Tabellen und beschrifteten Kopfangaben. */ function labelledFacts(document: DecodedSourceDocument): Array<{ label: string; value: string; objectPath: string }> { const pairs: Array<{ label: string; value: string; objectPath: string }> = []; document.tables.forEach(table => table.rows.forEach(row => { // Die Kopfzeile beschriftet die Spalten, nicht den Wert daneben. Ohne // diese Ausnahme laese sich aus "Profile | Selected" die Angabe // "Profile: Selected" ableiten. if (row.cells.length !== 2 || row.index === table.headerCandidates[0]?.rowIndex) return; const label = clean(row.cells[0]?.decodedValue).replace(/:$/, ''); const value = clean(row.cells[1]?.decodedValue); if (label && value) pairs.push({ label, value, objectPath: `html/tables/${table.index}/rows/${row.index}` }); })); const head = document.rawText.slice(0, MAXIMUM_LABEL_SCAN_CHARACTERS); const labelled = /(?:^|>|\n)\s*([A-Za-z][A-Za-z ]{2,28})\s*:\s*([^<\n]{1,160})/g; let match: RegExpExecArray | null; while ((match = labelled.exec(head))) { const label = clean(match[1]); const value = clean(match[2]); if (label && value) pairs.push({ label, value, objectPath: `html/facts/${propertyKey(label)}` }); } return pairs; } function systemObservations(document: DecodedSourceDocument): EnvironmentObservationCandidate[] { const seen = new Set(); return labelledFacts(document).flatMap(pair => { const fact = SYSTEM_FACTS.find(([pattern]) => pattern.test(pair.label)); if (!fact || seen.has(fact[1])) return []; seen.add(fact[1]); const subject = fact[1].startsWith('benchmark.') || fact[1] === 'report.generated_at' ? benchmarkSubject(document) : assessedSystem(document); return [observation(document, subject, fact[1], fact[2], pair.value, pair.objectPath)]; }); } function summaryObservations(document: DecodedSourceDocument): EnvironmentObservationCandidate[] { const observations: EnvironmentObservationCandidate[] = []; const name = benchmarkName(document); if (name) { observations.push(observation(document, benchmarkSubject(document), 'benchmark.name', 'Benchmark', name, 'html/title')); } const score = GAUGE_SCORE.exec(document.rawText)?.[1]; const numericScore = score === undefined ? null : normalizeNumericValue(score); if (numericScore !== null) { observations.push(observation( document, benchmarkSubject(document), 'benchmark.score', 'Benchmark score', numericScore, 'html/summary/gauge' )); } const summary = extractCisCatSummary(document.rawText); if (summary) { CIS_CAT_RESULT_CATEGORIES.forEach(category => observations.push(observation( document, benchmarkSubject(document), `benchmark.result_count.${category}`, `Recommendations ${category}`, summary.counts[category], `html/summary/${category}` ))); if (summary.scoredTotal !== undefined) observations.push(observation( document, benchmarkSubject(document), 'benchmark.scored_total', 'Scored recommendations', summary.scoredTotal, 'html/summary/scored-total' )); if (summary.scoredPassed !== undefined) observations.push(observation( document, benchmarkSubject(document), 'benchmark.scored_passed', 'Scored recommendations passed', summary.scoredPassed, 'html/summary/scored-passed' )); } return observations; } /** Uebersichtstabelle mit Punktwert je Benchmarkabschnitt. */ function sectionScoreTable(document: DecodedSourceDocument): ImportTable | undefined { return document.tables.find(table => { const headers = table.headerCandidates[0]?.headers.map(value => clean(value)) ?? []; const has = (pattern: RegExp) => headers.some(header => pattern.test(header)); return has(/^description$/i) && has(/^pass$/i) && has(/^fail$/i) && has(/^percent$/i); }); } function sectionScoreContext(document: DecodedSourceDocument): { observations: EnvironmentObservationCandidate[]; section?: ScannerReportSectionCandidate; } { const table = sectionScoreTable(document); const header = table?.headerCandidates[0]; if (!table || !header) return { observations: [] }; const headers = header.headers.map(value => clean(value)); const column = (pattern: RegExp) => headers.findIndex(value => pattern.test(value)); const descriptionIndex = column(/^description$/i); const percentIndex = column(/^percent$/i); const rows = table.rows.filter(row => row.index !== header.rowIndex); const observations = rows.flatMap(row => { const description = clean(row.cells[descriptionIndex]?.decodedValue); const section = TOP_LEVEL_SECTION.exec(description); const percent = normalizeNumericValue(row.cells[percentIndex]?.decodedValue); if (!section || percent === null) return []; return [observation( document, { type: 'benchmark-section', identity: section[1], label: clean(section[2]) }, `benchmark.section_score.${section[1]}`, clean(section[2]), percent, `html/tables/${table.index}/rows/${row.index}` )]; }); return { observations, section: { version: 1, sectionKey: 'cis-cat-section-scores', label: 'Benchmark section scores', ordinal: 1, state: rows.length ? 'observed' : 'empty', tables: [{ name: 'Section scores', sourceTableIndex: table.index, headers, rows: rows.map(row => headers.map((_, index) => clean(row.cells[index]?.decodedValue))) }], source: { fileName: document.file.name, tableIndex: table.index }, attributes: { rowCount: rows.length } } }; } export function cisCatEnvironmentContext(document: DecodedSourceDocument): { environmentObservations: EnvironmentObservationCandidate[]; reportSections: ScannerReportSectionCandidate[]; } { const sectionScores = sectionScoreContext(document); // Reihenfolge ist die Rangfolge: die Titelseite nennt System und Benchmark // ausdruecklich, beschriftete Wertepaare sind nur der Rueckfall. const seen = new Set(); const environmentObservations = [ ...coverPageObservations(document), ...systemObservations(document), ...summaryObservations(document), ...sectionScores.observations ].filter(item => !seen.has(item.property) && seen.add(item.property)); const summarySection: ScannerReportSectionCandidate = { version: 1, sectionKey: 'cis-cat-assessment-summary', label: 'CIS-CAT assessment summary', ordinal: 0, state: environmentObservations.length ? 'observed' : 'empty', tables: [], source: { fileName: document.file.name, objectPath: 'html/summary' }, attributes: { observationCount: environmentObservations.length } }; return { environmentObservations, reportSections: [summarySection, ...(sectionScores.section ? [sectionScores.section] : [])] }; }