308 lines
15 KiB
TypeScript
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] : [])]
|
|
};
|
|
}
|