TenantAtlas/app/Services/Intune/CompliancePolicyNormalizer.php
ahmido da1adbdeb5 Spec 119: Drift cutover to Baseline Compare (golden master) (#144)
Implements Spec 119 (Drift Golden Master Cutover):

- Baseline Compare is the only drift writer (`source = baseline.compare`).
- Drift findings now store diff-compatible `evidence_jsonb` (summary.kind, baseline/current policy_version_id refs, fidelity + provenance).
- Findings UI renders one-sided diffs for `missing_policy`/`unexpected_policy` when a single ref exists; otherwise shows explicit “diff unavailable”.
- Removes legacy drift generator runtime (jobs/services/UI) and related tests.
- Adds one-time migration to delete legacy drift findings (`finding_type=drift` where source is null or != baseline.compare).
- Scopes baseline capture & landing duplicate warnings to latest completed inventory sync.
- Canonicalizes compliance `scheduledActionsForRule` drift signal and keeps legacy snapshots comparable.

Tests:
- `vendor/bin/sail artisan test --compact` (full suite per tasks)
- Focused pack: BaselinePolicyVersionResolverTest, BaselineCompareDriftEvidenceContractTest, DriftFindingDiffUnavailableTest, LegacyDriftFindingsCleanupMigrationTest, ComplianceNoncomplianceActionsDriftTest

Notes:
- Livewire v4+ / Filament v5 compatible (no legacy APIs).
- No new external dependencies.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #144
2026-03-06 14:30:49 +00:00

505 lines
16 KiB
PHP

<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class CompliancePolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$normalized['settings'] = array_values(array_filter(
$normalized['settings'],
fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general'
));
foreach ($this->buildComplianceBlocks($snapshot) as $block) {
$normalized['settings'][] = $block;
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
$flat = $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
return array_merge($flat, $this->flattenNoncomplianceActionsForDiff($snapshot));
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildComplianceBlocks(array $snapshot): array
{
$blocks = [];
$groups = $this->groupedFields();
$usedKeys = [];
foreach ($groups as $title => $group) {
$rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []);
if ($rows === []) {
continue;
}
$blocks[] = [
'type' => 'table',
'title' => $title,
'rows' => $rows,
];
$usedKeys = array_merge($usedKeys, $group['keys']);
}
$additionalRows = $this->buildAdditionalRows($snapshot, $usedKeys);
if ($additionalRows !== []) {
$blocks[] = [
'type' => 'table',
'title' => 'Additional Settings',
'rows' => $additionalRows,
];
}
return $blocks;
}
/**
* @return array<string, mixed>
*/
private function flattenNoncomplianceActionsForDiff(array $snapshot): array
{
$actions = $this->canonicalNoncomplianceActions($snapshot['scheduledActionsForRule'] ?? null);
if ($actions === []) {
return [];
}
$countsByType = [];
foreach ($actions as $action) {
$actionType = (string) $action['action_type'];
$countsByType[$actionType] = ($countsByType[$actionType] ?? 0) + 1;
}
$occurrencesByType = [];
$flat = [];
foreach ($actions as $action) {
$actionType = (string) $action['action_type'];
$occurrencesByType[$actionType] = ($occurrencesByType[$actionType] ?? 0) + 1;
$label = 'Actions for noncompliance > '.$this->actionTypeLabel($actionType);
if (($countsByType[$actionType] ?? 0) > 1) {
$label .= ' #'.$occurrencesByType[$actionType];
}
$ruleName = $action['rule_name'] ?? null;
if (is_string($ruleName) && $ruleName !== '') {
$flat[$label.' > Rule name'] = $ruleName;
}
$flat[$label.' > Grace period'] = $this->formatGracePeriod($action['grace_period_hours'] ?? null);
$flat[$label.' > Notification template ID'] = $action['notification_template_id'] ?? null;
}
return $flat;
}
private function resolveNotificationTemplateKey(array $config): ?string
{
if (array_key_exists('notificationTemplateId', $config)) {
return 'notificationTemplateId';
}
if (array_key_exists('notificationMessageTemplateId', $config)) {
return 'notificationMessageTemplateId';
}
return null;
}
private function isEmptyGuid(string $value): bool
{
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
}
/**
* @return array<int, array{
* action_type: string,
* grace_period_hours: ?int,
* notification_template_id: ?string,
* rule_name: ?string
* }>
*/
private function canonicalNoncomplianceActions(mixed $scheduled): array
{
if (! is_array($scheduled)) {
return [];
}
$actions = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$ruleName = $rule['ruleName'] ?? null;
$ruleName = is_string($ruleName) ? trim($ruleName) : null;
$ruleName = $ruleName !== '' ? $ruleName : null;
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
continue;
}
foreach ($configs as $config) {
if (! is_array($config)) {
continue;
}
$actionType = $config['actionType'] ?? null;
$actionType = is_string($actionType) ? strtolower(trim($actionType)) : null;
if ($actionType === null || $actionType === '') {
continue;
}
$gracePeriodHours = $config['gracePeriodHours'] ?? null;
$gracePeriodHours = is_numeric($gracePeriodHours) ? (int) $gracePeriodHours : null;
$actions[] = [
'action_type' => $actionType,
'grace_period_hours' => $gracePeriodHours,
'notification_template_id' => $this->normalizeNotificationTemplateId($config),
'rule_name' => $ruleName,
];
}
}
usort($actions, function (array $left, array $right): int {
$actionTypeComparison = $left['action_type'] <=> $right['action_type'];
if ($actionTypeComparison !== 0) {
return $actionTypeComparison;
}
$gracePeriodComparison = ($left['grace_period_hours'] ?? PHP_INT_MAX) <=> ($right['grace_period_hours'] ?? PHP_INT_MAX);
if ($gracePeriodComparison !== 0) {
return $gracePeriodComparison;
}
$templateComparison = ($left['notification_template_id'] ?? "\u{10FFFF}") <=> ($right['notification_template_id'] ?? "\u{10FFFF}");
if ($templateComparison !== 0) {
return $templateComparison;
}
return ($left['rule_name'] ?? "\u{10FFFF}") <=> ($right['rule_name'] ?? "\u{10FFFF}");
});
return $actions;
}
private function normalizeNotificationTemplateId(array $config): ?string
{
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($templateKey === null) {
return null;
}
$templateId = $config[$templateKey] ?? null;
$templateId = is_string($templateId) ? trim($templateId) : null;
if ($templateId === null || $templateId === '' || $this->isEmptyGuid($templateId)) {
return null;
}
return $templateId;
}
private function actionTypeLabel(string $actionType): string
{
return match ($actionType) {
'block' => 'Mark device noncompliant',
'notification' => 'Send notification',
'retire' => 'Add device to retire list',
'wipe' => 'Wipe device',
default => Str::headline($actionType),
};
}
private function formatGracePeriod(?int $hours): ?string
{
if ($hours === null) {
return null;
}
if ($hours === 0) {
return '0 hours';
}
$days = intdiv($hours, 24);
$remainingHours = $hours % 24;
$parts = [];
if ($days > 0) {
$parts[] = $days === 1 ? '1 day' : $days.' days';
}
if ($remainingHours > 0) {
$parts[] = $remainingHours === 1 ? '1 hour' : $remainingHours.' hours';
}
$label = implode(' ', $parts);
if ($label === '') {
return $hours === 1 ? '1 hour' : $hours.' hours';
}
return sprintf('%s (%d hours)', $label, $hours);
}
/**
* @return array{keys: array<int, string>, labels?: array<string, string>}
*/
private function groupedFields(): array
{
return [
'Password & Access' => [
'keys' => [
'passwordRequired',
'passwordRequiredType',
'passwordBlockSimple',
'passwordMinimumLength',
'passwordMinimumCharacterSetCount',
'passwordExpirationDays',
'passwordMinutesOfInactivityBeforeLock',
'passwordPreviousPasswordBlockCount',
'passwordRequiredToUnlockFromIdle',
],
'labels' => [
'passwordRequired' => 'Password required',
'passwordRequiredType' => 'Password required type',
'passwordBlockSimple' => 'Block simple passwords',
'passwordMinimumLength' => 'Password minimum length',
'passwordMinimumCharacterSetCount' => 'Password minimum character set count',
'passwordExpirationDays' => 'Password expiration days',
'passwordMinutesOfInactivityBeforeLock' => 'Password idle lock (minutes)',
'passwordPreviousPasswordBlockCount' => 'Password history count',
'passwordRequiredToUnlockFromIdle' => 'Password required to unlock from idle',
],
],
'Defender & Threat Protection' => [
'keys' => [
'defenderEnabled',
'defenderVersion',
'antivirusRequired',
'antiSpywareRequired',
'rtpEnabled',
'signatureOutOfDate',
'deviceThreatProtectionEnabled',
'deviceThreatProtectionRequiredSecurityLevel',
'requireHealthyDeviceReport',
],
'labels' => [
'defenderEnabled' => 'Microsoft Defender enabled',
'defenderVersion' => 'Defender version',
'antivirusRequired' => 'Antivirus required',
'antiSpywareRequired' => 'Anti-spyware required',
'rtpEnabled' => 'Real-time protection enabled',
'signatureOutOfDate' => 'Signature out of date (days)',
'deviceThreatProtectionEnabled' => 'Device threat protection enabled',
'deviceThreatProtectionRequiredSecurityLevel' => 'Threat protection required level',
'requireHealthyDeviceReport' => 'Require healthy device report',
],
],
'Encryption & Integrity' => [
'keys' => [
'bitLockerEnabled',
'storageRequireEncryption',
'tpmRequired',
'secureBootEnabled',
'codeIntegrityEnabled',
'memoryIntegrityEnabled',
'kernelDmaProtectionEnabled',
'firmwareProtectionEnabled',
'virtualizationBasedSecurityEnabled',
'earlyLaunchAntiMalwareDriverEnabled',
],
'labels' => [
'bitLockerEnabled' => 'BitLocker required',
'storageRequireEncryption' => 'Storage encryption required',
'tpmRequired' => 'TPM required',
'secureBootEnabled' => 'Secure boot required',
'codeIntegrityEnabled' => 'Code integrity required',
'memoryIntegrityEnabled' => 'Memory integrity required',
'kernelDmaProtectionEnabled' => 'Kernel DMA protection required',
'firmwareProtectionEnabled' => 'Firmware protection required',
'virtualizationBasedSecurityEnabled' => 'Virtualization-based security required',
'earlyLaunchAntiMalwareDriverEnabled' => 'Early launch anti-malware required',
],
],
'Operating System' => [
'keys' => [
'osMinimumVersion',
'osMaximumVersion',
'mobileOsMinimumVersion',
'mobileOsMaximumVersion',
'validOperatingSystemBuildRanges',
'wslDistributions',
],
'labels' => [
'osMinimumVersion' => 'OS minimum version',
'osMaximumVersion' => 'OS maximum version',
'mobileOsMinimumVersion' => 'Mobile OS minimum version',
'mobileOsMaximumVersion' => 'Mobile OS maximum version',
'validOperatingSystemBuildRanges' => 'Valid OS build ranges',
'wslDistributions' => 'Allowed WSL distributions',
],
],
'Firewall' => [
'keys' => [
'activeFirewallRequired',
],
'labels' => [
'activeFirewallRequired' => 'Active firewall required',
],
],
'Compliance Signals' => [
'keys' => [
'configurationManagerComplianceRequired',
'deviceCompliancePolicyScript',
],
'labels' => [
'configurationManagerComplianceRequired' => 'ConfigMgr compliance required',
'deviceCompliancePolicyScript' => 'Compliance policy script',
],
],
];
}
/**
* @param array<string, mixed> $labels
* @return array<int, array<string, mixed>>
*/
private function buildRows(array $snapshot, array $keys, array $labels = []): array
{
$rows = [];
foreach ($keys as $key) {
if (! array_key_exists($key, $snapshot)) {
continue;
}
$rows[] = [
'label' => $labels[$key] ?? Str::headline($key),
'value' => $this->formatValue($snapshot[$key]),
];
}
return $rows;
}
/**
* @param array<int, string> $usedKeys
* @return array<int, array<string, mixed>>
*/
private function buildAdditionalRows(array $snapshot, array $usedKeys): array
{
$ignoredKeys = array_merge($this->ignoredKeys(), $usedKeys);
$rows = [];
foreach ($snapshot as $key => $value) {
if (! is_string($key)) {
continue;
}
if (in_array($key, $ignoredKeys, true)) {
continue;
}
$rows[] = [
'label' => Str::headline($key),
'value' => $this->formatValue($value),
];
}
return $rows;
}
/**
* @return array<int, string>
*/
private function ignoredKeys(): array
{
return [
'@odata.context',
'@odata.type',
'id',
'version',
'createdDateTime',
'lastModifiedDateTime',
'supportsScopeTags',
'roleScopeTagIds',
'assignments',
'createdBy',
'lastModifiedBy',
'omaSettings',
'settings',
'settingsDelta',
'displayName',
'description',
'name',
'platform',
'platforms',
'technologies',
'settingCount',
'settingsCount',
'templateReference',
'scheduledActionsForRule@odata.context',
'scheduledActionsForRule',
];
}
private function formatValue(mixed $value): mixed
{
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}