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;