>, settings_table?: array, warnings: array} */ 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 */ 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> */ 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 */ 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 */ 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, labels?: array} */ 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 $labels * @return array> */ 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 $usedKeys * @return array> */ 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 */ 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; } }