TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php
ahmido f08924525d Spec 117: Baseline Drift Engine + evidence fidelity/provenance (#142)
Implements Spec 117 (Golden Master Baseline Drift Engine):

- Adds provider-chain resolver for current state hashes (content evidence via PolicyVersion, meta evidence via inventory)
- Updates baseline capture + compare jobs to use resolver and persist provenance + fidelity
- Adds evidence_fidelity column/index + Filament UI badge/filter/provenance display for findings
- Adds performance guard test + integration tests for drift, fidelity semantics, provenance, filter behavior
- UX fix: Policies list shows "Sync from Intune" header action only when records exist; empty-state CTA remains and is functional

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicySyncCtaPlacementTest.php`
- `vendor/bin/sail artisan test --compact --filter=Baseline`

Checklist:
- specs/117-baseline-drift-engine/checklists/requirements.md ✓

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #142
2026-03-03 07:23:01 +00:00

1143 lines
43 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\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\BaselineScope;
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,
): void {
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
$hashResolver ??= app(CurrentStateHashResolver::class);
$metaEvidenceProvider ??= app(MetaEvidenceProvider::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();
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
if ($effectiveTypes === []) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: [],
coveredTypes: [],
uncoveredTypes: [],
errorsRecorded: 1,
);
return;
}
$inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant);
$coverage = $inventorySyncRun instanceof OperationRun
? InventoryCoverage::fromContext($inventorySyncRun->context)
: null;
if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
return;
}
$coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()));
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
if ($coveredTypes === []) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
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;
$baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes);
$currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey());
$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,
));
$resolvedCurrentEvidence = $hashResolver->resolveForSubjects(
tenant: $tenant,
subjects: $subjects,
since: $since,
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
$resolvedCurrentMetaEvidence = $metaEvidenceProvider->resolve(
tenant: $tenant,
subjects: $subjects,
since: $since,
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
);
$resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence(
baselineItems: $baselineItems,
currentItems: $currentItems,
resolvedBestEvidence: $resolvedCurrentEvidence,
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
);
$computeResult = $this->computeDrift(
$baselineItems,
$currentItems,
$resolvedEffectiveCurrentEvidence,
$this->resolveSeverityMapping($workspace, $settingsResolver),
);
$driftResults = $computeResult['drift'];
$evidenceGaps = $computeResult['evidence_gaps'];
$upsertResult = $this->upsertFindings(
$tenant,
$profile,
$snapshotId,
$scopeKey,
$driftResults,
);
$severityBreakdown = $this->countBySeverity($driftResults);
$countsByChangeType = $this->countByChangeType($driftResults);
$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'],
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value,
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: OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts,
);
}
$coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedCurrentEvidence);
$baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems);
$overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0
|| ($coverageBreakdown['resolved_meta'] ?? 0) > 0
|| ($evidenceGaps['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(),
'coverage' => [
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
'proof' => true,
...$coverageBreakdown,
...$baselineCoverage,
],
'fidelity' => $overallFidelity,
'evidence_gaps' => $evidenceGaps,
],
);
$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, $tenant, $profile, $initiator, $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;
}
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,
): 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 : [];
$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,
'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',
'evidence_gaps' => [
'missing_baseline' => 0,
'missing_current' => 0,
'missing_both' => 0,
],
],
);
$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, $tenant, $profile, $initiator, $summaryCounts);
}
/**
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
*
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function loadBaselineItems(int $snapshotId, array $policyTypes): array
{
$items = [];
if ($policyTypes === []) {
return $items;
}
$query = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId);
$query->whereIn('policy_type', $policyTypes);
$query
->orderBy('id')
->chunk(500, function ($snapshotItems) use (&$items): void {
foreach ($snapshotItems as $item) {
$key = $item->policy_type.'|'.$item->subject_external_id;
$items[$key] = [
'subject_type' => (string) $item->subject_type,
'subject_external_id' => (string) $item->subject_external_id,
'policy_type' => (string) $item->policy_type,
'baseline_hash' => (string) $item->baseline_hash,
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
];
}
});
return $items;
}
/**
* Load current inventory items keyed by "policy_type|external_id".
*
* @return array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}>
*/
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 [];
}
$query->whereIn('policy_type', $policyTypes);
$items = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items): void {
foreach ($inventoryItems as $inventoryItem) {
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
$items[$key] = [
'subject_external_id' => (string) $inventoryItem->external_id,
'policy_type' => (string) $inventoryItem->policy_type,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
],
];
}
});
return $items;
}
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, 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> $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, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>, evidence_gaps: array{missing_baseline: int, missing_current: int, missing_both: int}}
*/
private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array
{
$drift = [];
$missingCurrentEvidence = 0;
foreach ($baselineItems as $key => $baselineItem) {
if (! array_key_exists($key, $currentItems)) {
$drift[] = [
'change_type' => 'missing_policy',
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'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'],
'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);
$drift[] = [
'change_type' => 'different_version',
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'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'],
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
'baseline_hash' => $baselineItem['baseline_hash'],
'current_hash' => $currentEvidence->hash,
'baseline' => [
'hash' => $baselineItem['baseline_hash'],
'provenance' => $baselineProvenance,
],
'current' => [
'hash' => $currentEvidence->hash,
'provenance' => $currentEvidence->provenance(),
],
],
];
}
}
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'],
'policy_type' => $currentItem['policy_type'],
'evidence_fidelity' => EvidenceProvenance::FidelityMeta,
'baseline_hash' => '',
'current_hash' => $currentEvidence->hash,
'evidence' => [
'change_type' => 'unexpected_policy',
'policy_type' => $currentItem['policy_type'],
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
],
];
}
}
return [
'drift' => $drift,
'evidence_gaps' => [
'missing_baseline' => 0,
'missing_current' => $missingCurrentEvidence,
'missing_both' => 0,
],
];
}
/**
* @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, 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,
int $baselineSnapshotId,
string $scopeKey,
array $driftResults,
): array {
$tenantId = (int) $tenant->getKey();
$observedAt = CarbonImmutable::now();
$processedCount = 0;
$createdCount = 0;
$reopenedCount = 0;
$unchangedCount = 0;
$seenFingerprints = [];
foreach ($driftResults as $driftItem) {
$recurrenceKey = $this->recurrenceKey(
tenantId: $tenantId,
baselineSnapshotId: $baselineSnapshotId,
policyType: $driftItem['policy_type'],
subjectExternalId: $driftItem['subject_external_id'],
changeType: $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 snapshot.
*/
private function recurrenceKey(
int $tenantId,
int $baselineSnapshotId,
string $policyType,
string $subjectExternalId,
string $changeType,
): string {
$parts = [
(string) $tenantId,
(string) $baselineSnapshotId,
$this->normalizeKeyPart($policyType),
$this->normalizeKeyPart($subjectExternalId),
$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,
): 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,
],
],
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,
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,
'findings_total' => $summaryCounts['total'] ?? 0,
'high' => $summaryCounts['high'] ?? 0,
'medium' => $summaryCounts['medium'] ?? 0,
'low' => $summaryCounts['low'] ?? 0,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}