diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml
new file mode 100644
index 00000000..09bd223e
--- /dev/null
+++ b/.github/workflows/update-catalog.yml
@@ -0,0 +1,103 @@
+name: Update Remediation Script Catalog
+
+# Runs weekly to pull new/updated scripts from JayRHa/EndpointAnalyticsRemediationScripts,
+# regenerate the catalog metadata + content bundle, commit metadata changes,
+# and upload the content bundle to Azure Blob Storage.
+#
+# Distribution strategy:
+# - script-catalog.json (metadata only, no PS1 text) ships embedded in the .exe via EmbeddedResource
+# - script-content-bundle.json (PS1 content) is served from Azure Blob Storage behind Azure CDN
+# - Clients cache the bundle in LiteDB for 7 days; CDN caches at edge to minimise origin hits
+# - A monthly Azure Cost Alert on the storage account acts as a DDoS cost guardrail
+# - No GitHub raw API calls at runtime; avoids the 60 req/hr unauthenticated rate limit
+
+on:
+ schedule:
+ # Every Monday at 02:00 UTC
+ - cron: "0 2 * * 1"
+ workflow_dispatch:
+ inputs:
+ force_refetch:
+ description: "Force re-fetch scripts from JayRHa repo even if no changes detected"
+ required: false
+ default: "false"
+ type: boolean
+
+jobs:
+ update-catalog:
+ runs-on: windows-latest
+ permissions:
+ contents: write # needed to commit updated script-catalog.json back to the branch
+ env:
+ # Expose optional CDN secrets as env vars so they can be tested in `if:` conditions
+ AZURE_CDN_PROFILE: ${{ secrets.AZURE_CDN_PROFILE }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Fetch scripts from JayRHa/EndpointAnalyticsRemediationScripts
+ shell: pwsh
+ run: |
+ $forceFlag = '${{ inputs.force_refetch }}' -eq 'true'
+ scripts/Fetch-CatalogScripts.ps1 `
+ -DestinationDir src/Intune.Commander.Core/CatalogSource `
+ -Force:$forceFlag
+
+ - name: Build catalog metadata and content bundle
+ shell: pwsh
+ run: |
+ scripts/Build-ScriptCatalog.ps1 `
+ -SourceDir src/Intune.Commander.Core/CatalogSource `
+ -MetadataOutputFile src/Intune.Commander.Core/Assets/script-catalog.json `
+ -ContentBundleOutputFile "${{ runner.temp }}/script-content-bundle.json"
+
+ - name: Commit updated catalog metadata if changed
+ shell: pwsh
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add src/Intune.Commander.Core/CatalogSource `
+ src/Intune.Commander.Core/Assets/script-catalog.json
+ $status = git status --porcelain
+ if ($status) {
+ git commit -m "chore: update remediation script catalog [skip ci]"
+ git push
+ Write-Host "Committed catalog updates."
+ } else {
+ Write-Host "No catalog changes detected; nothing to commit."
+ }
+
+ - name: Log in to Azure
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Upload content bundle to Azure Blob Storage
+ shell: pwsh
+ env:
+ AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }}
+ run: |
+ az storage blob upload `
+ --account-name $env:AZURE_STORAGE_ACCOUNT `
+ --file "${{ runner.temp }}/script-content-bundle.json" `
+ --container-name intunecommander `
+ --name catalog/script-content-bundle.json `
+ --content-type "application/json" `
+ --overwrite true `
+ --auth-mode login
+ Write-Host "Content bundle uploaded successfully."
+
+ - name: Purge Azure CDN cache for content bundle
+ # Only runs when the optional CDN secrets are configured
+ if: ${{ env.AZURE_CDN_PROFILE != '' }}
+ shell: pwsh
+ run: |
+ az cdn endpoint purge `
+ --resource-group "${{ secrets.AZURE_CDN_RESOURCE_GROUP }}" `
+ --profile-name "${{ secrets.AZURE_CDN_PROFILE }}" `
+ --name "${{ secrets.AZURE_CDN_ENDPOINT }}" `
+ --content-paths "/catalog/script-content-bundle.json"
+ Write-Host "CDN cache purged."
diff --git a/planning/devicehealthscripts_v2.md b/planning/devicehealthscripts_v2.md
new file mode 100644
index 00000000..682863f4
--- /dev/null
+++ b/planning/devicehealthscripts_v2.md
@@ -0,0 +1,538 @@
+# Plan: Wave 13 — Remediation Script Catalog + Execution
+
+## Overview
+
+Build a built-in catalog of Windows remediation (detection + remediation) scripts sourced from
+JayRHa/EndpointAnalyticsRemediationScripts. Extends Device Health Scripts with run summaries,
+per-device output, on-demand deployment to devices, and result tracking.
+
+Branch: `feature/wave13-remediation-catalog` — starts after Wave 12 merges.
+
+---
+
+## Architecture Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| **Metadata-only in the exe** | Embedding PS1 text in a PE binary triggers AV heuristics (Defender, CrowdStrike). The exe contains only name/description/category JSON. |
+| **Script content on Azure Blob CDN** | Avoids GitHub raw rate limits (60 req/hr unauthenticated) and keeps the exe clean. One bundle fetch per 7 days, served via Azure CDN. |
+| **Source .ps1 files committed to repo** | `CatalogSource/` is the canonical source of truth. No runtime GitHub dependency. CI generates the CDN bundle from these files. |
+| **MSBuild generates metadata JSON** | `BeforeTargets="Build"` runs `Build-ScriptCatalog.ps1`; output is committed to git so CI doesn't need to re-run it unless sources change. |
+| **`IScriptCatalogService` as DI Singleton** | Metadata is immutable at runtime; service is available before auth (no `GraphServiceClient` needed). |
+| **In-memory on-demand tracking** | `OnDemandDeploymentRecord` resets on app exit — no persistence needed for now. |
+| **`IDeviceService` instantiated post-auth** | Requires `GraphServiceClient`; follows the same pattern as all other Graph services. |
+
+---
+
+## Two-Artifact Build Pipeline
+
+```
+CatalogSource/{slug}/detect.ps1
+CatalogSource/{slug}/remediate.ps1
+ |
+ | Build-ScriptCatalog.ps1
+ |
+ +------+----------+
+ | |
+script-catalog.json script-content-bundle.json
+(metadata only) (all PS1 content keyed by slug)
+ | |
+EmbeddedResource az storage blob upload
+in Core.dll → Azure Blob CDN
+ | (stable public URL, no rate limit)
+ships in .exe fetched once at runtime,
+ cached 7 days in LiteDB
+```
+
+---
+
+## Phase 1: Catalog Ingestion Pipeline
+
+### Step 1 — Commit source scripts
+
+- Create `src/Intune.Commander.Core/CatalogSource/` — one sub-folder per script containing
+ `detect.ps1` + `remediate.ps1` (names normalized)
+- Write `scripts/Fetch-CatalogScripts.ps1` — **one-time developer tool**:
+ - `git clone https://github.com/JayRHa/EndpointAnalyticsRemediationScripts` to temp dir
+ - Walk each sub-folder; find detection/remediation pair; normalize filenames
+ - Copy to `src/Intune.Commander.Core/CatalogSource/{FolderName}/`; save as ASCII (per CLAUDE.md PowerShell encoding rule)
+- Developer runs once; output .ps1 files committed to git
+
+### Step 2 — `scripts/Build-ScriptCatalog.ps1`
+
+Parameters:
+- `-SourceDir` — path to `CatalogSource/`
+- `-MetadataOutputFile` — path to `Assets/script-catalog.json` (**metadata only**)
+- `-ContentBundleOutputFile` — path to `artifacts/script-content-bundle.json` (**PS1 content**)
+
+Per-folder logic:
+- Read first ~30 lines of `detect.ps1` to parse comment headers
+- Regex: `^#\s*([\w ]+):\s*(.+)$` → extract Name, Description, Category, Version, Publisher
+- Fall back to folder name as display name if `# Name:` absent
+- **Metadata file**: emit `{ id, name, description, category, version, publisher }` — **no content fields**
+- **Content bundle**: emit `{ "version": "...", "generatedAt": "...", "scripts": { "slug": { "detectionContent": "...", "remediationContent": "..." } } }`
+- Sort by category then name (deterministic output, no spurious git diffs)
+- MSBuild target passes only `-MetadataOutputFile`; release CI passes both
+
+### Step 3 — MSBuild target in `Intune.Commander.Core.csproj`
+
+```xml
+
+
+
+
+
+
+
+```
+
+- `pwsh` pre-installed on `ubuntu-latest` and `windows-latest` — no CI yaml changes
+- Committed `script-catalog.json` means CI skips regeneration when source is unchanged
+
+### Step 4 — Models
+
+**`src/Intune.Commander.Core/Models/ScriptCatalogEntry.cs`**
+```csharp
+public sealed class ScriptCatalogEntry
+{
+ public required string Id { get; init; } // folder-name slug
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public string? Category { get; init; }
+ public string? Version { get; init; }
+ public string? Publisher { get; init; }
+}
+```
+
+**`src/Intune.Commander.Core/Models/ScriptContentBundle.cs`**
+```csharp
+public sealed class ScriptContentBundle
+{
+ public required string Version { get; init; }
+ public DateTimeOffset GeneratedAt { get; init; }
+ public required Dictionary Scripts { get; init; }
+}
+
+public sealed class ScriptContent
+{
+ public required string DetectionContent { get; init; }
+ public required string RemediationContent { get; init; }
+}
+```
+
+### Step 5 — `IScriptCatalogService` + `ScriptCatalogService`
+
+```csharp
+public interface IScriptCatalogService
+{
+ IReadOnlyList GetAll();
+ ScriptCatalogEntry? GetById(string id);
+ Task GetScriptContentAsync(string id, CancellationToken cancellationToken = default);
+ Task FetchContentBundleAsync(CancellationToken cancellationToken = default);
+}
+```
+
+Implementation notes:
+- Constructor: load metadata from `GetManifestResourceStream("Intune.Commander.Core.Assets.script-catalog.json")`
+ (follows `ConditionalAccessPptExportService` pattern exactly — same `typeof(T).Assembly` approach)
+- `FetchContentBundleAsync`: check `ICacheService` using `tenantId = "global"`, `dataType = "ScriptContentBundle"`, with 7-day TTL first.
+ `ICacheService` only supports `List` values, so the bundle is wrapped as a single-item `List` — use `cache.Get("global", "ScriptContentBundle")?[0]` to read and `cache.Set("global", "ScriptContentBundle", new List { bundle }, TimeSpan.FromDays(7))` to write.
+ The `"global"` tenantId is a documented convention for non-tenant-scoped, globally-shared cache artifacts (not a real AAD tenant ID).
+ On cache miss, call `HttpClient.GetFromJsonAsync(BlobUrl, ct)`; store in cache; return.
+- `BlobUrl`: **private const string** — not user-configurable (prevents SSRF):
+ `const string BlobUrl = "https://{account}.blob.core.windows.net/intunecommander/catalog/script-content-bundle.json";`
+- `GetScriptContentAsync`: calls `FetchContentBundleAsync` then looks up by id
+- `IHttpClientFactory` injected; uses named client `"CatalogContent"` registered in DI
+- Registered as **Singleton** in `ServiceCollectionExtensions.AddIntuneCommanderCore()`
+
+### Step 6 — Azure Blob Storage
+
+- Container: `intunecommander`, public read
+- Blob: `catalog/script-content-bundle.json`
+- GitHub secret: `AZURE_CREDENTIALS` (service principal with `Storage Blob Data Contributor` on container only)
+- Setup script: `scripts/Setup-CatalogStorage.ps1` — provisions storage account + container + uploads initial bundle
+ (analogous to existing `scripts/Setup-IntegrationTestApp.ps1`)
+
+### Step 7 — CI: Azure upload step in `build-release.yml` + `codesign.yml`
+
+After `dotnet publish`, add:
+```yaml
+- name: Generate script content bundle
+ run: pwsh -NonInteractive -File scripts/Build-ScriptCatalog.ps1
+ -SourceDir src/Intune.Commander.Core/CatalogSource
+ -MetadataOutputFile src/Intune.Commander.Core/Assets/script-catalog.json
+ -ContentBundleOutputFile artifacts/script-content-bundle.json
+
+- name: Upload content bundle to Azure
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+- run: az storage blob upload
+ --file artifacts/script-content-bundle.json
+ --container-name intunecommander
+ --name catalog/script-content-bundle.json
+ --overwrite true
+```
+
+### Step 8 — Tests (`ScriptCatalogServiceTests.cs`)
+
+- `GetAll()` returns non-empty list (embedded resource loaded successfully)
+- All entries have non-null `Id`, `Name`
+- No `ScriptCatalogEntry` has a content field (verify model shape)
+- `GetById` round-trips correctly
+- Interface contract via reflection (method signatures + CancellationToken params)
+- `FetchContentBundleAsync` — unit test with mock `HttpClient` (no real network in unit tests)
+- `GetScriptContentAsync` — returns null for unknown id; returns content for known id from mock bundle
+
+---
+
+## Phase 2: Extend DeviceHealthScriptService
+
+### Step 9 — Extend `IDeviceHealthScriptService` + `DeviceHealthScriptService`
+
+New methods:
+```csharp
+Task GetRunSummaryAsync(string scriptId, CancellationToken cancellationToken = default);
+Task> GetDeviceRunStatesAsync(string scriptId, CancellationToken cancellationToken = default);
+Task InitiateOnDemandRemediationAsync(string managedDeviceId, string scriptId, CancellationToken cancellationToken = default);
+```
+
+Implementation:
+- `GetRunSummaryAsync`: `_graphClient.DeviceManagement.DeviceHealthScripts[id].RunSummary.GetAsync(ct)`
+- `GetDeviceRunStatesAsync`: manual `OdataNextLink` loop on `.DeviceRunStates`, `$expand=managedDevice`, `$top=200`
+- `InitiateOnDemandRemediationAsync`: `_graphClient.DeviceManagement.ManagedDevices[deviceId].InitiateOnDemandProactiveRemediation.PostAsync(new InitiateOnDemandProactiveRemediationPostRequestBody { ScriptPolicyId = scriptId }, ct)`
+
+Also fix: change `$top=200` to `$top=999` on the initial `ListDeviceHealthScriptsAsync` call (per copilot-instructions.md convention).
+
+### Step 10 — New `IDeviceService` + `DeviceService`
+
+```csharp
+public interface IDeviceService
+{
+ Task> SearchDevicesAsync(string query, CancellationToken cancellationToken = default);
+ Task GetDeviceAsync(string deviceId, CancellationToken cancellationToken = default);
+}
+```
+
+Implementation:
+- `SearchDevicesAsync`: filter `contains(deviceName,'{query}')`, `$top=50`, `$select=id,deviceName,operatingSystem,osVersion,lastSyncDateTime,managementState`
+- Input `query` must be validated (non-null, non-empty, no OData injection characters) before inserting into filter string
+- Instantiated post-auth in `MainWindowViewModel.ConnectToProfile` alongside other Graph services
+
+### Step 11 — `DeviceHealthScriptExport` model
+
+```csharp
+public sealed class DeviceHealthScriptExport
+{
+ public required DeviceHealthScript Script { get; init; }
+ public List Assignments { get; init; } = [];
+}
+```
+
+Parity with `DeviceManagementScriptExport`. Update `ExportService` to use this wrapper and bundle assignments.
+
+### Step 12 — `OnDemandDeploymentRecord` model
+
+```csharp
+public sealed class OnDemandDeploymentRecord
+{
+ public Guid Id { get; init; } = Guid.NewGuid();
+ public required string DeviceId { get; init; }
+ public required string DeviceName { get; init; }
+ public required string ScriptId { get; init; }
+ public required string ScriptName { get; init; }
+ public DateTimeOffset DispatchedAt { get; init; } = DateTimeOffset.UtcNow;
+ public string Status { get; set; } = "Pending"; // "Pending" | "Completed" | "Error"
+ public DeviceHealthScriptDeviceState? Result { get; set; }
+ public string? ErrorMessage { get; set; }
+}
+```
+
+### Step 13 — Tests for Phase 2
+
+- `DeviceHealthScriptServiceExtendedTests.cs`: contract tests for 3 new methods via reflection
+- `DeviceServiceTests.cs`: interface contract test; verify filter injection guard (empty/null/special-char query)
+- `DeviceHealthScriptExportTests.cs`: verify model shape and default values
+
+---
+
+## Phase 3: UI
+
+### Step 14 — `MainWindowViewModel.Remediation.cs` (new partial file)
+
+Properties and commands:
+```csharp
+[ObservableProperty] DeviceHealthScriptRunSummary? _selectedScriptRunSummary;
+[ObservableProperty] bool _isLoadingRunStates;
+ObservableCollection SelectedScriptDeviceRunStates { get; }
+ObservableCollection OnDemandDeployments { get; }
+
+[RelayCommand] Task LoadRunStatesAsync(CancellationToken ct)
+[RelayCommand] Task OpenOnDemandDialogAsync()
+[RelayCommand] Task RefreshDeploymentStatusAsync(OnDemandDeploymentRecord record, CancellationToken ct)
+```
+
+`LoadRunStatesAsync` populates both `SelectedScriptRunSummary` and `SelectedScriptDeviceRunStates`
+for the currently `SelectedDeviceHealthScript`. Triggered by selection change and by a manual refresh button.
+
+`RefreshDeploymentStatusAsync` calls `GetDeviceRunStatesAsync` for the script, finds the matching
+device run state, updates the record's `Status` and `Result` in-place.
+
+### Step 15 — Catalog VM additions to `MainWindowViewModel.cs`
+
+```csharp
+[ObservableProperty] bool _isCatalogPanelOpen;
+[ObservableProperty] string _catalogSearchText = string.Empty;
+[ObservableProperty] ScriptCatalogEntry? _selectedCatalogEntry;
+ObservableCollection CatalogEntries { get; }
+ObservableCollection FilteredCatalogEntries { get; }
+
+[RelayCommand] void ToggleCatalogPanel()
+[RelayCommand] Task DeployFromCatalogAsync(CancellationToken ct)
+[RelayCommand] Task PreviewCatalogEntryAsync(ScriptCatalogEntry entry, CancellationToken ct)
+```
+
+- `_scriptCatalogService` injected from DI in constructor (Singleton, available before auth)
+- `CatalogEntries` populated immediately from `_scriptCatalogService.GetAll()` in constructor
+- `FilteredCatalogEntries` filtered on `CatalogSearchText` change (same pattern as other `Filtered*` collections)
+- `DeployFromCatalogAsync`:
+ 1. Call `_scriptCatalogService.GetScriptContentAsync(entry.Id, ct)` (fetches bundle if not cached)
+ 2. Show IsBusy while fetching
+ 3. Call `_deviceHealthScriptService!.CreateDeviceHealthScriptAsync(script, ct)`
+ 4. Refresh `DeviceHealthScripts` collection
+ 5. Show success toast / StatusText
+- `PreviewCatalogEntryAsync`: loads content into a `CatalogPreviewContent` observable property for display
+- Add `_deviceService` field; instantiated post-auth
+
+### Step 16 — `OnDemandDeployWindow.axaml` + `OnDemandDeployViewModel.cs`
+
+ViewModel:
+```csharp
+// Receives: scriptId, scriptName from parent
+[ObservableProperty] string _deviceSearchText = string.Empty;
+[ObservableProperty] bool _isSearching;
+ObservableCollection SearchResults { get; }
+ObservableCollection TargetDevices { get; } // selected device list
+
+[RelayCommand] Task SearchDevicesAsync(CancellationToken ct) // debounced
+[RelayCommand] void AddDevice(ManagedDevice device)
+[RelayCommand] void RemoveDevice(DeviceDeployTarget target)
+[RelayCommand] Task DeployAsync(CancellationToken ct)
+```
+
+`DeployAsync`:
+1. For each device in `TargetDevices`:
+ - Call `InitiateOnDemandRemediationAsync(device.Id, scriptId, ct)`
+ - On success: create `OnDemandDeploymentRecord` with `Status = "Pending"`; add to parent VM's `OnDemandDeployments`
+ - On error: update target's inline status to show error message
+2. Close window after all dispatched
+
+Pattern: `GroupLookupWindow` / `AssignmentReportWindow` modal (opens with `ShowDialog`).
+
+### Step 17 — Inline detail pane in `MainWindow.axaml`
+
+In the Device Health Scripts section's detail pane area, add:
+
+**Run Summary strip** (hidden when no script selected):
+```
+[ No Issues: 42 ] [ Detected: 5 ] [ Remediated: 3 ] [ Error: 1 ] [ Pending: 2 ]
+```
+Each count bound to `SelectedScriptRunSummary.*DeviceCount`.
+
+**Device Run States section**:
+- "Load Run States" button → `LoadRunStatesAsync` (with spinner bound to `IsLoadingRunStates`)
+- Inline `DataGrid` bound to `SelectedScriptDeviceRunStates`:
+ - Columns: Device Name (from `ManagedDevice.DeviceName`), Detection State, Remediation State, Last Sync
+ - "View Output" button per row → opens `RawJsonWindow` showing detection + remediation output text
+
+**On-Demand section**:
+- "Deploy On-Demand" button → `OpenOnDemandDialogAsync`
+- Collapsible `DataGrid` of `OnDemandDeployments`:
+ - Columns: Device Name, Script Name, Dispatched At, Status
+ - "Refresh" button per row → `RefreshDeploymentStatusAsync`
+
+### Step 18 — Catalog flyout in `MainWindow.axaml`
+
+In Device Health Scripts toolbar area:
+- "Browse Catalog" toggle button → `ToggleCatalogPanel`
+
+Flyout panel (right-side `Grid` column, toggled via `IsVisible` bound to `IsCatalogPanelOpen`):
+```
+[ Search catalog... ] (bound to CatalogSearchText)
+┌─────────────────────────────────────────┐
+│ Name │ Category │ Version │
+│ Fix Bitlocker │ Security │ 1.2 │
+│ ... │ ... │ ... │
+└─────────────────────────────────────────┘
+[ Deploy to Tenant ] [ Preview ] [ Close ]
+
+"Preview" expander:
+ Detection Script:
+ ┌──────────────────────────────────────┐
+ │ (read-only TextBox, loaded on demand)│
+ └──────────────────────────────────────┘
+ Remediation Script:
+ ┌──────────────────────────────────────┐
+ │ (fetches bundle if not cached) │
+ └──────────────────────────────────────┘
+```
+
+---
+
+## Phase 4: CI Updates
+
+### Step 19 — `build-release.yml` additions
+
+After `dotnet publish`:
+```yaml
+- name: Generate script content bundle
+ shell: pwsh
+ run: |
+ scripts/Build-ScriptCatalog.ps1 `
+ -SourceDir src/Intune.Commander.Core/CatalogSource `
+ -MetadataOutputFile src/Intune.Commander.Core/Assets/script-catalog.json `
+ -ContentBundleOutputFile ${{ runner.temp }}/script-content-bundle.json
+
+- uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+- name: Upload content bundle
+ run: |
+ az storage blob upload `
+ --file "${{ runner.temp }}/script-content-bundle.json" `
+ --container-name intunecommander `
+ --name catalog/script-content-bundle.json `
+ --overwrite true
+```
+
+Same step added to `codesign.yml` for signed releases.
+
+**Secrets needed (repository-level):**
+- `AZURE_CREDENTIALS` — service principal JSON with `Storage Blob Data Contributor` on container only
+
+---
+
+## Full File Manifest
+
+### New files
+| File | Purpose |
+|------|---------|
+| `scripts/Fetch-CatalogScripts.ps1` | One-time JayRHa import tool |
+| `scripts/Build-ScriptCatalog.ps1` | Generates metadata + content bundle |
+| `scripts/Setup-CatalogStorage.ps1` | Provisions Azure Blob Storage |
+| `src/Intune.Commander.Core/CatalogSource/**` | Committed .ps1 source files |
+| `src/Intune.Commander.Core/Assets/script-catalog.json` | Generated + committed embedded resource |
+| `src/Intune.Commander.Core/Models/ScriptCatalogEntry.cs` | Catalog metadata model |
+| `src/Intune.Commander.Core/Models/ScriptContentBundle.cs` | CDN bundle deserialize target |
+| `src/Intune.Commander.Core/Models/ScriptContent.cs` | Detection + remediation content pair |
+| `src/Intune.Commander.Core/Models/DeviceHealthScriptExport.cs` | Script + assignments export wrapper |
+| `src/Intune.Commander.Core/Models/OnDemandDeploymentRecord.cs` | In-memory run tracking |
+| `src/Intune.Commander.Core/Services/IScriptCatalogService.cs` | Catalog service interface |
+| `src/Intune.Commander.Core/Services/ScriptCatalogService.cs` | Catalog + CDN fetch implementation |
+| `src/Intune.Commander.Core/Services/IDeviceService.cs` | Device search interface |
+| `src/Intune.Commander.Core/Services/DeviceService.cs` | Graph device search implementation |
+| `src/Intune.Commander.Desktop/ViewModels/MainWindowViewModel.Remediation.cs` | Run states + on-demand VM logic |
+| `src/Intune.Commander.Desktop/ViewModels/OnDemandDeployViewModel.cs` | On-demand deploy window VM |
+| `src/Intune.Commander.Desktop/Views/OnDemandDeployWindow.axaml` | Deploy window XAML |
+| `src/Intune.Commander.Desktop/Views/OnDemandDeployWindow.axaml.cs` | Deploy window code-behind |
+| `tests/.../Services/ScriptCatalogServiceTests.cs` | Catalog service tests |
+| `tests/.../Services/DeviceHealthScriptServiceExtendedTests.cs` | Extended service contract tests |
+| `tests/.../Services/DeviceServiceTests.cs` | Device service tests |
+
+### Modified files
+| File | Change |
+|------|--------|
+| `Intune.Commander.Core.csproj` | MSBuild target + `` |
+| `IDeviceHealthScriptService.cs` | 3 new method signatures |
+| `DeviceHealthScriptService.cs` | Implement new methods; fix $top=200→999 |
+| `ServiceCollectionExtensions.cs` | Register `IScriptCatalogService` singleton + `IHttpClientFactory` named client |
+| `ExportService.cs` | Use `DeviceHealthScriptExport` wrapper |
+| `MainWindowViewModel.cs` | Catalog fields, `_deviceService`, `_scriptCatalogService` |
+| `MainWindowViewModel.Search.cs` | `FilteredCatalogEntries` filter |
+| `MainWindow.axaml` | Catalog flyout + detail pane extensions |
+| `.github/workflows/build-release.yml` | Azure upload step |
+| `.github/workflows/codesign.yml` | Azure upload step |
+
+---
+
+## Verification Checklist
+
+- [ ] `dotnet build` succeeds; `script-catalog.json` in `Assets/` contains only metadata (no PS1 text)
+- [ ] `strings Intune.Commander.Desktop.exe | grep "Write-Output\|Param\|\$env:"` returns zero matches
+- [ ] `dotnet test --filter "Category!=Integration"` passes with ≥40% coverage
+- [ ] `ScriptCatalogService.GetAll()` returns entries matching committed `CatalogSource/` sub-folder count
+- [ ] App launches; "Browse Catalog" flyout opens and shows catalog entries
+- [ ] Expanding "Preview" triggers one HTTP fetch to the Azure blob; second expand uses LiteDB cache
+- [ ] "Deploy to Tenant" creates a real `DeviceHealthScript` in a dev tenant
+- [ ] On-demand deploy window: device search returns results; deploy adds `Pending` record to VM
+- [ ] After device checks in: "Refresh" on a deployment record updates `Status` to `Completed`
+- [ ] Run summary strip shows correct counts after `LoadRunStatesAsync`
+- [ ] `build-release.yml` artifact `.exe` size is AV-clean (submit to VirusTotal as integration check)
+- [ ] Azure blob `GET` returns valid JSON; `Content-Type: application/json`
+
+---
+
+## Distribution & Cost Controls
+
+### Why not pull directly from GitHub at runtime?
+GitHub's unauthenticated raw-content API is rate-limited to 60 requests per hour per IP. An app with
+many concurrent users would immediately start hitting `429` errors. The Azure Blob CDN approach avoids
+this entirely — no GitHub API calls at runtime.
+
+### Protecting the Azure Storage account from excessive costs
+The content bundle is JSON (~50 KB for ~200 scripts). Even without caching, a 50 KB download per user
+per week is negligible. However, defensive measures are still warranted:
+
+| Layer | Mechanism |
+|-------|-----------|
+| **Client-side cache** | Bundle cached in LiteDB for 7 days; typical user makes ≤4 fetches/month |
+| **Azure CDN** | Place Azure CDN in front of Blob Storage; edge nodes serve most requests from cache (origin hit only on CDN miss or after TTL expiry). CDN bandwidth is cheaper than origin egress. |
+| **Azure Cost Alert** | Set a monthly budget alert (e.g. $10/month) on the Storage Account via Azure Cost Management. Alert fires before charges become significant. |
+| **CDN rate-limiting rule** | Azure CDN Premium (Verizon) supports rate-limiting rules by IP. Standard CDN (Akamai/Microsoft) can redirect to an error page when traffic spikes via custom rules. |
+| **Immutable URL design** | The blob URL is a `private const string` in `ScriptCatalogService` — users cannot redirect it to another host (prevents SSRF and cost-shifting). |
+| **Restricted CORS** | Blob container CORS policy allows only `GET` from `https://intunecommander.app` (if a web companion ever exists); desktop app traffic is origin-less and still permitted. |
+
+### GitHub Action: `update-catalog.yml`
+`.github/workflows/update-catalog.yml` was added to automate the weekly catalog refresh:
+- Fetches scripts from `JayRHa/EndpointAnalyticsRemediationScripts` via `scripts/Fetch-CatalogScripts.ps1`
+- Regenerates `script-catalog.json` (metadata, embedded in exe) and `script-content-bundle.json` (PS1 content for CDN)
+- Commits updated `script-catalog.json` back to the branch if changed (`[skip ci]` to avoid infinite loop)
+- Uploads content bundle to Azure Blob Storage
+- Optionally purges Azure CDN edge cache (configure via `AZURE_CDN_PROFILE` / `AZURE_CDN_ENDPOINT` secrets)
+
+> **Supply-chain risk**: `Fetch-CatalogScripts.ps1` pulls PowerShell content from an external repo
+> (`JayRHa/EndpointAnalyticsRemediationScripts`). If that upstream repo or account is compromised,
+> malicious scripts could be ingested into the bundle and shipped to managed devices.
+> Mitigations required before the workflow goes live:
+>
+> - **Pin to a specific commit SHA** in `Fetch-CatalogScripts.ps1` (not a branch head); record the
+> verified SHA in the script and update it only after manual review.
+> - **Require human approval** before the catalog bundle is published: add a `workflow_dispatch`
+> approval gate (GitHub Environments with required reviewers) so a maintainer signs off on every
+> ingest run.
+> - **Checksum / signature verification**: compute a SHA-256 hash of each `.ps1` file after fetch
+> and record it in `script-catalog.json`; the client-side `ScriptCatalogService` should reject
+> any bundle entry whose content hash does not match.
+
+Required secrets:
+| Secret | Purpose |
+|--------|---------|
+| `AZURE_CREDENTIALS` | Service principal JSON; needs `Storage Blob Data Contributor` on container only |
+| `AZURE_STORAGE_ACCOUNT` | Storage account name used in `az storage blob upload --account-name` |
+| `AZURE_CDN_RESOURCE_GROUP` | (Optional) Azure CDN resource group |
+| `AZURE_CDN_PROFILE` | (Optional) Azure CDN profile name; omit to skip cache-purge step |
+| `AZURE_CDN_ENDPOINT` | (Optional) Azure CDN endpoint name |
+
+---
+
+## Open Questions
+
+1. **Azure subscription**: existing subscription or new one? Needs Storage Account name for the `BlobUrl` constant.
+2. **Catalog size filter**: JayRHa repo has ~200 scripts. Filter by platform (Windows-only)? Maximum entries cap?
+3. **Bundle versioning**: should the app reject a bundle whose `version` doesn't match a minimum supported version?
+4. **Offline mode**: if Azure blob is unreachable and LiteDB cache is expired, show "Catalog content unavailable — check network" vs. silently show metadata-only with disabled Deploy button?
+5. **Script signing**: should scripts deployed to Intune via the catalog be signed? (Intune can enforce PS signing policies)
+6. **Azure CDN tier**: Standard Microsoft CDN is sufficient for this use case (no IP-based rate limiting needed unless abuse is detected). Start with Standard; upgrade to Premium Verizon only if cost alerts fire.