From f61cc44ddd2e9ae7f117988a6a1b9b66a8c4a435 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 8 Jan 2026 02:26:27 +0100 Subject: [PATCH] fix(inventory): stop settings catalog being stored as security baselines --- app/Services/Intune/PolicySyncService.php | 19 +------ .../Inventory/InventorySyncService.php | 53 ++++++++++++++++++ .../plan.md | 15 +++++ .../spec.md | 18 ++++++ .../tasks.md | 7 +++ .../Inventory/InventorySyncServiceTest.php | 55 +++++++++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 specs/045-settingscatalog-classification/plan.md create mode 100644 specs/045-settingscatalog-classification/spec.md create mode 100644 specs/045-settingscatalog-classification/tasks.md diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index ec42c31..891cbd6 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -235,13 +235,9 @@ private function isEndpointSecurityConfigurationPolicy(array $policyData): bool return false; } - foreach ($templateReference as $value) { - if (is_string($value) && stripos($value, 'endpoint') !== false) { - return true; - } - } + $templateFamily = $templateReference['templateFamily'] ?? null; - return false; + return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity'); } private function isSecurityBaselineConfigurationPolicy(array $policyData): bool @@ -253,17 +249,8 @@ private function isSecurityBaselineConfigurationPolicy(array $policyData): bool } $templateFamily = $templateReference['templateFamily'] ?? null; - if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) { - return true; - } - foreach ($templateReference as $value) { - if (is_string($value) && stripos($value, 'baseline') !== false) { - return true; - } - } - - return false; + return is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0; } private function isEnrollmentStatusPageItem(array $policyData): bool diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 69f8e44..59c2509 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -140,6 +140,10 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal continue; } + if ($this->shouldSkipPolicyForSelectedType($policyType, $policyData)) { + continue; + } + $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; if (! is_string($externalId) || $externalId === '') { continue; @@ -215,6 +219,55 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal } } + private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool + { + $configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; + + if (! in_array($selectedPolicyType, $configurationPolicyTypes, true)) { + return false; + } + + return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType; + } + + private function resolveConfigurationPolicyType(array $policyData): string + { + $templateReference = $policyData['templateReference'] ?? null; + $templateFamily = null; + if (is_array($templateReference)) { + $templateFamily = $templateReference['templateFamily'] ?? null; + } + + if (is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0) { + return 'securityBaselinePolicy'; + } + + if ($this->isEndpointSecurityConfigurationPolicy($policyData, $templateFamily)) { + return 'endpointSecurityPolicy'; + } + + return 'settingsCatalogPolicy'; + } + + private function isEndpointSecurityConfigurationPolicy(array $policyData, ?string $templateFamily): bool + { + $technologies = $policyData['technologies'] ?? null; + + if (is_string($technologies) && strcasecmp(trim($technologies), 'endpointSecurity') === 0) { + return true; + } + + if (is_array($technologies)) { + foreach ($technologies as $technology) { + if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) { + return true; + } + } + } + + return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity'); + } + /** * @return array> */ diff --git a/specs/045-settingscatalog-classification/plan.md b/specs/045-settingscatalog-classification/plan.md new file mode 100644 index 0000000..ecc02bc --- /dev/null +++ b/specs/045-settingscatalog-classification/plan.md @@ -0,0 +1,15 @@ +# Plan: Settings Catalog Classification + +## Approach +- Trace how Inventory derives **Type** and **Category** for policies. +- Fix policy-type resolution/canonicalization so Settings Catalog cannot be classified as Security Baselines. +- Add a regression test at the classification layer. + +## Expected Touch Points +- `app/Services/Intune/PolicyClassificationService.php` +- Possibly Inventory mapping/display logic if category is derived elsewhere +- `tests/` regression coverage + +## Rollout +- Code change affects future sync runs. +- To correct existing rows, rerun the Inventory Sync for the tenant. diff --git a/specs/045-settingscatalog-classification/spec.md b/specs/045-settingscatalog-classification/spec.md new file mode 100644 index 0000000..d4b299b --- /dev/null +++ b/specs/045-settingscatalog-classification/spec.md @@ -0,0 +1,18 @@ +# Spec: Settings Catalog Classification + +## Problem +Some Settings Catalog policies show up as **Type: Security Baselines** and **Category: Endpoint Security** in Inventory UI. + +## Goal +Ensure Settings Catalog policies are consistently classified as: +- **Type**: Settings Catalog Policy +- **Category**: Configuration + +## Non-Goals +- Changing UI layout or adding new filters +- Bulk cleanup of historical data beyond what a normal re-sync updates + +## Acceptance Criteria +- Classification logic never maps Settings Catalog policies to Security Baselines +- A regression test covers the misclassification scenario +- After the next Inventory Sync, affected items are stored with the correct `policy_type`/category diff --git a/specs/045-settingscatalog-classification/tasks.md b/specs/045-settingscatalog-classification/tasks.md new file mode 100644 index 0000000..7115ecd --- /dev/null +++ b/specs/045-settingscatalog-classification/tasks.md @@ -0,0 +1,7 @@ +# Tasks: Settings Catalog Classification + +- [x] T001 Run Spec Kit prerequisites + gather context +- [x] T002 Locate where Inventory Type/Category is derived +- [x] T003 Fix Settings Catalog misclassification in policy classification +- [x] T004 Add regression test +- [x] T005 Run targeted tests + Pint (dirty) diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php index 6f6fb7f..af437b4 100644 --- a/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -101,6 +101,61 @@ public function request(string $method, string $path, array $options = []): Grap expect($items->first()->last_seen_run_id)->toBe($runB->id); }); +test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () { + $tenant = Tenant::factory()->create(); + + $settingsCatalogLookalike = [ + 'id' => 'pol-1', + 'name' => 'Windows 11 SettingsCatalog-Test', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => [ + 'templateDisplayName' => 'Windows Security Baseline (name only)', + ], + ]; + + $securityBaseline = [ + 'id' => 'pol-2', + 'name' => 'Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateFamily' => 'securityBaseline', + ], + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'settingsCatalogPolicy' => [$settingsCatalogLookalike, $securityBaseline], + 'securityBaselinePolicy' => [$settingsCatalogLookalike, $securityBaseline], + ])); + + $selection = [ + 'policy_types' => ['settingsCatalogPolicy', 'securityBaselinePolicy'], + 'categories' => ['Configuration', 'Endpoint Security'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'securityBaselinePolicy') + ->where('external_id', 'pol-1') + ->exists())->toBeFalse(); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'settingsCatalogPolicy') + ->where('external_id', 'pol-1') + ->exists())->toBeTrue(); + + expect(\App\Models\InventoryItem::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'securityBaselinePolicy') + ->where('external_id', 'pol-2') + ->exists())->toBeTrue(); +}); + test('meta whitelist drops unknown keys without failing', function () { $tenant = Tenant::factory()->create();