completed_at || ! $current->completed_at) { throw new RuntimeException('Baseline/current run must be finished.'); } $observedAt = CarbonImmutable::instance($current->completed_at); /** @var array $selection */ $selection = is_array($current->context) ? $current->context : []; $policyTypes = Arr::get($selection, 'policy_types'); if (! is_array($policyTypes)) { $policyTypes = []; } $policyTypes = array_values(array_filter(array_map('strval', $policyTypes))); $created = 0; $resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT); $seenRecurrenceKeys = []; Policy::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('policy_type', $policyTypes) ->orderBy('id') ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, $observedAt, &$seenRecurrenceKeys, &$created): void { foreach ($policies as $policy) { if (! $policy instanceof Policy) { continue; } $baselineVersion = $this->versionForRun($policy, $baseline); $currentVersion = $this->versionForRun($policy, $current); if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) { $policyType = (string) ($policy->policy_type ?? ''); $platform = is_string($policy->platform ?? null) ? $policy->platform : null; $baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : []; $currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot) ? $currentVersion->snapshot : []; $baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform); $currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform); $baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized); $currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized); if ($baselineSnapshotHash !== $currentSnapshotHash) { $changeType = match (true) { $baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed', ! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added', default => 'modified', }; $rawEvidence = [ 'change_type' => $changeType, 'summary' => [ 'kind' => 'policy_snapshot', 'changed_fields' => ['snapshot_hash'], ], 'baseline' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $baselineVersion?->getKey(), 'snapshot_hash' => $baselineSnapshotHash, ], 'current' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $currentVersion?->getKey(), 'snapshot_hash' => $currentSnapshotHash, ], ]; $dimension = $this->recurrenceDimension('policy_snapshot', $changeType); $wasNew = $this->upsertDriftFinding( tenant: $tenant, baseline: $baseline, current: $current, scopeKey: $scopeKey, subjectType: 'policy', subjectExternalId: (string) $policy->external_id, severity: $resolvedSeverity, dimension: $dimension, rawEvidence: $rawEvidence, observedAt: $observedAt, seenRecurrenceKeys: $seenRecurrenceKeys, ); if ($wasNew) { $created++; } } } if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) { continue; } $baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []; $currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : []; $baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments); $currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments); if ($baselineAssignmentsHash !== $currentAssignmentsHash) { $rawEvidence = [ 'change_type' => 'modified', 'summary' => [ 'kind' => 'policy_assignments', 'changed_fields' => ['assignments_hash'], ], 'baseline' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $baselineVersion->getKey(), 'assignments_hash' => $baselineAssignmentsHash, ], 'current' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $currentVersion->getKey(), 'assignments_hash' => $currentAssignmentsHash, ], ]; $dimension = $this->recurrenceDimension('policy_assignments', 'modified'); $wasNew = $this->upsertDriftFinding( tenant: $tenant, baseline: $baseline, current: $current, scopeKey: $scopeKey, subjectType: 'assignment', subjectExternalId: (string) $policy->external_id, severity: $resolvedSeverity, dimension: $dimension, rawEvidence: $rawEvidence, observedAt: $observedAt, seenRecurrenceKeys: $seenRecurrenceKeys, ); if ($wasNew) { $created++; } } $baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags); $currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags); if ($baselineScopeTagIds === null || $currentScopeTagIds === null) { continue; } $baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds); $currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds); if ($baselineScopeTagsHash === $currentScopeTagsHash) { continue; } $rawEvidence = [ 'change_type' => 'modified', 'summary' => [ 'kind' => 'policy_scope_tags', 'changed_fields' => ['scope_tags_hash'], ], 'baseline' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $baselineVersion->getKey(), 'scope_tags_hash' => $baselineScopeTagsHash, ], 'current' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $currentVersion->getKey(), 'scope_tags_hash' => $currentScopeTagsHash, ], ]; $dimension = $this->recurrenceDimension('policy_scope_tags', 'modified'); $wasNew = $this->upsertDriftFinding( tenant: $tenant, baseline: $baseline, current: $current, scopeKey: $scopeKey, subjectType: 'scope_tag', subjectExternalId: (string) $policy->external_id, severity: $resolvedSeverity, dimension: $dimension, rawEvidence: $rawEvidence, observedAt: $observedAt, seenRecurrenceKeys: $seenRecurrenceKeys, ); if ($wasNew) { $created++; } } }); $this->resolveStaleDriftFindings( tenant: $tenant, scopeKey: $scopeKey, seenRecurrenceKeys: $seenRecurrenceKeys, observedAt: $observedAt, ); return $created; } private function recurrenceDimension(string $kind, string $changeType): string { $kind = strtolower(trim($kind)); $changeType = strtolower(trim($changeType)); return match ($kind) { 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType), default => $kind, }; } private function recurrenceKey( int $tenantId, string $scopeKey, string $subjectType, string $subjectExternalId, string $dimension, ): string { return hash('sha256', sprintf( 'drift:%d:%s:%s:%s:%s', $tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension, )); } /** * @param array $seenRecurrenceKeys * @param array $rawEvidence */ private function upsertDriftFinding( Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey, string $subjectType, string $subjectExternalId, string $severity, string $dimension, array $rawEvidence, CarbonImmutable $observedAt, array &$seenRecurrenceKeys, ): bool { $tenantId = (int) $tenant->getKey(); $recurrenceKey = $this->recurrenceKey($tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension); $seenRecurrenceKeys[] = $recurrenceKey; $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('recurrence_key', $recurrenceKey) ->first(); $wasNew = ! $finding instanceof Finding; if ($wasNew) { $finding = new Finding; } else { $this->observeFinding($finding, $observedAt, (int) $current->getKey()); } $finding->forceFill([ 'tenant_id' => $tenantId, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => $scopeKey, 'baseline_operation_run_id' => $baseline->getKey(), 'current_operation_run_id' => $current->getKey(), 'recurrence_key' => $recurrenceKey, 'fingerprint' => $recurrenceKey, 'subject_type' => $subjectType, 'subject_external_id' => $subjectExternalId, 'severity' => $severity, 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), ]); if ($wasNew) { $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); $finding->forceFill([ 'status' => Finding::STATUS_NEW, 'acknowledged_at' => null, 'acknowledged_by_user_id' => null, 'first_seen_at' => $observedAt, 'last_seen_at' => $observedAt, 'times_seen' => 1, 'sla_days' => $slaDays, 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); } $status = (string) $finding->status; if ($status === Finding::STATUS_RESOLVED) { $resolvedAt = $finding->resolved_at; if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { $slaDays = $this->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' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); } } $finding->save(); return $wasNew; } 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; } } /** * @param array $seenRecurrenceKeys */ private function resolveStaleDriftFindings( Tenant $tenant, string $scopeKey, array $seenRecurrenceKeys, CarbonImmutable $observedAt, ): void { $staleFindingsQuery = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) ->whereNotNull('recurrence_key') ->whereIn('status', Finding::openStatusesForQuery()); if ($seenRecurrenceKeys !== []) { $staleFindingsQuery->whereNotIn('recurrence_key', $seenRecurrenceKeys); } $staleFindings = $staleFindingsQuery->get(); foreach ($staleFindings as $finding) { if (! $finding instanceof Finding) { continue; } $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => $observedAt, 'resolved_reason' => 'no_longer_detected', ])->save(); } } private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion { if (! $run->completed_at) { return null; } return PolicyVersion::query() ->where('tenant_id', $policy->tenant_id) ->where('policy_id', $policy->getKey()) ->where('captured_at', '<=', $run->completed_at) ->latest('captured_at') ->first(); } private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string { $workspace = $tenant->workspace; if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) { $workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first(); } if (! $workspace instanceof Workspace) { return Finding::SEVERITY_MEDIUM; } $resolved = $this->settingsResolver->resolveValue( workspace: $workspace, domain: 'drift', key: 'severity_mapping', tenant: $tenant, ); if (! is_array($resolved)) { return Finding::SEVERITY_MEDIUM; } foreach ($resolved as $mappedFindingType => $mappedSeverity) { if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) { continue; } if ($mappedFindingType !== $findingType) { continue; } $normalizedSeverity = strtolower($mappedSeverity); if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) { return $normalizedSeverity; } break; } return Finding::SEVERITY_MEDIUM; } /** * @return array */ private function supportedSeverities(): array { return [ Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL, ]; } }