Assessor_2.0/src/features/linked-results/result-message.ts

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 }))
];
}