## Summary - add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels - keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging - add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries ## Verification - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php` - result: `71 passed (467 assertions)` ## Filament / Platform Notes - Livewire compliance: unchanged and compatible with Livewire v4.0+ - Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location - Global search: no new globally searchable resource added; existing global search behavior is unchanged - Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged - Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets` - Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #156
587 lines
22 KiB
PHP
587 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
final class BaselineCompareStats
|
|
{
|
|
/**
|
|
* @param array<string, int> $severityCounts
|
|
* @param list<string> $uncoveredTypes
|
|
* @param array<string, int> $evidenceGapsTopReasons
|
|
*/
|
|
private function __construct(
|
|
public readonly string $state,
|
|
public readonly ?string $message,
|
|
public readonly ?string $profileName,
|
|
public readonly ?int $profileId,
|
|
public readonly ?int $snapshotId,
|
|
public readonly ?int $duplicateNamePoliciesCount,
|
|
public readonly ?int $operationRunId,
|
|
public readonly ?int $findingsCount,
|
|
public readonly array $severityCounts,
|
|
public readonly ?string $lastComparedHuman,
|
|
public readonly ?string $lastComparedIso,
|
|
public readonly ?string $failureReason,
|
|
public readonly ?string $reasonCode = null,
|
|
public readonly ?string $reasonMessage = null,
|
|
public readonly ?string $coverageStatus = null,
|
|
public readonly ?int $uncoveredTypesCount = null,
|
|
public readonly array $uncoveredTypes = [],
|
|
public readonly ?string $fidelity = null,
|
|
public readonly ?int $evidenceGapsCount = null,
|
|
public readonly array $evidenceGapsTopReasons = [],
|
|
public readonly ?array $rbacRoleDefinitionSummary = null,
|
|
) {}
|
|
|
|
public static function forTenant(?Tenant $tenant): self
|
|
{
|
|
if (! $tenant instanceof Tenant) {
|
|
return self::empty('no_tenant', 'No tenant selected.');
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
|
return self::empty(
|
|
'no_assignment',
|
|
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
|
|
);
|
|
}
|
|
|
|
$profile = $assignment->baselineProfile;
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
return self::empty(
|
|
'no_assignment',
|
|
'The assigned baseline profile no longer exists.',
|
|
);
|
|
}
|
|
|
|
$profileName = (string) $profile->name;
|
|
$profileId = (int) $profile->getKey();
|
|
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
|
|
|
$profileScope = BaselineScope::fromJsonb(
|
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
|
);
|
|
$overrideScope = $assignment->override_scope_jsonb !== null
|
|
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
|
: null;
|
|
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
|
|
|
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
|
|
|
if ($snapshotId === null) {
|
|
return self::empty(
|
|
'no_snapshot',
|
|
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
);
|
|
}
|
|
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'baseline_compare')
|
|
->latest('id')
|
|
->first();
|
|
|
|
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
|
|
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
|
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
|
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
|
|
|
// Active run (queued/running)
|
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
|
return new self(
|
|
state: 'comparing',
|
|
message: 'A baseline comparison is currently in progress.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: null,
|
|
lastComparedIso: null,
|
|
failureReason: null,
|
|
reasonCode: $reasonCode,
|
|
reasonMessage: $reasonMessage,
|
|
coverageStatus: $coverageStatus,
|
|
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
fidelity: $fidelity,
|
|
evidenceGapsCount: $evidenceGapsCount,
|
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
|
);
|
|
}
|
|
|
|
// Failed run — explicit error state
|
|
if ($latestRun instanceof OperationRun && $latestRun->outcome === 'failed') {
|
|
$failureSummary = is_array($latestRun->failure_summary) ? $latestRun->failure_summary : [];
|
|
$failureReason = $failureSummary['message']
|
|
?? $failureSummary['reason']
|
|
?? 'The comparison job failed. Check the run details for more information.';
|
|
|
|
return new self(
|
|
state: 'failed',
|
|
message: (string) $failureReason,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
|
|
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
|
failureReason: (string) $failureReason,
|
|
reasonCode: $reasonCode,
|
|
reasonMessage: $reasonMessage,
|
|
coverageStatus: $coverageStatus,
|
|
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
fidelity: $fidelity,
|
|
evidenceGapsCount: $evidenceGapsCount,
|
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
|
);
|
|
}
|
|
|
|
$lastComparedHuman = null;
|
|
$lastComparedIso = null;
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
|
$lastComparedHuman = $latestRun->finished_at->diffForHumans();
|
|
$lastComparedIso = $latestRun->finished_at->toIso8601String();
|
|
}
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
// Single grouped query instead of 4 separate COUNT queries
|
|
$severityRows = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->selectRaw('severity, count(*) as cnt')
|
|
->groupBy('severity')
|
|
->pluck('cnt', 'severity');
|
|
|
|
$totalFindings = (int) $severityRows->sum();
|
|
$severityCounts = [
|
|
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
|
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
|
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
|
];
|
|
|
|
if ($totalFindings > 0) {
|
|
return new self(
|
|
state: 'ready',
|
|
message: null,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
|
findingsCount: $totalFindings,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
reasonCode: $reasonCode,
|
|
reasonMessage: $reasonMessage,
|
|
coverageStatus: $coverageStatus,
|
|
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
fidelity: $fidelity,
|
|
evidenceGapsCount: $evidenceGapsCount,
|
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
|
);
|
|
}
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && in_array($latestRun->outcome, ['succeeded', 'partially_succeeded'], true)) {
|
|
return new self(
|
|
state: 'ready',
|
|
message: $latestRun->outcome === 'succeeded'
|
|
? 'No open drift findings for this baseline comparison. The tenant matches the baseline.'
|
|
: 'Comparison completed with warnings. Findings may be incomplete due to missing coverage.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: 0,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
reasonCode: $reasonCode,
|
|
reasonMessage: $reasonMessage,
|
|
coverageStatus: $coverageStatus,
|
|
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
fidelity: $fidelity,
|
|
evidenceGapsCount: $evidenceGapsCount,
|
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
|
);
|
|
}
|
|
|
|
return new self(
|
|
state: 'idle',
|
|
message: 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: null,
|
|
findingsCount: null,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
reasonCode: $reasonCode,
|
|
reasonMessage: $reasonMessage,
|
|
coverageStatus: $coverageStatus,
|
|
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
|
uncoveredTypes: $uncoveredTypes,
|
|
fidelity: $fidelity,
|
|
evidenceGapsCount: $evidenceGapsCount,
|
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a DTO for widget consumption (only open/new findings).
|
|
*/
|
|
public static function forWidget(?Tenant $tenant): self
|
|
{
|
|
if (! $tenant instanceof Tenant) {
|
|
return self::empty('no_tenant', null);
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->with('baselineProfile')
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
|
return self::empty('no_assignment', null);
|
|
}
|
|
|
|
$profile = $assignment->baselineProfile;
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
$severityRows = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey)
|
|
->where('status', Finding::STATUS_NEW)
|
|
->selectRaw('severity, count(*) as cnt')
|
|
->groupBy('severity')
|
|
->pluck('cnt', 'severity');
|
|
|
|
$totalFindings = (int) $severityRows->sum();
|
|
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'baseline_compare')
|
|
->where('context->baseline_profile_id', (string) $profile->getKey())
|
|
->whereNotNull('completed_at')
|
|
->latest('completed_at')
|
|
->first();
|
|
|
|
return new self(
|
|
state: $totalFindings > 0 ? 'ready' : 'idle',
|
|
message: null,
|
|
profileName: (string) $profile->name,
|
|
profileId: (int) $profile->getKey(),
|
|
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
|
duplicateNamePoliciesCount: null,
|
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
|
findingsCount: $totalFindings,
|
|
severityCounts: [
|
|
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
|
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
|
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
|
],
|
|
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
|
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
|
{
|
|
$policyTypes = $effectiveScope->allTypes();
|
|
|
|
if ($policyTypes === []) {
|
|
return 0;
|
|
}
|
|
|
|
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
|
|
|
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
|
|
/**
|
|
* @var array<string, int> $countsByKey
|
|
*/
|
|
$countsByKey = [];
|
|
|
|
$query = InventoryItem::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->whereIn('policy_type', $policyTypes)
|
|
->whereNotNull('display_name')
|
|
->select(['id', 'policy_type', 'display_name']);
|
|
|
|
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
|
}
|
|
|
|
$query
|
|
->orderBy('id')
|
|
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
|
|
foreach ($inventoryItems as $inventoryItem) {
|
|
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
|
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
|
|
|
if ($subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey;
|
|
$countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1;
|
|
}
|
|
});
|
|
|
|
$duplicatePolicies = 0;
|
|
|
|
foreach ($countsByKey as $count) {
|
|
if ($count > 1) {
|
|
$duplicatePolicies += $count;
|
|
}
|
|
}
|
|
|
|
return $duplicatePolicies;
|
|
};
|
|
|
|
if (app()->environment('testing')) {
|
|
return $compute();
|
|
}
|
|
|
|
$cacheKey = sprintf(
|
|
'baseline_compare:tenant:%d:duplicate_names:%s:%s',
|
|
(int) $tenant->getKey(),
|
|
hash('sha256', implode('|', $policyTypes)),
|
|
$latestInventorySyncRunId ?? 'all',
|
|
);
|
|
|
|
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
|
}
|
|
|
|
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
|
{
|
|
$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(['id']);
|
|
|
|
return $run instanceof OperationRun ? (int) $run->getKey() : null;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?string, 1: list<string>, 2: ?string}
|
|
*/
|
|
private static function coverageInfoForRun(?OperationRun $run): array
|
|
{
|
|
if (! $run instanceof OperationRun) {
|
|
return [null, [], null];
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
|
|
if (! is_array($baselineCompare)) {
|
|
return [null, [], null];
|
|
}
|
|
|
|
$coverage = $baselineCompare['coverage'] ?? null;
|
|
$coverage = is_array($coverage) ? $coverage : [];
|
|
|
|
$proof = $coverage['proof'] ?? null;
|
|
$proof = is_bool($proof) ? $proof : null;
|
|
|
|
$uncoveredTypes = $coverage['uncovered_types'] ?? null;
|
|
$uncoveredTypes = is_array($uncoveredTypes) ? array_values(array_filter($uncoveredTypes, 'is_string')) : [];
|
|
$uncoveredTypes = array_values(array_unique(array_filter(array_map('trim', $uncoveredTypes), fn (string $type): bool => $type !== '')));
|
|
sort($uncoveredTypes, SORT_STRING);
|
|
|
|
$coverageStatus = null;
|
|
|
|
if ($proof === false) {
|
|
$coverageStatus = 'unproven';
|
|
} elseif ($uncoveredTypes !== []) {
|
|
$coverageStatus = 'warning';
|
|
} elseif ($proof === true) {
|
|
$coverageStatus = 'ok';
|
|
}
|
|
|
|
$fidelity = $baselineCompare['fidelity'] ?? null;
|
|
$fidelity = is_string($fidelity) ? trim($fidelity) : null;
|
|
$fidelity = $fidelity !== '' ? $fidelity : null;
|
|
|
|
return [$coverageStatus, $uncoveredTypes, $fidelity];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?string, 1: ?string}
|
|
*/
|
|
private static function reasonInfoForRun(?OperationRun $run): array
|
|
{
|
|
if (! $run instanceof OperationRun) {
|
|
return [null, null];
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
|
|
if (! is_array($baselineCompare)) {
|
|
return [null, null];
|
|
}
|
|
|
|
$reasonCode = $baselineCompare['reason_code'] ?? null;
|
|
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : null;
|
|
$reasonCode = $reasonCode !== '' ? $reasonCode : null;
|
|
|
|
$enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null;
|
|
|
|
return [$reasonCode, $enum?->message()];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?int, 1: array<string, int>}
|
|
*/
|
|
private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
|
{
|
|
if (! $run instanceof OperationRun) {
|
|
return [null, []];
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
|
|
if (! is_array($baselineCompare)) {
|
|
return [null, []];
|
|
}
|
|
|
|
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
|
|
|
if (! is_array($gaps)) {
|
|
return [null, []];
|
|
}
|
|
|
|
$count = $gaps['count'] ?? null;
|
|
$count = is_numeric($count) ? (int) $count : null;
|
|
|
|
$byReason = $gaps['by_reason'] ?? null;
|
|
$byReason = is_array($byReason) ? $byReason : [];
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($byReason as $reason => $value) {
|
|
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
|
continue;
|
|
}
|
|
|
|
$intValue = (int) $value;
|
|
|
|
if ($intValue <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[trim($reason)] = $intValue;
|
|
}
|
|
|
|
if ($count === null) {
|
|
$count = array_sum($normalized);
|
|
}
|
|
|
|
arsort($normalized);
|
|
|
|
return [$count, array_slice($normalized, 0, 6, true)];
|
|
}
|
|
|
|
/**
|
|
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}|null
|
|
*/
|
|
private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?array
|
|
{
|
|
if (! $run instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
$summary = is_array($baselineCompare) ? ($baselineCompare['rbac_role_definitions'] ?? null) : null;
|
|
|
|
if (! is_array($summary)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'total_compared' => (int) ($summary['total_compared'] ?? 0),
|
|
'unchanged' => (int) ($summary['unchanged'] ?? 0),
|
|
'modified' => (int) ($summary['modified'] ?? 0),
|
|
'missing' => (int) ($summary['missing'] ?? 0),
|
|
'unexpected' => (int) ($summary['unexpected'] ?? 0),
|
|
];
|
|
}
|
|
|
|
private static function empty(
|
|
string $state,
|
|
?string $message,
|
|
?string $profileName = null,
|
|
?int $profileId = null,
|
|
?int $duplicateNamePoliciesCount = null,
|
|
): self {
|
|
return new self(
|
|
state: $state,
|
|
message: $message,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: null,
|
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
|
operationRunId: null,
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: null,
|
|
lastComparedIso: null,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
}
|