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
505 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|