Assessor_2.0/src/importers/cis-cat-environment-context.ts

308 lines
15 KiB
TypeScript

/**
* 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 - <Benchmark>". */
const REPORT_TITLE = /<title\b[^>]*>([\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 = /<div\b[^>]*\bid\s*=\s*["']coverPageTitle["'][^>]*>([\s\S]{0,4000}?)<\/div\s*>/i;
/** Titelseite: Benchmarkzeile, Profil, Erhebungszeitpunkt und Dauer. */
const COVER_SUBTITLE_BLOCK = /<div\b[^>]*\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 = /<li\b[^>]*>([\s\S]{0,400}?)<\/li\s*>/gi;
const HEADING = /<h([12])\b[^>]*>([\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<string>();
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<string>();
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] : [])]
};
}