## Summary - remove the dead legacy drift-computation path from `CompareBaselineToTenantJob` so the strategy-driven compare engine is the only execution path left in the orchestration file - tighten compare guard and regression coverage around strategy selection, strategy execution context, findings, gaps, and no-drift outcomes - fix the repo-wide suite blockers uncovered during validation by making the governance taxonomy registry test-double compatible and aligning the capture capability guard test with current unsupported-scope behavior - add the Spec 205 planning artifacts and mark the implementation tasks complete ## Verification - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests --stop-on-failure` - result: `3659 passed, 8 skipped (21016 assertions)` - browser smoke test passed on the Baseline Compare landing surface via the local smoke-login flow ## Notes - no Filament resource, panel, global search, destructive action, or asset registration behavior was changed - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - the compare path remains strategy-driven and Livewire v4 / Filament v5 assumptions are unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #235
2141 lines
80 KiB
PHP
2141 lines
80 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
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\BaselineSnapshotTruthResolver;
|
|
use App\Services\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Services\Findings\FindingSlaPolicy;
|
|
use App\Services\Findings\FindingWorkflowService;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Support\Baselines\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\Baselines\BaselineScope;
|
|
use App\Support\Baselines\BaselineSubjectKey;
|
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
|
use App\Support\Baselines\Compare\CompareState;
|
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
|
use App\Support\Baselines\Compare\CompareStrategySelection;
|
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
|
use App\Support\Baselines\Compare\StrategySelectionState;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
use App\Support\Baselines\SubjectResolver;
|
|
use App\Support\Inventory\InventoryCoverage;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
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 BridgesFailedOperationRun;
|
|
use Dispatchable;
|
|
use InteractsWithQueue;
|
|
use Queueable;
|
|
use SerializesModels;
|
|
|
|
public int $timeout = 300;
|
|
|
|
public bool $failOnTimeout = true;
|
|
|
|
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,
|
|
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
|
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
|
?CompareStrategyRegistry $compareStrategyRegistry = null,
|
|
): void {
|
|
$settingsResolver ??= app(SettingsResolver::class);
|
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
|
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
|
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
|
$compareStrategyRegistry ??= app(CompareStrategyRegistry::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 = $this->truthfulTypesFromContext($context, $effectiveScope);
|
|
$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();
|
|
|
|
if (! $snapshot instanceof BaselineSnapshot) {
|
|
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
|
}
|
|
|
|
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
|
|
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
|
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
|
? (string) $snapshotResolution['reason_code']
|
|
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
|
|
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$context['baseline_compare'] = array_merge(
|
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
[
|
|
'reason_code' => $reasonCode,
|
|
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
|
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
|
],
|
|
);
|
|
$context['result'] = array_merge(
|
|
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
[
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
],
|
|
);
|
|
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
|
|
|
$this->operationRun->update(['context' => $context]);
|
|
$this->operationRun->refresh();
|
|
|
|
$operationRunService->finalizeBlockedRun(
|
|
run: $this->operationRun,
|
|
reasonCode: $reasonCode,
|
|
message: $this->snapshotBlockedMessage($reasonCode),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var BaselineSnapshot $snapshot */
|
|
$snapshot = $snapshotResolution['snapshot'];
|
|
$snapshotId = (int) $snapshot->getKey();
|
|
|
|
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
|
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
|
$this->operationRun->update(['context' => $context]);
|
|
$this->operationRun->refresh();
|
|
|
|
if (! $strategySelection->isSupported()) {
|
|
$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: $coveredTypes,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
|
captureMode: $captureMode,
|
|
reasonCode: BaselineCompareReasonCode::UnsupportedSubjects,
|
|
evidenceGapsByReason: [
|
|
$this->strategySelectionGapReason($strategySelection) => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
|
],
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$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'],
|
|
'subject_key' => (string) $item['subject_key'],
|
|
],
|
|
$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 = [];
|
|
$phaseGapSubjects = [];
|
|
$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'] : [];
|
|
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
|
$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,
|
|
);
|
|
|
|
$strategy = $compareStrategyRegistry->resolve($strategySelection->strategyKey);
|
|
$orchestrationContext = new CompareOrchestrationContext(
|
|
workspaceId: (int) $workspace->getKey(),
|
|
tenantId: (int) $tenant->getKey(),
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
baselineSnapshotId: (int) $snapshot->getKey(),
|
|
operationRunId: (int) $this->operationRun->getKey(),
|
|
normalizedScope: $effectiveScope->toStoredJsonb(),
|
|
strategySelection: $strategySelection,
|
|
coverageContext: [
|
|
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
|
'effective_types' => $effectiveTypes,
|
|
'covered_types' => $coveredTypes,
|
|
'uncovered_types' => $uncoveredTypes,
|
|
],
|
|
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
);
|
|
|
|
try {
|
|
$compareResult = $strategy->compare(
|
|
context: $orchestrationContext,
|
|
tenant: $tenant,
|
|
baselineItems: $baselineItems,
|
|
currentItems: $currentItems,
|
|
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
|
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
|
);
|
|
} catch (\Throwable $exception) {
|
|
$failedContext = $this->withCompareStrategyDiagnostics(
|
|
context: is_array($this->operationRun->context) ? $this->operationRun->context : [],
|
|
strategySelection: $strategySelection,
|
|
executionDiagnostics: [
|
|
'failed' => true,
|
|
'exception_class' => $exception::class,
|
|
],
|
|
);
|
|
|
|
$this->operationRun->update(['context' => $failedContext]);
|
|
$this->operationRun->refresh();
|
|
|
|
$this->completeWithCoverageWarning(
|
|
operationRunService: $operationRunService,
|
|
auditLogger: $auditLogger,
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
initiator: $initiator,
|
|
inventorySyncRun: $inventorySyncRun,
|
|
coverageProof: true,
|
|
effectiveTypes: $effectiveTypes,
|
|
coveredTypes: $coveredTypes,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
|
captureMode: $captureMode,
|
|
reasonCode: BaselineCompareReasonCode::StrategyFailed,
|
|
evidenceGapsByReason: [
|
|
'strategy_failed' => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
|
],
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$normalizedStrategyResults = $this->normalizeStrategySubjectResults($compareResult['subject_results'] ?? []);
|
|
$driftResults = $normalizedStrategyResults['drift_results'];
|
|
$driftGaps = $normalizedStrategyResults['gap_counts'];
|
|
$rbacRoleDefinitionSummary = is_array($compareResult['diagnostics']['rbac_role_definitions'] ?? null)
|
|
? $compareResult['diagnostics']['rbac_role_definitions']
|
|
: $this->emptyRbacRoleDefinitionSummary();
|
|
$strategyGapSubjects = $normalizedStrategyResults['gap_subjects'];
|
|
$strategyStateCounts = $normalizedStrategyResults['state_counts'];
|
|
$strategyDiagnostics = is_array($compareResult['diagnostics'] ?? null)
|
|
? $compareResult['diagnostics']
|
|
: [];
|
|
|
|
$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);
|
|
|
|
$gapSubjects = $this->collectGapSubjects(
|
|
ambiguousKeys: $ambiguousKeys,
|
|
phaseGapSubjects: $phaseGapSubjects ?? [],
|
|
driftGapSubjects: $strategyGapSubjects,
|
|
);
|
|
|
|
$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,
|
|
($strategyStateCounts[CompareState::Failed->value] ?? 0) > 0 => BaselineCompareReasonCode::StrategyFailed,
|
|
($strategyStateCounts[CompareState::Ambiguous->value] ?? 0) > 0 => BaselineCompareReasonCode::AmbiguousSubjects,
|
|
($strategyStateCounts[CompareState::Unsupported->value] ?? 0) > 0 => BaselineCompareReasonCode::UnsupportedSubjects,
|
|
$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,
|
|
'strategy' => $this->strategyContext(
|
|
strategySelection: $strategySelection,
|
|
executionDiagnostics: $strategyDiagnostics,
|
|
stateCounts: $strategyStateCounts,
|
|
),
|
|
'evidence_capture' => $phaseStats,
|
|
'evidence_gaps' => [
|
|
'count' => $gapsCount,
|
|
'by_reason' => $gapsByReason,
|
|
...$gapsByReason,
|
|
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
|
],
|
|
'resume_token' => $resumeToken,
|
|
'coverage' => [
|
|
'effective_types' => $effectiveTypes,
|
|
'covered_types' => $coveredTypes,
|
|
'uncovered_types' => $uncoveredTypes,
|
|
'proof' => true,
|
|
...$coverageBreakdown,
|
|
...$baselineCoverage,
|
|
],
|
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
|
'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,
|
|
];
|
|
$updatedContext = $this->withCompareReasonTranslation(
|
|
$updatedContext,
|
|
$reasonCode?->value,
|
|
);
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* @param array{
|
|
* captured_versions?: array<string, array{
|
|
* policy_type: string,
|
|
* subject_external_id: string,
|
|
* version: \App\Models\PolicyVersion,
|
|
* observed_at: string,
|
|
* observed_operation_run_id: ?int
|
|
* }>
|
|
* } $phaseResult
|
|
* @return array<string, ResolvedEvidence>
|
|
*/
|
|
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 \App\Models\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' => [],
|
|
],
|
|
'rbac_role_definitions' => $this->emptyRbacRoleDefinitionSummary(),
|
|
'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' => [],
|
|
];
|
|
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
|
|
|
$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 : [];
|
|
$displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null);
|
|
$displayName = is_string($displayName) ? $displayName : null;
|
|
|
|
$subjectKey = $this->normalizeSubjectKey(
|
|
policyType: (string) $item->policy_type,
|
|
storedSubjectKey: is_string($item->subject_key) ? $item->subject_key : null,
|
|
displayName: $displayName,
|
|
);
|
|
|
|
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)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
|
{
|
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
unset($context['reason_translation'], $context['next_steps']);
|
|
|
|
return $context;
|
|
}
|
|
|
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
|
|
|
if ($translation === null) {
|
|
return $context;
|
|
}
|
|
|
|
$context['reason_translation'] = $translation->toArray();
|
|
$context['reason_code'] = $reasonCode;
|
|
|
|
if ($translation->toLegacyNextSteps() !== []) {
|
|
$context['next_steps'] = $translation->toLegacyNextSteps();
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function withCompareStrategySelection(array $context, CompareStrategySelection $strategySelection): array
|
|
{
|
|
$context['baseline_compare'] = array_merge(
|
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
[
|
|
'strategy' => $this->strategyContext($strategySelection),
|
|
],
|
|
);
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, mixed> $executionDiagnostics
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function withCompareStrategyDiagnostics(
|
|
array $context,
|
|
CompareStrategySelection $strategySelection,
|
|
array $executionDiagnostics,
|
|
array $stateCounts = [],
|
|
): array {
|
|
$context['baseline_compare'] = array_merge(
|
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
[
|
|
'strategy' => $this->strategyContext($strategySelection, $executionDiagnostics, $stateCounts),
|
|
],
|
|
);
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $executionDiagnostics
|
|
* @param array<string, int> $stateCounts
|
|
* @return array{
|
|
* key: ?string,
|
|
* selection_state: string,
|
|
* matched_scope_entries: list<array<string, mixed>>,
|
|
* rejected_scope_entries: list<array<string, mixed>>,
|
|
* operator_reason: string,
|
|
* diagnostics: array<string, mixed>,
|
|
* execution_diagnostics: array<string, mixed>,
|
|
* state_counts: array<string, int>
|
|
* }
|
|
*/
|
|
private function strategyContext(
|
|
CompareStrategySelection $strategySelection,
|
|
array $executionDiagnostics = [],
|
|
array $stateCounts = [],
|
|
): array {
|
|
return [
|
|
'key' => $strategySelection->strategyKey?->value,
|
|
'selection_state' => $strategySelection->selectionState->value,
|
|
'matched_scope_entries' => $strategySelection->matchedScopeEntries,
|
|
'rejected_scope_entries' => $strategySelection->rejectedScopeEntries,
|
|
'operator_reason' => $strategySelection->operatorReason,
|
|
'diagnostics' => $strategySelection->diagnostics,
|
|
'execution_diagnostics' => $executionDiagnostics,
|
|
'state_counts' => $stateCounts,
|
|
];
|
|
}
|
|
|
|
private function strategySelectionGapReason(CompareStrategySelection $strategySelection): string
|
|
{
|
|
return $strategySelection->selectionState === StrategySelectionState::Mixed
|
|
? 'mixed_scope'
|
|
: 'unsupported_subjects';
|
|
}
|
|
|
|
/**
|
|
* @param mixed $subjectResults
|
|
* @return array{
|
|
* drift_results: 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>}>,
|
|
* gap_counts: array<string, int>,
|
|
* gap_subjects: list<array<string, mixed>>,
|
|
* state_counts: array<string, int>
|
|
* }
|
|
*/
|
|
private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
|
{
|
|
if (! is_array($subjectResults)) {
|
|
return [
|
|
'drift_results' => [],
|
|
'gap_counts' => [],
|
|
'gap_subjects' => [],
|
|
'state_counts' => [],
|
|
];
|
|
}
|
|
|
|
$driftResults = [];
|
|
$gapCounts = [];
|
|
$gapSubjects = [];
|
|
$stateCounts = [];
|
|
|
|
foreach ($subjectResults as $subjectResult) {
|
|
if (! $subjectResult instanceof CompareSubjectResult) {
|
|
continue;
|
|
}
|
|
|
|
$state = $subjectResult->compareState->value;
|
|
$stateCounts[$state] = ($stateCounts[$state] ?? 0) + 1;
|
|
|
|
if ($subjectResult->compareState === CompareState::Drift && $subjectResult->findingCandidate instanceof CompareFindingCandidate) {
|
|
$driftResults[] = [
|
|
'change_type' => $subjectResult->findingCandidate->changeType,
|
|
'severity' => $subjectResult->findingCandidate->severity,
|
|
'subject_type' => $subjectResult->projection->platformSubjectClass,
|
|
'subject_external_id' => $subjectResult->subjectIdentity->externalSubjectId ?? '',
|
|
'subject_key' => $subjectResult->subjectIdentity->subjectKey,
|
|
'policy_type' => $subjectResult->subjectIdentity->subjectTypeKey,
|
|
'evidence_fidelity' => $subjectResult->evidenceQuality,
|
|
'baseline_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash'))
|
|
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash')
|
|
: '',
|
|
'current_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash'))
|
|
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash')
|
|
: '',
|
|
'evidence' => $subjectResult->findingCandidate->evidencePayload,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $subjectResult->isGapState()) {
|
|
continue;
|
|
}
|
|
|
|
$reasonCode = $subjectResult->gapReasonCode() ?? $this->defaultGapReasonForState($subjectResult->compareState);
|
|
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
|
|
$gapSubjects[] = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
|
|
}
|
|
|
|
ksort($gapCounts);
|
|
ksort($stateCounts);
|
|
|
|
return [
|
|
'drift_results' => $driftResults,
|
|
'gap_counts' => $gapCounts,
|
|
'gap_subjects' => $gapSubjects,
|
|
'state_counts' => $stateCounts,
|
|
];
|
|
}
|
|
|
|
private function defaultGapReasonForState(CompareState $state): string
|
|
{
|
|
return match ($state) {
|
|
CompareState::Unsupported => 'unsupported_subject',
|
|
CompareState::Ambiguous => 'ambiguous_match',
|
|
CompareState::Failed => 'strategy_failed',
|
|
default => 'missing_current',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $reasonCode): array
|
|
{
|
|
$descriptor = $this->subjectResolver()->describeForCompare(
|
|
policyType: $subjectResult->subjectIdentity->subjectTypeKey,
|
|
subjectExternalId: $subjectResult->subjectIdentity->externalSubjectId,
|
|
subjectKey: $subjectResult->subjectIdentity->subjectKey,
|
|
);
|
|
|
|
$outcome = match ($reasonCode) {
|
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
|
};
|
|
|
|
return array_merge($descriptor->toArray(), $outcome->toArray(), [
|
|
'reason_code' => $reasonCode,
|
|
'search_text' => strtolower(implode(' ', array_filter([
|
|
$subjectResult->subjectIdentity->subjectTypeKey,
|
|
$subjectResult->subjectIdentity->subjectKey,
|
|
$reasonCode,
|
|
$subjectResult->projection->operatorLabel,
|
|
]))),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
|
$subjectKey = $this->normalizeSubjectKey(
|
|
policyType: (string) $inventoryItem->policy_type,
|
|
displayName: is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null,
|
|
subjectExternalId: is_string($inventoryItem->external_id) ? $inventoryItem->external_id : 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' => array_replace($metaJsonb, [
|
|
'display_name' => $metaJsonb['display_name'] ?? $inventoryItem->display_name,
|
|
'category' => $metaJsonb['category'] ?? $inventoryItem->category,
|
|
'platform' => $metaJsonb['platform'] ?? $inventoryItem->platform,
|
|
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
|
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
|
]),
|
|
];
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
private function snapshotBlockedMessage(string $reasonCode): string
|
|
{
|
|
return match ($reasonCode) {
|
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
|
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
|
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
|
|
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
|
|
default => 'No consumable baseline snapshot is currently available for compare.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return list<string>
|
|
*/
|
|
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
|
{
|
|
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
|
|
|
if (is_array($truthfulTypes)) {
|
|
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
|
|
|
if ($truthfulTypes !== []) {
|
|
sort($truthfulTypes, SORT_STRING);
|
|
|
|
return $truthfulTypes;
|
|
}
|
|
}
|
|
|
|
return $effectiveScope->allTypes();
|
|
}
|
|
|
|
private function normalizeSubjectKey(
|
|
string $policyType,
|
|
?string $storedSubjectKey = null,
|
|
?string $displayName = null,
|
|
?string $subjectExternalId = null,
|
|
): string {
|
|
$storedSubjectKey = is_string($storedSubjectKey) ? trim(mb_strtolower($storedSubjectKey)) : '';
|
|
|
|
if ($storedSubjectKey !== '') {
|
|
return $storedSubjectKey;
|
|
}
|
|
|
|
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
|
|
}
|
|
|
|
/**
|
|
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
|
*/
|
|
private function emptyRbacRoleDefinitionSummary(): array
|
|
{
|
|
return [
|
|
'total_compared' => 0,
|
|
'unchanged' => 0,
|
|
'modified' => 0,
|
|
'missing' => 0,
|
|
'unexpected' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
private const GAP_SUBJECTS_LIMIT = 50;
|
|
|
|
/**
|
|
* @param list<string> $ambiguousKeys
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
|
{
|
|
$subjects = [];
|
|
$seen = [];
|
|
|
|
if ($ambiguousKeys !== []) {
|
|
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
|
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
|
continue;
|
|
}
|
|
|
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
|
|
|
if ($policyType === null || $subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
|
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
|
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
|
|
|
if (isset($seen[$fingerprint])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$fingerprint] = true;
|
|
$subjects[] = $record;
|
|
}
|
|
}
|
|
|
|
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
|
|
|
if (isset($seen[$fingerprint])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$fingerprint] = true;
|
|
$subjects[] = $record;
|
|
}
|
|
|
|
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
|
|
|
if (isset($seen[$fingerprint])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$fingerprint] = true;
|
|
$subjects[] = $record;
|
|
}
|
|
|
|
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function normalizeStructuredGapSubjects(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$subjects = [];
|
|
|
|
foreach ($value as $record) {
|
|
if (! is_array($record)) {
|
|
continue;
|
|
}
|
|
|
|
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$subjects[] = $record;
|
|
}
|
|
|
|
return $subjects;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function normalizeLegacyGapSubjects(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$subjects = [];
|
|
|
|
foreach ($value as $reasonCode => $keys) {
|
|
if (! is_string($reasonCode) || ! is_array($keys)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($keys as $key) {
|
|
if (! is_string($key) || $key === '') {
|
|
continue;
|
|
}
|
|
|
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
|
|
|
if ($policyType === null || $subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
|
$outcome = match ($reasonCode) {
|
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
|
};
|
|
|
|
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
|
$record['reason_code'] = $reasonCode;
|
|
$subjects[] = $record;
|
|
}
|
|
}
|
|
|
|
return $subjects;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?string, 1: ?string}
|
|
*/
|
|
private function splitGapSubjectKey(string $value): array
|
|
{
|
|
$parts = explode('|', $value, 2);
|
|
|
|
if (count($parts) !== 2) {
|
|
return [null, null];
|
|
}
|
|
|
|
[$policyType, $subjectKey] = $parts;
|
|
$policyType = trim($policyType);
|
|
$subjectKey = trim($subjectKey);
|
|
|
|
if ($policyType === '' || $subjectKey === '') {
|
|
return [null, null];
|
|
}
|
|
|
|
return [$policyType, $subjectKey];
|
|
}
|
|
|
|
private function subjectResolver(): SubjectResolver
|
|
{
|
|
return app(SubjectResolver::class);
|
|
}
|
|
|
|
/**
|
|
* @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 = [];
|
|
$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)) {
|
|
$finding->save();
|
|
|
|
app(FindingWorkflowService::class)->reopenBySystem(
|
|
finding: $finding,
|
|
tenant: $tenant,
|
|
reopenedAt: $observedAt,
|
|
operationRunId: (int) $this->operationRun->getKey(),
|
|
);
|
|
|
|
$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<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(),
|
|
);
|
|
}
|
|
}
|