diff --git a/.gitignore b/.gitignore
index 391b662a..968e2642 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,9 @@ yarn-error.log*
.npm
.eslintcache
+# TypeScript incremental build cache
+*.tsbuildinfo
+
# Environment variables
.env
.env*.local
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs
new file mode 100644
index 00000000..c50b9002
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs
@@ -0,0 +1,42 @@
+using System;
+using System.IO;
+using System.Linq;
+using AutopilotMonitor.Agent.Core.Security;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Security
+{
+ ///
+ /// Smoke test for : every pinned System32 binary must
+ /// exist on the test host. This catches typos, bad Path.Combine segments,
+ /// and accidental rewrites of the historical WindowsPowerShell\v1.0 path —
+ /// all failure modes that would brick the Agent on every Windows device.
+ /// Windows-only (the Agent runs on net48 and CI runs on Windows agents).
+ ///
+ public class SystemPathsTests
+ {
+ [Fact]
+ public void AllPinnedPaths_ExistOnWindows()
+ {
+ Assert.Equal(PlatformID.Win32NT, Environment.OSVersion.Platform);
+
+ var paths = new (string name, string path)[]
+ {
+ (nameof(SystemPaths.Cmd), SystemPaths.Cmd),
+ (nameof(SystemPaths.Shutdown), SystemPaths.Shutdown),
+ (nameof(SystemPaths.TzUtil), SystemPaths.TzUtil),
+ (nameof(SystemPaths.Netsh), SystemPaths.Netsh),
+ (nameof(SystemPaths.PowerShell), SystemPaths.PowerShell),
+ };
+
+ var missing = paths
+ .Where(p => !File.Exists(p.path))
+ .Select(p => $"{p.name}: {p.path}")
+ .ToList();
+
+ Assert.True(
+ missing.Count == 0,
+ "SystemPaths point to missing binaries:\n " + string.Join("\n ", missing));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index a80d4992..a6ea319d 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-889
+890
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
index f82603e9..0b3a7ded 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
@@ -220,6 +220,27 @@ public class EndpointPolicyCatalogCompletenessTests
string.Join("\n", invalid.Select(i => $" - {i}")));
}
+ ///
+ /// The three global/apps/* routes must be locked to GlobalAdminOnly with
+ /// TenantScoping.None (the optional ?tenantId= query param is intentional cross-tenant
+ /// scoping, enforced inside the function — not by the middleware).
+ /// A future accidental downgrade of the policy tier would be caught here, whereas
+ /// the generic completeness test only guarantees that *some* entry exists.
+ ///
+ [Theory]
+ [InlineData("GET", "/api/global/apps/list", "global/apps/list")]
+ [InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
+ [InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
+ public void GlobalAppsRoutes_AreGlobalAdminOnly(string method, string path, string expectedTemplate)
+ {
+ var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
+
+ Assert.NotNull(entry);
+ Assert.Equal(expectedTemplate, entry!.RouteTemplate);
+ Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
+ Assert.Equal(TenantScoping.None, entry.TenantScoping);
+ }
+
///
/// Named capture group for {tenantId} correctly extracts the value from request paths.
///
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAppsTenantIdValidationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAppsTenantIdValidationTests.cs
new file mode 100644
index 00000000..b4830c44
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAppsTenantIdValidationTests.cs
@@ -0,0 +1,39 @@
+using AutopilotMonitor.Functions.Functions.Apps;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Unit tests for the shared ?tenantId= query-param validator used by
+/// the three global/apps/* Global-Admin endpoints
+/// (, ,
+/// ).
+///
+/// A malformed tenantId previously reached the table-storage query layer as a
+/// raw string; this test ensures the guard short-circuits with a predictable
+/// false (→ 400 BadRequest in the caller) for anything that isn't a GUID.
+///
+public class GlobalAppsTenantIdValidationTests
+{
+ [Theory]
+ [InlineData(null)] // not supplied → aggregate all tenants
+ [InlineData("")] // empty string → same
+ [InlineData("00000000-0000-0000-0000-000000000000")] // empty GUID is still valid-format
+ [InlineData("11111111-2222-3333-4444-555555555555")] // normal GUID
+ [InlineData("{11111111-2222-3333-4444-555555555555}")] // braced GUID (Guid.TryParse accepts)
+ public void Accepts_NullEmpty_And_ValidGuids(string? raw)
+ {
+ Assert.True(AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(raw));
+ }
+
+ [Theory]
+ [InlineData("not-a-guid")]
+ [InlineData("notaguid")]
+ [InlineData("11111111-2222-3333-4444")] // truncated
+ [InlineData("11111111-2222-3333-4444-5555555555555")] // too long
+ [InlineData("'; DROP TABLE devices; --")] // injection attempt
+ [InlineData("../admin")]
+ public void Rejects_MalformedValues(string raw)
+ {
+ Assert.False(AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(raw));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ValidateBootstrapCodeResponseShapeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ValidateBootstrapCodeResponseShapeTests.cs
new file mode 100644
index 00000000..d2a8f99e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ValidateBootstrapCodeResponseShapeTests.cs
@@ -0,0 +1,86 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Contract tests for , the DTO returned
+/// by GET /api/bootstrap/validate/{code}.
+///
+/// The values in this response are interpolated into a PowerShell bootstrap
+/// script that runs as SYSTEM during Autopilot OOBE, and the Next.js frontend
+/// at src/Web/autopilot-monitor-web/lib/bootstrapValidation.ts enforces
+/// a very strict shape (GUID tenantId+token, locked-down AgentDownloadUrl, etc.).
+///
+/// These tests mirror the frontend's invariants so that a backend rename or
+/// change to AgentDownloadUrl is caught in CI — not during OOBE rollout.
+///
+public class ValidateBootstrapCodeResponseShapeTests
+{
+ ///
+ /// Mirrors the frontend regex at bootstrapValidation.ts (lines ~119–125).
+ ///
+ private static readonly Regex AgentDownloadUrlPath =
+ new(@"^/agent/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.zip$", RegexOptions.Compiled);
+
+ ///
+ /// The response DTO must expose exactly these public instance properties with
+ /// these types. Any rename or removal breaks the frontend validator silently.
+ ///
+ [Fact]
+ public void ValidateBootstrapCodeResponse_HasExpectedPublicProperties()
+ {
+ var expected = new Dictionary
+ {
+ ["Success"] = typeof(bool),
+ ["TenantId"] = typeof(string),
+ ["Token"] = typeof(string),
+ ["AgentDownloadUrl"] = typeof(string),
+ ["ExpiresAt"] = typeof(DateTime),
+ ["Message"] = typeof(string),
+ };
+
+ var actual = typeof(ValidateBootstrapCodeResponse)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .ToDictionary(p => p.Name, p => p.PropertyType);
+
+ // Missing / renamed properties
+ var missing = expected.Keys.Except(actual.Keys).ToList();
+ Assert.True(missing.Count == 0,
+ "ValidateBootstrapCodeResponse is missing expected properties: " + string.Join(", ", missing));
+
+ // Unexpected additions (forces a deliberate test update when the contract grows)
+ var extra = actual.Keys.Except(expected.Keys).ToList();
+ Assert.True(extra.Count == 0,
+ "ValidateBootstrapCodeResponse has unexpected extra properties (update frontend validator + this test): "
+ + string.Join(", ", extra));
+
+ // Type mismatches
+ foreach (var kvp in expected)
+ {
+ Assert.Equal(kvp.Value, actual[kvp.Key]);
+ }
+ }
+
+ ///
+ /// The hardcoded agent download URL in ValidateBootstrapCodeFunction must
+ /// satisfy the frontend's host allow-list and path regex.
+ ///
+ [Fact]
+ public void AgentDownloadUrl_MatchesFrontendAllowList()
+ {
+ // Keep in sync with ValidateBootstrapCodeFunction.cs — if that literal
+ // changes, both this assertion and bootstrapValidation.ts must be updated.
+ const string HardcodedAgentUrl =
+ "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
+
+ Assert.True(Uri.TryCreate(HardcodedAgentUrl, UriKind.Absolute, out var uri),
+ "AgentDownloadUrl is not a well-formed absolute URI");
+
+ Assert.Equal(Uri.UriSchemeHttps, uri!.Scheme);
+ Assert.Equal("autopilotmonitor.blob.core.windows.net", uri.Host);
+ Assert.True(AgentDownloadUrlPath.IsMatch(uri.AbsolutePath),
+ $"AgentDownloadUrl path '{uri.AbsolutePath}' does not match the frontend allow-list regex");
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
index 5ca7907f..d2f9c2fb 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
@@ -11,6 +11,22 @@ namespace AutopilotMonitor.Functions.Functions.Apps
///
internal static class AppsAnalyticsHelper
{
+ // ── Query param validation ──────────────────────────────────────────
+
+ ///
+ /// Validates the optional ?tenantId= query parameter shared by all
+ /// three global/apps/* endpoints. Null / empty is allowed (means
+ /// "aggregate across all tenants"); any non-empty value must parse as a GUID.
+ /// Returns true when the value is acceptable; otherwise false
+ /// (caller should emit a 400).
+ ///
+ public static bool IsValidOptionalTenantIdQueryParam(string? raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ return true;
+ return Guid.TryParse(raw, out _);
+ }
+
// ── Data loaders ────────────────────────────────────────────────────
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
index 5279f41a..3c2341c1 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
+ if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
+ {
+ var bad2 = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad2.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
+ return bad2;
+ }
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
index 807dc298..43cffa3c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
@@ -49,6 +49,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
+ if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
+ {
+ var bad2 = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad2.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
+ return bad2;
+ }
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
index 7447119d..fc632d4c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
@@ -35,6 +35,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
+ if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
+ return bad;
+ }
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;