2361 lines
92 KiB
PHP
2361 lines
92 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\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Baselines\BaselineAutoCloseService;
|
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
|
use App\Services\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
|
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\Drift\DriftHasher;
|
|
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
|
use App\Services\Findings\FindingSlaPolicy;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
|
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\BaselineScope;
|
|
use App\Support\Baselines\BaselineSubjectKey;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
use App\Support\Inventory\InventoryCoverage;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use RuntimeException;
|
|
|
|
class CompareBaselineToTenantJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private array $baselineContentHashCache = [];
|
|
|
|
public ?OperationRun $operationRun = null;
|
|
|
|
public function __construct(
|
|
public OperationRun $run,
|
|
) {
|
|
$this->operationRun = $run;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, object>
|
|
*/
|
|
public function middleware(): array
|
|
{
|
|
return [new TrackOperationRun];
|
|
}
|
|
|
|
public function handle(
|
|
BaselineSnapshotIdentity $snapshotIdentity,
|
|
AuditLogger $auditLogger,
|
|
OperationRunService $operationRunService,
|
|
?SettingsResolver $settingsResolver = null,
|
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
|
?CurrentStateHashResolver $hashResolver = null,
|
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
|
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
|
): void {
|
|
$settingsResolver ??= app(SettingsResolver::class);
|
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
|
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
|
|
|
if (! $this->operationRun instanceof OperationRun) {
|
|
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
|
|
|
return;
|
|
}
|
|
|
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
|
$snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0);
|
|
|
|
$profile = BaselineProfile::query()->find($profileId);
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
|
|
}
|
|
|
|
$tenant = Tenant::query()->find($this->operationRun->tenant_id);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
throw new RuntimeException("Workspace #{$tenant->workspace_id} not found.");
|
|
}
|
|
|
|
$initiator = $this->operationRun->user_id
|
|
? User::query()->find($this->operationRun->user_id)
|
|
: null;
|
|
|
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
|
$effectiveTypes = $effectiveScope->allTypes();
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
|
? $profile->capture_mode
|
|
: BaselineCaptureMode::Opportunistic;
|
|
|
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
|
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(['id', 'captured_at']);
|
|
|
|
if (! $snapshot instanceof BaselineSnapshot) {
|
|
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
|
}
|
|
|
|
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
|
? CarbonImmutable::instance($snapshot->captured_at)
|
|
: null;
|
|
|
|
$baselineResult = $this->loadBaselineItems($snapshotId, $coveredTypes);
|
|
$baselineItems = $baselineResult['items'];
|
|
$baselineGaps = $baselineResult['gaps'];
|
|
|
|
$currentResult = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey());
|
|
$currentItems = $currentResult['items'];
|
|
$currentGaps = $currentResult['gaps'];
|
|
|
|
$ambiguousKeys = array_values(array_unique(array_filter(array_merge(
|
|
is_array($baselineResult['ambiguous_keys'] ?? null) ? $baselineResult['ambiguous_keys'] : [],
|
|
is_array($currentResult['ambiguous_keys'] ?? null) ? $currentResult['ambiguous_keys'] : [],
|
|
), 'is_string')));
|
|
|
|
foreach ($ambiguousKeys as $ambiguousKey) {
|
|
unset($baselineItems[$ambiguousKey], $currentItems[$ambiguousKey]);
|
|
}
|
|
|
|
$subjects = array_values(array_map(
|
|
static fn (array $item): array => [
|
|
'policy_type' => (string) $item['policy_type'],
|
|
'subject_external_id' => (string) $item['subject_external_id'],
|
|
],
|
|
$currentItems,
|
|
));
|
|
|
|
$subjectsTotal = count($subjects);
|
|
|
|
$this->auditStarted(
|
|
auditLogger: $auditLogger,
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
initiator: $initiator,
|
|
captureMode: $captureMode,
|
|
subjectsTotal: $subjectsTotal,
|
|
effectiveScope: $effectiveScope,
|
|
);
|
|
|
|
$phaseStats = [
|
|
'requested' => 0,
|
|
'succeeded' => 0,
|
|
'skipped' => 0,
|
|
'failed' => 0,
|
|
'throttled' => 0,
|
|
];
|
|
$phaseResult = [];
|
|
$phaseGaps = [];
|
|
$resumeToken = null;
|
|
|
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
|
$budgets = [
|
|
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
|
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
|
'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3),
|
|
];
|
|
|
|
$resumeTokenIn = null;
|
|
|
|
if (is_array($context['baseline_compare'] ?? null)) {
|
|
$resumeTokenIn = $context['baseline_compare']['resume_token'] ?? null;
|
|
}
|
|
|
|
$phaseResult = $contentCapturePhase->capture(
|
|
tenant: $tenant,
|
|
subjects: $subjects,
|
|
purpose: PolicyVersionCapturePurpose::BaselineCompare,
|
|
budgets: $budgets,
|
|
resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null,
|
|
operationRunId: (int) $this->operationRun->getKey(),
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
createdBy: $initiator?->email,
|
|
);
|
|
|
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
|
}
|
|
|
|
$resolvedCurrentEvidenceByExternalId = $hashResolver->resolveForSubjects(
|
|
tenant: $tenant,
|
|
subjects: $subjects,
|
|
since: $since,
|
|
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
|
);
|
|
|
|
$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,
|
|
);
|
|
|
|
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
|
|
$driftHasher = app(DriftHasher::class);
|
|
$settingsNormalizer = app(SettingsNormalizer::class);
|
|
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
|
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
|
|
|
$computeResult = $this->computeDrift(
|
|
tenant: $tenant,
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
baselineSnapshotId: (int) $snapshot->getKey(),
|
|
compareOperationRunId: (int) $this->operationRun->getKey(),
|
|
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
|
baselineItems: $baselineItems,
|
|
currentItems: $currentItems,
|
|
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
|
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
|
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
|
hasher: $driftHasher,
|
|
settingsNormalizer: $settingsNormalizer,
|
|
assignmentsNormalizer: $assignmentsNormalizer,
|
|
scopeTagsNormalizer: $scopeTagsNormalizer,
|
|
contentEvidenceProvider: $contentEvidenceProvider,
|
|
);
|
|
$driftResults = $computeResult['drift'];
|
|
$driftGaps = $computeResult['evidence_gaps'];
|
|
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
|
|
|
|
$upsertResult = $this->upsertFindings(
|
|
$tenant,
|
|
$profile,
|
|
$scopeKey,
|
|
$driftResults,
|
|
);
|
|
|
|
$severityBreakdown = $this->countBySeverity($driftResults);
|
|
$countsByChangeType = $this->countByChangeType($driftResults);
|
|
|
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
|
$gapsCount = array_sum($gapsByReason);
|
|
|
|
$summaryCounts = [
|
|
'total' => count($driftResults),
|
|
'processed' => count($driftResults),
|
|
'succeeded' => (int) $upsertResult['processed_count'],
|
|
'failed' => 0,
|
|
'errors_recorded' => count($uncoveredTypes),
|
|
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
|
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
|
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
|
|
'findings_created' => (int) $upsertResult['created_count'],
|
|
'findings_reopened' => (int) $upsertResult['reopened_count'],
|
|
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
|
|
];
|
|
|
|
$warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== [];
|
|
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: $outcome,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
|
|
$resolvedCount = 0;
|
|
|
|
if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) {
|
|
$resolvedCount = $baselineAutoCloseService->resolveStaleFindings(
|
|
tenant: $tenant,
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
seenFingerprints: $upsertResult['seen_fingerprints'],
|
|
currentOperationRunId: (int) $this->operationRun->getKey(),
|
|
);
|
|
|
|
$summaryCounts['findings_resolved'] = $resolvedCount;
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: $outcome,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
}
|
|
|
|
$coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedEffectiveCurrentEvidence);
|
|
$baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems);
|
|
|
|
$overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0
|
|
|| ($coverageBreakdown['resolved_meta'] ?? 0) > 0
|
|
|| ($gapsByReason['missing_current'] ?? 0) > 0
|
|
? EvidenceProvenance::FidelityMeta
|
|
: EvidenceProvenance::FidelityContent;
|
|
|
|
$reasonCode = null;
|
|
|
|
if ($subjectsTotal === 0) {
|
|
$reasonCode = BaselineCompareReasonCode::NoSubjectsInScope;
|
|
} elseif (count($driftResults) === 0) {
|
|
$reasonCode = match (true) {
|
|
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
|
$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,
|
|
'evidence_capture' => $phaseStats,
|
|
'evidence_gaps' => [
|
|
'count' => $gapsCount,
|
|
'by_reason' => $gapsByReason,
|
|
...$gapsByReason,
|
|
],
|
|
'resume_token' => $resumeToken,
|
|
'coverage' => [
|
|
'effective_types' => $effectiveTypes,
|
|
'covered_types' => $coveredTypes,
|
|
'uncovered_types' => $uncoveredTypes,
|
|
'proof' => true,
|
|
...$coverageBreakdown,
|
|
...$baselineCoverage,
|
|
],
|
|
'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,
|
|
];
|
|
$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: 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 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' => [],
|
|
];
|
|
|
|
$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)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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' => [
|
|
'display_name' => $inventoryItem->display_name,
|
|
'category' => $inventoryItem->category,
|
|
'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;
|
|
}
|
|
|
|
/**
|
|
* Compare baseline items vs current inventory and produce drift results.
|
|
*
|
|
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
|
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
|
* @param array<string, string> $severityMapping
|
|
* @return array{
|
|
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
|
* evidence_gaps: array<string, int>,
|
|
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
|
* }
|
|
*/
|
|
private function computeDrift(
|
|
Tenant $tenant,
|
|
int $baselineProfileId,
|
|
int $baselineSnapshotId,
|
|
int $compareOperationRunId,
|
|
int $inventorySyncRunId,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
|
DriftHasher $hasher,
|
|
SettingsNormalizer $settingsNormalizer,
|
|
AssignmentsNormalizer $assignmentsNormalizer,
|
|
ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
ContentEvidenceProvider $contentEvidenceProvider,
|
|
): array {
|
|
$drift = [];
|
|
$evidenceGaps = [];
|
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
|
|
|
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: null,
|
|
);
|
|
|
|
$currentMissingProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
$currentItem = $currentItems[$key] ?? null;
|
|
|
|
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
|
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
|
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
|
|
|
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
|
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
|
tenant: $tenant,
|
|
baselineItem: $baselineItem,
|
|
baselineProvenance: $baselineProvenance,
|
|
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
|
);
|
|
$baselineComparableHash = $this->effectiveBaselineHash(
|
|
tenant: $tenant,
|
|
baselineItem: $baselineItem,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
contentEvidenceProvider: $contentEvidenceProvider,
|
|
);
|
|
|
|
if (! is_array($currentItem)) {
|
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
|
|
$displayName = is_string($displayName) ? (string) $displayName : null;
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'missing_policy',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $displayName,
|
|
baselineHash: $baselineComparableHash,
|
|
currentHash: null,
|
|
baselineProvenance: $baselineProvenance,
|
|
currentProvenance: $currentMissingProvenance,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: null,
|
|
summaryKind: 'policy_snapshot',
|
|
baselineProfileId: $baselineProfileId,
|
|
baselineSnapshotId: $baselineSnapshotId,
|
|
compareOperationRunId: $compareOperationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$evidence['summary']['kind'] = 'rbac_role_definition';
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: null,
|
|
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
|
currentMeta: [],
|
|
diffKind: 'missing',
|
|
);
|
|
}
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$rbacRoleDefinitionSummary['missing']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$drift[] = [
|
|
'change_type' => 'missing_policy',
|
|
'severity' => $isRbacRoleDefinition
|
|
? Finding::SEVERITY_HIGH
|
|
: $this->severityForChangeType($severityMapping, 'missing_policy'),
|
|
'subject_type' => $baselineItem['subject_type'],
|
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
|
'subject_key' => $subjectKey,
|
|
'policy_type' => $policyType,
|
|
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
|
'baseline_hash' => $baselineComparableHash,
|
|
'current_hash' => '',
|
|
'evidence' => $evidence,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
|
|
|
if ($baselineComparableHash !== $currentEvidence->hash) {
|
|
$displayName = $currentItem['meta_jsonb']['display_name']
|
|
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
|
|
|
$displayName = is_string($displayName) ? (string) $displayName : null;
|
|
$roleDefinitionDiff = null;
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
if ($baselinePolicyVersionId === null) {
|
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($currentPolicyVersionId === null) {
|
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
normalizer: $roleDefinitionNormalizer,
|
|
);
|
|
|
|
if ($roleDefinitionDiff === null) {
|
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$summaryKind = $isRbacRoleDefinition
|
|
? 'rbac_role_definition'
|
|
: $this->selectSummaryKind(
|
|
tenant: $tenant,
|
|
policyType: $policyType,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
hasher: $hasher,
|
|
settingsNormalizer: $settingsNormalizer,
|
|
assignmentsNormalizer: $assignmentsNormalizer,
|
|
scopeTagsNormalizer: $scopeTagsNormalizer,
|
|
);
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'different_version',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $displayName,
|
|
baselineHash: $baselineComparableHash,
|
|
currentHash: (string) $currentEvidence->hash,
|
|
baselineProvenance: $baselineProvenance,
|
|
currentProvenance: $currentEvidence->tenantProvenance(),
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
summaryKind: $summaryKind,
|
|
baselineProfileId: $baselineProfileId,
|
|
baselineSnapshotId: $baselineSnapshotId,
|
|
compareOperationRunId: $compareOperationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: $baselinePolicyVersionId,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
|
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
|
diffKind: (string) $roleDefinitionDiff['diff_kind'],
|
|
roleDefinitionDiff: $roleDefinitionDiff,
|
|
);
|
|
$rbacRoleDefinitionSummary['modified']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$drift[] = [
|
|
'change_type' => 'different_version',
|
|
'severity' => $isRbacRoleDefinition
|
|
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
|
|
: $this->severityForChangeType($severityMapping, 'different_version'),
|
|
'subject_type' => $baselineItem['subject_type'],
|
|
'subject_external_id' => $currentItem['subject_external_id'],
|
|
'subject_key' => $subjectKey,
|
|
'policy_type' => $policyType,
|
|
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
|
'baseline_hash' => $baselineComparableHash,
|
|
'current_hash' => $currentEvidence->hash,
|
|
'evidence' => $evidence,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$rbacRoleDefinitionSummary['unchanged']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (! array_key_exists($key, $baselineItems)) {
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
|
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
|
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
|
|
|
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
|
|
$displayName = is_string($displayName) ? (string) $displayName : null;
|
|
|
|
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
|
|
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
$evidence = $this->buildDriftEvidenceContract(
|
|
changeType: 'unexpected_policy',
|
|
policyType: $policyType,
|
|
subjectKey: $subjectKey,
|
|
displayName: $displayName,
|
|
baselineHash: null,
|
|
currentHash: (string) $currentEvidence->hash,
|
|
baselineProvenance: $baselinePlaceholderProvenance,
|
|
currentProvenance: $currentEvidence->tenantProvenance(),
|
|
baselinePolicyVersionId: null,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
summaryKind: 'policy_snapshot',
|
|
baselineProfileId: $baselineProfileId,
|
|
baselineSnapshotId: $baselineSnapshotId,
|
|
compareOperationRunId: $compareOperationRunId,
|
|
inventorySyncRunId: $inventorySyncRunId,
|
|
);
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$evidence['summary']['kind'] = 'rbac_role_definition';
|
|
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
|
tenant: $tenant,
|
|
baselinePolicyVersionId: null,
|
|
currentPolicyVersionId: $currentPolicyVersionId,
|
|
baselineMeta: [],
|
|
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
|
diffKind: 'unexpected',
|
|
);
|
|
}
|
|
|
|
if ($isRbacRoleDefinition) {
|
|
$rbacRoleDefinitionSummary['unexpected']++;
|
|
$rbacRoleDefinitionSummary['total_compared']++;
|
|
}
|
|
|
|
$drift[] = [
|
|
'change_type' => 'unexpected_policy',
|
|
'severity' => $isRbacRoleDefinition
|
|
? Finding::SEVERITY_MEDIUM
|
|
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => $currentItem['subject_external_id'],
|
|
'subject_key' => $subjectKey,
|
|
'policy_type' => $policyType,
|
|
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
|
'baseline_hash' => '',
|
|
'current_hash' => $currentEvidence->hash,
|
|
'evidence' => $evidence,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'drift' => $drift,
|
|
'evidence_gaps' => $evidenceGaps,
|
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
|
|
*/
|
|
private function effectiveBaselineHash(
|
|
Tenant $tenant,
|
|
array $baselineItem,
|
|
?int $baselinePolicyVersionId,
|
|
ContentEvidenceProvider $contentEvidenceProvider,
|
|
): string {
|
|
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
|
|
|
|
if ($baselinePolicyVersionId === null) {
|
|
return $storedHash;
|
|
}
|
|
|
|
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
|
|
return $this->baselineContentHashCache[$baselinePolicyVersionId];
|
|
}
|
|
|
|
$baselineVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($baselinePolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion) {
|
|
return $storedHash;
|
|
}
|
|
|
|
$hash = $contentEvidenceProvider->fromPolicyVersion(
|
|
version: $baselineVersion,
|
|
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
|
)->hash;
|
|
|
|
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
|
|
|
|
return $hash;
|
|
}
|
|
|
|
private function resolveBaselinePolicyVersionId(
|
|
Tenant $tenant,
|
|
array $baselineItem,
|
|
array $baselineProvenance,
|
|
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
|
): ?int {
|
|
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
|
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
|
|
|
|
if (is_numeric($versionReferenceId)) {
|
|
return (int) $versionReferenceId;
|
|
}
|
|
|
|
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
|
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
|
|
|
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
|
|
return null;
|
|
}
|
|
|
|
$observedAt = $baselineProvenance['observed_at'] ?? null;
|
|
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
|
|
|
|
if (! is_string($observedAt) || $observedAt === '') {
|
|
return null;
|
|
}
|
|
|
|
return $baselinePolicyVersionResolver->resolve(
|
|
tenant: $tenant,
|
|
policyType: (string) ($baselineItem['policy_type'] ?? ''),
|
|
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
|
|
observedAt: $observedAt,
|
|
);
|
|
}
|
|
|
|
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
|
|
{
|
|
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
|
|
|
|
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
|
}
|
|
|
|
private function selectSummaryKind(
|
|
Tenant $tenant,
|
|
string $policyType,
|
|
?int $baselinePolicyVersionId,
|
|
?int $currentPolicyVersionId,
|
|
DriftHasher $hasher,
|
|
SettingsNormalizer $settingsNormalizer,
|
|
AssignmentsNormalizer $assignmentsNormalizer,
|
|
ScopeTagsNormalizer $scopeTagsNormalizer,
|
|
): string {
|
|
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($baselinePolicyVersionId);
|
|
|
|
$currentVersion = PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($currentPolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$platform = is_string($baselineVersion->platform ?? null)
|
|
? (string) $baselineVersion->platform
|
|
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
|
|
|
|
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
|
|
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
|
|
|
|
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
|
|
snapshot: $baselineSnapshot,
|
|
policyType: $policyType,
|
|
platform: $platform,
|
|
);
|
|
$currentNormalized = $settingsNormalizer->normalizeForDiff(
|
|
snapshot: $currentSnapshot,
|
|
policyType: $policyType,
|
|
platform: $platform,
|
|
);
|
|
|
|
$baselineSnapshotHash = $hasher->hashNormalized([
|
|
'settings' => $baselineNormalized,
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
|
|
]);
|
|
$currentSnapshotHash = $hasher->hashNormalized([
|
|
'settings' => $currentNormalized,
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
|
|
]);
|
|
|
|
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
|
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
|
|
|
$baselineAssignmentsHash = $hasher->hashNormalized([
|
|
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
|
|
]);
|
|
$currentAssignmentsHash = $hasher->hashNormalized([
|
|
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
|
|
]);
|
|
|
|
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
|
return 'policy_assignments';
|
|
}
|
|
|
|
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
|
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
|
|
|
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
$baselineScopeTagsHash = $hasher->hashNormalized([
|
|
'scope_tag_ids' => $baselineScopeTagIds,
|
|
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
|
|
]);
|
|
$currentScopeTagsHash = $hasher->hashNormalized([
|
|
'scope_tag_ids' => $currentScopeTagIds,
|
|
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
|
|
]);
|
|
|
|
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
|
|
return 'policy_scope_tags';
|
|
}
|
|
|
|
return 'policy_snapshot';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
|
|
{
|
|
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
|
|
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
|
|
|
|
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
|
}
|
|
|
|
/**
|
|
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
|
|
* @param array<string, mixed> $currentProvenance
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildDriftEvidenceContract(
|
|
string $changeType,
|
|
string $policyType,
|
|
string $subjectKey,
|
|
?string $displayName,
|
|
?string $baselineHash,
|
|
?string $currentHash,
|
|
array $baselineProvenance,
|
|
array $currentProvenance,
|
|
?int $baselinePolicyVersionId,
|
|
?int $currentPolicyVersionId,
|
|
string $summaryKind,
|
|
int $baselineProfileId,
|
|
int $baselineSnapshotId,
|
|
int $compareOperationRunId,
|
|
int $inventorySyncRunId,
|
|
): array {
|
|
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
|
|
|
|
return [
|
|
'change_type' => $changeType,
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'display_name' => $displayName,
|
|
'summary' => [
|
|
'kind' => $summaryKind,
|
|
],
|
|
'baseline' => [
|
|
'policy_version_id' => $baselinePolicyVersionId,
|
|
'hash' => $baselineHash,
|
|
'provenance' => $baselineProvenance,
|
|
],
|
|
'current' => [
|
|
'policy_version_id' => $currentPolicyVersionId,
|
|
'hash' => $currentHash,
|
|
'provenance' => $currentProvenance,
|
|
],
|
|
'fidelity' => $fidelity,
|
|
'provenance' => [
|
|
'baseline_profile_id' => $baselineProfileId,
|
|
'baseline_snapshot_id' => $baselineSnapshotId,
|
|
'compare_operation_run_id' => $compareOperationRunId,
|
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineMeta
|
|
* @param array<string, mixed> $currentMeta
|
|
* @param array{
|
|
* baseline: array<string, mixed>,
|
|
* current: array<string, mixed>,
|
|
* changed_keys: list<string>,
|
|
* metadata_keys: list<string>,
|
|
* permission_keys: list<string>,
|
|
* diff_kind: string,
|
|
* diff_fingerprint: string
|
|
* }|null $roleDefinitionDiff
|
|
* @return array{
|
|
* diff_kind: string,
|
|
* diff_fingerprint: string,
|
|
* changed_keys: list<string>,
|
|
* metadata_keys: list<string>,
|
|
* permission_keys: list<string>,
|
|
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
|
|
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
|
|
* }
|
|
*/
|
|
private function buildRoleDefinitionEvidencePayload(
|
|
Tenant $tenant,
|
|
?int $baselinePolicyVersionId,
|
|
?int $currentPolicyVersionId,
|
|
array $baselineMeta,
|
|
array $currentMeta,
|
|
string $diffKind,
|
|
?array $roleDefinitionDiff = null,
|
|
): array {
|
|
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
|
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
|
|
|
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
|
|
? $roleDefinitionDiff['baseline']
|
|
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
|
|
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
|
|
? $roleDefinitionDiff['current']
|
|
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
|
|
|
|
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
|
|
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
|
|
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
|
|
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
|
|
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
|
|
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
|
|
: $this->roleDefinitionPermissionKeys($changedKeys);
|
|
|
|
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
|
|
? (string) $roleDefinitionDiff['diff_kind']
|
|
: $diffKind;
|
|
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
|
|
? (string) $roleDefinitionDiff['diff_fingerprint']
|
|
: hash(
|
|
'sha256',
|
|
json_encode([
|
|
'diff_kind' => $resolvedDiffKind,
|
|
'changed_keys' => $changedKeys,
|
|
'baseline' => $baselineNormalized,
|
|
'current' => $currentNormalized,
|
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
|
);
|
|
|
|
return [
|
|
'diff_kind' => $resolvedDiffKind,
|
|
'diff_fingerprint' => $diffFingerprint,
|
|
'changed_keys' => $changedKeys,
|
|
'metadata_keys' => $metadataKeys,
|
|
'permission_keys' => $permissionKeys,
|
|
'baseline' => [
|
|
'normalized' => $baselineNormalized,
|
|
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
|
|
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
|
|
],
|
|
'current' => [
|
|
'normalized' => $currentNormalized,
|
|
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
|
|
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
|
|
{
|
|
if ($policyVersionId === null) {
|
|
return null;
|
|
}
|
|
|
|
return PolicyVersion::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->find($policyVersionId);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $meta
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
|
{
|
|
if ($version instanceof PolicyVersion) {
|
|
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
|
|
is_array($version->snapshot) ? $version->snapshot : [],
|
|
'intuneRoleDefinition',
|
|
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
|
);
|
|
}
|
|
|
|
$normalized = [];
|
|
$displayName = $meta['display_name'] ?? null;
|
|
|
|
if (is_string($displayName) && trim($displayName) !== '') {
|
|
$normalized['Role definition > Display name'] = trim($displayName);
|
|
}
|
|
|
|
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
|
|
if (is_bool($isBuiltIn)) {
|
|
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
|
|
}
|
|
|
|
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
|
|
if (is_numeric($rolePermissionCount)) {
|
|
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineNormalized
|
|
* @param array<string, mixed> $currentNormalized
|
|
* @return list<string>
|
|
*/
|
|
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
|
|
{
|
|
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
|
|
sort($keys, SORT_STRING);
|
|
|
|
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $keys
|
|
* @return list<string>
|
|
*/
|
|
private function roleDefinitionPermissionKeys(array $keys): array
|
|
{
|
|
return array_values(array_filter(
|
|
$keys,
|
|
fn (string $key): bool => str_starts_with($key, 'Permission block ')
|
|
));
|
|
}
|
|
|
|
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
|
{
|
|
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
|
return 'content';
|
|
}
|
|
|
|
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
|
|
return 'mixed';
|
|
}
|
|
|
|
return 'meta';
|
|
}
|
|
|
|
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{
|
|
* baseline: array<string, mixed>,
|
|
* current: array<string, mixed>,
|
|
* changed_keys: list<string>,
|
|
* metadata_keys: list<string>,
|
|
* permission_keys: list<string>,
|
|
* diff_kind: string,
|
|
* diff_fingerprint: string
|
|
* }|null
|
|
*/
|
|
private function resolveRoleDefinitionDiff(
|
|
Tenant $tenant,
|
|
int $baselinePolicyVersionId,
|
|
int $currentPolicyVersionId,
|
|
IntuneRoleDefinitionNormalizer $normalizer,
|
|
): ?array {
|
|
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
|
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
|
|
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
|
return null;
|
|
}
|
|
|
|
return $normalizer->classifyDiff(
|
|
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
|
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
|
platform: is_string($currentVersion->platform ?? null)
|
|
? (string) $currentVersion->platform
|
|
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array{diff_kind?: string}|null $roleDefinitionDiff
|
|
*/
|
|
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
|
|
{
|
|
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
|
|
'metadata_only' => Finding::SEVERITY_LOW,
|
|
default => Finding::SEVERITY_HIGH,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* @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)) {
|
|
$severity = (string) $driftItem['severity'];
|
|
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => $observedAt,
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
|
]);
|
|
|
|
$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(),
|
|
);
|
|
}
|
|
}
|