Assessor_2.0/src/domain/unified-result.ts

858 lines
39 KiB
TypeScript

import type {
Assessment,
AssessmentProject,
CanonicalExposureState,
CanonicalOutcome,
CanonicalFindingStatus,
CanonicalResultType,
CanonicalSeverity,
CanonicalTechnicalExecutionState,
FindingDefinition,
FindingOccurrence,
FindingReview,
ImportFileRecord,
JsonValue,
ReviewPriority,
ScanRun,
SourcePosition
} from './canonical-model';
import type { PolicyFindingOccurrence } from './policy-finding';
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
import type { ImportArtifact, ImportProcessingRun } from './ingestion-persistence';
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis, StoredGpoPolicyIdentityResolution } from './gpo-analysis-persistence';
import type { GpoPolicyObservation } from './gpo-policy';
import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-contract-mapping';
import { isDeviation } from './deviation-predicate';
import { severityRank } from './severity-order';
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation';
export type UnifiedOutcome = CanonicalOutcome;
export type TechnicalExecutionState = CanonicalTechnicalExecutionState;
export type UnifiedReviewStatus = 'unreviewed' | 'open' | 'in-progress' | 'resolved';
export type UnifiedReviewDisposition = 'accepted' | 'deferred' | 'not-relevant';
export type UnifiedLifecycleState = 'new' | 'persistent' | 'resolved' | 'reoccurred' | 'unknown';
export type UnifiedEffectiveState =
| 'confirmed_configured_deviation' | 'configured_match' | 'not_applicable'
| 'unsupported' | 'unresolved_identity' | 'incomplete_observation'
| 'missing_baseline_recommendation' | 'not_evaluated' | 'unknown_effective_state';
export interface UnifiedResultCategory {
namespace: string;
categoryCode: string;
categoryLabel: string;
categoryType: 'source' | 'canonical';
isPrimary: boolean;
sourceTool?: string;
sourceProfileId?: string;
sourceProfileVersion?: string;
}
export interface UnifiedResultProjection {
resultProjectionId: string;
sourceKind: UnifiedResultSourceKind;
sourceResultId: string;
projectId: string;
assessmentId?: string;
collectionRunId: string;
scanRunId?: string;
gpoSnapshotId?: string;
evaluationRunId?: string;
sourceTool?: string;
sourceToolVersion?: string;
sourceProfileId?: string;
sourceProfileVersion?: string;
mappingRuleVersion?: string;
normalizationRuleVersion?: string;
presentationVersion?: string;
processingCompatibilityKey?: string;
processingStatus?: ProcessingReadinessStatus;
reprocessingAvailable?: boolean;
sourceRuleId?: string;
canonicalDefinitionId?: string;
importArtifactId?: string;
sourceViewId?: string;
sourceLocator?: SourcePosition;
environmentProperty?: string;
environmentSubjectType?: string;
environmentSubjectIdentity?: string;
environmentValueType?: EnvironmentValueType;
environmentState?: EnvironmentCollectionState;
environmentOriginalValue?: JsonValue;
environmentNormalizedValue?: JsonValue;
environmentSectionKey?: string;
environmentAttributes?: Record<string, JsonValue>;
policyScope?: GpoPolicyObservation['scope'];
policyDeliveryMechanism?: GpoPolicyObservation['deliveryMechanism'];
policyState?: GpoPolicyObservation['state'];
policyIdentityStatus?: GpoPolicyIdentityResolutionStatus;
policyIdentityKey?: string;
policyValues?: JsonValue[];
policyDiagnosticCodes?: string[];
policyBackupInstanceId?: string;
title: string;
description?: string;
recommendation?: string;
affectedAsset?: string;
score?: number;
severityOriginal: JsonValue;
severityNormalized: CanonicalSeverity;
outcomeOriginal: JsonValue;
outcomeNormalized: UnifiedOutcome;
technicalExecutionState: TechnicalExecutionState;
/**
* RESULT_CONTRACT-Facetten (docs/RESULT_CONTRACT.md), additiv realisiert
* über src/domain/result-contract-mapping.ts. exposureState existiert nur
* bei resultType = 'exposure_finding'.
*/
resultType: CanonicalResultType;
isIoe: boolean;
/**
* Abgeleitet ueber src/domain/deviation-predicate.ts, nicht persistiert.
* Bewusst breiter als `isIoe`, damit Funde ohne festgestellte Exposition
* (Outcome `warning`) nicht aus den Abweichungsansichten fallen.
*/
isDeviation: boolean;
exposureState?: CanonicalExposureState;
effectiveState?: UnifiedEffectiveState;
lifecycleState: UnifiedLifecycleState;
mappingConfidence: number;
applicabilityState?: string;
completenessState?: string;
scopeState?: 'known' | 'unknown';
effectivenessVerification?: 'verified' | 'not-verified';
scopeConfidence?: number;
reviewStatus: UnifiedReviewStatus;
reviewDisposition?: UnifiedReviewDisposition;
reviewPriority?: ReviewPriority;
reviewOwner?: string;
reviewDueDate?: string;
sourceObservedAt?: string;
importedAt: string;
evaluatedAt?: string;
createdAt: string;
categories: UnifiedResultCategory[];
}
export interface UnifiedResultQuery {
projectId?: string;
assessmentId?: string;
assessmentIds?: string[];
scanRunId?: string;
scanRunIds?: string[];
evaluationRunId?: string;
sourceKind?: UnifiedResultSourceKind[];
sourceTool?: string[];
severity?: CanonicalSeverity[];
outcome?: UnifiedOutcome[];
technicalExecutionState?: TechnicalExecutionState[];
resultType?: CanonicalResultType[];
isIoe?: boolean;
deviation?: boolean;
mappingRuleVersion?: string[];
sourceProfileVersion?: string[];
processingStatus?: ProcessingReadinessStatus[];
reviewDisposition?: UnifiedReviewDisposition[];
lifecycleState?: UnifiedLifecycleState[];
categoryNamespace?: string[];
categoryValue?: string[];
affectedAsset?: string;
environmentState?: EnvironmentCollectionState[];
environmentProperty?: string[];
environmentSubjectType?: string[];
search?: string;
observedFrom?: string;
observedTo?: string;
offset?: number;
limit?: number;
}
export interface UnifiedResultQueryResult {
total: number;
items: UnifiedResultProjection[];
}
export interface UnifiedResultFacetValue {
value: string;
label: string;
count: number;
namespace?: string;
}
export interface UnifiedResultFacet {
id: 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
| 'reviewStatus' | 'reviewDisposition' | 'lifecycleState' | 'effectiveState' | 'category'
| 'resultType' | 'isIoe' | 'deviation' | 'mappingRuleVersion' | 'sourceProfileVersion' | 'processingStatus'
| 'environmentState' | 'environmentProperty' | 'environmentSubjectType';
label: string;
values: UnifiedResultFacetValue[];
}
export interface UnifiedResultFacetResult {
total: number;
facets: UnifiedResultFacet[];
}
export interface ScannerUnifiedResultSource {
project: AssessmentProject;
assessment: Assessment;
scanRun: ScanRun;
importFile: ImportFileRecord;
definition: FindingDefinition;
occurrence: FindingOccurrence;
reviews: FindingReview[];
}
export interface PolicyUnifiedResultSource {
definition: FindingDefinition;
occurrence: PolicyFindingOccurrence;
}
export interface EnvironmentUnifiedResultSource {
project: AssessmentProject;
assessment: Assessment;
scanRun: ScanRun;
importFile: ImportFileRecord;
observation: EnvironmentObservation;
}
type GpoPolicyIdentityResolutionStatus = StoredGpoPolicyIdentityResolution['resolution']['status'];
export interface PolicyObservationUnifiedResultSource {
artifact: ImportArtifact;
backup: StoredGpoBackupInstance;
observation: GpoPolicyObservation;
resolution?: StoredGpoPolicyIdentityResolution;
}
export function outcomeFromLegacyStatus(status: CanonicalFindingStatus): UnifiedOutcome {
if (status === 'failed') return 'fail';
if (status === 'passed') return 'pass';
if (status === 'error') return 'execution_error';
if (status === 'not-run') return 'not_run';
if (status === 'not-applicable') return 'not_applicable';
if (status === 'manual') return 'informational';
if (status === 'investigate') return 'warning';
return 'unknown';
}
export function technicalStateFromLegacyStatus(status: CanonicalFindingStatus): TechnicalExecutionState {
if (status === 'error') return 'failed';
if (status === 'not-run') return 'not-run';
if (status === 'unknown') return 'unknown';
return 'completed';
}
function matchingReview(source: ScannerUnifiedResultSource): FindingReview | undefined {
return source.reviews
.filter(review => review.target.kind === 'occurrence'
? review.target.occurrenceId === source.occurrence.id
: review.target.definitionId === source.definition.id)
.sort((left, right) => {
const leftOccurrence = left.target.kind === 'occurrence' ? 1 : 0;
const rightOccurrence = right.target.kind === 'occurrence' ? 1 : 0;
return rightOccurrence - leftOccurrence || right.revision - left.revision || right.id.localeCompare(left.id);
})[0];
}
function reviewDimensions(review?: FindingReview): {
status: UnifiedReviewStatus;
disposition?: UnifiedReviewDisposition;
} {
if (!review || review.workflowStatus === 'unreviewed') return { status: 'unreviewed' };
if (review.workflowStatus === 'open') return { status: 'open' };
if (review.workflowStatus === 'in-progress') return { status: 'in-progress' };
if (review.workflowStatus === 'accepted') return { status: 'resolved', disposition: 'accepted' };
if (review.workflowStatus === 'deferred') return { status: 'resolved', disposition: 'deferred' };
if (review.workflowStatus === 'not-relevant') return { status: 'resolved', disposition: 'not-relevant' };
return { status: 'resolved' };
}
function categoryStrings(value: JsonValue): string[] {
if (typeof value === 'string') return value.trim() ? [value.trim()] : [];
if (Array.isArray(value)) return value.flatMap(categoryStrings);
return [];
}
export function scannerResultCategories(
definition: FindingDefinition,
occurrence: FindingOccurrence,
importFile: ImportFileRecord
): UnifiedResultCategory[] {
const values = [
...(definition.category?.trim() ? [definition.category.trim()] : []),
...occurrence.fieldMappings
.filter(mapping => mapping.targetRole === 'category')
.flatMap(mapping => categoryStrings(mapping.normalizedValue))
];
const unique = [...new Set(values)];
return unique.map((value, index) => ({
namespace: occurrence.sourceIdentity.tool.id,
categoryCode: value,
categoryLabel: value,
categoryType: 'source',
isPrimary: index === 0,
sourceTool: occurrence.sourceIdentity.tool.id,
sourceProfileId: importFile.processedWith.profileId,
sourceProfileVersion: importFile.processedWith.profileVersion
}));
}
function mappingConfidence(occurrence: FindingOccurrence): number {
const scores = [
occurrence.severity.provenance.confidence.score,
occurrence.status.provenance.confidence.score,
occurrence.outcome?.provenance.confidence.score,
occurrence.technicalExecutionState?.provenance.confidence.score,
...occurrence.fieldMappings.map(mapping => mapping.provenance.confidence.score)
].filter((score): score is number => score !== undefined && Number.isFinite(score));
return scores.length ? Math.min(...scores) : 0;
}
export function createScannerUnifiedResult(source: ScannerUnifiedResultSource): UnifiedResultProjection {
const review = matchingReview(source);
const dimensions = reviewDimensions(review);
const outcomeNormalized = source.occurrence.outcome?.normalizedValue
?? outcomeFromLegacyStatus(source.occurrence.status.normalizedValue ?? 'unknown');
const resultType = source.occurrence.resultType
?? deriveResultType(source.occurrence.sourceIdentity.tool.id);
const exposureState = source.occurrence.exposureState
?? deriveExposureState(resultType, outcomeNormalized);
const isIoe = source.occurrence.isIoe
?? deriveIsIoe(resultType, exposureState).isIoe;
return {
resultProjectionId: `scanner-finding:${source.occurrence.id}`,
sourceKind: 'scanner-finding',
sourceResultId: source.occurrence.id,
projectId: source.project.id,
assessmentId: source.assessment.id,
collectionRunId: source.scanRun.id,
scanRunId: source.scanRun.id,
sourceTool: source.occurrence.sourceIdentity.tool.id,
sourceToolVersion: source.occurrence.sourceIdentity.tool.version,
sourceProfileId: source.importFile.processedWith.profileId,
sourceProfileVersion: source.importFile.processedWith.profileVersion,
mappingRuleVersion: source.occurrence.mappingRuleVersion ?? source.importFile.processedWith.mappingVersion,
normalizationRuleVersion: source.importFile.processedWith.normalizationVersion,
presentationVersion: source.importFile.processedWith.presentationVersion,
processingCompatibilityKey: source.importFile.processedWith.compatibilityKey,
processingStatus: processingReadiness(source.importFile.processedWith.profileId, source.importFile.processedWith),
reprocessingAvailable: source.importFile.sourceArtifact.kind !== 'not-retained',
sourceRuleId: source.occurrence.sourceIdentity.originalRuleId
?? source.occurrence.sourceIdentity.canonicalRuleId,
canonicalDefinitionId: source.definition.id,
importArtifactId: source.importFile.id,
sourceViewId: source.occurrence.sourceTableId,
sourceLocator: source.occurrence.sourcePosition,
title: source.definition.title,
description: source.definition.description,
recommendation: source.definition.recommendation,
affectedAsset: source.occurrence.affectedAsset?.normalizedValue ?? undefined,
score: source.occurrence.score?.normalizedValue ?? undefined,
severityOriginal: source.occurrence.severity.originalValue,
severityNormalized: source.occurrence.severity.normalizedValue ?? 'unknown',
outcomeOriginal: source.occurrence.outcome?.originalValue ?? source.occurrence.status.originalValue,
outcomeNormalized: outcomeNormalized,
technicalExecutionState: source.occurrence.technicalExecutionState?.normalizedValue
?? technicalStateFromLegacyStatus(source.occurrence.status.normalizedValue ?? 'unknown'),
resultType,
isIoe,
isDeviation: isDeviation(isIoe, outcomeNormalized),
exposureState,
lifecycleState: 'unknown',
mappingConfidence: mappingConfidence(source.occurrence),
reviewStatus: dimensions.status,
reviewDisposition: dimensions.disposition,
reviewPriority: review?.priority,
reviewOwner: review?.owner,
reviewDueDate: review?.dueAt,
sourceObservedAt: source.occurrence.observedAt,
importedAt: source.importFile.importedAt,
createdAt: source.importFile.importedAt,
categories: scannerResultCategories(source.definition, source.occurrence, source.importFile)
};
}
export function createPolicyUnifiedResult(source: PolicyUnifiedResultSource): UnifiedResultProjection {
const { definition, occurrence } = source;
return {
resultProjectionId: `policy-evaluation:${occurrence.id}`,
sourceKind: 'policy-evaluation',
sourceResultId: occurrence.id,
projectId: occurrence.projectId,
collectionRunId: occurrence.evaluationRunId,
gpoSnapshotId: occurrence.snapshotId,
evaluationRunId: occurrence.evaluationRunId,
sourceTool: 'group-policy-baseline',
sourceProfileId: occurrence.environmentProfileId,
sourceProfileVersion: String(occurrence.environmentProfileRevision),
mappingRuleVersion: 'policy-finding-factory-1.0.0',
normalizationRuleVersion: 'policy-finding-factory-1.0.0',
presentationVersion: 'policy-finding-factory-1.0.0',
processingCompatibilityKey: 'policy-finding-canonical-v1',
processingStatus: 'current',
reprocessingAvailable: true,
sourceRuleId: occurrence.riskRuleId,
canonicalDefinitionId: definition.id,
sourceViewId: occurrence.aggregationStateId,
title: definition.title,
description: definition.description,
recommendation: definition.recommendation,
severityOriginal: occurrence.severity,
severityNormalized: occurrence.severity,
outcomeOriginal: occurrence.classification,
outcomeNormalized: occurrence.outcome,
technicalExecutionState: 'completed',
resultType: 'policy_deviation',
isIoe: false,
isDeviation: true,
effectiveState: 'unknown_effective_state',
lifecycleState: 'unknown',
mappingConfidence: 1,
applicabilityState: 'explicit-recommendation',
completenessState: 'complete',
scopeState: occurrence.scopeState,
effectivenessVerification: occurrence.effectiveState,
scopeConfidence: 0,
reviewStatus: 'unreviewed',
importedAt: occurrence.createdAt,
evaluatedAt: occurrence.createdAt,
createdAt: occurrence.createdAt,
categories: [{
namespace: 'assessor-policy',
categoryCode: definition.category ?? 'group-policy-baseline',
categoryLabel: definition.category ?? 'group-policy-baseline',
categoryType: 'canonical',
isPrimary: true,
sourceTool: 'group-policy-baseline',
sourceProfileId: occurrence.environmentProfileId,
sourceProfileVersion: String(occurrence.environmentProfileRevision)
}]
};
}
function policyObservationTitle(observation: GpoPolicyObservation): string {
const section = observation.identity.section.trim();
const key = observation.identity.key.trim();
return [section, key].filter(Boolean).join(' · ');
}
function policyObservationLocator(observation: GpoPolicyObservation): SourcePosition {
const suffix = observation.sourceLines?.length
? `#lines=${observation.sourceLines.join(',')}`
: observation.sourceRecords?.length
? `#records=${observation.sourceRecords.map(record => record.index).join(',')}`
: '';
return { objectPath: observation.sourcePath + suffix };
}
/**
* Projects a parsed GPO setting as reviewable evidence. This is deliberately
* not a policy deviation: only the Finding Factory may create one after a
* complete baseline evaluation. The projection makes imported GPO content
* visible without weakening that safety boundary.
*/
export function createPolicyObservationUnifiedResult(source: PolicyObservationUnifiedResultSource): UnifiedResultProjection {
const { artifact, backup, observation, resolution } = source;
const resolved = resolution?.resolution.status === 'resolved' ? resolution.resolution.identity : undefined;
const unresolvedReason = resolution?.resolution.status === 'unresolved'
? resolution.resolution.reasonCode
: 'policy-identity-resolution-missing';
const policyIdentityKey = resolved?.id
?? `${observation.identity.namespace}:${observation.identity.section}:${observation.identity.key}`;
const diagnosticCodes = [...new Set([
...observation.diagnosticCodes,
...(resolved ? [] : [unresolvedReason])
])].sort();
const needsReview = !resolved || observation.state === 'conflicted' || diagnosticCodes.length > 0;
return {
resultProjectionId: `policy-observation:${observation.id}`,
sourceKind: 'policy-observation',
sourceResultId: observation.id,
projectId: backup.projectId,
assessmentId: artifact.assessmentId,
collectionRunId: backup.processingRunId,
sourceTool: 'group-policy',
sourceProfileId: observation.parser.id,
sourceProfileVersion: observation.parser.version,
mappingRuleVersion: resolved?.resolver.version ?? resolution?.analysisRunId ?? 'unresolved',
normalizationRuleVersion: observation.reducer.version,
presentationVersion: 'gpo-policy-observation-1.0.0',
processingCompatibilityKey: 'gpo-policy-observation-v1',
processingStatus: 'current',
reprocessingAvailable: true,
sourceRuleId: resolved?.id,
importArtifactId: artifact.id,
sourceViewId: backup.id,
sourceLocator: policyObservationLocator(observation),
title: policyObservationTitle(observation),
description: resolved
? 'Imported Group Policy setting with a resolved technical identity.'
: `Imported Group Policy setting requiring identity review (${unresolvedReason}).`,
severityOriginal: null,
severityNormalized: 'unknown',
outcomeOriginal: observation.state,
outcomeNormalized: needsReview ? 'warning' : 'informational',
technicalExecutionState: 'completed',
resultType: 'informational',
isIoe: false,
isDeviation: false,
effectiveState: resolved ? 'not_evaluated' : 'unresolved_identity',
lifecycleState: 'unknown',
mappingConfidence: resolved ? 1 : 0,
completenessState: backup.instance.inventory.status,
scopeState: 'unknown',
effectivenessVerification: 'not-verified',
scopeConfidence: 0,
reviewStatus: needsReview ? 'open' : 'unreviewed',
sourceObservedAt: backup.instance.backupCreatedAt ?? backup.createdAt,
importedAt: artifact.createdAt,
createdAt: backup.createdAt,
policyScope: observation.scope,
policyDeliveryMechanism: observation.deliveryMechanism,
policyState: observation.state,
policyIdentityStatus: resolved ? 'resolved' : 'unresolved',
policyIdentityKey,
policyValues: observation.values as unknown as JsonValue[],
policyDiagnosticCodes: diagnosticCodes,
policyBackupInstanceId: backup.instance.id,
categories: [{
namespace: 'group-policy',
categoryCode: observation.deliveryMechanism,
categoryLabel: observation.deliveryMechanism,
categoryType: 'source',
isPrimary: true,
sourceTool: 'group-policy',
sourceProfileId: observation.parser.id,
sourceProfileVersion: observation.parser.version
}]
};
}
export function createCurrentPolicyObservationUnifiedResults(
artifacts: ImportArtifact[],
processingRuns: ImportProcessingRun[],
backups: StoredGpoBackupInstance[],
analyses: StoredGpoPolicyAnalysis[]
): UnifiedResultProjection[] {
const artifactById = new Map(artifacts.map(artifact => [artifact.id, artifact]));
const supersededProcessingIds = new Set(processingRuns.map(run => run.supersedesRunId).filter(Boolean));
const currentProcessingIds = new Set(processingRuns
.filter(run => !supersededProcessingIds.has(run.id) && run.status !== 'failed')
.map(run => run.id));
const supersededAnalysisIds = new Set(analyses.map(record => record.run.supersedesAnalysisRunId).filter(Boolean));
const resolutionByObservation = new Map<string, StoredGpoPolicyIdentityResolution>();
for (const analysis of analyses) {
if (supersededAnalysisIds.has(analysis.run.id) || !currentProcessingIds.has(analysis.run.sourceProcessingRunId)) continue;
for (const resolution of analysis.resolutions) resolutionByObservation.set(resolution.observationId, resolution);
}
return backups
.filter(backup => currentProcessingIds.has(backup.processingRunId))
.flatMap(backup => {
const artifact = artifactById.get(backup.artifactId);
if (!artifact) return [];
return (backup.instance.policyObservations ?? []).map(observation => createPolicyObservationUnifiedResult({
artifact,
backup,
observation,
resolution: resolutionByObservation.get(observation.id)
}));
})
.sort((left, right) => left.importedAt.localeCompare(right.importedAt)
|| left.resultProjectionId.localeCompare(right.resultProjectionId));
}
function environmentOutcome(state: EnvironmentCollectionState): UnifiedOutcome {
if (state === 'parse-failed') return 'execution_error';
if (['not-assessed', 'access-denied', 'license-restricted', 'unsupported', 'unknown'].includes(state)) return 'not_evaluated';
return 'informational';
}
function environmentExecutionState(state: EnvironmentCollectionState): TechnicalExecutionState {
if (state === 'parse-failed') return 'failed';
if (['not-assessed', 'access-denied', 'license-restricted', 'unsupported'].includes(state)) return 'not-run';
if (state === 'unknown') return 'unknown';
return 'completed';
}
function environmentPropertyGroup(property: string): string {
return property.split('.')[0]?.trim() || 'environment';
}
export function createEnvironmentUnifiedResult(source: EnvironmentUnifiedResultSource): UnifiedResultProjection {
const { observation, importFile, scanRun, assessment, project } = source;
const subjectIdentity = observation.subject.identity ?? observation.subject.label;
const normalized = observation.normalizedValue ?? observation.originalValue;
return {
resultProjectionId: `environment-observation:${observation.id}`,
sourceKind: 'environment-observation',
sourceResultId: observation.id,
projectId: project.id,
assessmentId: assessment.id,
collectionRunId: scanRun.id,
scanRunId: scanRun.id,
sourceTool: observation.tool.id,
sourceToolVersion: observation.tool.version,
sourceProfileId: importFile.processedWith.profileId,
sourceProfileVersion: importFile.processedWith.profileVersion,
mappingRuleVersion: observation.mappingVersion,
normalizationRuleVersion: importFile.processedWith.normalizationVersion,
presentationVersion: importFile.processedWith.presentationVersion,
processingCompatibilityKey: importFile.processedWith.compatibilityKey,
processingStatus: processingReadiness(importFile.processedWith.profileId, importFile.processedWith),
reprocessingAvailable: importFile.sourceArtifact.kind !== 'not-retained',
importArtifactId: importFile.id,
sourceViewId: observation.sectionKey,
sourceLocator: observation.source,
environmentProperty: observation.property,
environmentSubjectType: observation.subject.type,
environmentSubjectIdentity: subjectIdentity,
environmentValueType: observation.valueType,
environmentState: observation.state,
environmentOriginalValue: observation.originalValue,
environmentNormalizedValue: normalized,
environmentSectionKey: observation.sectionKey,
environmentAttributes: observation.attributes,
title: observation.label,
affectedAsset: subjectIdentity,
severityOriginal: null,
severityNormalized: 'info',
outcomeOriginal: observation.state,
outcomeNormalized: environmentOutcome(observation.state),
technicalExecutionState: environmentExecutionState(observation.state),
resultType: 'informational',
isIoe: false,
isDeviation: false,
lifecycleState: 'unknown',
mappingConfidence: 1,
completenessState: observation.state,
reviewStatus: 'unreviewed',
sourceObservedAt: observation.observedAt,
importedAt: importFile.importedAt,
createdAt: importFile.importedAt,
categories: [{
namespace: 'environment-subject',
categoryCode: observation.subject.type,
categoryLabel: observation.subject.type,
categoryType: 'canonical',
isPrimary: true,
sourceTool: observation.tool.id,
sourceProfileId: importFile.processedWith.profileId,
sourceProfileVersion: importFile.processedWith.profileVersion
}, {
namespace: 'environment-property',
categoryCode: environmentPropertyGroup(observation.property),
categoryLabel: environmentPropertyGroup(observation.property),
categoryType: 'canonical',
isPrimary: false,
sourceTool: observation.tool.id,
sourceProfileId: importFile.processedWith.profileId,
sourceProfileVersion: importFile.processedWith.profileVersion
}]
};
}
function includesAny<T>(values: T[] | undefined, actual: T | undefined): boolean {
return !values?.length || actual !== undefined && values.includes(actual);
}
function normalizedText(value: string): string {
return value.normalize('NFKC').toLocaleLowerCase('de-DE');
}
export function filterUnifiedResults(items: UnifiedResultProjection[], query: UnifiedResultQuery = {}): UnifiedResultProjection[] {
const sourceTools = query.sourceTool?.map(normalizedText);
const search = query.search?.trim() ? normalizedText(query.search.trim()) : '';
const asset = query.affectedAsset?.trim() ? normalizedText(query.affectedAsset.trim()) : '';
const from = query.observedFrom ? Date.parse(query.observedFrom) : Number.NEGATIVE_INFINITY;
const to = query.observedTo ? Date.parse(query.observedTo) : Number.POSITIVE_INFINITY;
return items.filter(item => {
if (query.projectId && item.projectId !== query.projectId) return false;
if (query.assessmentId && item.assessmentId !== query.assessmentId) return false;
if (query.assessmentIds?.length && (!item.assessmentId || !query.assessmentIds.includes(item.assessmentId))) return false;
if (query.scanRunId && item.scanRunId !== query.scanRunId) return false;
if (query.scanRunIds?.length && item.scanRunId && !query.scanRunIds.includes(item.scanRunId)) return false;
if (query.scanRunIds?.length && !item.scanRunId && !query.assessmentIds?.includes(item.assessmentId ?? '')) return false;
if (query.evaluationRunId && item.evaluationRunId !== query.evaluationRunId) return false;
if (!includesAny(query.sourceKind, item.sourceKind)) return false;
if (sourceTools?.length && (!item.sourceTool || !sourceTools.includes(normalizedText(item.sourceTool)))) return false;
if (!includesAny(query.severity, item.severityNormalized)) return false;
if (!includesAny(query.outcome, item.outcomeNormalized)) return false;
if (!includesAny(query.technicalExecutionState, item.technicalExecutionState)) return false;
if (!includesAny(query.resultType, item.resultType)) return false;
if (query.isIoe !== undefined && item.isIoe !== query.isIoe) return false;
if (query.deviation !== undefined && item.sourceKind !== 'policy-observation' && item.isDeviation !== query.deviation) return false;
if (!includesAny(query.mappingRuleVersion, item.mappingRuleVersion)) return false;
if (!includesAny(query.sourceProfileVersion, item.sourceProfileVersion)) return false;
if (!includesAny(query.processingStatus, item.processingStatus)) return false;
if (!includesAny(query.reviewDisposition, item.reviewDisposition)) return false;
if (!includesAny(query.lifecycleState, item.lifecycleState)) return false;
if (!includesAny(query.environmentState, item.environmentState)) return false;
if (!includesAny(query.environmentProperty, item.environmentProperty)) return false;
if (!includesAny(query.environmentSubjectType, item.environmentSubjectType)) return false;
if (query.categoryNamespace?.length && !item.categories.some(category => query.categoryNamespace!.includes(category.namespace))) return false;
if (query.categoryValue?.length && !item.categories.some(category => query.categoryValue!.includes(category.categoryCode)
|| query.categoryValue!.includes(category.categoryLabel))) return false;
if (asset && !normalizedText(item.affectedAsset ?? '').includes(asset)) return false;
const observed = Date.parse(item.sourceObservedAt ?? item.evaluatedAt ?? item.importedAt);
if (observed < from || observed > to) return false;
if (search) {
const text = normalizedText(JSON.stringify({
title: item.title,
description: item.description,
recommendation: item.recommendation,
asset: item.affectedAsset,
sourceRuleId: item.sourceRuleId,
environmentProperty: item.environmentProperty,
environmentSubjectType: item.environmentSubjectType,
environmentSubjectIdentity: item.environmentSubjectIdentity,
environmentState: item.environmentState,
environmentOriginalValue: item.environmentOriginalValue,
environmentNormalizedValue: item.environmentNormalizedValue,
policyScope: item.policyScope,
policyDeliveryMechanism: item.policyDeliveryMechanism,
policyState: item.policyState,
policyIdentityStatus: item.policyIdentityStatus,
policyIdentityKey: item.policyIdentityKey,
policyValues: item.policyValues,
policyDiagnosticCodes: item.policyDiagnosticCodes,
categories: item.categories.map(category => category.categoryLabel)
}));
if (!text.includes(search)) return false;
}
return true;
});
}
export function queryUnifiedResultItems(items: UnifiedResultProjection[], query: UnifiedResultQuery = {}): UnifiedResultQueryResult {
// Rangfolge aus src/domain/severity-order.ts: werkzeugunabhaengig, weil
// severityNormalized fuer alle Quellen auf dieselbe Skala abgebildet wird.
// Zuvor stand hier eine zweite, wortgleiche Kopie derselben Tabelle.
const filtered = filterUnifiedResults(items, query)
.sort((left, right) => severityRank(left.severityNormalized) - severityRank(right.severityNormalized)
|| left.importedAt.localeCompare(right.importedAt)
|| left.resultProjectionId.localeCompare(right.resultProjectionId));
const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(10000, Math.max(0, query.limit ?? 1000));
return { total: filtered.length, items: filtered.slice(offset, offset + limit) };
}
/**
* Facetten-Werte werden aus zwei getrennten Mengen gebaut:
* - `universe`: legt innerhalb von Projekt/Assessment/Scan-Lauf die
* grundsaetzlich bekannten Werte fest.
* - `countScope`: wendet alle uebrigen aktiven Filter an, nimmt aber den
* eigenen Facettenfilter aus. So bleiben erreichbare Alternativen waehlbar.
* Werte mit Trefferzahl null werden nicht an die Darstellung weitergegeben.
*/
function scopeOnlyQuery(query: UnifiedResultQuery): UnifiedResultQuery {
return {
projectId: query.projectId,
assessmentId: query.assessmentId,
assessmentIds: query.assessmentIds,
scanRunId: query.scanRunId,
scanRunIds: query.scanRunIds,
evaluationRunId: query.evaluationRunId
};
}
export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], query: UnifiedResultQuery = {}): UnifiedResultFacetResult {
const baseQuery: UnifiedResultQuery = { ...query, offset: undefined, limit: undefined };
const universe = filterUnifiedResults(items, scopeOnlyQuery(query));
const filtered = filterUnifiedResults(items, baseQuery);
const withoutOwnFilter = (...keys: Array<keyof UnifiedResultQuery>): UnifiedResultProjection[] => {
if (!keys.length) return filtered;
const next = { ...baseQuery };
keys.forEach(key => { next[key] = undefined; });
return filterUnifiedResults(items, next);
};
function facet(
id: UnifiedResultFacet['id'],
label: string,
getValue: (item: UnifiedResultProjection) => string | undefined,
...ownQueryKeys: Array<keyof UnifiedResultQuery>
): UnifiedResultFacet {
const countScope = withoutOwnFilter(...ownQueryKeys);
const counts = new Map<string, number>();
countScope.forEach(item => {
const value = getValue(item);
if (value === undefined) return;
counts.set(value, (counts.get(value) ?? 0) + 1);
});
const seen = new Set<string>();
const values: UnifiedResultFacetValue[] = [];
universe.forEach(item => {
const value = getValue(item);
if (value === undefined || seen.has(value)) return;
seen.add(value);
const count = counts.get(value) ?? 0;
if (count > 0) values.push({ value, label: value, count });
});
// Der Schweregrad folgt seiner fachlichen Rangfolge, nicht dem Alphabet -
// sonst stuende "critical" zwischen "high" und "info".
values.sort(id === 'severity'
? (left, right) => severityRank(left.value as CanonicalSeverity) - severityRank(right.value as CanonicalSeverity)
: (left, right) => left.label.localeCompare(right.label));
return { id, label, values };
}
function categoryFacet(): UnifiedResultFacet {
const key = (namespace: string, code: string) => namespace + '::' + code;
const countScope = withoutOwnFilter('categoryValue');
const counts = new Map<string, number>();
countScope.forEach(item => item.categories.forEach(category => {
const mapKey = key(category.namespace, category.categoryCode);
counts.set(mapKey, (counts.get(mapKey) ?? 0) + 1);
}));
const seen = new Map<string, { label: string; namespace: string; code: string }>();
universe.forEach(item => item.categories.forEach(category => {
const mapKey = key(category.namespace, category.categoryCode);
if (!seen.has(mapKey)) seen.set(mapKey, { label: category.categoryLabel, namespace: category.namespace, code: category.categoryCode });
}));
const values: UnifiedResultFacetValue[] = [...seen.entries()].map(([mapKey, meta]) => ({
value: meta.code, label: meta.label, namespace: meta.namespace, count: counts.get(mapKey) ?? 0
})).filter(value => value.count > 0)
.sort((left, right) => (left.namespace ?? '').localeCompare(right.namespace ?? '') || left.label.localeCompare(right.label));
return { id: 'category', label: 'Categories', values };
}
const findingAxis = (item: UnifiedResultProjection): boolean =>
item.sourceKind !== 'environment-observation' && item.sourceKind !== 'policy-observation';
return {
total: filtered.length,
facets: [
facet('sourceKind', 'Origin Type', item => item.sourceKind, 'sourceKind'),
facet('sourceTool', 'Source Tool', item => item.sourceTool, 'sourceTool'),
facet('environmentState', 'Environment State', item => item.environmentState, 'environmentState'),
facet('environmentSubjectType', 'Environment Subject', item => item.environmentSubjectType, 'environmentSubjectType'),
facet('environmentProperty', 'Environment Property', item => item.environmentProperty, 'environmentProperty'),
facet('severity', 'Severity', item => findingAxis(item) ? item.severityNormalized : undefined, 'severity'),
facet('outcome', 'Outcome', item => findingAxis(item) ? item.outcomeNormalized : undefined, 'outcome'),
facet('technicalExecutionState', 'Execution', item => findingAxis(item) ? item.technicalExecutionState : undefined, 'technicalExecutionState'),
facet('resultType', 'Result Type', item => findingAxis(item) ? item.resultType : undefined, 'resultType'),
facet('isIoe', 'IOE', item => findingAxis(item) ? String(item.isIoe) : undefined, 'isIoe'),
facet('deviation', 'Deviation', item => findingAxis(item) ? String(item.isDeviation) : undefined, 'deviation'),
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'),
facet('reviewStatus', 'Review Status', item => findingAxis(item) ? item.reviewStatus : undefined),
facet('reviewDisposition', 'Review Disposition', item => item.reviewDisposition, 'reviewDisposition'),
facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'),
facet('effectiveState', 'Effectiveness', item => item.effectiveState),
categoryFacet()
]
};
}
export function listUnifiedResultCategories(items: UnifiedResultProjection[]): UnifiedResultCategory[] {
const categories = new Map<string, UnifiedResultCategory>();
items.flatMap(item => item.categories).forEach(category => {
const key = `${category.namespace}\u0000${category.categoryCode}\u0000${category.sourceProfileId ?? ''}\u0000${category.sourceProfileVersion ?? ''}`;
if (!categories.has(key)) categories.set(key, category);
});
return [...categories.values()].sort((left, right) => left.namespace.localeCompare(right.namespace)
|| left.categoryLabel.localeCompare(right.categoryLabel));
}