858 lines
39 KiB
TypeScript
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));
|
|
}
|