260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
import type { FindingRecord, RecordField } from '../../domain/finding';
|
|
import type { DatasetSheet } from '../../domain/dataset';
|
|
import type { CanonicalOutcome } from '../../domain/canonical-model';
|
|
import { normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
|
|
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
|
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
|
|
|
|
/** Ein benannter Ergebnisabschnitt des Results-Fensters. */
|
|
export interface ResultSection {
|
|
title: string;
|
|
value: string;
|
|
diagnostic?: MaesterExecutionError;
|
|
}
|
|
|
|
|
|
/** Profilgesteuerte Sichtbarkeit fachlicher Titel verknuepfter Belegtabellen. */
|
|
export function showResultSheetTitlesFor(toolId?: string): boolean {
|
|
if (toolId === 'group-policy') return true;
|
|
if (!toolId) return false;
|
|
return toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))
|
|
?.presentation.showResultSheetTitles ?? false;
|
|
}
|
|
|
|
/** Formatiert einen Belegtabellentitel ausschließlich über deklarative Profilregeln. */
|
|
export function resultSheetTitleFor(toolId: string | undefined, sheetName: string): string | undefined {
|
|
if (!toolId) return undefined;
|
|
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation;
|
|
if (!presentation?.showResultSheetTitles) return undefined;
|
|
const title = sheetName.trim();
|
|
const rule = presentation.resultSheetTitleRules?.find(candidate => candidate.pattern.test(title));
|
|
return rule ? title.replace(rule.pattern, rule.replacement).trim() : title;
|
|
}
|
|
/** Profilgesteuerte Markierung fachlich relevanter Belegtabellenzeilen. */
|
|
export function resultSheetRowHighlightedFor(toolId: string | undefined, record: FindingRecord): boolean {
|
|
if (!toolId) return false;
|
|
const patterns = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))
|
|
?.presentation.resultSheetHighlightedRowPatterns;
|
|
if (!patterns?.length) return false;
|
|
const value = record.fields.filter(field => field.value).map(field => field.value.trim()).join(' ').trim();
|
|
return patterns.some(pattern => pattern.test(value));
|
|
}
|
|
export type ResultSheetRowTone = 'criterion' | 'match' | 'violation' | 'highlight';
|
|
|
|
/** Profilgesteuerte semantische Farbe einer Belegzeile. */
|
|
export function resultSheetRowToneFor(toolId: string | undefined, record: FindingRecord, findingOutcome?: CanonicalOutcome): ResultSheetRowTone | undefined {
|
|
if (!toolId) return record.highlighted ? 'highlight' : undefined;
|
|
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation;
|
|
const value = record.fields.filter(field => field.value).map(field => field.value.trim()).join(' ').trim();
|
|
if (presentation?.resultSheetCriterionRowPatterns?.some(pattern => pattern.test(value))) return 'criterion';
|
|
const statusCells = presentation?.resultSheetRowStatusCells;
|
|
if (statusCells) {
|
|
const cells = record.fields.map(field => field.value.trim()).filter(Boolean);
|
|
const match = cells.some(cell => statusCells.match.some(pattern => pattern.test(cell)));
|
|
const violation = cells.some(cell => statusCells.violation.some(pattern => pattern.test(cell)));
|
|
// Contradicting markers in one row prove neither state.
|
|
if (match !== violation) return match ? 'match' : 'violation';
|
|
}
|
|
if (record.highlighted && presentation?.resultSheetEvaluatedRowTone === 'violation') {
|
|
if (findingOutcome === 'pass') return 'match';
|
|
if (findingOutcome === 'fail') return 'violation';
|
|
return undefined;
|
|
}
|
|
if (record.highlighted || presentation?.resultSheetHighlightedRowPatterns?.some(pattern => pattern.test(value))) return 'highlight';
|
|
return undefined;
|
|
}
|
|
|
|
/** Outcome des Findings, das die verknuepfte Ergebnistabelle fachlich bewertet. */
|
|
export function resultOutcomeFor(record: FindingRecord, fallbackToolId?: string): CanonicalOutcome | undefined {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const status = resultStatusFieldFor(record, fallbackToolId);
|
|
return outcomeForStatusValue(toolId, status?.value);
|
|
}
|
|
export interface PromotedResultSheetTitles {
|
|
tableTitles: ReadonlyMap<string, string>;
|
|
consumedSectionIndexes: ReadonlySet<number>;
|
|
}
|
|
|
|
function normalizedResultHeading(value: string): string {
|
|
return value.trim().replace(/:s*$/, '').toLocaleLowerCase('en-US');
|
|
}
|
|
|
|
/**
|
|
* Ordnet Markdown-Listenpunkte eines profilkonfigurierten Ergebnisabschnitts
|
|
* den Belegtabellen derselben Markdown-Ueberschrift in Quellreihenfolge zu.
|
|
* Ein Abschnitt wird nur ausgeblendet, wenn wirklich jeder seiner Punkte eine
|
|
* Tabelle erhalten hat; unvollstaendige Daten bleiben ansonsten sichtbar.
|
|
*/
|
|
export function promotedResultSheetTitlesFor(
|
|
toolId: string | undefined,
|
|
sections: ResultSection[],
|
|
sheets: Array<Pick<DatasetSheet, 'id' | 'name'>>
|
|
): PromotedResultSheetTitles {
|
|
const patterns = toolProfileDefinitions.find(profile => profile.id === toolId?.toLocaleLowerCase('en-US'))
|
|
?.presentation.resultSectionListSheetTitles;
|
|
if (!patterns?.length) return { tableTitles: new Map(), consumedSectionIndexes: new Set() };
|
|
|
|
const entriesByGroup = new Map<string, Array<{ title: string; sectionIndex: number }>>();
|
|
const totalsBySection = new Map<number, number>();
|
|
sections.forEach((section, sectionIndex) => {
|
|
if (!patterns.some(pattern => pattern.test(section.title.trim()))) return;
|
|
let group = '';
|
|
for (const line of section.value.replace(/\r\n?/g, '\n').split('\n')) {
|
|
const heading = /^\s*#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
if (heading) {
|
|
group = normalizedResultHeading(heading[1]);
|
|
continue;
|
|
}
|
|
const bullet = /^\s*[-*+]\s+(.+?)\s*$/.exec(line);
|
|
if (!bullet || !group) continue;
|
|
const entries = entriesByGroup.get(group) ?? [];
|
|
entries.push({ title: bullet[1].trim(), sectionIndex });
|
|
entriesByGroup.set(group, entries);
|
|
totalsBySection.set(sectionIndex, (totalsBySection.get(sectionIndex) ?? 0) + 1);
|
|
}
|
|
});
|
|
|
|
const assigned = new Map<string, { title: string; sectionIndex: number }>();
|
|
const assignedCounts = new Map<number, number>();
|
|
for (const sheet of sheets) {
|
|
const group = normalizedResultHeading(resultSheetTitleFor(toolId, sheet.name) ?? '');
|
|
const entry = entriesByGroup.get(group)?.shift();
|
|
if (!entry) continue;
|
|
assigned.set(sheet.id, entry);
|
|
assignedCounts.set(entry.sectionIndex, (assignedCounts.get(entry.sectionIndex) ?? 0) + 1);
|
|
}
|
|
|
|
const consumedSectionIndexes = new Set<number>();
|
|
for (const [sectionIndex, total] of totalsBySection) {
|
|
if (total > 0 && assignedCounts.get(sectionIndex) === total) consumedSectionIndexes.add(sectionIndex);
|
|
}
|
|
const tableTitles = new Map<string, string>();
|
|
for (const [sheetId, entry] of assigned) {
|
|
if (consumedSectionIndexes.has(entry.sectionIndex)) tableTitles.set(sheetId, entry.title);
|
|
}
|
|
return { tableTitles, consumedSectionIndexes };
|
|
}
|
|
/** Titel des werkzeuggebundenen Ergebnistextes (siehe `resultMessageFor`). */
|
|
const TOOL_MESSAGE_TITLE = 'Test result';
|
|
|
|
/**
|
|
* Werkzeuggebundener Ergebnistext eines Datensatzes, wie ihn Maester in einer
|
|
* Spalte "Result" liefert (siehe `resultMessageHeaders` im Toolprofil).
|
|
*
|
|
* Anders als bei Purple Knight, wo "Result" ein kurzes Statuswort ist und der
|
|
* eigentliche Text separat in "Result Message" steht, ist Maesters "Result"
|
|
* selbst schon der vollstaendige Befund - ohne eigene Ergebnisdatei oder
|
|
* Ergebnistabelle. Er gehoert deshalb in dasselbe Results-Fenster wie
|
|
* verknuepfte Ergebnistabellen, nicht in den Finding-Fliesstext: sonst stuende
|
|
* dieselbe Aussage doppelt da, einmal im Reader und einmal im Popup.
|
|
*/
|
|
export function resultMessageFor(record: FindingRecord, fallbackToolId?: string): string | undefined {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId)?.presentation;
|
|
const headers = presentation?.resultMessageHeaders;
|
|
if (!headers?.length) return undefined;
|
|
for (const header of headers) {
|
|
const field = record.fields.find(field => field.value && header.test(field.label.trim()) && isMessageField(field, record, presentation));
|
|
if (field) return field.value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isMessageField(field: RecordField, record: FindingRecord, presentation: (typeof toolProfileDefinitions)[number]['presentation'] | undefined): boolean {
|
|
if (!presentation?.resultMessageHeaders?.some(pattern => pattern.test(field.label.trim()))) return false;
|
|
const statuses = presentation.statusHeaders ?? [/^(?:status|evaluation|result)$/i];
|
|
if (!statuses.some(pattern => pattern.test(field.label.trim()))) return true;
|
|
if (!/^(?:pass(?:ed)?|fail(?:ed)?|investigate|skipped|not[ -]?run|error|unknown|manual)$/i.test(field.value.trim())) return true;
|
|
// An overlapping label is a message only when a separate populated status
|
|
// exists. Native reports keep Result as status and ResultDetail as evidence.
|
|
return record.fields.some(other => other.value && statuses.some(pattern => pattern.test(other.label.trim()))
|
|
&& !presentation.resultMessageHeaders?.some(pattern => pattern.test(other.label.trim())));
|
|
}
|
|
|
|
/** Waehlt den sichtbaren Status ueber die geordnete Profilpraeferenz. */
|
|
export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: string): RecordField | undefined {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId)?.presentation;
|
|
const headers = presentation?.statusHeaders ?? [/^(?:status|evaluation|result)$/i];
|
|
for (const header of headers) {
|
|
const field = record.fields.find(candidate =>
|
|
candidate.value
|
|
&& header.test(candidate.label.trim())
|
|
&& !(/^(?:result|evaluation)$/i.test(candidate.label.trim())
|
|
&& /^Security_Assessment_Report_.+\([A-Z]{2}\d{6}\)$/i.test(candidate.value.trim()))
|
|
&& !isMessageField(candidate, record, presentation));
|
|
if (field) {
|
|
const displayLabel = presentation?.statusDisplayRules
|
|
?.find(rule => rule.patterns.some(pattern => pattern.test(field.value.trim())))?.label;
|
|
return displayLabel ? { ...field, value: displayLabel } : field;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Menschlich lesbare Marke je kanonischem Outcome (Rueckfall ohne Quellwort). */
|
|
const OUTCOME_MARK_LABELS: Record<CanonicalOutcome, string> = {
|
|
fail: 'Failed', pass: 'Passed', warning: 'Warning', informational: 'Informational',
|
|
execution_error: 'Error', not_run: 'Not run', not_applicable: 'Not applicable',
|
|
not_evaluated: 'Not evaluated', unknown: 'Unknown'
|
|
};
|
|
|
|
export interface FindingOutcomeMark {
|
|
outcome: CanonicalOutcome;
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* Outcome-Marke eines rohen Work-Datensatzes fuer die kompakten Kartenlisten
|
|
* und den Reader-Kopf. Fuehrt das Werkzeug ein eigenes Statuswort, bleibt dieses
|
|
* die Beschriftung. Liefert ein Werkzeug seine Funde ohne Statusspalte (jede
|
|
* Defender-CVE ist eine Abweichung), traegt das Profil den Ausfall-Outcome
|
|
* (`normalization.missingStatusOutcome`) nach, damit die Liste nicht ohne
|
|
* Outcome-Hinweis bleibt. Werkzeuge ohne deklarierten Ausfall-Outcome behalten
|
|
* das bisherige Verhalten - ohne Statusfeld keine Marke.
|
|
*/
|
|
export function findingOutcomeMark(record: FindingRecord, fallbackToolId?: string): FindingOutcomeMark | undefined {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const statusField = resultStatusFieldFor(record, fallbackToolId);
|
|
if (statusField) {
|
|
return { outcome: outcomeForStatusValue(toolId, statusField.value) ?? 'unknown', label: statusField.value };
|
|
}
|
|
const outcome = normalizeStatusDimensionsFor(toolId, '').outcome;
|
|
return outcome === 'unknown' ? undefined : { outcome, label: OUTCOME_MARK_LABELS[outcome] };
|
|
}
|
|
/**
|
|
* Felder, die als eigener benannter Abschnitt in das Results-Fenster gehoeren
|
|
* (siehe `resultSectionHeaders` im Toolprofil).
|
|
*
|
|
* CIS-CAT versteckt Rule Result XML und die Rahmenwerk-Zuordnungen im Bericht
|
|
* hinter Aufklappverweisen. Sie sind damit Belegdaten derselben Art wie die
|
|
* ebenfalls versteckten Assessment-Evidence-Tabellen und stehen deshalb im
|
|
* selben Fenster - nicht in der Lesespalte. Der Reader blendet genau diese
|
|
* Felder aus (siehe `selectFindingFields`).
|
|
*/
|
|
export function resultSectionFields(record: FindingRecord, fallbackToolId?: string): RecordField[] {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const headers = toolProfileDefinitions.find(profile => profile.id === toolId)?.presentation.resultSectionHeaders;
|
|
if (!headers?.length) return [];
|
|
return record.fields.filter(field => field.value && headers.some(pattern => pattern.test(field.label.trim())));
|
|
}
|
|
|
|
/**
|
|
* Alle Textabschnitte des Results-Fensters eines Datensatzes: der
|
|
* werkzeuggebundene Ergebnistext zuerst, danach die aufgeklappten Belegtexte
|
|
* in Feldreihenfolge.
|
|
*/
|
|
export function resultSectionsFor(record: FindingRecord, fallbackToolId?: string): ResultSection[] {
|
|
const toolId = toolIdForRecord(record, fallbackToolId);
|
|
const message = resultMessageFor(record, fallbackToolId);
|
|
const diagnostic = toolId === 'maester' && message ? parseMaesterExecutionError(message) : undefined;
|
|
return [
|
|
...(message ? [{
|
|
title: TOOL_MESSAGE_TITLE,
|
|
value: message,
|
|
...(diagnostic ? { diagnostic } : {})
|
|
}] : []),
|
|
...resultSectionFields(record, fallbackToolId).map(field => ({ title: field.label, value: field.value }))
|
|
];
|
|
}
|