find($this->tenantId); if (! $tenant instanceof Tenant) { return; } $initiator = $this->initiatorUserId !== null ? User::query()->find($this->initiatorUserId) : null; $operationRun = $operationRuns->ensureRunWithIdentity( tenant: $tenant, type: 'findings.lifecycle.backfill', identityInputs: [ 'tenant_id' => $this->tenantId, 'trigger' => 'backfill', ], context: [ 'workspace_id' => $this->workspaceId, 'initiator_user_id' => $this->initiatorUserId, ], initiator: $initiator instanceof User ? $initiator : null, ); $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); if (! $lock->get()) { if ($operationRun->status !== OperationRunStatus::Completed->value) { $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Blocked->value, failures: [ [ 'code' => 'findings.lifecycle.backfill.lock_busy', 'message' => 'Another findings lifecycle backfill is already running for this tenant.', ], ], ); } return; } try { $total = (int) Finding::query() ->where('tenant_id', $tenant->getKey()) ->count(); $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Running->value, outcome: OperationRunOutcome::Pending->value, summaryCounts: [ 'total' => $total, 'processed' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0, ], ); $operationRun->refresh(); $backfillStartedAt = $operationRun->started_at !== null ? CarbonImmutable::instance($operationRun->started_at) : CarbonImmutable::now('UTC'); Finding::query() ->where('tenant_id', $tenant->getKey()) ->orderBy('id') ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void { $processed = 0; $updated = 0; $skipped = 0; foreach ($findings as $finding) { if (! $finding instanceof Finding) { continue; } $processed++; $originalAttributes = $finding->getAttributes(); $this->backfillLifecycleFields($finding, $backfillStartedAt); $this->backfillLegacyAcknowledgedStatus($finding); $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); $this->backfillDriftRecurrenceKey($finding); if ($finding->isDirty()) { $finding->save(); $updated++; } else { $finding->setRawAttributes($originalAttributes, sync: true); $skipped++; } } $operationRuns->incrementSummaryCounts($operationRun, [ 'processed' => $processed, 'updated' => $updated, 'skipped' => $skipped, ]); }); $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); if ($consolidatedDuplicates > 0) { $operationRuns->incrementSummaryCounts($operationRun, [ 'updated' => $consolidatedDuplicates, ]); } $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, ); } catch (Throwable $e) { $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [[ 'code' => 'findings.lifecycle.backfill.failed', 'reason_code' => $reasonCode, 'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.', ]], ); throw $e; } finally { $lock->release(); } } private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void { $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; if ($finding->first_seen_at === null) { $finding->first_seen_at = $createdAt; } if ($finding->last_seen_at === null) { $finding->last_seen_at = $createdAt; } if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { $lastSeen = CarbonImmutable::instance($finding->last_seen_at); $firstSeen = CarbonImmutable::instance($finding->first_seen_at); if ($lastSeen->lessThan($firstSeen)) { $finding->last_seen_at = $firstSeen; } } $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($timesSeen < 1) { $finding->times_seen = 1; } } private function backfillLegacyAcknowledgedStatus(Finding $finding): void { if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { return; } $finding->status = Finding::STATUS_TRIAGED; if ($finding->triaged_at === null) { if ($finding->acknowledged_at !== null) { $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); } elseif ($finding->created_at !== null) { $finding->triaged_at = CarbonImmutable::instance($finding->created_at); } } } private function backfillSlaFields( Finding $finding, Tenant $tenant, FindingSlaPolicy $slaPolicy, CarbonImmutable $backfillStartedAt, ): void { if (! Finding::isOpenStatus((string) $finding->status)) { return; } if ($finding->sla_days === null) { $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); } if ($finding->due_at === null) { $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); } } private function backfillDriftRecurrenceKey(Finding $finding): void { if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { return; } if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { return; } $tenantId = (int) ($finding->tenant_id ?? 0); $scopeKey = (string) ($finding->scope_key ?? ''); $subjectType = (string) ($finding->subject_type ?? ''); $subjectExternalId = (string) ($finding->subject_external_id ?? ''); if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { return; } $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; $kind = Arr::get($evidence, 'summary.kind'); $changeType = Arr::get($evidence, 'change_type'); $kind = is_string($kind) ? $kind : ''; $changeType = is_string($changeType) ? $changeType : ''; if ($kind === '') { return; } $dimension = $this->recurrenceDimension($kind, $changeType); $finding->recurrence_key = hash('sha256', sprintf( 'drift:%d:%s:%s:%s:%s', $tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension, )); } 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 !== '' ? $changeType : 'modified'), default => $kind, }; } private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int { $duplicateKeys = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->whereNotNull('recurrence_key') ->select(['recurrence_key']) ->groupBy('recurrence_key') ->havingRaw('COUNT(*) > 1') ->pluck('recurrence_key') ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') ->values(); if ($duplicateKeys->isEmpty()) { return 0; } $consolidated = 0; foreach ($duplicateKeys as $recurrenceKey) { if (! is_string($recurrenceKey) || $recurrenceKey === '') { continue; } $findings = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('recurrence_key', $recurrenceKey) ->orderBy('id') ->get(); $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); foreach ($findings as $finding) { if (! $finding instanceof Finding) { continue; } if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { continue; } $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => $backfillStartedAt, 'resolved_reason' => 'consolidated_duplicate', 'recurrence_key' => null, ])->save(); $consolidated++; } } return $consolidated; } /** * @param Collection $findings */ private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding { if ($findings->isEmpty()) { return null; } $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); if ($alreadyCanonical instanceof Finding) { return $alreadyCanonical; } /** @var Finding $sorted */ $sorted = $candidates ->sortByDesc(function (Finding $finding): array { $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; return [ max($lastSeen, $createdAt), (int) $finding->getKey(), ]; }) ->first(); return $sorted; } }