1505 lines
56 KiB
PHP
1505 lines
56 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Jobs\Middleware\TrackOperationRun;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Baselines\BaselineAutoCloseService;
|
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
|
use App\Services\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Support\Baselines\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
|
use App\Support\Baselines\BaselineScope;
|
|
use App\Support\Baselines\BaselineSubjectKey;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
use App\Support\Inventory\InventoryCoverage;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use RuntimeException;
|
|
|
|
class CompareBaselineToTenantJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public ?OperationRun $operationRun = null;
|
|
|
|
public function __construct(
|
|
public OperationRun $run,
|
|
) {
|
|
$this->operationRun = $run;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, object>
|
|
*/
|
|
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,
|
|
): 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);
|
|
|
|
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) {
|
|
$rolloutGate->assertEnabled();
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
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,
|
|
];
|
|
$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(),
|
|
);
|
|
|
|
$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,
|
|
);
|
|
|
|
$computeResult = $this->computeDrift(
|
|
$baselineItems,
|
|
$currentItems,
|
|
$resolvedEffectiveCurrentEvidence,
|
|
$this->resolveSeverityMapping($workspace, $settingsResolver),
|
|
);
|
|
$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;
|
|
|
|
$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,
|
|
],
|
|
);
|
|
$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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
|
* @param array<string, ResolvedEvidence|null> $resolvedBestEvidence
|
|
* @param array<string, ResolvedEvidence> $resolvedMetaEvidence
|
|
* @return array<string, ResolvedEvidence|null>
|
|
*/
|
|
private function resolveEffectiveCurrentEvidence(
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedBestEvidence,
|
|
array $resolvedMetaEvidence,
|
|
): array {
|
|
/**
|
|
* @var array<string, string>
|
|
*/
|
|
$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<string, array{subject_external_id: string, policy_type: string}> $currentItems
|
|
* @param array<string, ResolvedEvidence|null> $resolvedByExternalId
|
|
* @return array<string, ResolvedEvidence|null>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
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,
|
|
): 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 = [
|
|
'coverage_unproven' => 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,
|
|
'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<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>,
|
|
* gaps: array<string, int>,
|
|
* ambiguous_keys: list<string>
|
|
* }
|
|
*/
|
|
private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
|
{
|
|
$items = [];
|
|
$gaps = [];
|
|
|
|
/**
|
|
* @var array<string, true>
|
|
*/
|
|
$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<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}>,
|
|
* gaps: array<string, int>,
|
|
* ambiguous_keys: list<string>
|
|
* }
|
|
*/
|
|
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<string, true>
|
|
*/
|
|
$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<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
|
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
|
* @param array<string, string> $severityMapping
|
|
* @return array{
|
|
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
|
* evidence_gaps: array<string, int>
|
|
* }
|
|
*/
|
|
private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array
|
|
{
|
|
$drift = [];
|
|
$missingCurrentEvidence = 0;
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
$currentItem = $currentItems[$key] ?? null;
|
|
|
|
if (! is_array($currentItem)) {
|
|
$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' => $baselineItem['subject_key'],
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'evidence_fidelity' => EvidenceProvenance::FidelityMeta,
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => '',
|
|
'evidence' => [
|
|
'change_type' => 'missing_policy',
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'subject_key' => $baselineItem['subject_key'],
|
|
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
|
],
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$missingCurrentEvidence++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($baselineItem['baseline_hash'] !== $currentEvidence->hash) {
|
|
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb']);
|
|
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
|
$evidenceFidelity = EvidenceProvenance::weakerFidelity($baselineFidelity, $currentEvidence->fidelity);
|
|
$displayName = $currentItem['meta_jsonb']['display_name']
|
|
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
|
|
|
$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' => $baselineItem['subject_key'],
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'evidence_fidelity' => $evidenceFidelity,
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => $currentEvidence->hash,
|
|
'evidence' => [
|
|
'change_type' => 'different_version',
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'subject_key' => $baselineItem['subject_key'],
|
|
'display_name' => $displayName,
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => $currentEvidence->hash,
|
|
'baseline' => [
|
|
'hash' => $baselineItem['baseline_hash'],
|
|
'provenance' => $baselineProvenance,
|
|
],
|
|
'current' => [
|
|
'hash' => $currentEvidence->hash,
|
|
'provenance' => $currentEvidence->tenantProvenance(),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (! array_key_exists($key, $baselineItems)) {
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$missingCurrentEvidence++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$drift[] = [
|
|
'change_type' => 'unexpected_policy',
|
|
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => $currentItem['subject_external_id'],
|
|
'subject_key' => $currentItem['subject_key'],
|
|
'policy_type' => $currentItem['policy_type'],
|
|
'evidence_fidelity' => $currentEvidence->fidelity,
|
|
'baseline_hash' => '',
|
|
'current_hash' => $currentEvidence->hash,
|
|
'evidence' => [
|
|
'change_type' => 'unexpected_policy',
|
|
'policy_type' => $currentItem['policy_type'],
|
|
'subject_key' => $currentItem['subject_key'],
|
|
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'drift' => $drift,
|
|
'evidence_gaps' => [
|
|
'missing_current' => $missingCurrentEvidence,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> ...$gaps
|
|
* @return array<string, int>
|
|
*/
|
|
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<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
|
* @return array{
|
|
* subjects_total: int,
|
|
* resolved_total: int,
|
|
* resolved_content: int,
|
|
* resolved_meta: int,
|
|
* policy_types_content: list<string>,
|
|
* policy_types_meta_only: list<string>
|
|
* }
|
|
*/
|
|
private function summarizeCurrentEvidenceCoverage(array $currentItems, array $resolvedCurrentEvidence): array
|
|
{
|
|
$subjectsTotal = count($currentItems);
|
|
$resolvedContent = 0;
|
|
$resolvedMeta = 0;
|
|
|
|
/**
|
|
* @var array<string, array{resolved_content: int, resolved_meta: int}>
|
|
*/
|
|
$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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
|
* @return array{
|
|
* baseline_total: int,
|
|
* baseline_content: int,
|
|
* baseline_meta: int,
|
|
* baseline_policy_types_content: list<string>,
|
|
* baseline_policy_types_meta_only: list<string>
|
|
* }
|
|
*/
|
|
private function summarizeBaselineEvidenceCoverage(array $baselineItems): array
|
|
{
|
|
$baselineTotal = count($baselineItems);
|
|
$baselineContent = 0;
|
|
$baselineMeta = 0;
|
|
|
|
/**
|
|
* @var array<string, array{baseline_content: int, baseline_meta: int}>
|
|
*/
|
|
$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<string, mixed> $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<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
|
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
|
|
*/
|
|
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 = [];
|
|
|
|
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) {
|
|
$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,
|
|
]);
|
|
|
|
$createdCount++;
|
|
} elseif (Finding::isTerminalStatus($finding->status)) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => now(),
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
]);
|
|
|
|
$reopenedCount++;
|
|
} 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<int, array{change_type: string}> $driftResults
|
|
* @return array<string, int>
|
|
*/
|
|
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<int, array{severity: string}> $driftResults
|
|
* @return array<string, int>
|
|
*/
|
|
private function countBySeverity(array $driftResults): array
|
|
{
|
|
$counts = [];
|
|
|
|
foreach ($driftResults as $item) {
|
|
$severity = $item['severity'];
|
|
$counts[$severity] = ($counts[$severity] ?? 0) + 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
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<string, string> $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(),
|
|
);
|
|
}
|
|
}
|