138 lines
6.9 KiB
TypeScript
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;
|
|
}
|