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; consumedSectionIndexes: ReadonlySet; } 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> ): 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>(); const totalsBySection = new Map(); 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(); const assignedCounts = new Map(); 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(); for (const [sectionIndex, total] of totalsBySection) { if (total > 0 && assignedCounts.get(sectionIndex) === total) consumedSectionIndexes.add(sectionIndex); } const tableTitles = new Map(); 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 = { 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 })) ]; }