*/ private array $baselineContentHashCache = []; public ?OperationRun $operationRun = null; public function __construct( public OperationRun $run, ) { $this->operationRun = $run; } /** * @return array */ public function middleware(): array { return [new TrackOperationRun]; } public function handle( BaselineSnapshotIdentity $snapshotIdentity, AuditLogger $auditLogger, OperationRunService $operationRunService, ?SettingsResolver $settingsResolver = null, ?BaselineAutoCloseService $baselineAutoCloseService = null, ?CurrentStateHashResolver $hashResolver = null, ?MetaEvidenceProvider $metaEvidenceProvider = null, ?BaselineContentCapturePhase $contentCapturePhase = null, ?BaselineFullContentRolloutGate $rolloutGate = null, ?ContentEvidenceProvider $contentEvidenceProvider = null, ): void { $settingsResolver ??= app(SettingsResolver::class); $baselineAutoCloseService ??= app(BaselineAutoCloseService::class); $hashResolver ??= app(CurrentStateHashResolver::class); $metaEvidenceProvider ??= app(MetaEvidenceProvider::class); $contentCapturePhase ??= app(BaselineContentCapturePhase::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class); $contentEvidenceProvider ??= app(ContentEvidenceProvider::class); if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); return; } $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; $profileId = (int) ($context['baseline_profile_id'] ?? 0); $snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0); $profile = BaselineProfile::query()->find($profileId); if (! $profile instanceof BaselineProfile) { throw new RuntimeException("BaselineProfile #{$profileId} not found."); } $tenant = Tenant::query()->find($this->operationRun->tenant_id); if (! $tenant instanceof Tenant) { throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found."); } $workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first(); if (! $workspace instanceof Workspace) { throw new RuntimeException("Workspace #{$tenant->workspace_id} not found."); } $initiator = $this->operationRun->user_id ? User::query()->find($this->operationRun->user_id) : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); $effectiveTypes = $effectiveScope->allTypes(); $scopeKey = 'baseline_profile:'.$profile->getKey(); $captureMode = $profile->capture_mode instanceof BaselineCaptureMode ? $profile->capture_mode : BaselineCaptureMode::Opportunistic; if ($captureMode === BaselineCaptureMode::FullContent) { try { $rolloutGate->assertEnabled(); } catch (RuntimeException) { $this->auditStarted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: 0, effectiveScope: $effectiveScope, ); $effectiveTypeCount = count($effectiveTypes); $gapCount = max(1, $effectiveTypeCount); $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: null, coverageProof: false, effectiveTypes: $effectiveTypes, coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: $gapCount, captureMode: $captureMode, reasonCode: BaselineCompareReasonCode::RolloutDisabled, evidenceGapsByReason: [ BaselineCompareReasonCode::RolloutDisabled->value => $gapCount, ], ); return; } } if ($effectiveTypes === []) { $this->auditStarted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: 0, effectiveScope: $effectiveScope, ); $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: null, coverageProof: false, effectiveTypes: [], coveredTypes: [], uncoveredTypes: [], errorsRecorded: 1, captureMode: $captureMode, reasonCode: BaselineCompareReasonCode::NoSubjectsInScope, evidenceGapsByReason: [], ); return; } $inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant); $coverage = $inventorySyncRun instanceof OperationRun ? InventoryCoverage::fromContext($inventorySyncRun->context) : null; if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) { $this->auditStarted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: 0, effectiveScope: $effectiveScope, ); $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: $inventorySyncRun, coverageProof: false, effectiveTypes: $effectiveTypes, coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), captureMode: $captureMode, ); return; } $coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes())); $uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes)); if ($coveredTypes === []) { $this->auditStarted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: 0, effectiveScope: $effectiveScope, ); $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: $inventorySyncRun, coverageProof: true, effectiveTypes: $effectiveTypes, coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), captureMode: $captureMode, ); return; } $snapshot = BaselineSnapshot::query() ->where('workspace_id', (int) $profile->workspace_id) ->where('baseline_profile_id', (int) $profile->getKey()) ->whereKey($snapshotId) ->first(['id', 'captured_at']); if (! $snapshot instanceof BaselineSnapshot) { throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found."); } $since = $snapshot->captured_at instanceof \DateTimeInterface ? CarbonImmutable::instance($snapshot->captured_at) : null; $baselineResult = $this->loadBaselineItems($snapshotId, $coveredTypes); $baselineItems = $baselineResult['items']; $baselineGaps = $baselineResult['gaps']; $currentResult = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey()); $currentItems = $currentResult['items']; $currentGaps = $currentResult['gaps']; $ambiguousKeys = array_values(array_unique(array_filter(array_merge( is_array($baselineResult['ambiguous_keys'] ?? null) ? $baselineResult['ambiguous_keys'] : [], is_array($currentResult['ambiguous_keys'] ?? null) ? $currentResult['ambiguous_keys'] : [], ), 'is_string'))); foreach ($ambiguousKeys as $ambiguousKey) { unset($baselineItems[$ambiguousKey], $currentItems[$ambiguousKey]); } $subjects = array_values(array_map( static fn (array $item): array => [ 'policy_type' => (string) $item['policy_type'], 'subject_external_id' => (string) $item['subject_external_id'], ], $currentItems, )); $subjectsTotal = count($subjects); $this->auditStarted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: $subjectsTotal, effectiveScope: $effectiveScope, ); $phaseStats = [ 'requested' => 0, 'succeeded' => 0, 'skipped' => 0, 'failed' => 0, 'throttled' => 0, ]; $phaseResult = []; $phaseGaps = []; $resumeToken = null; if ($captureMode === BaselineCaptureMode::FullContent) { $budgets = [ 'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200), 'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5), 'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3), ]; $resumeTokenIn = null; if (is_array($context['baseline_compare'] ?? null)) { $resumeTokenIn = $context['baseline_compare']['resume_token'] ?? null; } $phaseResult = $contentCapturePhase->capture( tenant: $tenant, subjects: $subjects, purpose: PolicyVersionCapturePurpose::BaselineCompare, budgets: $budgets, resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null, operationRunId: (int) $this->operationRun->getKey(), baselineProfileId: (int) $profile->getKey(), createdBy: $initiator?->email, ); $phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats; $phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : []; $resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null; } $resolvedCurrentEvidenceByExternalId = $hashResolver->resolveForSubjects( tenant: $tenant, subjects: $subjects, since: $since, latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), ); $resolvedCurrentEvidenceByExternalId = array_replace( $resolvedCurrentEvidenceByExternalId, $this->resolveCapturedCurrentEvidenceByExternalId($phaseResult), ); $resolvedCurrentMetaEvidenceByExternalId = $metaEvidenceProvider->resolve( tenant: $tenant, subjects: $subjects, since: $since, latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), ); $resolvedCurrentEvidence = $this->rekeyResolvedEvidenceBySubjectKey( currentItems: $currentItems, resolvedByExternalId: $resolvedCurrentEvidenceByExternalId, ); $resolvedCurrentMetaEvidence = $this->rekeyResolvedEvidenceBySubjectKey( currentItems: $currentItems, resolvedByExternalId: $resolvedCurrentMetaEvidenceByExternalId, ); $resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence( baselineItems: $baselineItems, currentItems: $currentItems, resolvedBestEvidence: $resolvedCurrentEvidence, resolvedMetaEvidence: $resolvedCurrentMetaEvidence, ); $baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class); $driftHasher = app(DriftHasher::class); $settingsNormalizer = app(SettingsNormalizer::class); $assignmentsNormalizer = app(AssignmentsNormalizer::class); $scopeTagsNormalizer = app(ScopeTagsNormalizer::class); $computeResult = $this->computeDrift( tenant: $tenant, baselineProfileId: (int) $profile->getKey(), baselineSnapshotId: (int) $snapshot->getKey(), compareOperationRunId: (int) $this->operationRun->getKey(), inventorySyncRunId: (int) $inventorySyncRun->getKey(), baselineItems: $baselineItems, currentItems: $currentItems, resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence, severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver), baselinePolicyVersionResolver: $baselinePolicyVersionResolver, hasher: $driftHasher, settingsNormalizer: $settingsNormalizer, assignmentsNormalizer: $assignmentsNormalizer, scopeTagsNormalizer: $scopeTagsNormalizer, contentEvidenceProvider: $contentEvidenceProvider, ); $driftResults = $computeResult['drift']; $driftGaps = $computeResult['evidence_gaps']; $upsertResult = $this->upsertFindings( $tenant, $profile, $scopeKey, $driftResults, ); $severityBreakdown = $this->countBySeverity($driftResults); $countsByChangeType = $this->countByChangeType($driftResults); $gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps); $gapsCount = array_sum($gapsByReason); $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), 'succeeded' => (int) $upsertResult['processed_count'], 'failed' => 0, 'errors_recorded' => count($uncoveredTypes), 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, 'findings_created' => (int) $upsertResult['created_count'], 'findings_reopened' => (int) $upsertResult['reopened_count'], 'findings_unchanged' => (int) $upsertResult['unchanged_count'], ]; $warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== []; $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: $outcome, summaryCounts: $summaryCounts, ); $resolvedCount = 0; if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) { $resolvedCount = $baselineAutoCloseService->resolveStaleFindings( tenant: $tenant, baselineProfileId: (int) $profile->getKey(), seenFingerprints: $upsertResult['seen_fingerprints'], currentOperationRunId: (int) $this->operationRun->getKey(), ); $summaryCounts['findings_resolved'] = $resolvedCount; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: $outcome, summaryCounts: $summaryCounts, ); } $coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedEffectiveCurrentEvidence); $baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems); $overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0 || ($coverageBreakdown['resolved_meta'] ?? 0) > 0 || ($gapsByReason['missing_current'] ?? 0) > 0 ? EvidenceProvenance::FidelityMeta : EvidenceProvenance::FidelityContent; $reasonCode = null; if ($subjectsTotal === 0) { $reasonCode = BaselineCompareReasonCode::NoSubjectsInScope; } elseif (count($driftResults) === 0) { $reasonCode = match (true) { $uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven, $resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete, default => BaselineCompareReasonCode::NoDriftDetected, }; } $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], [ 'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(), 'since' => $since?->toIso8601String(), 'subjects_total' => $subjectsTotal, 'evidence_capture' => $phaseStats, 'evidence_gaps' => [ 'count' => $gapsCount, 'by_reason' => $gapsByReason, ...$gapsByReason, ], 'resume_token' => $resumeToken, 'coverage' => [ 'effective_types' => $effectiveTypes, 'covered_types' => $coveredTypes, 'uncovered_types' => $uncoveredTypes, 'proof' => true, ...$coverageBreakdown, ...$baselineCoverage, ], 'fidelity' => $overallFidelity, 'reason_code' => $reasonCode?->value, ], ); $updatedContext['findings'] = array_merge( is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], [ 'counts_by_change_type' => $countsByChangeType, ], ); $updatedContext['result'] = [ 'findings_total' => count($driftResults), 'findings_upserted' => (int) $upsertResult['processed_count'], 'findings_resolved' => $resolvedCount, 'severity_breakdown' => $severityBreakdown, ]; $this->operationRun->update(['context' => $updatedContext]); $this->auditCompleted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: $subjectsTotal, evidenceCaptureStats: $phaseStats, gaps: [ 'count' => $gapsCount, 'by_reason' => $gapsByReason, ], summaryCounts: $summaryCounts, ); } /** * Baseline hashes depend on which evidence fidelity was available at capture time (content vs meta). * * Current state evidence is therefore selected to be comparable to the baseline hash: * - If baseline evidence fidelity is meta: force meta evidence for current (inventory meta contract). * - If baseline evidence fidelity is content: require current content evidence (since-rule); otherwise treat as a gap. * * @param array}> $baselineItems * @param array}> $currentItems * @param array $resolvedBestEvidence * @param array $resolvedMetaEvidence * @return array */ private function resolveEffectiveCurrentEvidence( array $baselineItems, array $currentItems, array $resolvedBestEvidence, array $resolvedMetaEvidence, ): array { /** * @var array */ $baselineFidelityByKey = []; foreach ($baselineItems as $key => $baselineItem) { $provenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []); $baselineFidelityByKey[$key] = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); } $effective = []; foreach ($currentItems as $key => $currentItem) { if (array_key_exists($key, $baselineItems)) { $baselineFidelity = $baselineFidelityByKey[$key] ?? EvidenceProvenance::FidelityMeta; if ($baselineFidelity === EvidenceProvenance::FidelityMeta) { $effective[$key] = $resolvedMetaEvidence[$key] ?? null; continue; } $best = $resolvedBestEvidence[$key] ?? null; if ($best instanceof ResolvedEvidence && $best->fidelity === EvidenceProvenance::FidelityContent) { $effective[$key] = $best; } else { $effective[$key] = null; } continue; } $effective[$key] = $resolvedBestEvidence[$key] ?? null; } return $effective; } /** * Rekey resolved evidence from "policy_type|external_id" to the current items key ("policy_type|subject_key"). * * @param array $currentItems * @param array $resolvedByExternalId * @return array */ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $resolvedByExternalId): array { $rekeyed = []; foreach ($currentItems as $key => $currentItem) { $policyType = (string) ($currentItem['policy_type'] ?? ''); $externalId = (string) ($currentItem['subject_external_id'] ?? ''); if ($policyType === '' || $externalId === '') { $rekeyed[$key] = null; continue; } $resolvedKey = $policyType.'|'.$externalId; $rekeyed[$key] = $resolvedByExternalId[$resolvedKey] ?? null; } return $rekeyed; } /** * @param array{ * captured_versions?: array * } $phaseResult * @return array */ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult): array { $capturedVersions = is_array($phaseResult['captured_versions'] ?? null) ? $phaseResult['captured_versions'] : []; if ($capturedVersions === []) { return []; } $contentEvidenceProvider = app(ContentEvidenceProvider::class); $resolved = []; foreach ($capturedVersions as $key => $capturedVersion) { $version = $capturedVersion['version'] ?? null; $subjectExternalId = trim((string) ($capturedVersion['subject_external_id'] ?? '')); $observedAt = $capturedVersion['observed_at'] ?? null; $observedAt = is_string($observedAt) && $observedAt !== '' ? CarbonImmutable::parse($observedAt) : null; $observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null; $observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null; if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') { continue; } $resolved[$key] = $contentEvidenceProvider->fromPolicyVersion( version: $version, subjectExternalId: $subjectExternalId, observedAt: $observedAt, observedOperationRunId: $observedOperationRunId, ); } return $resolved; } private function completeWithCoverageWarning( OperationRunService $operationRunService, AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, ?OperationRun $inventorySyncRun, bool $coverageProof, array $effectiveTypes, array $coveredTypes, array $uncoveredTypes, int $errorsRecorded, BaselineCaptureMode $captureMode, BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven, ?array $evidenceGapsByReason = null, ): void { $summaryCounts = [ 'total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'errors_recorded' => max(1, $errorsRecorded), 'high' => 0, 'medium' => 0, 'low' => 0, 'findings_created' => 0, 'findings_reopened' => 0, 'findings_unchanged' => 0, ]; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::PartiallySucceeded->value, summaryCounts: $summaryCounts, ); $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $evidenceCapture = [ 'requested' => 0, 'succeeded' => 0, 'skipped' => 0, 'failed' => 0, 'throttled' => 0, ]; $evidenceGapsByReason ??= [ BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded), ]; $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], [ 'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null, 'subjects_total' => 0, 'evidence_capture' => $evidenceCapture, 'evidence_gaps' => [ 'count' => array_sum($evidenceGapsByReason), 'by_reason' => $evidenceGapsByReason, ...$evidenceGapsByReason, ], 'resume_token' => null, 'reason_code' => $reasonCode->value, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), 'uncovered_types' => array_values($uncoveredTypes), 'proof' => $coverageProof, 'subjects_total' => 0, 'resolved_total' => 0, 'resolved_content' => 0, 'resolved_meta' => 0, 'policy_types_content' => [], 'policy_types_meta_only' => [], ], 'fidelity' => 'meta', ], ); $updatedContext['findings'] = array_merge( is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], [ 'counts_by_change_type' => [], ], ); $updatedContext['result'] = [ 'findings_total' => 0, 'findings_upserted' => 0, 'findings_resolved' => 0, 'severity_breakdown' => [], ]; $this->operationRun->update(['context' => $updatedContext]); $this->auditCompleted( auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, captureMode: $captureMode, subjectsTotal: 0, evidenceCaptureStats: $evidenceCapture, gaps: [ 'count' => array_sum($evidenceGapsByReason), 'by_reason' => $evidenceGapsByReason, ], summaryCounts: $summaryCounts, ); } /** * Load baseline snapshot items keyed by "policy_type|subject_key". * * @return array{ * items: array}>, * gaps: array, * ambiguous_keys: list * } */ private function loadBaselineItems(int $snapshotId, array $policyTypes): array { $items = []; $gaps = []; /** * @var array */ $ambiguousKeys = []; if ($policyTypes === []) { return [ 'items' => $items, 'gaps' => $gaps, 'ambiguous_keys' => [], ]; } $query = BaselineSnapshotItem::query() ->where('baseline_snapshot_id', $snapshotId); $query->whereIn('policy_type', $policyTypes); $query ->orderBy('id') ->chunk(500, function ($snapshotItems) use (&$items, &$gaps, &$ambiguousKeys): void { foreach ($snapshotItems as $item) { $metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; $subjectKey = is_string($item->subject_key) ? trim((string) $item->subject_key) : ''; if ($subjectKey === '') { $displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null); $subjectKey = BaselineSubjectKey::fromDisplayName(is_string($displayName) ? $displayName : null) ?? ''; } else { $subjectKey = BaselineSubjectKey::fromDisplayName($subjectKey) ?? ''; } if ($subjectKey === '') { $gaps['missing_subject_key_baseline'] = ($gaps['missing_subject_key_baseline'] ?? 0) + 1; continue; } $key = $item->policy_type.'|'.$subjectKey; if (array_key_exists($key, $ambiguousKeys)) { continue; } if (array_key_exists($key, $items)) { $ambiguousKeys[$key] = true; unset($items[$key]); $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; continue; } $items[$key] = [ 'subject_type' => (string) $item->subject_type, 'subject_external_id' => (string) $item->subject_external_id, 'subject_key' => $subjectKey, 'policy_type' => (string) $item->policy_type, 'baseline_hash' => (string) $item->baseline_hash, 'meta_jsonb' => $metaJsonb, ]; } }); ksort($gaps); return [ 'items' => $items, 'gaps' => $gaps, 'ambiguous_keys' => array_values(array_keys($ambiguousKeys)), ]; } /** * Load current inventory items keyed by "policy_type|subject_key". * * @return array{ * items: array}>, * gaps: array, * ambiguous_keys: list * } */ private function loadCurrentInventory( Tenant $tenant, array $policyTypes, ?int $latestInventorySyncRunId = null, ): array { $query = InventoryItem::query() ->where('tenant_id', $tenant->getKey()); if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) { $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); } if ($policyTypes === []) { return [ 'items' => [], 'gaps' => [], 'ambiguous_keys' => [], ]; } $query->whereIn('policy_type', $policyTypes); $items = []; $gaps = []; /** * @var array */ $ambiguousKeys = []; $query->orderBy('policy_type') ->orderBy('external_id') ->chunk(500, function ($inventoryItems) use (&$items, &$gaps, &$ambiguousKeys): void { foreach ($inventoryItems as $inventoryItem) { $subjectKey = BaselineSubjectKey::fromDisplayName(is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null) ?? ''; if ($subjectKey === '') { $gaps['missing_subject_key_current'] = ($gaps['missing_subject_key_current'] ?? 0) + 1; continue; } $key = $inventoryItem->policy_type.'|'.$subjectKey; if (array_key_exists($key, $ambiguousKeys)) { continue; } if (array_key_exists($key, $items)) { $ambiguousKeys[$key] = true; unset($items[$key]); $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; continue; } $items[$key] = [ 'subject_external_id' => (string) $inventoryItem->external_id, 'subject_key' => $subjectKey, 'policy_type' => (string) $inventoryItem->policy_type, 'meta_jsonb' => [ 'display_name' => $inventoryItem->display_name, 'category' => $inventoryItem->category, 'platform' => $inventoryItem->platform, ], ]; } }); ksort($gaps); return [ 'items' => $items, 'gaps' => $gaps, 'ambiguous_keys' => array_values(array_keys($ambiguousKeys)), ]; } private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun { $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('type', OperationRunType::InventorySync->value) ->where('status', OperationRunStatus::Completed->value) ->orderByDesc('completed_at') ->orderByDesc('id') ->first(); return $run instanceof OperationRun ? $run : null; } /** * Compare baseline items vs current inventory and produce drift results. * * @param array}> $baselineItems * @param array}> $currentItems * @param array $resolvedCurrentEvidence * @param array $severityMapping * @return array{ * drift: array}>, * evidence_gaps: array * } */ private function computeDrift( Tenant $tenant, int $baselineProfileId, int $baselineSnapshotId, int $compareOperationRunId, int $inventorySyncRunId, array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping, BaselinePolicyVersionResolver $baselinePolicyVersionResolver, DriftHasher $hasher, SettingsNormalizer $settingsNormalizer, AssignmentsNormalizer $assignmentsNormalizer, ScopeTagsNormalizer $scopeTagsNormalizer, ContentEvidenceProvider $contentEvidenceProvider, ): array { $drift = []; $missingCurrentEvidence = 0; $baselinePlaceholderProvenance = EvidenceProvenance::build( fidelity: EvidenceProvenance::FidelityMeta, source: EvidenceProvenance::SourceInventory, observedAt: null, observedOperationRunId: null, ); $currentMissingProvenance = EvidenceProvenance::build( fidelity: EvidenceProvenance::FidelityMeta, source: EvidenceProvenance::SourceInventory, observedAt: null, observedOperationRunId: $inventorySyncRunId, ); foreach ($baselineItems as $key => $baselineItem) { $currentItem = $currentItems[$key] ?? null; $policyType = (string) ($baselineItem['policy_type'] ?? ''); $subjectKey = (string) ($baselineItem['subject_key'] ?? ''); $baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []); $baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId( tenant: $tenant, policyType: $policyType, subjectKey: $subjectKey, baselineProvenance: $baselineProvenance, baselinePolicyVersionResolver: $baselinePolicyVersionResolver, ); $baselineComparableHash = $this->effectiveBaselineHash( tenant: $tenant, baselineItem: $baselineItem, baselinePolicyVersionId: $baselinePolicyVersionId, contentEvidenceProvider: $contentEvidenceProvider, ); if (! is_array($currentItem)) { $displayName = $baselineItem['meta_jsonb']['display_name'] ?? null; $displayName = is_string($displayName) ? (string) $displayName : null; $evidence = $this->buildDriftEvidenceContract( changeType: 'missing_policy', policyType: $policyType, subjectKey: $subjectKey, displayName: $displayName, baselineHash: $baselineComparableHash, currentHash: null, baselineProvenance: $baselineProvenance, currentProvenance: $currentMissingProvenance, baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: null, summaryKind: 'policy_snapshot', baselineProfileId: $baselineProfileId, baselineSnapshotId: $baselineSnapshotId, compareOperationRunId: $compareOperationRunId, inventorySyncRunId: $inventorySyncRunId, ); $drift[] = [ 'change_type' => 'missing_policy', 'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'subject_key' => $subjectKey, 'policy_type' => $policyType, 'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta), 'baseline_hash' => $baselineComparableHash, 'current_hash' => '', 'evidence' => $evidence, ]; continue; } $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; if (! $currentEvidence instanceof ResolvedEvidence) { $missingCurrentEvidence++; continue; } if ($baselineComparableHash !== $currentEvidence->hash) { $displayName = $currentItem['meta_jsonb']['display_name'] ?? ($baselineItem['meta_jsonb']['display_name'] ?? null); $displayName = is_string($displayName) ? (string) $displayName : null; $currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence); $summaryKind = $this->selectSummaryKind( tenant: $tenant, policyType: $policyType, baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: $currentPolicyVersionId, hasher: $hasher, settingsNormalizer: $settingsNormalizer, assignmentsNormalizer: $assignmentsNormalizer, scopeTagsNormalizer: $scopeTagsNormalizer, ); $evidence = $this->buildDriftEvidenceContract( changeType: 'different_version', policyType: $policyType, subjectKey: $subjectKey, displayName: $displayName, baselineHash: $baselineComparableHash, currentHash: (string) $currentEvidence->hash, baselineProvenance: $baselineProvenance, currentProvenance: $currentEvidence->tenantProvenance(), baselinePolicyVersionId: $baselinePolicyVersionId, currentPolicyVersionId: $currentPolicyVersionId, summaryKind: $summaryKind, baselineProfileId: $baselineProfileId, baselineSnapshotId: $baselineSnapshotId, compareOperationRunId: $compareOperationRunId, inventorySyncRunId: $inventorySyncRunId, ); $drift[] = [ 'change_type' => 'different_version', 'severity' => $this->severityForChangeType($severityMapping, 'different_version'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $currentItem['subject_external_id'], 'subject_key' => $subjectKey, 'policy_type' => $policyType, 'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta), 'baseline_hash' => $baselineComparableHash, 'current_hash' => $currentEvidence->hash, 'evidence' => $evidence, ]; } } foreach ($currentItems as $key => $currentItem) { if (! array_key_exists($key, $baselineItems)) { $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; if (! $currentEvidence instanceof ResolvedEvidence) { $missingCurrentEvidence++; continue; } $policyType = (string) ($currentItem['policy_type'] ?? ''); $subjectKey = (string) ($currentItem['subject_key'] ?? ''); $displayName = $currentItem['meta_jsonb']['display_name'] ?? null; $displayName = is_string($displayName) ? (string) $displayName : null; $currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence); $evidence = $this->buildDriftEvidenceContract( changeType: 'unexpected_policy', policyType: $policyType, subjectKey: $subjectKey, displayName: $displayName, baselineHash: null, currentHash: (string) $currentEvidence->hash, baselineProvenance: $baselinePlaceholderProvenance, currentProvenance: $currentEvidence->tenantProvenance(), baselinePolicyVersionId: null, currentPolicyVersionId: $currentPolicyVersionId, summaryKind: 'policy_snapshot', baselineProfileId: $baselineProfileId, baselineSnapshotId: $baselineSnapshotId, compareOperationRunId: $compareOperationRunId, inventorySyncRunId: $inventorySyncRunId, ); $drift[] = [ 'change_type' => 'unexpected_policy', 'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'), 'subject_type' => 'policy', 'subject_external_id' => $currentItem['subject_external_id'], 'subject_key' => $subjectKey, 'policy_type' => $policyType, 'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta), 'baseline_hash' => '', 'current_hash' => $currentEvidence->hash, 'evidence' => $evidence, ]; } } return [ 'drift' => $drift, 'evidence_gaps' => [ 'missing_current' => $missingCurrentEvidence, ], ]; } /** * @param array{subject_external_id: string, baseline_hash: string} $baselineItem */ private function effectiveBaselineHash( Tenant $tenant, array $baselineItem, ?int $baselinePolicyVersionId, ContentEvidenceProvider $contentEvidenceProvider, ): string { $storedHash = (string) ($baselineItem['baseline_hash'] ?? ''); if ($baselinePolicyVersionId === null) { return $storedHash; } if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) { return $this->baselineContentHashCache[$baselinePolicyVersionId]; } $baselineVersion = PolicyVersion::query() ->where('tenant_id', (int) $tenant->getKey()) ->find($baselinePolicyVersionId); if (! $baselineVersion instanceof PolicyVersion) { return $storedHash; } $hash = $contentEvidenceProvider->fromPolicyVersion( version: $baselineVersion, subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''), )->hash; $this->baselineContentHashCache[$baselinePolicyVersionId] = $hash; return $hash; } private function resolveBaselinePolicyVersionId( Tenant $tenant, string $policyType, string $subjectKey, array $baselineProvenance, BaselinePolicyVersionResolver $baselinePolicyVersionResolver, ): ?int { $baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); $baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory); if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) { return null; } $observedAt = $baselineProvenance['observed_at'] ?? null; $observedAt = is_string($observedAt) ? trim($observedAt) : null; if (! is_string($observedAt) || $observedAt === '') { return null; } return $baselinePolicyVersionResolver->resolve( tenant: $tenant, policyType: $policyType, subjectKey: $subjectKey, observedAt: $observedAt, ); } private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int { $policyVersionId = $evidence->meta['policy_version_id'] ?? null; return is_numeric($policyVersionId) ? (int) $policyVersionId : null; } private function selectSummaryKind( Tenant $tenant, string $policyType, ?int $baselinePolicyVersionId, ?int $currentPolicyVersionId, DriftHasher $hasher, SettingsNormalizer $settingsNormalizer, AssignmentsNormalizer $assignmentsNormalizer, ScopeTagsNormalizer $scopeTagsNormalizer, ): string { if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) { return 'policy_snapshot'; } $baselineVersion = PolicyVersion::query() ->where('tenant_id', (int) $tenant->getKey()) ->find($baselinePolicyVersionId); $currentVersion = PolicyVersion::query() ->where('tenant_id', (int) $tenant->getKey()) ->find($currentPolicyVersionId); if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) { return 'policy_snapshot'; } $platform = is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null); $baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : []; $currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : []; $baselineNormalized = $settingsNormalizer->normalizeForDiff( snapshot: $baselineSnapshot, policyType: $policyType, platform: $platform, ); $currentNormalized = $settingsNormalizer->normalizeForDiff( snapshot: $currentSnapshot, policyType: $policyType, platform: $platform, ); $baselineSnapshotHash = $hasher->hashNormalized([ 'settings' => $baselineNormalized, 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'), ]); $currentSnapshotHash = $hasher->hashNormalized([ 'settings' => $currentNormalized, 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'), ]); if ($baselineSnapshotHash !== $currentSnapshotHash) { return 'policy_snapshot'; } $baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []; $currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : []; $baselineAssignmentsHash = $hasher->hashNormalized([ 'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments), 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'), ]); $currentAssignmentsHash = $hasher->hashNormalized([ 'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments), 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'), ]); if ($baselineAssignmentsHash !== $currentAssignmentsHash) { return 'policy_assignments'; } $baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags); $currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags); if ($baselineScopeTagIds === null || $currentScopeTagIds === null) { return 'policy_snapshot'; } $baselineScopeTagsHash = $hasher->hashNormalized([ 'scope_tag_ids' => $baselineScopeTagIds, 'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'), ]); $currentScopeTagsHash = $hasher->hashNormalized([ 'scope_tag_ids' => $currentScopeTagIds, 'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'), ]); if ($baselineScopeTagsHash !== $currentScopeTagsHash) { return 'policy_scope_tags'; } return 'policy_snapshot'; } /** * @return array */ private function fingerprintBucket(PolicyVersion $version, string $bucket): array { $secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : []; $bucketFingerprints = $secretFingerprints[$bucket] ?? []; return is_array($bucketFingerprints) ? $bucketFingerprints : []; } /** * @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance * @param array $currentProvenance * @return array */ private function buildDriftEvidenceContract( string $changeType, string $policyType, string $subjectKey, ?string $displayName, ?string $baselineHash, ?string $currentHash, array $baselineProvenance, array $currentProvenance, ?int $baselinePolicyVersionId, ?int $currentPolicyVersionId, string $summaryKind, int $baselineProfileId, int $baselineSnapshotId, int $compareOperationRunId, int $inventorySyncRunId, ): array { $fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId); return [ 'change_type' => $changeType, 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'display_name' => $displayName, 'summary' => [ 'kind' => $summaryKind, ], 'baseline' => [ 'policy_version_id' => $baselinePolicyVersionId, 'hash' => $baselineHash, 'provenance' => $baselineProvenance, ], 'current' => [ 'policy_version_id' => $currentPolicyVersionId, 'hash' => $currentHash, 'provenance' => $currentProvenance, ], 'fidelity' => $fidelity, 'provenance' => [ 'baseline_profile_id' => $baselineProfileId, 'baseline_snapshot_id' => $baselineSnapshotId, 'compare_operation_run_id' => $compareOperationRunId, 'inventory_sync_run_id' => $inventorySyncRunId, ], ]; } private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string { if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) { return 'content'; } if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) { return 'mixed'; } return 'meta'; } /** * @param array ...$gaps * @return array */ private function mergeGapCounts(array ...$gaps): array { $merged = []; foreach ($gaps as $gap) { foreach ($gap as $reason => $count) { if (! is_string($reason) || ! is_numeric($count)) { continue; } $count = (int) $count; if ($count <= 0) { continue; } $merged[$reason] = ($merged[$reason] ?? 0) + $count; } } ksort($merged); return $merged; } /** * @param array}> $currentItems * @param array $resolvedCurrentEvidence * @return array{ * subjects_total: int, * resolved_total: int, * resolved_content: int, * resolved_meta: int, * policy_types_content: list, * policy_types_meta_only: list * } */ private function summarizeCurrentEvidenceCoverage(array $currentItems, array $resolvedCurrentEvidence): array { $subjectsTotal = count($currentItems); $resolvedContent = 0; $resolvedMeta = 0; /** * @var array */ $resolvedByType = []; foreach ($currentItems as $key => $item) { $type = (string) ($item['policy_type'] ?? ''); if ($type === '') { continue; } $resolvedByType[$type] ??= ['resolved_content' => 0, 'resolved_meta' => 0]; $evidence = $resolvedCurrentEvidence[$key] ?? null; if (! $evidence instanceof ResolvedEvidence) { continue; } if ($evidence->fidelity === EvidenceProvenance::FidelityContent) { $resolvedContent++; $resolvedByType[$type]['resolved_content']++; } else { $resolvedMeta++; $resolvedByType[$type]['resolved_meta']++; } } $policyTypesContent = []; $policyTypesMetaOnly = []; foreach ($resolvedByType as $policyType => $counts) { if ($counts['resolved_content'] > 0) { $policyTypesContent[] = $policyType; continue; } if ($counts['resolved_meta'] > 0) { $policyTypesMetaOnly[] = $policyType; } } sort($policyTypesContent, SORT_STRING); sort($policyTypesMetaOnly, SORT_STRING); return [ 'subjects_total' => $subjectsTotal, 'resolved_total' => $resolvedContent + $resolvedMeta, 'resolved_content' => $resolvedContent, 'resolved_meta' => $resolvedMeta, 'policy_types_content' => $policyTypesContent, 'policy_types_meta_only' => $policyTypesMetaOnly, ]; } /** * @param array}> $baselineItems * @return array{ * baseline_total: int, * baseline_content: int, * baseline_meta: int, * baseline_policy_types_content: list, * baseline_policy_types_meta_only: list * } */ private function summarizeBaselineEvidenceCoverage(array $baselineItems): array { $baselineTotal = count($baselineItems); $baselineContent = 0; $baselineMeta = 0; /** * @var array */ $countsByType = []; foreach ($baselineItems as $key => $item) { $type = (string) ($item['policy_type'] ?? ''); if ($type === '') { continue; } $countsByType[$type] ??= ['baseline_content' => 0, 'baseline_meta' => 0]; $provenance = $this->baselineProvenanceFromMetaJsonb($item['meta_jsonb'] ?? []); $fidelity = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); if ($fidelity === EvidenceProvenance::FidelityContent) { $baselineContent++; $countsByType[$type]['baseline_content']++; } else { $baselineMeta++; $countsByType[$type]['baseline_meta']++; } } $policyTypesContent = []; $policyTypesMetaOnly = []; foreach ($countsByType as $policyType => $counts) { if (($counts['baseline_content'] ?? 0) > 0) { $policyTypesContent[] = $policyType; continue; } if (($counts['baseline_meta'] ?? 0) > 0) { $policyTypesMetaOnly[] = $policyType; } } sort($policyTypesContent, SORT_STRING); sort($policyTypesMetaOnly, SORT_STRING); return [ 'baseline_total' => $baselineTotal, 'baseline_content' => $baselineContent, 'baseline_meta' => $baselineMeta, 'baseline_policy_types_content' => $policyTypesContent, 'baseline_policy_types_meta_only' => $policyTypesMetaOnly, ]; } /** * @param array $metaJsonb * @return array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} */ private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array { $evidence = $metaJsonb; if (is_array($metaJsonb['evidence'] ?? null)) { $evidence = $metaJsonb['evidence']; } $fidelity = $evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta; $fidelity = is_string($fidelity) ? strtolower(trim($fidelity)) : EvidenceProvenance::FidelityMeta; if (! EvidenceProvenance::isValidFidelity($fidelity)) { $fidelity = EvidenceProvenance::FidelityMeta; } $source = $evidence['source'] ?? EvidenceProvenance::SourceInventory; $source = is_string($source) ? strtolower(trim($source)) : EvidenceProvenance::SourceInventory; if (! EvidenceProvenance::isValidSource($source)) { $source = EvidenceProvenance::SourceInventory; } $observedAt = $evidence['observed_at'] ?? null; $observedAt = is_string($observedAt) ? trim($observedAt) : null; $observedAtCarbon = null; if (is_string($observedAt) && $observedAt !== '') { try { $observedAtCarbon = CarbonImmutable::parse($observedAt); } catch (\Throwable) { $observedAtCarbon = null; } } $observedOperationRunId = $evidence['observed_operation_run_id'] ?? null; $observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null; return EvidenceProvenance::build( fidelity: $fidelity, source: $source, observedAt: $observedAtCarbon, observedOperationRunId: $observedOperationRunId, ); } /** * Upsert drift findings using stable fingerprints. * * @param array}> $driftResults * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ private function upsertFindings( Tenant $tenant, BaselineProfile $profile, string $scopeKey, array $driftResults, ): array { $tenantId = (int) $tenant->getKey(); $baselineProfileId = (int) $profile->getKey(); $observedAt = CarbonImmutable::now(); $processedCount = 0; $createdCount = 0; $reopenedCount = 0; $unchangedCount = 0; $seenFingerprints = []; $slaPolicy = app(FindingSlaPolicy::class); foreach ($driftResults as $driftItem) { $subjectKey = (string) ($driftItem['subject_key'] ?? ''); if (trim($subjectKey) === '') { continue; } $recurrenceKey = $this->recurrenceKey( tenantId: $tenantId, baselineProfileId: $baselineProfileId, policyType: (string) ($driftItem['policy_type'] ?? ''), subjectKey: $subjectKey, changeType: (string) ($driftItem['change_type'] ?? ''), ); $fingerprint = $recurrenceKey; $seenFingerprints[] = $fingerprint; $finding = Finding::query() ->where('tenant_id', $tenantId) ->where('fingerprint', $fingerprint) ->first(); $isNewFinding = ! $finding instanceof Finding; if ($isNewFinding) { $finding = new Finding; } else { $this->observeFinding( finding: $finding, observedAt: $observedAt, currentOperationRunId: (int) $this->operationRun->getKey(), ); } $finding->forceFill([ 'tenant_id' => $tenantId, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'subject_type' => $driftItem['subject_type'], 'subject_external_id' => $driftItem['subject_external_id'], 'severity' => $driftItem['severity'], 'fingerprint' => $fingerprint, 'recurrence_key' => $recurrenceKey, 'evidence_jsonb' => $driftItem['evidence'], 'evidence_fidelity' => $driftItem['evidence_fidelity'] ?? EvidenceProvenance::FidelityMeta, 'baseline_operation_run_id' => null, 'current_operation_run_id' => (int) $this->operationRun->getKey(), ]); if ($isNewFinding) { $severity = (string) $driftItem['severity']; $slaDays = $slaPolicy->daysForSeverity($severity, $tenant); $finding->forceFill([ 'status' => Finding::STATUS_NEW, 'reopened_at' => null, 'resolved_at' => null, 'resolved_reason' => null, 'acknowledged_at' => null, 'acknowledged_by_user_id' => null, 'first_seen_at' => $observedAt, 'last_seen_at' => $observedAt, 'times_seen' => 1, 'sla_days' => $slaDays, 'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); $createdCount++; } elseif ((string) $finding->status === Finding::STATUS_RESOLVED) { $resolvedAt = $finding->resolved_at !== null ? CarbonImmutable::instance($finding->resolved_at) : null; if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) { $severity = (string) $driftItem['severity']; $slaDays = $slaPolicy->daysForSeverity($severity, $tenant); $finding->forceFill([ 'status' => Finding::STATUS_REOPENED, 'reopened_at' => $observedAt, 'resolved_at' => null, 'resolved_reason' => null, 'closed_at' => null, 'closed_reason' => null, 'closed_by_user_id' => null, 'sla_days' => $slaDays, 'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); $reopenedCount++; } else { $unchangedCount++; } } else { $unchangedCount++; } $finding->save(); $processedCount++; } return [ 'processed_count' => $processedCount, 'created_count' => $createdCount, 'reopened_count' => $reopenedCount, 'unchanged_count' => $unchangedCount, 'seen_fingerprints' => array_values(array_unique($seenFingerprints)), ]; } private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { $finding->last_seen_at = $observedAt; } $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) { $finding->times_seen = max(0, $timesSeen) + 1; } elseif ($timesSeen < 1) { $finding->times_seen = 1; } } /** * Stable identity for baseline-compare findings, scoped to a baseline profile + subject key. */ private function recurrenceKey( int $tenantId, int $baselineProfileId, string $policyType, string $subjectKey, string $changeType, ): string { $parts = [ (string) $tenantId, (string) $baselineProfileId, $this->normalizeKeyPart($policyType), $this->normalizeKeyPart($subjectKey), $this->normalizeKeyPart($changeType), ]; return hash('sha256', implode('|', $parts)); } private function normalizeKeyPart(string $value): string { return trim(mb_strtolower($value)); } /** * @param array $driftResults * @return array */ private function countByChangeType(array $driftResults): array { $counts = []; foreach ($driftResults as $item) { $changeType = (string) ($item['change_type'] ?? ''); if ($changeType === '') { continue; } $counts[$changeType] = ($counts[$changeType] ?? 0) + 1; } ksort($counts); return $counts; } /** * @param array $driftResults * @return array */ private function countBySeverity(array $driftResults): array { $counts = []; foreach ($driftResults as $item) { $severity = $item['severity']; $counts[$severity] = ($counts[$severity] ?? 0) + 1; } return $counts; } /** * @return array */ private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array { try { $mapping = $settingsResolver->resolveValue( workspace: $workspace, domain: 'baseline', key: 'severity_mapping', ); } catch (\InvalidArgumentException) { // Settings keys are registry-backed; if this key is missing (e.g. during rollout), // fall back to built-in defaults rather than failing the entire compare run. return []; } return is_array($mapping) ? $mapping : []; } /** * @param array $severityMapping */ private function severityForChangeType(array $severityMapping, string $changeType): string { $severity = $severityMapping[$changeType] ?? null; if (! is_string($severity) || $severity === '') { return match ($changeType) { 'missing_policy' => Finding::SEVERITY_HIGH, 'different_version' => Finding::SEVERITY_MEDIUM, default => Finding::SEVERITY_LOW, }; } return $severity; } private function auditStarted( AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, BaselineCaptureMode $captureMode, int $subjectsTotal, BaselineScope $effectiveScope, ): void { $auditLogger->log( tenant: $tenant, action: 'baseline.compare.started', context: [ 'metadata' => [ 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, 'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value, 'capture_mode' => $captureMode->value, 'scope_types_total' => count($effectiveScope->allTypes()), 'subjects_total' => $subjectsTotal, ], ], actorId: $initiator?->id, actorEmail: $initiator?->email, actorName: $initiator?->name, resourceType: 'baseline_profile', resourceId: (string) $profile->getKey(), ); } private function auditCompleted( AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, BaselineCaptureMode $captureMode, int $subjectsTotal, array $evidenceCaptureStats, array $gaps, array $summaryCounts, ): void { $auditLogger->log( tenant: $tenant, action: 'baseline.compare.completed', context: [ 'metadata' => [ 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, 'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value, 'capture_mode' => $captureMode->value, 'subjects_total' => $subjectsTotal, 'findings_total' => $summaryCounts['total'] ?? 0, 'high' => $summaryCounts['high'] ?? 0, 'medium' => $summaryCounts['medium'] ?? 0, 'low' => $summaryCounts['low'] ?? 0, 'evidence_capture' => $evidenceCaptureStats, 'gaps' => $gaps, ], ], actorId: $initiator?->id, actorEmail: $initiator?->email, actorName: $initiator?->name, resourceType: 'operation_run', resourceId: (string) $this->operationRun->getKey(), ); } }