Assessor_2.0/src/importers/status-normalization.ts

138 lines
6.9 KiB
TypeScript

import type {
CanonicalFindingStatus,
CanonicalOutcome,
CanonicalTechnicalExecutionState
} from '../domain/canonical-model';
import type { FindingRecord } from '../domain/finding';
import { outcomeFromLegacyStatus, technicalStateFromLegacyStatus } from '../domain/unified-result';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
import { clean } from '../shared/text';
export interface StatusDimensions {
legacy: CanonicalFindingStatus;
outcome: CanonicalOutcome;
technical: CanonicalTechnicalExecutionState;
}
const UNKNOWN_DIMENSIONS: StatusDimensions = { legacy: 'unknown', outcome: 'unknown', technical: 'unknown' };
/**
* Werkzeugunabhaengige Rueckfallregeln fuer Quellen ohne (oder mit fuer diesen
* Wert unvollstaendigem) deklariertem Profil-Mapping.
*
* Die Reihenfolge ist bedeutungstragend: verneinende und mehrwortige
* Formulierungen zuerst, damit "failed to run" nicht als fachliches Scheitern
* und "not passed" nicht als Bestehen gelesen wird. Die beiden unspezifischen
* Teilstring-Regeln am Ende bilden das frueher einzige Verhalten ab und bleiben
* erhalten, damit bereits funktionierende Quellformate nicht regressieren.
*
* `outcome` wird nur gesetzt, wo es bewusst von der Ableitung aus dem
* Legacy-Status abweicht (siehe `matched|detected`: ein Treffer ist ein
* Hinweis, kein bestandener oder fehlgeschlagener Test).
*/
const fallbackRules: Array<{ pattern: RegExp; legacy: CanonicalFindingStatus; outcome?: CanonicalOutcome }> = [
{ pattern: /^not configured$/, legacy: 'open', outcome: 'informational' },
// A retained setting without an applicable baseline rule is evidence, not a deviation.
{ pattern: /^no baseline rule$/, legacy: 'open', outcome: 'informational' },
// A Group Policy Preference is retained evidence, never a baseline verdict.
{ pattern: /^not comparable$/, legacy: 'open', outcome: 'informational' },
{ pattern: /^misconfigured$/, legacy: 'failed' },
{ pattern: /failed\s*to\s*run|execution.?error|technical.?error/, legacy: 'error' },
{ pattern: /not\s*(?:applicable|relevant)|irrelevant|nicht\s*(?:anwendbar|relevant)/, legacy: 'not-applicable' },
{ pattern: /not\s*(?:run|selected|executed)|nicht\s*(?:ausgef(?:ue|ü)hrt|ausgew(?:ae|ä)hlt)|skipped|cancell?ed|abgebrochen/, legacy: 'not-run' },
{ pattern: /not\s*pass(?:ed)?|nicht\s*bestanden/, legacy: 'failed' },
{ pattern: /ioe(?:s)?\s*(?:found|detected)|indicator\s*of\s*exposure/, legacy: 'failed' },
{ pattern: /resolved|closed|behoben/, legacy: 'resolved' },
{ pattern: /^pass(?:ed)?$|bestanden/, legacy: 'passed' },
{ pattern: /^fail(?:ed)?$|fehlgeschlagen/, legacy: 'failed' },
{ pattern: /matched|detected|gefunden/, legacy: 'open', outcome: 'warning' },
{ pattern: /error|fehler/, legacy: 'error' },
{ pattern: /manual/, legacy: 'manual' },
{ pattern: /investigate|pr(?:ue|ü)fen/, legacy: 'investigate' },
{ pattern: /^conflicts?$|^duplicates?$/, legacy: 'open', outcome: 'warning' },
{ pattern: /open|offen/, legacy: 'open' },
{ pattern: /pass/, legacy: 'passed' },
{ pattern: /fail/, legacy: 'failed' }
];
const definitionsByToolId = new Map(toolProfileDefinitions.map(definition => [definition.id, definition]));
function dimensionsFor(legacy: CanonicalFindingStatus, outcome?: CanonicalOutcome): StatusDimensions {
return {
legacy,
outcome: outcome ?? outcomeFromLegacyStatus(legacy),
technical: technicalStateFromLegacyStatus(legacy)
};
}
/**
* Wendet zuerst das im Werkzeugprofil deklarierte Status-Mapping an
* (ToolProfileDefinition.normalization.status, z. B. commonStatusMappings mit
* "IOEs found" -> failed fuer Purple Knight) und faellt nur dann auf die
* generischen Regeln zurueck, wenn das Profil den Quellwert nicht kennt.
*
* Zuvor war dieses deklarierte Mapping vollstaendig unbenutzt: die Pipeline
* besass eine eigene, unvollstaendige Regexliste ohne Purple-Knight-Vokabular,
* wodurch dessen Befunde ("IOEs found", "Not Relevant", "Not selected",
* "Canceled") als unknown statt fail/not_applicable/not_run in der
* Result-Tabelle landeten - und die echten Expositionen damit auch nie als
* is_ioe markiert wurden.
*/
export function normalizeStatusDimensionsFor(toolId: string | undefined, value: string): StatusDimensions {
const cleaned = clean(value);
if (!cleaned) return UNKNOWN_DIMENSIONS;
const declared = toolId ? definitionsByToolId.get(toolId)?.normalization.status : undefined;
const declaredMatch = declared?.find(mapping => mapping.patterns.some(pattern => pattern.test(cleaned)));
if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue);
const fallback = fallbackRules.find(rule => rule.pattern.test(cleaned.toLocaleLowerCase('de-DE')));
return fallback ? dimensionsFor(fallback.legacy, fallback.outcome) : UNKNOWN_DIMENSIONS;
}
export function normalizeStatusFor(toolId: string | undefined, value: string): CanonicalFindingStatus {
return normalizeStatusDimensionsFor(toolId, value).legacy;
}
const toolIdByName = new Map(toolProfileDefinitions.flatMap(definition =>
[definition.name, ...(definition.runNames ?? [])].map(name => [name.toLocaleLowerCase('en-US'), definition.id] as const)));
/** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */
const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i;
/**
* Werkzeug-ID eines einzelnen Datensatzes.
*
* Die Projektaggregation in Work mischt die Ergebnisse aller Werkzeuge eines
* Mandanten in einem Blatt und traegt deshalb die Profil-ID `generic`. Wird der
* Status damit normalisiert, greifen die generischen Rueckfallregeln statt des
* deklarierten Werkzeugmappings - PingCastles `Matched` gilt dort als blosser
* Hinweis (`warning`) statt als erkannte Exposition (`fail`), und Work weicht
* von der Result DB ab. Die Herkunft je Datensatz aufzuloesen verhindert genau
* das.
*/
export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string): string | undefined {
const field = record.fields.find(candidate =>
SCAN_PROVENANCE_LABEL.test(candidate.label.trim()) && candidate.value);
const toolName = clean(field?.value.split(/\s*(?:\u00c2\u00b7|\u00b7)\s*/)[0] ?? '').toLocaleLowerCase('en-US');
return toolName === 'gpos' ? 'group-policy' : toolIdByName.get(toolName) ?? fallbackToolId;
}
/**
* Outcome eines rohen Statusfeldes, wie es die Arbeitsansichten anzeigen.
*
* Work arbeitet auf der Rohprojektion des Datasets und kennt den kanonischen
* Outcome eines Vorkommens nicht. Ohne diese Ableitung sehen dort `Passed`,
* `Failed` und `Investigate` identisch aus, obwohl sie fachlich das Gegenteil
* voneinander bedeuten. Die Ableitung verwendet dieselbe werkzeuggebundene
* Normalisierung wie der Importpfad, damit Work und Result DB denselben Wert
* zeigen.
*/
export function outcomeForStatusValue(
toolId: string | undefined,
value: string | undefined
): CanonicalOutcome | undefined {
if (!clean(value ?? '')) return undefined;
return normalizeStatusDimensionsFor(toolId, value as string).outcome;
}