Compare commits

...

12 Commits

Author SHA1 Message Date
Oliver Kieselbach a47e94f9a5 fix(security): diagnostics packager reads every file through a handle validated against its pinned source folder; a subdirectory swapped for a junction between enumeration and open is skipped instead of copied
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package.

PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:06:50 +02:00
Oliver Kieselbach ecfa59d049 fix(security): PUT preview/notification-email requires a member role once the tenant has members; roleless writes only in the signup window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:57:35 +02:00
Oliver Kieselbach 6c50a54249 fix(security): escape tenantId in metrics-summary and device-snapshot OData filters; GUID-gate tenantId query param on global metrics/search routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:55:15 +02:00
Oliver Kieselbach 6e3487756a fix(security): gather-rule on_event feedback loop — timeline feed carries the event source and never dispatches on_event for gather-emitted events; per-rule on_event cap (100/session); backend rejects rules triggering on their own output type / gather_result
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:54:44 +02:00
Oliver Kieselbach dbb154bae9 fix(security): percent-encode every path-position id in lib/api.ts; session pages gate URL-derived id on GUID before any fetch
A crafted /sessions?id=x%2F..%2F..%2Fglobal%2Fy%23 deep link let the victim's browser resolve
dot-segments into a different API route and truncate builder suffixes via '#', turning UI
actions into attacker-chosen authenticated same-origin requests. Hydration now also requires
the returned sessionId to match the requested one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:45:34 +02:00
Oliver Kieselbach c41c2327ab fix(security): validate enrollment summary branding image URL (https-only, no quotes); launcher drops unsafe URLs instead of interpolating them into the dialog command line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:15:07 +02:00
Oliver Kieselbach 83effea592 fix(security): hosted diagnostics upload SAS is create-only and bound to the requesting session; existing blob => 409
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:13:00 +02:00
Oliver Kieselbach 1a4a37b77a fix(web): hardware whitelist editor splits typed comma lists into entries ("Dell*, *" stays allow-all); only insights buttons neutralize ','
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:56:37 +02:00
Oliver Kieselbach f56851fc9f fix(security): hardware whitelist — one added entry is always one pattern; server rejects malformed CSV
The tenant hardware whitelist is stored as a CSV and split on ',' at
enforcement time. Manufacturer/model strings in the Rejected-Hardware
insights come from unauthenticated distress signals, so a value like
"Dell Inc.,*" added via "+ Mfr" appended a standalone "*" and silently
turned the manufacturer gate into allow-all.

- Web: shared hardwareWhitelist helper; "+ Mfr"/"+ Model" and the manual
  editor neutralize ',' to the single-char wildcard '?' so a value can
  never split the list.
- Backend: TenantConfigValidation.ValidateHardwareWhitelist in ValidateModel
  (PUT + patch) rejects blank items, control chars, over-long/too many entries.
- Shared: ParseWhitelist trims entries so enforcement matches the editor view.
- Tests: 8 xUnit + 5 vitest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:55 +02:00
Oliver Kieselbach cbe410cbbc fix(security): reject path-shaped sessionId/tenantId in session-report blob names; BlobNameGuard at every GetBlobClient sink
SubmitSessionReport built the report blob name from the raw route sessionId. Route values are
percent-decoded, so an encoded '/'+'..' reached Azure.Storage.Blobs, which keeps separators, and
System.Uri collapsed the dot segments into a write outside the session-reports container.

- SubmitSessionReport / SubmitDiagFilesReport: 400 unless sessionId/tenantId are GUIDs
- SessionReportService: EnsureValidGuid at entry of both submit paths
- New Security/BlobNameGuard (flat-name invariant) applied in SessionReportService,
  SessionReportDiagnosticsArchiveCopier (pre-check + upload) and the GA download function
- Tests: BlobNameGuardTests, copier rejects non-flat destination without storage calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:13 +02:00
Oliver Kieselbach 43ddc60111 fix(security): enforce tenant suspension in PolicyEnforcementMiddleware, not only auth/me
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:34:29 +02:00
Oliver Kieselbach 82b26b4ab9 fix(security): HTML-encode domain in transactional mails; freeze DomainName on config PUT, validate first-login seed
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:52 +02:00
67 changed files with 2843 additions and 323 deletions
+50 -6
View File
@@ -1,10 +1,10 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, the handle-validated reads that close the enumerate-then-open junction race, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal, security]
timestamp: 2026-08-30
---
# Schema
@@ -90,10 +90,50 @@ validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
the handle-validated read (next section), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Reads are validated on the handle, not the path
Every guard above judges a path string. Reading the bytes from that string later —
`File.GetAttributes` on the candidate, then a `FileStream` open after earlier files have been
compressed — leaves a window: under any folder a local user can write to (the user-profile
token paths, `ProgramData` subtrees, `C:\Install\Log`) that user can turn a subdirectory into
a junction in between. An attribute check on the file resolves through a junction in a
*parent* component, and SYSTEM would copy the junction target into the package.
`PinnedSourceFolder` (`Monitoring/Runtime/PinnedSourceFolder.cs`, Win32 declarations in
`Monitoring/Interop/FileNativeMethods.cs`) closes the window by deciding on the handle that
is read:
* `AddLogFiles` pins the section folder once. It is opened with
`FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, must not be a reparse point
itself, and its `GetFinalPathNameByHandle` result — every reparse point resolved — must
equal the validated lexical path (8.3 names expanded through `GetLongPathName`, so a
`%TEMP%`-style short name is not mistaken for a junction). Anything else skips the section
with `FOLDER REJECTED (<reason>)`; a missing folder stays `FOLDER MISSING`. The handle is
held, with `FILE_LIST_DIRECTORY` access and no delete share, until the section is done:
NTFS refuses to rename or delete a directory while it or anything beneath it is open, so
the validated chain cannot be swapped underneath the build either.
* Every candidate file is opened by `PinnedSourceFolder.TryOpenFile` with
`FILE_FLAG_OPEN_REPARSE_POINT` — a symlink at the final component is opened as itself and
refused (`SKIPPED (reparse point)`) — and the handle's final path must be exactly
`<canonical folder>\<relative path>`. A subdirectory that became a junction after
enumeration resolves elsewhere: `SKIPPED (resolved outside validated folder)`, naming only
the requested path in the manifest, never the resolved one. The length charged against the
caps and the stream copied into the archive come from that same handle; any other open
failure is `FAILED (open): <path>: <reason>`.
* `CollectFilesNoReparseDirs` still skips reparse-point directories while enumerating — as a
candidate filter and to keep the walk out of junction loops — but nothing it decides is
trusted at copy time.
* The `.evtx` channel export is the agent's own file under an unguessable name in SYSTEM's
temp folder and is streamed as-is. Hard links are out of scope: Windows refuses to create
one to a file the caller cannot write.
The race itself is pinned by `DiagnosticsPackageServiceTests`: the `BeforeSourceFileOpen`
seam swaps a subdirectory for a junction after enumeration and before the open, and the
archive must not carry the file.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
@@ -120,6 +160,8 @@ same-named folder share one ZIP folder.
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
`BeforeSourceFileOpen` fires with each candidate path after enumeration and before its open
— the point at which the junction-race test swaps the directory.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
@@ -139,9 +181,11 @@ entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, `AddLogFiles` / `AddLogFile` / `OpenSource`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PinnedSourceFolder.cs` — pinned folder handle, `TryOpenFile`, `SourceFile`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/FileNativeMethods.cs` — `CreateFileW`, `GetFileInformationByHandle`, `GetFinalPathNameByHandleW`, `GetLongPathNameW`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure, junction swapped in after enumeration, junction as section folder), `PinnedSourceFolderTests` (final-path equality, short names, junction in the chain, symlink at the final component, rename refused while pinned), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
scope always may write; a roleless caller may write only while the tenant has **no enabled
member** at all. Once any member exists the window is closed for good — the address feeds the
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
employee with a JWT but no product role must not be able to redirect or clear it (403).
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
@@ -107,6 +115,13 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
matching the built-ins. Subjects are not editable (pinned constants).
* The domain is HTML-encoded at the point of substitution (`EmailTemplateService.Render` and the
built-in template builders) — the mail goes out under the product's signed sender to a
tenant-chosen recipient, so no writer of `DomainName` may ever inject markup into it.
`DomainName` itself is tenant identity: seeded once at first login from the UPN domain
(`AuthFunction`, only when it passes `TenantConfigValidation.IsValidDomainName` — a strict
dotted host name), preserved from the stored row on the full-model config PUT for every
caller, and "never writable" in the field-patch flow.
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
read path is not — a broken store surfaces as an error instead of silently showing the
+1 -1
View File
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, handle-validated reads against the junction race, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
+16
View File
@@ -1,5 +1,21 @@
# Log
## 2026-08-30 (1)
* **Update**: `agent/diagnostics-package.md` — reads are validated on the handle, not the path: `PinnedSourceFolder` pins each section folder (its final path must equal the validated path; held open so the chain cannot be renamed) and opens every file with `FILE_FLAG_OPEN_REPARSE_POINT`, requiring the handle's final path to be `<canonical folder>\<relative path>`. A subdirectory swapped for a junction between enumeration and open is skipped (`SKIPPED (resolved outside validated folder)`) instead of copied into the package.
## 2026-08-29 (12)
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
## 2026-08-29 (11)
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
## 2026-08-29 (10)
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
## 2026-08-29 (9)
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
+16 -3
View File
@@ -8,7 +8,7 @@ tags:
- backend
- gather-rules
- config
timestamp: 2026-08-04T00:00:00+02:00
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
@@ -89,6 +89,18 @@ Consequences of the emitted-event feed:
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
emitted `EnrollmentEvent`).
* **Feedback-loop guard** (2026-08-29): gather output travels the same post → ingress →
reducer → emitter → stream path, so the stream would hand the executor's own events straight
back to `OnEvent`. A rule with `triggerEventType == outputEventType` (or two rules chained
A→B→A) then executed and emitted without bound for the whole session — `emitMode:
on_change` does not help when the collected result varies. `TimelineEventStream` therefore
publishes the event **source**, and `GatherRuleExecutorHost` never dispatches `on_event` for
`Source == GatherRuleExecutor.SourceName` (phase declarations on such events are still
honoured). Second layer: `GatherRuleExecutor.MaxOnEventExecutionsPerRule` (100 per rule and
session) bounds any cycle that reaches the executor through a non-gather event; the boundary
is logged once (agent log Warning + debug-trace `trigger` line). Backend validation rejects
`on_event` rules whose trigger equals their own output type or `gather_result` — such a rule
could never fire.
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
off the raw signal stream deliberately — their collections are about OS state at the raw
boundary (ESP exit, keep-awake), not about timeline agreement.
@@ -103,7 +115,8 @@ Consequences of the emitted-event feed:
`_startupRulesExecuted` dedup; it is not part of the `WaitForStartupRules` latch).
* `phase_change` — evaluated against the **new** phase; the scope gate runs before the
per-(rule, phase) dedup so an out-of-scope pass does not consume the execution slot.
* `on_event` — gated by the current phase.
* `on_event` — gated by the current phase; never fires on gather output; capped at
`MaxOnEventExecutionsPerRule` per rule and session.
* **Before the first phase signal** of a session (`_currentPhase == Unknown`) scoped rules are
inactive. Sessions that never produce phase signals never run them (documented behavior).
* **From-phase latch**: activates when `phase != Unknown && phase != Failed && (int)phase >=
@@ -164,4 +177,4 @@ Consequences of the emitted-event feed:
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests`, `GatherRuleExecutorOnEventCapTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
@@ -0,0 +1,54 @@
using System;
using System.Diagnostics;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
/// <summary>
/// Creates NTFS reparse points for guard tests through <c>cmd.exe /c mklink</c> — the stock
/// System32 binary started as-is, nothing copied or renamed (EDR-neutral). Directory
/// junctions need no privilege; file symlinks need SeCreateSymbolicLinkPrivilege or
/// Developer Mode, so that helper reports failure instead of throwing.
/// </summary>
internal static class NtfsLinks
{
public static void CreateJunction(string link, string target)
{
var output = RunMklink($"/J \"{link}\" \"{target}\"");
if (!Directory.Exists(link) || (File.GetAttributes(link) & FileAttributes.ReparsePoint) == 0)
throw new InvalidOperationException($"mklink /J failed for '{link}' -> '{target}': {output}");
}
public static bool TryCreateFileSymlink(string link, string target)
{
RunMklink($"\"{link}\" \"{target}\"");
return File.Exists(link) && (File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0;
}
/// <summary>Removes a junction or symlink itself, never the target's content.</summary>
public static void RemoveLink(string link)
{
if (Directory.Exists(link)) Directory.Delete(link, recursive: false);
else if (File.Exists(link)) File.Delete(link);
}
private static string RunMklink(string arguments)
{
var psi = new ProcessStartInfo
{
FileName = Path.Combine(Environment.SystemDirectory, "cmd.exe"),
Arguments = "/c mklink " + arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var process = Process.Start(psi);
if (process == null) throw new InvalidOperationException("cmd.exe did not start");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(15000);
return (stdout + stderr).Trim();
}
}
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// on_event has no once-per-(rule, phase) dedup like the phase triggers. The per-rule
/// execution cap (<see cref="GatherRuleExecutor.MaxOnEventExecutionsPerRule"/>) bounds any
/// trigger cycle that survives the host's source guard (e.g. two rules chained through an
/// intermediate non-gather event) so a mis-authored rule can never spin for a whole session.
/// Executions are observed through the emitted events; the rule uses a registry path that
/// exists on every Windows box so each execution emits exactly one event.
/// </summary>
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorOnEventCapTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly object _gate = new object();
private readonly GatherRuleExecutor _executor;
public GatherRuleExecutorOnEventCapTests()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_executor = new GatherRuleExecutor("sess", "tenant",
evt => { lock (_gate) _events.Add(evt); }, logger)
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_executor.Dispose();
_tmp.Dispose();
}
private int EventCount { get { lock (_gate) return _events.Count; } }
private bool WaitForEventCount(int count, int timeoutMs = 20000)
{
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (EventCount >= count) return true;
Thread.Sleep(25);
}
return EventCount >= count;
}
private static GatherRule OnEventRule(string id, string triggerEventType) => new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion",
Trigger = "on_event",
TriggerEventType = triggerEventType,
OutputEventType = "gather_cap_test",
Enabled = true,
};
[Fact]
public void OnEvent_ExecutesAtMostMaxOnEventExecutionsPerRule_PerSession()
{
_executor.UpdateRules(new List<GatherRule> { OnEventRule("GATHER-CAP-001", "ext_event") });
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
for (var i = 0; i < cap + 25; i++)
_executor.OnEvent("ext_event");
Assert.True(WaitForEventCount(cap), $"expected {cap} executions, got {EventCount}");
Thread.Sleep(500);
Assert.Equal(cap, EventCount);
// Further triggers stay ignored for the rest of the session.
_executor.OnEvent("ext_event");
Thread.Sleep(300);
Assert.Equal(cap, EventCount);
}
[Fact]
public void OnEventCap_IsPerRule_NotGlobal()
{
_executor.UpdateRules(new List<GatherRule>
{
OnEventRule("GATHER-CAP-002", "ext_event"),
OnEventRule("GATHER-CAP-003", "ext_event"),
});
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
for (var i = 0; i < cap + 5; i++)
_executor.OnEvent("ext_event");
Assert.True(WaitForEventCount(2 * cap), $"expected {2 * cap} executions, got {EventCount}");
Thread.Sleep(500);
Assert.Equal(2 * cap, EventCount);
}
}
}
@@ -811,5 +811,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.False(DiagnosticsPackageService.IsReparsePoint(FileAttributes.ReadOnly | FileAttributes.Hidden));
Assert.False(DiagnosticsPackageService.IsReparsePoint(FileAttributes.Archive));
}
// ========================================== handle-validated reads (TOCTOU) ====
// Every guard up to here inspects a PATH: the config guard, the enumeration's reparse
// skip, the per-file attribute check. A local user who can write under a recursive
// source folder swaps a subdirectory for a junction between enumeration and open, and
// the bytes would come from the junction target. The read is validated on the handle
// that is actually copied, so the swapped file is skipped and the manifest says why.
private static string AllZipEntryTexts(byte[] zipBytes)
{
using var ms = new MemoryStream(zipBytes);
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
var sb = new StringBuilder();
foreach (var entry in archive.Entries)
{
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
sb.AppendLine(reader.ReadToEnd());
}
return sb.ToString();
}
[Fact]
public void BuildArchiveBytes_skips_file_whose_parent_became_a_junction_after_enumeration()
{
using var rig = new Rig();
using var outside = new TempDirectory();
var sub1 = Path.Combine(rig.StateFolder, "sub1");
var sub2 = Path.Combine(rig.StateFolder, "sub2");
Directory.CreateDirectory(sub1);
Directory.CreateDirectory(sub2);
File.WriteAllText(Path.Combine(sub1, "decoy.txt"), "decoy");
File.WriteAllText(Path.Combine(sub2, "x.txt"), "harmless");
File.WriteAllText(Path.Combine(outside.Path, "x.txt"), "OUTSIDE-THE-VALIDATED-FOLDER");
var svc = rig.Build();
var swapped = false;
svc.BeforeSourceFileOpen = candidate =>
{
if (swapped || !candidate.EndsWith(Path.Combine("sub2", "x.txt"), System.StringComparison.OrdinalIgnoreCase))
return;
// Enumeration is done and sub2 passed its reparse check — now it becomes a junction.
Directory.Delete(sub2, recursive: true);
NtfsLinks.CreateJunction(sub2, outside.Path);
swapped = true;
};
try
{
var bytes = svc.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.True(swapped, "race seam never fired for sub2/x.txt");
var entries = ZipEntryNames(bytes);
Assert.Contains("AgentState/sub1/decoy.txt", entries);
Assert.DoesNotContain("AgentState/sub2/x.txt", entries);
Assert.DoesNotContain("OUTSIDE-THE-VALIDATED-FOLDER", AllZipEntryTexts(bytes));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
Assert.NotNull(manifest);
Assert.Contains("SKIPPED (resolved outside validated folder)", manifest!);
Assert.Contains("ADDED: AgentState/sub1/decoy.txt", manifest!);
}
finally
{
NtfsLinks.RemoveLink(sub2);
}
}
[Fact]
public void BuildArchiveBytes_rejects_source_folder_that_is_a_junction()
{
using var rig = new Rig();
using var outside = new TempDirectory();
File.WriteAllText(Path.Combine(outside.Path, "snapshot.json"), "{\"leak\":true}");
var link = Path.Combine(rig.Tmp.Path, "StateLink");
NtfsLinks.CreateJunction(link, outside.Path);
try
{
var bytes = rig.Build(sectionFolderOverrides: new Dictionary<string, string> { ["AgentState"] = link })
.BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.DoesNotContain(entries, e => e.StartsWith("AgentState/"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
Assert.NotNull(manifest);
Assert.Contains("FOLDER REJECTED (reparse point)", manifest!);
}
finally
{
NtfsLinks.RemoveLink(link);
}
}
}
}
@@ -0,0 +1,234 @@
#nullable enable
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// PinnedSourceFolder is the diagnostics packager's answer to the enumerate-then-open race:
/// the folder and every file are judged on the handle that is read, never on the path
/// string. Junctions come from the stock mklink (no privilege needed); the file-symlink
/// case degrades to a no-op on hosts without the symlink privilege.
/// </summary>
public sealed class PinnedSourceFolderTests
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetShortPathNameW(string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer);
private static string? ShortPathOf(string path)
{
var sb = new StringBuilder(1024);
var len = GetShortPathNameW(path, sb, (uint)sb.Capacity);
return len == 0 || len >= sb.Capacity ? null : sb.ToString();
}
private static string LongPathOf(string path) => PinnedSourceFolder.TryGetLongPath(path) ?? path;
private static bool SamePath(string a, string b) =>
string.Equals(a.TrimEnd('\\'), b.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
[Fact]
public void TryOpen_pins_a_real_folder_and_reports_where_it_points()
{
using var tmp = new TempDirectory();
var folder = Path.Combine(tmp.Path, "Logs");
Directory.CreateDirectory(folder);
using var pinned = PinnedSourceFolder.TryOpen(folder + "\\", out var rejection);
Assert.Null(rejection);
Assert.NotNull(pinned);
Assert.Equal(folder, pinned!.LexicalPath);
Assert.True(SamePath(LongPathOf(folder), pinned.CanonicalPath), $"canonical '{pinned.CanonicalPath}' vs '{folder}'");
}
[Fact]
public void TryOpen_accepts_a_short_name_path_and_canonicalizes_it()
{
using var tmp = new TempDirectory();
var folder = Path.Combine(tmp.Path, "LongFolderNameForShortNames");
Directory.CreateDirectory(folder);
// 8.3 generation may be disabled on the volume; then the short form IS the long form
// and this degrades to the plain case (CI runners hand out C:\Users\RUNNER~1\...).
var shortForm = ShortPathOf(folder) ?? folder;
using var pinned = PinnedSourceFolder.TryOpen(shortForm, out var rejection);
Assert.Null(rejection);
Assert.NotNull(pinned);
Assert.True(SamePath(LongPathOf(folder), pinned!.CanonicalPath), $"canonical '{pinned.CanonicalPath}' vs '{folder}' (opened as '{shortForm}')");
}
[Fact]
public void TryOpen_reports_a_missing_folder()
{
using var tmp = new TempDirectory();
var pinned = PinnedSourceFolder.TryOpen(Path.Combine(tmp.Path, "nope"), out var rejection);
Assert.Null(pinned);
Assert.Equal(PinnedSourceFolder.RejectMissing, rejection);
}
[Fact]
public void TryOpen_rejects_a_folder_that_is_a_junction()
{
using var tmp = new TempDirectory();
using var target = new TempDirectory();
var link = Path.Combine(tmp.Path, "link");
NtfsLinks.CreateJunction(link, target.Path);
try
{
var pinned = PinnedSourceFolder.TryOpen(link, out var rejection);
Assert.Null(pinned);
Assert.Equal(PinnedSourceFolder.RejectReparsePoint, rejection);
}
finally
{
NtfsLinks.RemoveLink(link);
}
}
[Fact]
public void TryOpen_rejects_a_folder_reached_through_a_junction()
{
using var tmp = new TempDirectory();
using var target = new TempDirectory();
Directory.CreateDirectory(Path.Combine(target.Path, "Logs"));
var link = Path.Combine(tmp.Path, "link");
NtfsLinks.CreateJunction(link, target.Path);
try
{
// link\Logs is a real directory reached THROUGH a junction: its own attributes are
// clean, only the handle's final path gives it away.
var pinned = PinnedSourceFolder.TryOpen(Path.Combine(link, "Logs"), out var rejection);
Assert.Null(pinned);
Assert.Equal(PinnedSourceFolder.RejectResolvedElsewhere, rejection);
}
finally
{
NtfsLinks.RemoveLink(link);
}
}
[Fact]
public void TryOpenFile_returns_the_bytes_of_a_real_file()
{
using var tmp = new TempDirectory();
var sub = Path.Combine(tmp.Path, "sub");
Directory.CreateDirectory(sub);
var file = Path.Combine(sub, "a.log");
File.WriteAllText(file, "hello");
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
Assert.NotNull(pinned);
using var source = pinned!.TryOpenFile(file, out var rejection);
Assert.Null(rejection);
Assert.NotNull(source);
Assert.Equal(5, source!.Length);
using var reader = new StreamReader(source.Stream, Encoding.UTF8);
Assert.Equal("hello", reader.ReadToEnd());
}
[Fact]
public void TryOpenFile_rejects_a_file_reached_through_a_junction_created_after_the_pin()
{
using var tmp = new TempDirectory();
using var outside = new TempDirectory();
var sub = Path.Combine(tmp.Path, "sub");
Directory.CreateDirectory(sub);
var file = Path.Combine(sub, "x.log");
File.WriteAllText(file, "harmless");
File.WriteAllText(Path.Combine(outside.Path, "x.log"), "outside");
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
Assert.NotNull(pinned);
// Enumeration would have listed sub\x.log by now — the attacker swaps sub for a junction.
Directory.Delete(sub, recursive: true);
NtfsLinks.CreateJunction(sub, outside.Path);
try
{
var source = pinned!.TryOpenFile(file, out var rejection);
Assert.Null(source);
Assert.Equal(PinnedSourceFolder.RejectResolvedElsewhere, rejection);
}
finally
{
NtfsLinks.RemoveLink(sub);
}
}
[Fact]
public void TryOpenFile_rejects_a_symlink_at_the_final_component()
{
using var tmp = new TempDirectory();
using var outside = new TempDirectory();
var targetFile = Path.Combine(outside.Path, "secret.log");
File.WriteAllText(targetFile, "outside");
var link = Path.Combine(tmp.Path, "x.log");
if (!NtfsLinks.TryCreateFileSymlink(link, targetFile))
return; // no SeCreateSymbolicLinkPrivilege / Developer Mode on this host — nothing to assert
try
{
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
Assert.NotNull(pinned);
var source = pinned!.TryOpenFile(link, out var rejection);
Assert.Null(source);
Assert.Equal(PinnedSourceFolder.RejectReparsePoint, rejection);
}
finally
{
NtfsLinks.RemoveLink(link);
}
}
[Fact]
public void TryOpenFile_refuses_a_candidate_outside_the_pinned_folder()
{
using var tmp = new TempDirectory();
using var other = new TempDirectory();
var elsewhere = Path.Combine(other.Path, "x.log");
File.WriteAllText(elsewhere, "x");
using var pinned = PinnedSourceFolder.TryOpen(tmp.Path, out _);
Assert.NotNull(pinned);
var source = pinned!.TryOpenFile(elsewhere, out var rejection);
Assert.Null(source);
Assert.Equal(PinnedSourceFolder.RejectOutsideFolder, rejection);
}
[Fact]
public void Pinned_folder_and_its_ancestors_cannot_be_renamed_while_pinned()
{
using var tmp = new TempDirectory();
var parent = Path.Combine(tmp.Path, "parent");
var folder = Path.Combine(parent, "Logs");
Directory.CreateDirectory(folder);
using (var pinned = PinnedSourceFolder.TryOpen(folder, out _))
{
Assert.NotNull(pinned);
Assert.ThrowsAny<Exception>(() => Directory.Move(folder, Path.Combine(parent, "Logs2")));
Assert.ThrowsAny<Exception>(() => Directory.Move(parent, Path.Combine(tmp.Path, "parent2")));
Assert.True(Directory.Exists(folder));
}
// Released: the same rename now succeeds.
Directory.Move(parent, Path.Combine(tmp.Path, "parent2"));
Assert.True(Directory.Exists(Path.Combine(tmp.Path, "parent2", "Logs")));
}
}
}
@@ -122,13 +122,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown, "Engine");
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown, "Engine");
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
// Only the engine's actual phase declaration fires the rule.
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
Assert.True(WaitForCount("gather_host_test", 1));
}
@@ -140,7 +140,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
Assert.True(WaitForCount("gather_host_test", 1));
}
@@ -151,14 +151,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
Assert.True(WaitForCount("gather_host_test", 1));
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
Thread.Sleep(300);
Assert.Equal(1, _sink.CountOf("gather_host_test"));
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
Assert.True(WaitForCount("gather_host_test", 2));
}
@@ -167,11 +167,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
Assert.True(WaitForCount("gather_host_test", 1));
}
[Fact]
public void OnEventRule_NeverFiresOnGatherExecutorOutput_BreaksSelfTriggerLoop()
{
// A rule whose TriggerEventType equals its own OutputEventType: the event it emits
// travels post → ingress → reducer → emitter → stream and comes back here with
// Source == GatherRuleExecutor. Without the source guard that re-enters OnEvent and
// the rule loops for the whole session (execute → emit → trigger → execute …).
BuildHost(Rule("GATHER-HOST-006", "on_event", triggerEventType: "gather_host_test"));
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown,
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
// The same event type from any other origin still triggers exactly once per event.
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown, "Engine");
Assert.True(WaitForCount("gather_host_test", 1));
Thread.Sleep(300);
Assert.Equal(1, _sink.CountOf("gather_host_test"));
}
[Fact]
public void PhaseDeclarationOnGatherSourcedEvent_StillDrivesPhaseTriggers()
{
// The source guard only suppresses on_event dispatch; a phase declaration is a
// timeline fact regardless of which component carried it.
BuildHost(Rule("GATHER-HOST-007", "phase_change", triggerPhase: "DeviceSetup"));
_stream.Publish("gather_host_test", EnrollmentPhase.DeviceSetup,
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
Assert.True(WaitForCount("gather_host_test", 1));
}
@@ -181,7 +214,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
_host!.Stop();
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
}
@@ -161,7 +161,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
using var r = new Rig();
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
var published = new List<(string EventType, EnrollmentPhase Phase)>();
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
stream.EventEmitted += (eventType, phase, _) => published.Add((eventType, phase));
var sut = new EventTimelineEmitter(r.Inner, stream);
sut.Emit(
@@ -190,8 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
using var r = new Rig();
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
var secondSubscriberCalls = 0;
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
stream.EventEmitted += (_, _, _) => throw new InvalidOperationException("subscriber bug");
stream.EventEmitted += (_, _, _) => secondSubscriberCalls++;
var sut = new EventTimelineEmitter(r.Inner, stream);
sut.Emit(
@@ -50,5 +50,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal(a, b);
}
// The branding URL is tenant config; the dialog parses arguments last-occurrence-wins, so
// an embedded quote must never terminate the argument and inject extra flags (CWE-88).
private const string BaseArgs = "--status-file \"C:\\x\\final-status.json\" --timeout 30 --cleanup";
[Fact]
public void BuildDialogArguments_omits_branding_when_empty()
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, null, null);
Assert.Equal(BaseArgs, args);
}
[Fact]
public void BuildDialogArguments_appends_clean_https_url_as_single_quoted_argument()
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, "https://cdn.example/logo.png?v=2", null);
Assert.Equal(BaseArgs + " --branding-url \"https://cdn.example/logo.png?v=2\"", args);
}
[Theory]
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0")]
[InlineData("https://cdn.example/logo.png\" --cleanup")]
[InlineData("https://cdn.example/logo.png --timeout 0")]
[InlineData("https://cdn.example/lo'go.png")]
[InlineData("https://cdn.example/logo.png\r\n")]
[InlineData("http://cdn.example/logo.png")]
[InlineData("file:///C:/Windows/x.png")]
[InlineData("cdn.example/logo.png")]
public void BuildDialogArguments_drops_unsafe_branding_url(string url)
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, url, null);
Assert.Equal(BaseArgs, args);
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
/// without following a reparse point at its final component, and ask the handle where it
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
/// </summary>
internal static class FileNativeMethods
{
public const uint GENERIC_READ = 0x80000000;
public const uint FILE_LIST_DIRECTORY = 0x0001;
public const uint FILE_READ_ATTRIBUTES = 0x0080;
public const uint FILE_SHARE_READ = 0x1;
public const uint FILE_SHARE_WRITE = 0x2;
public const uint OPEN_EXISTING = 3;
public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
public const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
public const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
public const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
/// <summary>GetFinalPathNameByHandle flags: FILE_NAME_NORMALIZED | VOLUME_NAME_DOS (both zero).</summary>
public const uint FINAL_PATH_NORMALIZED_DOS = 0;
[StructLayout(LayoutKind.Sequential)]
public struct BY_HANDLE_FILE_INFORMATION
{
public uint FileAttributes;
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetFileInformationByHandle(
SafeFileHandle hFile,
out BY_HANDLE_FILE_INFORMATION lpFileInformation);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint GetFinalPathNameByHandleW(
SafeFileHandle hFile,
StringBuilder lpszFilePath,
uint cchFilePath,
uint dwFlags);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint GetLongPathNameW(
string lpszShortPath,
StringBuilder lpszLongPath,
uint cchBuffer);
}
}
@@ -102,6 +102,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// hundreds of MB of fixture data. Production callers leave this at Default.
internal DiagnosticsBudget Budget { get; set; } = DiagnosticsBudget.Default;
// Test seam: invoked with each candidate path right before it is opened, so a test can
// reproduce the enumerate-then-open race (a subdirectory swapped for a junction) at the
// exact point production is exposed. Null in production.
internal Action<string> BeforeSourceFileOpen { get; set; }
// Tracks per-build inclusion totals + skip reasons. Threaded through every
// AddLogFiles call so caps are global across all sections, not per section.
private sealed class BudgetTracker
@@ -562,138 +567,162 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern,
BudgetTracker tracker, bool includeSubfolders = false)
{
if (!Directory.Exists(sourceFolder))
// The folder stays pinned (open handle) for this whole section. The path guards and
// the enumeration below only ever judge path strings; every byte that reaches the
// archive comes from a handle that PinnedSourceFolder verified to point inside the
// validated location at the moment it was opened.
using (var pinned = PinnedSourceFolder.TryOpen(sourceFolder, out var folderRejection))
{
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
return;
}
if (pinned == null)
{
if (folderRejection == PinnedSourceFolder.RejectMissing)
{
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
}
else
{
_logger.Warning($"Diagnostics source folder rejected ({folderRejection}): {sourceFolder}");
ManifestLine($"FOLDER REJECTED ({folderRejection}): {sourceFolder} (pattern '{searchPattern}')");
}
return;
}
List<string> files;
try
{
files = EnumerateFilesNoReparseDirs(sourceFolder, searchPattern, includeSubfolders);
}
catch (Exception ex)
{
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
return;
}
if (files.Count == 0)
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
foreach (var file in files)
{
List<string> files;
try
{
// Per-file reparse check: a top-level file in the folder may itself be a
// symlink/junction even though its parent directory is real.
FileAttributes attrs;
try { attrs = File.GetAttributes(file); }
catch (Exception ex)
{
_logger.Warning($"Failed to read attributes for {file}: {ex.Message}");
continue;
}
if (IsReparsePoint(attrs))
{
_logger.Warning($"Skipping reparse-point file: {file}");
tracker.RecordSkip(file, "reparse", 0);
ManifestLine($"SKIPPED (reparse point): {file}");
continue;
}
long length;
try { length = new FileInfo(file).Length; }
catch (Exception ex)
{
_logger.Warning($"Failed to stat {file}: {ex.Message}");
continue;
}
// Active event-log channels hold their .evtx exclusively locked — a raw
// FileStream open fails with a sharing violation. Export the channel via
// wevtutil instead and stream the export (deleted below). Session a11102f4:
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
string streamSource = file;
string tempExport = null;
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
{
tempExport = TryExportEventLogChannel(file);
if (tempExport != null)
{
streamSource = tempExport;
try { length = new FileInfo(tempExport).Length; }
catch { /* keep original length estimate */ }
}
else
{
// Export failed (channel not resolvable / wevtutil error) — fall through
// to the raw copy. Inactive or archived evtx files are not locked, so
// the plain FileStream often still succeeds; if the channel IS active
// and locked, the existing per-file catch logs the failure.
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
}
}
try
{
if (length > tracker.Budget.MaxSingleFileBytes)
{
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
tracker.RecordSkip(file, "size", length);
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
continue;
}
if (tracker.WouldExceedCount())
{
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
tracker.RecordSkip(file, "count", length);
ManifestLine($"SKIPPED (file-count cap): {file}");
continue;
}
if (tracker.WouldExceedTotal(length))
{
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
tracker.RecordSkip(file, "total", length);
ManifestLine($"SKIPPED (total-bytes cap): {file}");
continue;
}
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var entryStream = entry.Open())
{
fs.CopyTo(entryStream);
}
tracker.RecordIncluded(length);
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
ManifestLine($"ADDED: {entryName} ({length} bytes)");
}
finally
{
if (tempExport != null)
{
try { File.Delete(tempExport); } catch { }
}
}
files = EnumerateFilesNoReparseDirs(pinned.LexicalPath, searchPattern, includeSubfolders);
}
catch (Exception ex)
{
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
return;
}
if (files.Count == 0)
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
foreach (var file in files)
AddLogFile(archive, pinned, file, zipFolder, tracker);
}
}
private void AddLogFile(ZipArchive archive, PinnedSourceFolder pinned, string file, string zipFolder, BudgetTracker tracker)
{
string tempExport = null;
try
{
// Active event-log channels hold their .evtx exclusively locked — a raw
// FileStream open fails with a sharing violation. Export the channel via
// wevtutil instead and stream the export (deleted below). Session a11102f4:
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
{
tempExport = TryExportEventLogChannel(file);
if (tempExport == null)
{
// Export failed (channel not resolvable / wevtutil error) — fall through
// to the raw copy. Inactive or archived evtx files are not locked, so
// the validated open often still succeeds; if the channel IS active
// and locked, the open failure is recorded below.
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
}
}
BeforeSourceFileOpen?.Invoke(file);
using (var source = OpenSource(pinned, file, tempExport, tracker))
{
if (source == null) return;
var length = source.Length;
if (length > tracker.Budget.MaxSingleFileBytes)
{
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
tracker.RecordSkip(file, "size", length);
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
return;
}
if (tracker.WouldExceedCount())
{
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
tracker.RecordSkip(file, "count", length);
ManifestLine($"SKIPPED (file-count cap): {file}");
return;
}
if (tracker.WouldExceedTotal(length))
{
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
tracker.RecordSkip(file, "total", length);
ManifestLine($"SKIPPED (total-bytes cap): {file}");
return;
}
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
var relativePath = file.Substring(pinned.LexicalPath.Length).TrimStart(Path.DirectorySeparatorChar);
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
// Stream straight from the validated handle into the entry — no per-file
// MemoryStream/byte[] copy.
using (var entryStream = entry.Open())
{
source.Stream.CopyTo(entryStream);
}
tracker.RecordIncluded(length);
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
ManifestLine($"ADDED: {entryName} ({length} bytes)");
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
}
finally
{
if (tempExport != null)
{
try { File.Delete(tempExport); } catch { }
}
}
}
// The readable source behind one candidate, or null once the skip has been recorded.
// An event-log export is the agent's own temp file and is streamed as-is; everything
// else must come back from the pinned folder's validated open.
private SourceFile OpenSource(PinnedSourceFolder pinned, string file, string tempExport, BudgetTracker tracker)
{
if (tempExport != null)
return SourceFile.OpenAgentOwned(tempExport);
var source = pinned.TryOpenFile(file, out var rejection);
if (source != null) return source;
switch (rejection)
{
case PinnedSourceFolder.RejectReparsePoint:
_logger.Warning($"Skipping reparse-point file: {file}");
tracker.RecordSkip(file, "reparse", 0);
ManifestLine($"SKIPPED (reparse point): {file}");
break;
case PinnedSourceFolder.RejectResolvedElsewhere:
// The path was inside the folder when it was enumerated; the handle is not.
// A directory in between became a junction or symlink — nothing from behind
// it is copied, and the manifest names only the path we were asked for.
_logger.Warning($"Skipping file that resolves outside its validated folder (reparse point introduced during packaging): {file}");
tracker.RecordSkip(file, "resolved-outside-folder", 0);
ManifestLine($"SKIPPED (resolved outside validated folder): {file}");
break;
default:
_logger.Warning($"Failed to open {file}: {rejection}");
ManifestLine($"FAILED (open): {file}: {rejection}");
break;
}
return null;
}
/// <summary>
@@ -0,0 +1,352 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// A diagnostics source folder held open for one section build, and the only way the
/// package reads bytes out of it.
///
/// The path guards (<see cref="Telemetry.Gather.DiagnosticsPathGuards"/>) and the
/// enumeration's reparse-point skip are check-then-act: they judge a path string, and the
/// bytes are read from that string later. Under any folder a local user can write to, a
/// subdirectory can become a junction between the check and the read, and the copy would
/// follow it into whatever SYSTEM can read. This class puts both decisions on the handle
/// that is actually read:
///
/// <list type="bullet">
/// <item>
/// <see cref="TryOpen"/> opens the folder once. The handle's final path
/// (<c>GetFinalPathNameByHandle</c> — every reparse point resolved) must equal the
/// validated lexical path, otherwise a junction or symlink sits somewhere in the chain
/// and the whole folder is rejected. The handle stays open, without a delete share,
/// until <see cref="Dispose"/>: NTFS refuses to rename or delete a directory while it
/// or anything beneath it is open, so the validated chain cannot be swapped underneath
/// the build either.
/// </item>
/// <item>
/// <see cref="TryOpenFile"/> opens each candidate with <c>FILE_FLAG_OPEN_REPARSE_POINT</c>
/// (a symlink at the final component is opened as itself and refused) and requires the
/// handle's final path to be exactly <c>&lt;canonical folder&gt;\&lt;relative path&gt;</c>.
/// A subdirectory that became a junction after enumeration resolves elsewhere and the
/// file is skipped. The stream handed back wraps that same validated handle.
/// </item>
/// </list>
///
/// Neither fact is re-derived from a path once the handle exists, so there is no window
/// between validation and use. Hard links are out of scope: Windows refuses to create one
/// to a file the caller cannot write.
/// </summary>
internal sealed class PinnedSourceFolder : IDisposable
{
public const string RejectMissing = "folder missing";
public const string RejectNotDirectory = "not a directory";
public const string RejectNotFile = "not a file";
public const string RejectReparsePoint = "reparse point";
public const string RejectResolvedElsewhere = "resolved outside validated folder";
public const string RejectOutsideFolder = "outside pinned folder";
private readonly SafeFileHandle _handle;
/// <summary>Full lexical path the caller validated, without a trailing separator.</summary>
public string LexicalPath { get; }
/// <summary>
/// Where the pinned handle really points; equal to <see cref="LexicalPath"/> up to
/// case and 8.3 expansion, as verified by <see cref="TryOpen"/>.
/// </summary>
public string CanonicalPath { get; }
private PinnedSourceFolder(SafeFileHandle handle, string lexicalPath, string canonicalPath)
{
_handle = handle;
LexicalPath = lexicalPath;
CanonicalPath = canonicalPath;
}
/// <summary>
/// Pins <paramref name="folder"/>. Returns null with a rejection reason (one of the
/// <c>Reject*</c> constants or an open/query error) when the folder is missing, is not
/// a directory, is a reparse point itself, or is reached through one.
/// </summary>
public static PinnedSourceFolder TryOpen(string folder, out string rejection)
{
string lexical;
try
{
lexical = TrimSeparators(Path.GetFullPath(folder));
}
catch (Exception ex)
{
rejection = $"invalid path: {ex.Message}";
return null;
}
if (!Directory.Exists(lexical))
{
rejection = File.Exists(lexical) ? RejectNotDirectory : RejectMissing;
return null;
}
SafeFileHandle handle = null;
try
{
// FILE_LIST_DIRECTORY (a data access) is what makes this handle count for share
// tracking: an attribute-only open would neither block nor be blocked, and a
// rename could slip past the missing delete share.
handle = FileNativeMethods.CreateFileW(
lexical,
FileNativeMethods.FILE_LIST_DIRECTORY | FileNativeMethods.FILE_READ_ATTRIBUTES,
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE,
IntPtr.Zero,
FileNativeMethods.OPEN_EXISTING,
FileNativeMethods.FILE_FLAG_BACKUP_SEMANTICS | FileNativeMethods.FILE_FLAG_OPEN_REPARSE_POINT,
IntPtr.Zero);
if (handle.IsInvalid)
{
rejection = "open failed: " + DescribeLastError();
return null;
}
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info))
{
rejection = "attributes unavailable: " + DescribeLastError();
return null;
}
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_REPARSE_POINT) != 0)
{
rejection = RejectReparsePoint;
return null;
}
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_DIRECTORY) == 0)
{
rejection = RejectNotDirectory;
return null;
}
var canonical = TryGetFinalPath(handle, out var finalPathError);
if (canonical == null)
{
rejection = "final path unavailable: " + finalPathError;
return null;
}
// The lexical path may carry 8.3 components (%TEMP%-style expansions); a final
// path never does. Expand before comparing so a short name is not mistaken for
// a reparse point — a failed expansion keeps the raw string and fails closed.
var expected = TryGetLongPath(lexical) ?? lexical;
if (!PathEquals(canonical, expected))
{
rejection = RejectResolvedElsewhere;
return null;
}
var pinned = new PinnedSourceFolder(handle, lexical, canonical);
handle = null; // owned by the instance from here on
rejection = null;
return pinned;
}
catch (Exception ex)
{
rejection = "open failed: " + ex.Message;
return null;
}
finally
{
handle?.Dispose();
}
}
/// <summary>
/// Opens <paramref name="candidatePath"/> (a lexical descendant of the pinned folder, as
/// produced by enumeration) for reading. Returns null with a rejection reason when the
/// candidate lies outside the folder, is a reparse point, is not a file, or — the race
/// this class exists for — its handle resolves anywhere other than
/// <c>&lt;canonical folder&gt;\&lt;relative path&gt;</c>.
/// </summary>
public SourceFile TryOpenFile(string candidatePath, out string rejection)
{
if (!TryGetRelativePath(candidatePath, out var relative))
{
rejection = RejectOutsideFolder;
return null;
}
var expected = CanonicalPath + Path.DirectorySeparatorChar + relative;
SafeFileHandle handle = null;
try
{
handle = FileNativeMethods.CreateFileW(
candidatePath,
FileNativeMethods.GENERIC_READ,
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE,
IntPtr.Zero,
FileNativeMethods.OPEN_EXISTING,
FileNativeMethods.FILE_FLAG_OPEN_REPARSE_POINT | FileNativeMethods.FILE_FLAG_SEQUENTIAL_SCAN,
IntPtr.Zero);
if (handle.IsInvalid)
{
rejection = "open failed: " + DescribeLastError();
return null;
}
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info))
{
rejection = "attributes unavailable: " + DescribeLastError();
return null;
}
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_REPARSE_POINT) != 0)
{
rejection = RejectReparsePoint;
return null;
}
if ((info.FileAttributes & FileNativeMethods.FILE_ATTRIBUTE_DIRECTORY) != 0)
{
rejection = RejectNotFile;
return null;
}
var finalPath = TryGetFinalPath(handle, out var finalPathError);
if (finalPath == null)
{
rejection = "final path unavailable: " + finalPathError;
return null;
}
if (!PathEquals(finalPath, expected))
{
rejection = RejectResolvedElsewhere;
return null;
}
var length = ((long)info.FileSizeHigh << 32) | info.FileSizeLow;
var stream = new FileStream(handle, FileAccess.Read, 81920);
handle = null; // owned by the stream from here on
rejection = null;
return new SourceFile(stream, length);
}
catch (Exception ex)
{
rejection = "open failed: " + ex.Message;
return null;
}
finally
{
handle?.Dispose();
}
}
public void Dispose() => _handle.Dispose();
private bool TryGetRelativePath(string candidatePath, out string relative)
{
relative = null;
string full;
try
{
full = Path.GetFullPath(candidatePath);
}
catch
{
return false;
}
if (full.Length <= LexicalPath.Length + 1) return false;
if (!full.StartsWith(LexicalPath, StringComparison.OrdinalIgnoreCase)) return false;
if (full[LexicalPath.Length] != Path.DirectorySeparatorChar) return false;
relative = full.Substring(LexicalPath.Length + 1);
return relative.Length > 0 && relative[relative.Length - 1] != Path.DirectorySeparatorChar;
}
/// <summary>Final (reparse-free, long-name) DOS path of an open handle, or null with the Win32 error.</summary>
private static string TryGetFinalPath(SafeFileHandle handle, out string error)
{
var buffer = new StringBuilder(512);
for (var attempt = 0; attempt < 2; attempt++)
{
var length = FileNativeMethods.GetFinalPathNameByHandleW(
handle, buffer, (uint)buffer.Capacity, FileNativeMethods.FINAL_PATH_NORMALIZED_DOS);
if (length == 0)
{
error = DescribeLastError();
return null;
}
if (length < buffer.Capacity)
{
error = null;
return StripExtendedPrefix(buffer.ToString());
}
buffer.EnsureCapacity((int)length + 1);
}
error = "path longer than the retry buffer";
return null;
}
/// <summary>Long-name form of a path (8.3 components expanded), or null when Windows cannot resolve it.</summary>
internal static string TryGetLongPath(string path)
{
var buffer = new StringBuilder(512);
for (var attempt = 0; attempt < 2; attempt++)
{
var length = FileNativeMethods.GetLongPathNameW(path, buffer, (uint)buffer.Capacity);
if (length == 0) return null;
if (length < buffer.Capacity) return buffer.ToString();
buffer.EnsureCapacity((int)length + 1);
}
return null;
}
private static string StripExtendedPrefix(string path)
{
if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) return @"\\" + path.Substring(8);
if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) return path.Substring(4);
return path;
}
private static string TrimSeparators(string path) =>
path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
private static bool PathEquals(string a, string b) =>
string.Equals(TrimSeparators(a), TrimSeparators(b), StringComparison.OrdinalIgnoreCase);
private static string DescribeLastError()
{
var code = Marshal.GetLastWin32Error();
return $"{new Win32Exception(code).Message} (win32 {code})";
}
}
/// <summary>
/// A readable source for one archive entry: the stream and the length the caps are charged
/// with. From <see cref="PinnedSourceFolder.TryOpenFile"/> the stream is the validated
/// handle itself.
/// </summary>
internal sealed class SourceFile : IDisposable
{
public Stream Stream { get; }
public long Length { get; }
public SourceFile(Stream stream, long length)
{
Stream = stream;
Length = length;
}
/// <summary>
/// A file the agent wrote itself — an event-log export under an unguessable name in
/// SYSTEM's temp folder — never a tenant-configured location, so a plain open is enough.
/// </summary>
public static SourceFile OpenAgentOwned(string path)
{
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return new SourceFile(stream, stream.Length);
}
public void Dispose() => Stream.Dispose();
}
}
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
Timestamp = DateTime.UtcNow,
EventType = Constants.EventTypes.SecurityWarning,
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Source = GatherRuleExecutor.SourceName,
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
Data = data
});
@@ -291,7 +291,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
Timestamp = entryUtc,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
@@ -76,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
Timestamp = DateTime.UtcNow,
EventType = Constants.EventTypes.SecurityWarning,
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Source = GatherRuleExecutor.SourceName,
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
Data = data
});
@@ -21,6 +21,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
/// </summary>
public class GatherRuleExecutor : IDisposable
{
/// <summary>
/// <see cref="EnrollmentEvent.Source"/> label stamped on every event the gather pipeline
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
/// gather output from re-triggering on_event rules.
/// </summary>
public const string SourceName = "GatherRuleExecutor";
/// <summary>
/// Upper bound on on_event executions per rule and session. on_event has no natural
/// once-per-(rule, phase) dedup like the phase triggers; this cap bounds any remaining
/// cycle (e.g. two rules chained through an intermediate non-gather event) and any
/// pathological trigger type that fires on every timeline entry.
/// </summary>
internal const int MaxOnEventExecutionsPerRule = 100;
private readonly AgentLogger _logger;
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
private readonly GatherRuleContext _context;
@@ -30,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
private readonly Dictionary<string, int> _onEventExecutions = new Dictionary<string, int>();
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
@@ -394,6 +410,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
continue;
}
// Per-rule execution cap (see MaxOnEventExecutionsPerRule). The counter is
// bumped once past the cap so the boundary is logged exactly once.
int executions;
lock (_scopeLock)
{
_onEventExecutions.TryGetValue(rule.RuleId, out executions);
if (executions <= MaxOnEventExecutionsPerRule)
_onEventExecutions[rule.RuleId] = executions + 1;
}
if (executions >= MaxOnEventExecutionsPerRule)
{
if (executions == MaxOnEventExecutionsPerRule)
{
_logger.Warning($"Gather rule {rule.RuleId} reached the on_event execution cap ({MaxOnEventExecutionsPerRule}) for this session — further {eventType} triggers are ignored");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
$"on_event {eventType} ignored: per-rule execution cap ({MaxOnEventExecutionsPerRule}) reached for this session");
}
continue;
}
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
@@ -482,7 +519,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Source = SourceName,
Message = $"Gather: {rule.Title}",
Data = result
});
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
// then phase/event triggers degrade off (startup + interval rules still run).
private readonly TimelineEventStream? _timelineEvents;
private Action<string, EnrollmentPhase>? _timelineHandler;
private Action<string, EnrollmentPhase, string>? _timelineHandler;
private readonly object _sync = new object();
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
@@ -101,8 +101,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
/// effect path; it also dedups phase rules per (rule, phase).
/// <para>
/// Feedback-loop guard: events the gather executor emitted itself (Source ==
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName"/>) travel the
/// same post → ingress → reducer → emitter → stream path and would otherwise re-enter
/// <c>OnEvent</c>. A rule whose TriggerEventType equals its own OutputEventType (or two
/// rules chained A→B→A) then executes and emits without bound for the whole session.
/// Gather output therefore never dispatches on_event rules; phase declarations are still
/// honoured (gather events carry Phase=Unknown, so this is a no-op in practice).
/// </para>
/// </summary>
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase, string source)
{
try
{
@@ -114,7 +123,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_executor.OnPhaseChanged(phase);
}
if (!string.IsNullOrEmpty(eventType))
if (!string.IsNullOrEmpty(eventType)
&& !string.Equals(source, Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName, StringComparison.Ordinal))
{
_executor.OnEvent(eventType);
}
@@ -33,18 +33,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
/// for the vast majority of events — only phase-declaration events carry a real phase,
/// which is exactly the set the UI timeline groups on).
/// which is exactly the set the UI timeline groups on), plus the originating
/// <see cref="EnrollmentEvent.Source"/> component label. The source lets subscribers that
/// themselves emit timeline events (the gather-rule executor) recognise their own output
/// and not re-trigger on it — see <see cref="GatherRuleExecutorHost"/>.
/// </summary>
public event Action<string, EnrollmentPhase>? EventEmitted;
public event Action<string, EnrollmentPhase, string>? EventEmitted;
internal void Publish(string eventType, EnrollmentPhase phase)
internal void Publish(string eventType, EnrollmentPhase phase, string source)
{
var handler = EventEmitted;
if (handler == null) return;
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
foreach (Action<string, EnrollmentPhase, string> subscriber in handler.GetInvocationList())
{
try { subscriber(eventType, phase); }
try { subscriber(eventType, phase, source); }
catch
{
// Subscriber isolation — see class doc: the emit path must never throw.
@@ -148,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
// Publish never throws (subscriber isolation inside the stream).
_timelineEvents?.Publish(evt.EventType, evt.Phase);
_timelineEvents?.Publish(evt.EventType, evt.Phase, evt.Source);
}
/// <summary>
@@ -132,10 +132,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
// 4) Compose args. Legacy contract: --status-file <path> --timeout <s> --cleanup [--branding-url <url>].
var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName);
var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl))
args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\"";
var args = BuildDialogArguments(
Path.Combine(tempDir, FinalStatusFileName),
configuration.EnrollmentSummaryTimeoutSeconds,
configuration.EnrollmentSummaryBrandingImageUrl,
logger);
// 5) Launch in user session with retry.
var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0
@@ -151,6 +152,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
return launched;
}
/// <summary>
/// Composes the dialog command line. The branding URL is tenant-supplied config and the
/// dialog parses arguments last-occurrence-wins, so a value carrying a double quote could
/// terminate its argument and override <c>--status-file</c>/<c>--timeout</c>/<c>--cleanup</c>
/// (CWE-88). The backend rejects such values, but the agent is the last line of defence:
/// only a plain absolute HTTPS URL without quotes, whitespace or control characters is
/// ever placed on the command line — anything else is dropped (the dialog then simply
/// shows no branding image).
/// </summary>
internal static string BuildDialogArguments(string statusFilePath, int timeoutSeconds, string brandingImageUrl, AgentLogger logger)
{
var args = $"--status-file \"{statusFilePath}\" --timeout {timeoutSeconds} --cleanup";
if (string.IsNullOrWhiteSpace(brandingImageUrl))
return args;
if (IsSafeBrandingImageUrl(brandingImageUrl))
return args + $" --branding-url \"{brandingImageUrl}\"";
logger?.Warning("SummaryDialogLauncher: EnrollmentSummaryBrandingImageUrl is not a plain absolute HTTPS URL — ignoring it.");
return args;
}
internal static bool IsSafeBrandingImageUrl(string url)
{
if (string.IsNullOrEmpty(url) || url.Length > 2048)
return false;
foreach (var ch in url)
{
if (ch == '"' || ch == '\'' || char.IsWhiteSpace(ch) || char.IsControl(ch))
return false;
}
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& uri.Scheme == Uri.UriSchemeHttps
&& !string.IsNullOrEmpty(uri.Host);
}
private static void TryCopy(string source, string destination)
{
try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); }
@@ -123,6 +123,22 @@ public class AuthFunctionSideEffectTests
// HandleNewTenantDomainAsync
// -------------------------------------------------------------------------
[Fact]
public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed()
{
var config = DefaultConfig();
config.DomainName = null!;
config.OnboardedBy = null;
// The seed is rendered into transactional mails: anything but a strict host name is refused.
await _sut.HandleNewTenantDomainAsync(config, TenantId, "user@<b>contoso.com</b>");
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task HandleNewTenantDomain_WhenDomainEmpty_ExtractsAndSaves()
{
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Security;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Request-derived blob names must be flat. Azure.Storage.Blobs keeps '/' in the blob URI and
/// System.Uri collapses '..' segments, so "x/../../other-container/y" would resolve outside the
/// intended container. The guard is the last line at every GetBlobClient sink.
/// </summary>
public class BlobNameGuardTests
{
[Theory]
[InlineData("11111111-1111-1111-1111-111111111111_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_diag_request_20260829_120000.zip")]
[InlineData("report.zip")]
[InlineData("a.b.c.zip")]
public void IsFlat_AcceptsFlatNames(string name)
{
Assert.True(BlobNameGuard.IsFlat(name));
Assert.Equal(name, BlobNameGuard.EnsureFlat(name, "name"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("TA_x/../../hosted-diagnostics/victim/payload_diag_request_1.zip")]
[InlineData("a/b.zip")]
[InlineData("a\\b.zip")]
[InlineData("..zip")]
[InlineData("a%2F..%2Fb.zip")]
[InlineData("a?b.zip")]
[InlineData("a#b.zip")]
public void IsFlat_RejectsPathShapedNames(string? name)
{
Assert.False(BlobNameGuard.IsFlat(name));
Assert.Throws<ArgumentException>(() => BlobNameGuard.EnsureFlat(name, "name"));
}
}
@@ -0,0 +1,68 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests
{
/// <summary>
/// The enrollment-summary branding image URL is delivered verbatim to every enrolling device,
/// fetched from the signed-in user's session and spliced into the summary-dialog command line.
/// The server must therefore only ever persist a plain absolute HTTPS URL — a value with an
/// embedded quote or a non-HTTPS scheme must fail the config PUT/PATCH.
/// </summary>
public class BrandingImageUrlValidationTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("https://cdn.example/logo.png")]
[InlineData("https://cdn.example:8443/assets/logo.png?v=2&x=y")]
[InlineData("https://cdn.example/logo%20one.png")]
public void ValidateBrandingImageUrl_WellFormed_ReturnsNull(string? url)
{
Assert.Null(TenantConfigValidation.ValidateBrandingImageUrl(url));
}
[Theory]
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0", "quotes")]
[InlineData("https://cdn.example/logo.png\"", "quotes")]
[InlineData("https://cdn.example/lo'go.png", "quotes")]
[InlineData("https://cdn.example/logo.png --cleanup", "whitespace")]
[InlineData("https://cdn.example/logo.png\r\n", "whitespace")]
[InlineData("http://cdn.example/logo.png", "HTTPS")]
[InlineData("file:///C:/Windows/logo.png", "HTTPS")]
[InlineData("ftp://cdn.example/logo.png", "HTTPS")]
[InlineData("cdn.example/logo.png", "absolute URL")]
[InlineData("https://localhost/logo.png", "localhost")]
[InlineData("https://10.0.0.1/logo.png", "IP address")]
public void ValidateBrandingImageUrl_Malformed_Rejected(string url, string expectedFragment)
{
var error = TenantConfigValidation.ValidateBrandingImageUrl(url);
Assert.NotNull(error);
Assert.Contains(expectedFragment, error);
}
[Fact]
public void ValidateBrandingImageUrl_Overlong_Rejected()
{
var url = "https://cdn.example/" + new string('a', TenantConfigValidation.MaxBrandingImageUrlLength);
Assert.Contains("characters", TenantConfigValidation.ValidateBrandingImageUrl(url));
}
[Fact]
public void ValidateModel_RejectsInjectedBrandingUrl()
{
var existing = new TenantConfiguration();
var candidate = new TenantConfiguration
{
EnrollmentSummaryBrandingImageUrl = "x\" --status-file \"C:\\some\\file\" --timeout \"0",
};
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true);
Assert.NotNull(error);
Assert.StartsWith("Invalid enrollment summary branding image URL", error);
}
}
}
@@ -247,6 +247,18 @@ public sealed class EmailServiceTests
Assert.Contains("linkedin.com", html);
}
[Fact]
public void BuiltInTemplates_HtmlEncodeDomain()
{
const string hostile = "<a href=\"https://evil.invalid\">x</a>";
foreach (var html in new[] { EmailTemplates.GetOffboardingFarewellHtml(hostile), EmailTemplates.GetPreviewApprovedHtml(hostile) })
{
Assert.DoesNotContain(hostile, html);
Assert.Contains("&lt;a href=&quot;https://evil.invalid&quot;&gt;x&lt;/a&gt;", html);
}
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
@@ -29,6 +29,30 @@ public sealed class EmailTemplateServiceTests
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
}
[Theory]
[InlineData("<b>x</b>", "&lt;b&gt;x&lt;/b&gt;")]
[InlineData("a\"b&c", "a&quot;b&amp;c")]
public void Render_HtmlEncodesDomain_ForOverrideTemplates(string domain, string expected)
{
var html = EmailTemplateService.Render("<p>{{domainName}}</p>", domain);
Assert.Equal($"<p>{expected}</p>", html);
}
[Theory]
[InlineData(EmailTemplateKind.Welcome)]
[InlineData(EmailTemplateKind.Farewell)]
public async Task BuiltIn_HtmlEncodesDomain_NoDoubleEncoding(EmailTemplateKind kind)
{
var (sut, _) = Build();
var html = await sut.GetHtmlAsync(kind, "<img src=x onerror=alert(1)>&\"");
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;img src=x onerror=alert(1)&gt;&amp;&quot;", html);
Assert.DoesNotContain("&amp;lt;", html);
}
[Fact]
public async Task Override_WinsAndRendersPlaceholder()
{
@@ -240,6 +240,45 @@ public class GatherRuleScopeFieldsTests
ValidationRule(activePhases: new List<string> { "" })));
}
private static GatherRule OnEventRule(string triggerEventType, string? outputEventType)
=> new GatherRule
{
RuleId = "GATHER-VAL-EVT",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\Contoso",
Trigger = "on_event",
TriggerEventType = triggerEventType,
OutputEventType = outputEventType!,
};
[Theory]
[InlineData("gather_proxy", "gather_proxy")] // self-cycle
[InlineData("GATHER_PROXY", "gather_proxy")] // case-insensitive (agent matches OrdinalIgnoreCase)
[InlineData("gather_result", null)] // self-cycle through the default output type
[InlineData("gather_result", "")]
[InlineData("gather_result", "gather_other")] // gather output namespace never triggers
public void Validate_RejectsOnEventRuleTriggeringOnGatherOutput(string trigger, string? output)
{
Assert.NotNull(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
}
[Theory]
[InlineData("enrollment_complete", "gather_proxy")]
[InlineData("app_install_failed", null)]
[InlineData("", "gather_proxy")] // partial/toggle payload passes through
public void Validate_AcceptsOnEventRuleOnNonGatherEvents(string trigger, string? output)
{
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
}
[Fact]
public void Validate_OutputEqualsTrigger_IsOnlyCheckedForOnEventTrigger()
{
var rule = OnEventRule("gather_same", "gather_same");
rule.Trigger = "startup";
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(rule));
}
[Fact]
public void Validate_RejectsBothScopeFieldsSet()
{
@@ -0,0 +1,102 @@
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests
{
/// <summary>
/// The hardware whitelist is stored as one CSV and split at enforcement time. A value that
/// carries the delimiter (e.g. an attacker-chosen distress-signal manufacturer "Dell Inc.,*")
/// must never be able to widen the gate: the server rejects malformed lists and the parser
/// yields exactly the trimmed, non-empty items.
/// </summary>
public class HardwareWhitelistCsvIntegrityTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("*")]
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation")]
[InlineData("Dell Inc.?*")]
[InlineData(" Dell* , HP* ")]
public void ValidateHardwareWhitelist_WellFormed_ReturnsNull(string? csv)
{
Assert.Null(TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Theory]
[InlineData("Dell Inc.,")]
[InlineData(",Dell*")]
[InlineData("Dell*,,HP*")]
[InlineData("Dell*, ,HP*")]
[InlineData(",")]
public void ValidateHardwareWhitelist_BlankItem_Rejected(string csv)
{
var error = TenantConfigValidation.ValidateHardwareWhitelist(csv);
Assert.NotNull(error);
Assert.Contains("must not be empty", error);
}
[Fact]
public void ValidateHardwareWhitelist_ControlCharacter_Rejected()
{
var csv = "Dell*" + (char)1 + ",HP*";
Assert.Contains("control characters", TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Fact]
public void ValidateHardwareWhitelist_OverlongEntry_Rejected()
{
Assert.Contains("128 characters",
TenantConfigValidation.ValidateHardwareWhitelist(new string('a', 129)));
}
[Fact]
public void ValidateHardwareWhitelist_TooManyEntries_Rejected()
{
var csv = string.Join(",", Enumerable.Repeat("x", 201));
Assert.Contains("200 entries", TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Fact]
public void ValidateModel_RejectsMalformedWhitelists_WithFieldSpecificMessage()
{
var existing = TenantConfiguration.CreateDefault("tenant");
var badMfr = TenantConfiguration.CreateDefault("tenant");
badMfr.ManufacturerWhitelist = "Dell*,Dell Inc.,";
Assert.StartsWith("Invalid manufacturer whitelist:",
TenantConfigValidation.ValidateModel(badMfr, existing, isGlobalAdmin: false));
var badModel = TenantConfiguration.CreateDefault("tenant");
badModel.ModelWhitelist = "Latitude*,,";
Assert.StartsWith("Invalid model whitelist:",
TenantConfigValidation.ValidateModel(badModel, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(
TenantConfiguration.CreateDefault("tenant"), existing, isGlobalAdmin: false));
}
[Fact]
public void ParseWhitelist_TrimsEntries_SoEditorViewMatchesEnforcement()
{
// The web editor displays trimmed items; enforcement must match the same patterns.
Assert.Equal(new[] { "Dell*", "HP*" }, TenantConfiguration.ParseWhitelist("Dell*, HP*"));
Assert.Equal(new[] { "*" }, TenantConfiguration.ParseWhitelist(" "));
}
[Fact]
public void NeutralizedDistressValue_MatchesOnlyItself_NotEveryManufacturer()
{
// "Dell Inc.,*" neutralized by the web helper to the single pattern "Dell Inc.?*".
var whitelist = TenantConfiguration.ParseWhitelist("Dell*,Dell Inc.?*");
Assert.DoesNotContain("*", whitelist);
Assert.False(HardwareWhitelistValidator.ValidateHardware("Contoso", "X1", whitelist, new[] { "*" }).IsValid);
Assert.True(HardwareWhitelistValidator.ValidateHardware("Dell Inc.,*", "X1", whitelist, new[] { "*" }).IsValid);
}
}
}
@@ -26,7 +26,9 @@ public class HostedDiagnosticsBlobServiceTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string SessionB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
private const string Filename = "AgentDiagnostics-" + SessionA + "-20260519T120000.zip";
// ── Path build + validation ────────────────────────────────────────────────────
@@ -89,7 +91,15 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync("not-a-guid", Filename));
() => svc.GenerateUploadSasAsync("not-a-guid", SessionA, Filename));
}
[Fact]
public async Task GenerateUploadSas_RejectsNonGuidSessionId()
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, "not-a-guid", Filename));
}
[Fact]
@@ -97,14 +107,64 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, "../other-tenant/escape.zip"));
() => svc.GenerateUploadSasAsync(TenantA, SessionA, "../other-tenant/escape.zip"));
}
// ── Session binding — one device must not be able to name another session's package ──
[Theory]
[InlineData("AgentDiagnostics-" + SessionB + "-20260519T120000.zip")] // foreign session
[InlineData("AgentDiagnostics-" + SessionA + "20260519T120000.zip")] // missing separator
[InlineData("AgentDiagnostics-" + SessionA + "-")] // nothing after the session
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.txt")] // not a zip
[InlineData("x-AgentDiagnostics-" + SessionA + "-20260519T120000.zip")] // wrong prefix
[InlineData("other.zip")]
public async Task GenerateUploadSas_RejectsFilenameNotBoundToSession(string filename)
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, SessionA, filename));
Assert.Empty(svc.CapturedSasBuilders);
}
[Theory]
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.zip")]
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000-preprov.zip")]
[InlineData("AgentDiagnostics-" + SessionA + "-20260724170902-server-requested.zip")]
[InlineData("agentdiagnostics-" + SessionA + "-20260519T120000.ZIP")] // case-insensitive
public async Task GenerateUploadSas_AcceptsEveryAgentProducedShapeForOwnSession(string filename)
{
var svc = new FakeHostedDiagnosticsBlobService();
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, filename);
Assert.Equal($"{TenantA}/{filename}", result.BlobPath);
}
// ── Create-only — an existing blob is never re-issued ─────────────────────────
[Fact]
public async Task GenerateUploadSas_ExistingBlob_ThrowsAlreadyExists_AndMintsNothing()
{
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantA}/{Filename}" };
var ex = await Assert.ThrowsAsync<HostedBlobAlreadyExistsException>(
() => svc.GenerateUploadSasAsync(TenantA, SessionA, Filename));
Assert.Equal($"{TenantA}/{Filename}", ex.BlobPath);
Assert.Empty(svc.CapturedSasBuilders);
}
[Fact]
public async Task GenerateUploadSas_ExistingBlobInOtherTenant_DoesNotBlockSameName()
{
// Existence is checked on the tenant-prefixed path, not on the bare filename.
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantB}/{Filename}" };
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
}
[Fact]
public async Task GenerateUploadSas_ReturnsTenantPrefixedBlobPath()
{
var svc = new FakeHostedDiagnosticsBlobService();
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
}
@@ -112,9 +172,9 @@ public class HostedDiagnosticsBlobServiceTests
public async Task GenerateUploadSas_EnsuresContainerOnce()
{
var svc = new FakeHostedDiagnosticsBlobService();
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantB, "other.zip");
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
Assert.Equal(1, svc.EnsureContainerCallCount);
}
@@ -124,7 +184,7 @@ public class HostedDiagnosticsBlobServiceTests
var svc = new FakeHostedDiagnosticsBlobService();
var requested = TimeSpan.FromHours(24);
var before = DateTime.UtcNow;
var result = await svc.GenerateUploadSasAsync(TenantA, Filename, requested);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename, requested);
var maxAllowed = before.Add(HostedDiagnosticsBlobService.MaxUploadSasTtl).AddMinutes(1); // tolerance
Assert.True(result.ExpiresAt <= maxAllowed,
$"ExpiresAt {result.ExpiresAt:O} exceeded MaxUploadSasTtl-bounded {maxAllowed:O}");
@@ -135,16 +195,16 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
var before = DateTime.UtcNow;
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
var delta = result.ExpiresAt - before;
Assert.InRange(delta.TotalMinutes, 14, 16);
}
[Fact]
public async Task GenerateUploadSas_PassesBlobScopedResourceAndWriteCreateOnlyPermsToBuilder()
public async Task GenerateUploadSas_PassesBlobScopedResourceAndCreateOnlyPermsToBuilder()
{
var svc = new FakeHostedDiagnosticsBlobService();
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Single(svc.CapturedSasBuilders);
var builder = svc.CapturedSasBuilders.Single();
@@ -152,24 +212,26 @@ public class HostedDiagnosticsBlobServiceTests
Assert.Equal($"{TenantA}/{Filename}", builder.BlobName);
Assert.Equal("diagnostics", builder.BlobContainerName);
// Reconstruct the permissions string to ensure ONLY Write + Create are set —
// ONLY Create — no Write (would allow replacing an existing same-tenant blob),
// no Read (would expose other-tenant blobs in cross-tenant scenarios),
// no Delete (cleanup is service-side via DeleteIfExistsAsync),
// no List (agent has no enumeration need).
var permsString = builder.Permissions ?? string.Empty;
Assert.Contains("w", permsString);
Assert.Contains("c", permsString);
Assert.DoesNotContain("r", permsString);
Assert.DoesNotContain("d", permsString);
Assert.DoesNotContain("l", permsString);
Assert.Equal("c", builder.Permissions);
}
[Fact]
public void UploadSasPermissions_IsCreateOnly()
{
// Pinned: Write must never come back — it is what turns a create into an overwrite.
Assert.Equal(BlobSasPermissions.Create, HostedDiagnosticsBlobService.UploadSasPermissions);
}
[Fact]
public async Task GenerateUploadSas_DifferentTenants_GetDifferentBlobPaths()
{
var svc = new FakeHostedDiagnosticsBlobService();
var a = await svc.GenerateUploadSasAsync(TenantA, Filename);
var b = await svc.GenerateUploadSasAsync(TenantB, Filename);
var a = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
var b = await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
Assert.NotEqual(a.BlobPath, b.BlobPath);
Assert.StartsWith(TenantA + "/", a.BlobPath);
Assert.StartsWith(TenantB + "/", b.BlobPath);
@@ -177,8 +239,6 @@ public class HostedDiagnosticsBlobServiceTests
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Theory]
[InlineData("not-a-guid", SessionA)]
[InlineData(TenantA, "not-a-guid")]
@@ -310,6 +370,9 @@ public class HostedDiagnosticsBlobServiceTests
public int EnsureContainerCallCount { get; private set; }
public List<BlobSasBuilder> CapturedSasBuilders { get; } = new();
/// <summary>Blob path that the fake reports as already existing (null = nothing exists).</summary>
public string? ExistingBlobPath { get; set; }
public FakeHostedDiagnosticsBlobService()
: base(
new BlobServiceClient("UseDevelopmentStorage=true"),
@@ -324,6 +387,9 @@ public class HostedDiagnosticsBlobServiceTests
return Task.CompletedTask;
}
protected override Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
=> Task.FromResult(ExistingBlobPath == blobPath);
protected override Task<Uri> BuildUploadSasUriAsync(
string blobPath, DateTimeOffset expiresOn, CancellationToken cancellationToken)
{
@@ -338,7 +404,7 @@ public class HostedDiagnosticsBlobServiceTests
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresOn = expiresOn,
};
builder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
builder.SetPermissions(HostedDiagnosticsBlobService.UploadSasPermissions);
CapturedSasBuilders.Add(builder);
return Task.FromResult(new Uri($"https://fake.blob.core.windows.net/diagnostics/{blobPath}?fake=sas"));
@@ -1308,4 +1308,119 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
Assert.Equal(string.Empty, result.Context!.CallerId);
}
// ── Tenant suspension gate (TenantConfiguration.Disabled / DisabledUntil) ──────────
// The operator kill-switch must revoke API access on the shared authorization path, not only
// on the SPA's auth/me probe — a direct REST caller with a valid token must get 403 too.
private static void AsSuspended(Harness h, string tenantId, DateTime? until = null, string? reason = null) =>
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = tenantId,
Disabled = true,
DisabledUntil = until,
DisabledReason = reason,
});
[Fact]
public async Task Suspended_HomeTenant_ReadRoute_IsForbidden_WithTenantSuspendedCode()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddDays(30), reason: "Abuse investigation");
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.Equal("TenantSuspended", result.ErrorCode);
Assert.Equal("Abuse investigation", result.ErrorMessage);
}
[Fact]
public async Task Suspended_HomeTenant_WriteRoute_IsForbidden()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_ExpiredDisabledUntil_IsAllowed()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddMinutes(-1)); // suspension window elapsed
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
}
[Fact]
public async Task Suspended_GlobalAdmin_BypassesGate_ToAdministerTenant()
{
const string upn = "ga@platform.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
AsSuspended(h, TenantA);
AsSuspended(h, TenantB);
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.True(own.Allowed);
Assert.True(cross.Allowed);
}
[Fact]
public async Task Suspended_DelegatedReader_TargetTenantSuspended_IsForbidden()
{
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
AsSuspended(h, TenantB); // home tenant A is fine, managed target B is suspended
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_DelegatedReader_HomeTenantSuspended_LosesDelegatedReach()
{
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
AsSuspended(h, TenantA); // the MSP's own home tenant is suspended
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_DeviceRoute_NoPrincipal_GateNotApplied()
{
// Anonymous/device routes carry no principal — SecurityValidator owns their tenant gate. The
// middleware must not touch tenant config here (no tenant to key on).
var h = BuildHarness();
AsSuspended(h, TenantA);
var result = await h.Middleware.DecideAsync("GET", "/api/health", null, null);
Assert.True(result.Allowed);
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
}
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// PUT /api/preview/notification-email writes tenant-shared state that feeds the welcome and
/// farewell mails. The route must admit roleless callers (first-touch signup) but only while the
/// tenant has no member yet — afterwards a member role is required, so an ordinary employee with a
/// valid JWT and no product role can no longer redirect or suppress the tenant's correspondence.
/// </summary>
public class PreviewNotificationEmailWriteGateTests
{
private const string TenantId = "11111111-2222-3333-4444-555555555555";
[Fact]
public void Route_is_AuthenticatedUserWithRole_so_the_function_can_see_the_resolved_role()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/preview/notification-email");
Assert.NotNull(entry);
Assert.Equal("preview/notification-email", entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry.Policy);
}
[Theory]
[InlineData(Constants.TenantRoles.Admin)]
[InlineData(Constants.TenantRoles.Operator)]
[InlineData(Constants.TenantRoles.Viewer)]
public async Task Member_role_may_write_even_when_the_tenant_has_members(string role)
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = role };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
admins.Verify(a => a.GetTenantAdminsAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Global_admin_may_write()
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { IsGlobalAdmin = true };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Roleless_caller_may_write_while_the_tenant_has_no_member_yet()
{
var admins = AdminsWith(enabledMember: false);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Roleless_caller_is_rejected_once_the_tenant_has_an_enabled_member()
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
[Fact]
public async Task Disabled_members_do_not_close_the_signup_window()
{
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
{
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
});
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
}
private static Mock<TenantAdminsService> AdminsWith(bool enabledMember)
{
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
var members = new List<TenantAdminEntity>();
if (enabledMember)
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
return admins;
}
}
@@ -55,6 +55,23 @@ public class SessionReportDiagnosticsArchiveCopierTests
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Theory]
[InlineData("x/../../hosted-diagnostics/22222222-2222-2222-2222-222222222222/payload_diag_archive_1.zip")]
[InlineData("sub/dir_diag_archive_1.zip")]
[InlineData("..\\escape_diag_archive_1.zip")]
public async Task CopyAsync_NonFlatDestination_Rejected_WithoutReadOrWrite(string destination)
{
// A path-shaped destination would let the SDK + System.Uri resolve the write outside
// the session-reports container. Reject before touching any storage.
var copier = new RecordingCopier();
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, destination);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
{
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -386,4 +388,40 @@ public class TenantContactEmailTests
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
}
// -------------------------------------------------------------------------
// IsValidDomainName — the only shape DomainName may be persisted in
// -------------------------------------------------------------------------
[Theory]
[InlineData("contoso.com")]
[InlineData("sub.domain.org")]
[InlineData("x-y.example")]
[InlineData("A1.B2.c3")]
public void IsValidDomainName_AcceptsHostNames(string domain)
=> Assert.True(TenantConfigValidation.IsValidDomainName(domain));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("localhost")]
[InlineData(".contoso.com")]
[InlineData("contoso.com.")]
[InlineData("con..toso.com")]
[InlineData("-contoso.com")]
[InlineData("contoso-.com")]
[InlineData("con toso.com")]
[InlineData("<b>contoso.com</b>")]
[InlineData("contoso.com\"onerror=\"x")]
[InlineData("contoso.com/path")]
[InlineData("user@contoso.com")]
public void IsValidDomainName_RejectsNonHostNames(string? domain)
=> Assert.False(TenantConfigValidation.IsValidDomainName(domain));
[Fact]
public void IsValidDomainName_RejectsOverlongNamesAndLabels()
{
Assert.False(TenantConfigValidation.IsValidDomainName(new string('a', 64) + ".com"));
Assert.False(TenantConfigValidation.IsValidDomainName(string.Join(".", Enumerable.Repeat(new string('a', 60), 5))));
}
}
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins that the caller-influenced tenantId interpolated into the metrics-summary (SessionsIndex)
/// and device-snapshot search (DeviceSnapshot) OData filters is escaped, so a quote in the value
/// cannot rewrite the filter grammar (e.g. <c>x' or PartitionKey ne '</c> dissolving the
/// single-tenant scope and the RowKey date-window bound).
/// </summary>
public class TenantFilterEscapingTests
{
private const string Injection = "x' or PartitionKey ne '";
[Fact]
public void MetricsSummaryFilter_EscapesQuotesInTenantId()
{
var filter = TableStorageService.BuildMetricsSummaryFilter(Injection, "cutoff");
Assert.Equal("RowKey lt 'cutoff' and PartitionKey eq 'x'' or PartitionKey ne '''", filter);
Assert.DoesNotContain(" or ", filter.Replace("'x'' or PartitionKey ne '''", ""));
}
[Fact]
public void MetricsSummaryFilter_NoTenant_KeepsWindowBoundOnly()
{
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter(null, "cutoff"));
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter("", "cutoff"));
}
[Fact]
public void DeviceSnapshotTenantFilter_EscapesQuotesInTenantId()
{
Assert.Equal("PartitionKey eq 'x'' or PartitionKey ne '''",
TableStorageService.BuildDeviceSnapshotTenantFilter(Injection));
Assert.Null(TableStorageService.BuildDeviceSnapshotTenantFilter(null));
}
}
@@ -110,6 +110,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
// admins are served the FULL config so this is normally a no-op for them.)
config.RestoreRedactedSecretsFrom(existingConfig);
// DomainName is tenant identity: seeded once from the first login's UPN (AuthFunction)
// and never client-writable — for ANY caller, GA included — mirroring the patch flow
// ("identity — never writable"). Without this preserve the full-model PUT persisted an
// arbitrary string here, which is rendered into the welcome/farewell mails.
config.DomainName = existingConfig.DomainName;
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
// headers, notification channels, diagnostics SAS, retention cap) — single source
// with the transactional field-patch flow (TenantConfigValidation).
@@ -2,6 +2,7 @@
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -19,8 +20,10 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// container SAS URL stored in <see cref="TenantConfiguration.DiagnosticsBlobSasUrl"/>.
/// Data stays in the customer's own storage account.</item>
/// <item><c>Hosted</c> (opt-in): mints a freshly-stamped, 15-min, blob-scoped,
/// Write+Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
/// own diagnostics container. Requires explicit admin opt-in via the UI; never
/// Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
/// own diagnostics container. The filename must carry the requesting session id
/// and the blob must not exist yet, so one device can neither name nor replace
/// another session's package. Requires explicit admin opt-in via the UI; never
/// silent.</item>
/// </list>
/// Called by the agent just before uploading — never at startup, never cached.
@@ -56,6 +59,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
private readonly ISessionDeletionInventoryReader _sessionRowReader;
private readonly SessionOwnerBindingObserver _ownerBinding;
public GetDiagnosticsUploadUrlFunction(
ILogger<GetDiagnosticsUploadUrlFunction> logger,
@@ -68,8 +73,12 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics)
HostedDiagnosticsBlobService hostedDiagnostics,
ISessionDeletionInventoryReader sessionRowReader,
SessionOwnerBindingObserver ownerBinding)
{
_sessionRowReader = sessionRowReader;
_ownerBinding = ownerBinding;
_logger = logger;
_configService = configService;
_adminConfigService = adminConfigService;
@@ -145,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
if (destination == DestinationHosted)
{
return await IssueHostedSasAsync(req, requestBody);
return await IssueHostedSasAsync(req, requestBody, validation);
}
if (destination == DestinationCustomerSas)
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
}
private async Task<HttpResponseData> IssueHostedSasAsync(
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody)
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody, SecurityValidationResult validation)
{
if (string.IsNullOrEmpty(requestBody.FileName))
{
@@ -250,18 +259,36 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
return badRequest;
}
// The blob name is bound to the session below, so the session id has to be a real
// one — and it is the coordinate for the owner-binding observation.
if (!SecurityValidator.IsValidGuid(requestBody.SessionId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
Success = false,
Message = "sessionId is required for hosted destination"
});
return badRequest;
}
// SESSION-OWNER-BINDING-SHADOW: same observation as the session-scoped writes, so
// stage-2 enforcement covers this endpoint with the same data. Never rejects here.
await ObserveSessionOwnerAsync(req, requestBody.TenantId, requestBody.SessionId, validation);
HostedUploadSasResult sasResult;
try
{
sasResult = await _hostedDiagnostics.GenerateUploadSasAsync(
requestBody.TenantId, requestBody.FileName);
requestBody.TenantId, requestBody.SessionId, requestBody.FileName);
}
catch (ArgumentException ex)
{
// Filename validation failed (path separator, traversal, oversize, etc.)
// Filename validation failed (path separator, traversal, oversize, not bound
// to the requesting session, etc.)
_logger.LogWarning(
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, file {FileName}: {Reason}",
requestBody.TenantId, requestBody.FileName, ex.Message);
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}, file {FileName}: {Reason}",
requestBody.TenantId, requestBody.SessionId, requestBody.FileName, ex.Message);
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
@@ -270,6 +297,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
});
return badRequest;
}
catch (HostedBlobAlreadyExistsException ex)
{
// A legitimate agent never re-requests an existing name (every package is
// freshly timestamped); this is either a replay or an overwrite attempt.
_logger.LogWarning(
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}: blob {BlobPath} already exists",
requestBody.TenantId, requestBody.SessionId, ex.BlobPath);
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
Success = false,
Message = "A diagnostics package with this name already exists"
});
return conflict;
}
_logger.LogInformation(
"GetDiagnosticsUploadUrl: Issuing Hosted upload URL for tenant {TenantId}, session {SessionId}, blob {BlobPath}, SAS expires {ExpiresAt}",
@@ -288,6 +330,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
return response;
}
private async Task ObserveSessionOwnerAsync(
HttpRequestData req, string tenantId, string sessionId, SecurityValidationResult validation)
{
try
{
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId);
_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url");
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"GetDiagnosticsUploadUrl: session-owner observation skipped for session {SessionId}", sessionId);
}
}
/// <summary>
/// Normalizes the destination string from TenantConfiguration. Null/empty becomes
/// CustomerSas (preserves existing behaviour for tenants that predate this field).
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -336,6 +337,14 @@ public class AuthFunction
if (string.IsNullOrEmpty(domain))
return;
// The seeded value is rendered into transactional mails — persist only a strict host name,
// regardless of what the identity provider's UPN charset happens to allow.
if (!TenantConfigValidation.IsValidDomainName(domain))
{
_logger.LogWarning("Refusing to seed DomainName for tenant {TenantId}: UPN domain is not a valid host name", tenantId);
return;
}
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
tenantConfig.DomainName = domain;
tenantConfig.UpdatedBy = upn;
@@ -49,6 +49,18 @@ public class MetricsSummaryFunction
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var filterTenantId = query["tenantId"];
if (!string.IsNullOrWhiteSpace(filterTenantId))
{
// Query value is interpolated into an OData filter downstream — GUID-gate it here
// (belt) in addition to ODataSanitizer at the sink (braces).
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
return bad;
}
filterTenantId = parsedTenantId.ToString("D");
}
var summary = await _metricsRepo.GetMetricsSummaryAsync(
string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId,
days);
@@ -1,5 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -44,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
}
// Prevent path traversal
if (blobName.Contains("..") || blobName.Contains("/") || blobName.Contains("\\"))
if (!BlobNameGuard.IsFlat(blobName))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -106,6 +107,14 @@ namespace AutopilotMonitor.Functions.Functions.Reports
return bad;
}
// TenantId becomes part of the blob name — must be a GUID, never path-shaped.
if (!SecurityValidator.IsValidGuid(request.TenantId))
{
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId." });
return invalid;
}
var metadata = await _sessionReportService.SubmitDiagFilesReportAsync(request, userIdentifier);
// Audit log — skip for Global Admins (their actions are not tenant-scoped)
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -105,6 +106,19 @@ namespace AutopilotMonitor.Functions.Functions.Reports
return bad;
}
// Identifier format gate: both values become part of the report blob name.
// Route values are percent-decoded, so an encoded '/' or '..' would otherwise
// reach the storage layer as a path segment.
if (!SecurityValidator.IsValidGuid(request.SessionId) || !SecurityValidator.IsValidGuid(request.TenantId))
{
_logger.LogWarning(
"SubmitSessionReport: BLOCKED non-GUID identifier user={User} tenant={TenantId} sessionIdLength={SessionIdLength}",
userIdentifier, request.TenantId, request.SessionId?.Length ?? 0);
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid sessionId or tenantId." });
return invalid;
}
// Submit report
var metadata = await _sessionReportService.SubmitReportAsync(request, userIdentifier);
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -167,12 +168,33 @@ namespace AutopilotMonitor.Functions.Functions.Rules
};
/// <summary>
/// Validates the phase-scope + emit-mode fields on create/update.
/// Returns an error message for a 400 response, or null when valid.
/// Validates the phase-scope + emit-mode fields and the on_event trigger wiring on
/// create/update. Returns an error message for a 400 response, or null when valid.
/// Toggle-style partial payloads carry none of these fields and pass through.
/// <para>
/// on_event: the agent never dispatches on_event rules for events the gather executor
/// itself emitted (feedback-loop guard, <c>GatherRuleExecutorHost</c>), so a rule that
/// triggers on its own output — or on the <c>gather_result</c> default output type —
/// could never fire and is rejected here with an explanatory message instead of being
/// stored as a dead rule.
/// </para>
/// </summary>
internal static string? ValidateScopeAndEmitMode(GatherRule rule)
{
if (string.Equals(rule.Trigger, "on_event", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(rule.TriggerEventType))
{
var outputEventType = string.IsNullOrEmpty(rule.OutputEventType)
? Constants.EventTypes.GatherResult
: rule.OutputEventType;
if (string.Equals(rule.TriggerEventType, outputEventType, StringComparison.OrdinalIgnoreCase))
return "triggerEventType must differ from the rule's own outputEventType — gather rules never trigger on their own output.";
if (string.Equals(rule.TriggerEventType, Constants.EventTypes.GatherResult, StringComparison.OrdinalIgnoreCase))
return $"triggerEventType '{Constants.EventTypes.GatherResult}' is the gather-rule output type — gather rules never trigger on gather output.";
}
var hasActivePhases = rule.ActivePhases != null && rule.ActivePhases.Count > 0;
var hasFromPhase = !string.IsNullOrEmpty(rule.ActiveFromPhase);
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules;
/// <summary>
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
/// All endpoints are Global Admin only, except PUT notification-email: that one is
/// AuthenticatedUserWithRole and gated in-function (member role, or the tenant has no members yet).
/// </summary>
public class PreviewWhitelistFunction
{
@@ -19,6 +21,7 @@ public class PreviewWhitelistFunction
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantApprovalService _tenantApprovalService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly IEmailService _emailService;
public PreviewWhitelistFunction(
@@ -26,12 +29,14 @@ public class PreviewWhitelistFunction
PreviewWhitelistService previewWhitelistService,
TenantApprovalService tenantApprovalService,
TenantConfigurationService tenantConfigurationService,
TenantAdminsService tenantAdminsService,
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantApprovalService = tenantApprovalService;
_tenantConfigurationService = tenantConfigurationService;
_tenantAdminsService = tenantAdminsService;
_emailService = emailService;
}
@@ -156,7 +161,8 @@ public class PreviewWhitelistFunction
/// <summary>
/// PUT /api/preview/notification-email
/// Saves the caller's notification email for the activation notice.
/// AuthenticatedUser policy — preview-blocked users can call this.
/// AuthenticatedUserWithRole policy — preview-blocked users can call this, but only while the
/// tenant has no members (see <see cref="MayWriteNotificationEmailAsync"/>).
/// </summary>
[Function("SavePreviewNotificationEmail")]
[Authorize]
@@ -174,6 +180,16 @@ public class PreviewWhitelistFunction
return bad;
}
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
{
_logger.LogWarning(
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "A tenant member role is required to change the notification email" });
return forbidden;
}
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
var email = body?.Email?.Trim();
@@ -208,6 +224,25 @@ public class PreviewWhitelistFunction
return response;
}
/// <summary>
/// The notification email is tenant-shared state consumed by privileged flows (welcome mail,
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
/// first-touch signup window, where the activating user is still roleless because the requester
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
/// or suppress the tenant's correspondence.
/// </summary>
internal static async Task<bool> MayWriteNotificationEmailAsync(
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
{
if (ctx.IsTenantMemberOrGlobalAdmin())
return true;
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
return !members.Any(m => m.IsEnabled);
}
/// <summary>
/// POST /api/preview/send-welcome-email/{tenantId}
/// Sends (or resends) the activation welcome email. Global Admin only.
@@ -56,6 +56,17 @@ public class SearchSessionsFunction
else
{
filterTenantId = query["tenantId"];
if (!string.IsNullOrWhiteSpace(filterTenantId))
{
// Interpolated into OData filters downstream — GUID-gate the raw query value.
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
return bad;
}
filterTenantId = parsedTenantId.ToString("D");
}
tenantId = filterTenantId;
scope = "search:global";
basePath = "/api/global/search/sessions";
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Config;
namespace AutopilotMonitor.Functions.Helpers
{
@@ -21,6 +22,120 @@ namespace AutopilotMonitor.Functions.Helpers
// RFC 5321 caps a forward path at 254 characters.
internal const int MaxContactEmailLength = 254;
// RFC 1035: a host name is at most 253 characters, labels at most 63.
internal const int MaxDomainNameLength = 253;
/// <summary>
/// True when <paramref name="domainName"/> is a strict DNS host name: dotted labels of
/// ASCII letters, digits and inner hyphens only. This is the ONLY shape DomainName may
/// ever be persisted in — it is rendered into transactional mails and admin views.
/// </summary>
internal static bool IsValidDomainName(string? domainName)
{
if (string.IsNullOrEmpty(domainName) || domainName.Length > MaxDomainNameLength)
return false;
var labels = domainName.Split('.');
if (labels.Length < 2)
return false;
foreach (var label in labels)
{
if (label.Length == 0 || label.Length > 63)
return false;
if (label[0] == '-' || label[label.Length - 1] == '-')
return false;
foreach (var ch in label)
{
var ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-';
if (!ok)
return false;
}
}
return true;
}
private const int MaxWhitelistEntries = 200;
private const int MaxWhitelistEntryLength = 128;
/// <summary>
/// Validates a hardware whitelist CSV (<see cref="TenantConfiguration.ManufacturerWhitelist"/> /
/// <see cref="TenantConfiguration.ModelWhitelist"/>). The CSV is split on ',' at enforcement
/// time, so this is the last line of defence against a value that was meant as ONE entry but
/// alters the list structure: blank items (",,", ", ,") are rejected rather than silently
/// dropped, and every item must be a printable, bounded pattern. Returns a user-facing
/// reason, or null when the list is well-formed. Empty/whitespace = allow all (documented).
/// </summary>
internal static string? ValidateHardwareWhitelist(string? csv)
{
if (string.IsNullOrWhiteSpace(csv))
return null;
var items = csv!.Split(',');
if (items.Length > MaxWhitelistEntries)
return $"at most {MaxWhitelistEntries} entries are allowed.";
foreach (var raw in items)
{
var entry = raw.Trim();
if (entry.Length == 0)
return "entries must not be empty (check for a stray or doubled comma).";
if (entry.Length > MaxWhitelistEntryLength)
return $"each entry must be at most {MaxWhitelistEntryLength} characters.";
foreach (var ch in entry)
{
if (char.IsControl(ch))
return "entries must not contain control characters.";
}
}
return null;
}
// Generous for a CDN asset URL; bounds the command line the agent composes from it.
internal const int MaxBrandingImageUrlLength = 2048;
/// <summary>
/// Validates <see cref="TenantConfiguration.EnrollmentSummaryBrandingImageUrl"/>. The value
/// is delivered verbatim to every enrolling device, fetched from the signed-in user's
/// session and spliced into the summary-dialog command line, so it is the one piece of
/// tenant config that is both a device egress target and a process argument. It must be a
/// plain absolute HTTPS URL to a DNS host — no quotes, whitespace or control characters
/// that could terminate a quoted argument. Returns an error message, or null when
/// valid/empty (empty = no branding image).
/// </summary>
internal static string? ValidateBrandingImageUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (url!.Length > MaxBrandingImageUrlLength)
return $"must be at most {MaxBrandingImageUrlLength} characters.";
foreach (var ch in url)
{
if (char.IsControl(ch) || char.IsWhiteSpace(ch))
return "must not contain whitespace or control characters.";
if (ch == '"' || ch == '\'')
return "must not contain quotes.";
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return "is not a valid absolute URL.";
if (uri.Scheme != Uri.UriSchemeHttps)
return "must use HTTPS.";
if (uri.IsLoopback || string.IsNullOrEmpty(uri.Host))
return "must not target localhost.";
if (System.Net.IPAddress.TryParse(uri.Host, out _))
return "must use a DNS hostname, not an IP address.";
return null;
}
/// <summary>
/// Validates a candidate configuration against the stored one. Returns a
/// user-facing error message (same wording the PUT endpoint always produced),
@@ -58,6 +173,18 @@ namespace AutopilotMonitor.Functions.Helpers
if (channelsError != null)
return $"Invalid notification channels: {channelsError}";
var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl);
if (brandingUrlError != null)
return $"Invalid enrollment summary branding image URL: {brandingUrlError}";
var manufacturerWhitelistError = ValidateHardwareWhitelist(candidate.ManufacturerWhitelist);
if (manufacturerWhitelistError != null)
return $"Invalid manufacturer whitelist: {manufacturerWhitelistError}";
var modelWhitelistError = ValidateHardwareWhitelist(candidate.ModelWhitelist);
if (modelWhitelistError != null)
return $"Invalid model whitelist: {modelWhitelistError}";
// Only validate the customer-supplied SAS URL when the tenant has actually selected
// the CustomerSas destination — a stale value left over from a prior CustomerSas
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
@@ -212,6 +213,29 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
decision.UserIdentifier, decision.UserRole, "CrossTenant");
}
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
// richer client payload). Both the caller's HOME tenant (JWT tid) and the request's TARGET tenant are
// checked: a suspended tenant's members lose everything, including delegated reach into other tenants,
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
if (principal != null && !hasGlobalScope)
{
var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId);
if (suspended != null)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(suspended.TenantId), logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "TenantSuspended",
!string.IsNullOrEmpty(suspended.DisabledReason)
? suspended.DisabledReason
: "Your tenant has been suspended. Please contact support for more information.",
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
}
}
// Merge delegated state from the two delegated admission paths:
// • scoped-route (RouteParam/QueryParam): a single-tenant cross-tenant read — delegatedRole +
// allowedTenantIds were set above.
@@ -259,6 +283,26 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole);
}
/// <summary>
/// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and
/// the request target (null when neither is suspended). <see cref="TenantConfigurationService.TryGetConfigurationAsync"/>
/// is cached (5 min TTL, same as the agent gate) and never persists a default row.
/// <see cref="TenantConfiguration.IsCurrentlyDisabled"/> already treats an elapsed DisabledUntil as
/// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login.
/// </summary>
private async Task<TenantConfiguration?> FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId)
{
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId);
if (homeConfig.IsCurrentlyDisabled())
return homeConfig;
if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
return null;
var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId);
return targetConfig.IsCurrentlyDisabled() ? targetConfig : null;
}
/// <summary>
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
@@ -0,0 +1,28 @@
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Last-line guard for request-derived blob names. Azure.Storage.Blobs appends the name to
/// the container URI preserving '/' and System.Uri collapses '..' segments, so a name such as
/// "x/../../other-container/y" resolves OUTSIDE the intended container. Every blob name that
/// contains caller-influenced material must be flat: no separators, no dot segments.
/// </summary>
public static class BlobNameGuard
{
public static bool IsFlat(string? blobName)
{
if (string.IsNullOrWhiteSpace(blobName)) return false;
if (blobName.Contains('/') || blobName.Contains('\\')) return false;
if (blobName.Contains("..")) return false;
if (blobName.Contains('%') || blobName.Contains('?') || blobName.Contains('#')) return false;
return true;
}
/// <summary>Throws <see cref="ArgumentException"/> when the name is not flat.</summary>
public static string EnsureFlat(string? blobName, string parameterName)
{
if (!IsFlat(blobName))
throw new ArgumentException("Blob name must be a flat name without path separators or dot segments.", parameterName);
return blobName!;
}
}
}
@@ -223,7 +223,11 @@ public static class EndpointAccessPolicyCatalog
// ProgressPortalFunction; regression history c4dabeee).
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
// roleless during first-touch signup, so the tier must admit non-members — but the address is
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
// resolved role: roleless callers may only write while the tenant has no member yet.
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
@@ -27,8 +27,8 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
/// (<c>AzureStorageAccountName</c>) or connection string (<c>AzureBlobStorageConnectionString</c>).
/// SAS issuance for the agent upload-url endpoint differs by auth path — User
/// Delegation SAS under MI, account-key SAS under connection-string — both produce
/// a blob-scoped, Write+Create-only, 15-min token that cannot reach other tenants'
/// prefixes.
/// a blob-scoped, Create-only, 15-min token that cannot reach other tenants'
/// prefixes and cannot replace an existing blob.
/// </para>
/// </summary>
public class HostedDiagnosticsBlobService
@@ -40,6 +40,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// requesting hour-long tokens.
public static readonly TimeSpan MaxUploadSasTtl = TimeSpan.FromMinutes(60);
/// <summary>
/// Permissions on the upload SAS. Create-only by design: the agent PUTs one new block
/// blob per package (fresh timestamped name), so Write — which would allow replacing an
/// existing blob under the same tenant prefix — is never needed.
/// </summary>
public const BlobSasPermissions UploadSasPermissions = BlobSasPermissions.Create;
/// <summary>Canonical package-name prefix; the session id follows immediately after.</summary>
internal const string PackageNamePrefix = "AgentDiagnostics-";
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<HostedDiagnosticsBlobService> _logger;
private readonly bool _usesManagedIdentity;
@@ -94,16 +104,21 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// -------- Public API --------
/// <summary>
/// Mints a blob-scoped, Write+Create-only SAS URI pinned to the EXACT path
/// Mints a blob-scoped, Create-only SAS URI pinned to the EXACT path
/// <c>{tenantId}/{filename}</c> for an agent's diagnostics-package upload. Cannot
/// be redirected to other tenants' prefixes. TTL is capped at
/// <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
/// be redirected to other tenants' prefixes, and the filename must carry the
/// caller's <paramref name="sessionId"/> (<c>AgentDiagnostics-{sessionId}-…zip</c>)
/// so one device cannot name another session's package. An already-existing blob
/// is never re-issued (<see cref="HostedBlobAlreadyExistsException"/>). TTL is
/// capped at <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
/// </summary>
public virtual async Task<HostedUploadSasResult> GenerateUploadSasAsync(
string tenantId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
string tenantId, string sessionId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
ValidateFilename(filename);
EnsureFilenameBoundToSession(filename, sessionId);
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(15);
if (effectiveTtl > MaxUploadSasTtl)
@@ -116,6 +131,14 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
await EnsureContainerAsync(cancellationToken);
// Create-only semantics, enforced twice: the SAS carries no Write permission (an
// existing blob cannot be replaced with it), and issuance itself refuses a name
// that is already taken so the conflict surfaces here as a 409 instead of as an
// opaque 403 from storage. Every package gets a fresh timestamped name, so a
// legitimate agent never asks for an existing one.
if (await BlobExistsAsync(blobPath, cancellationToken))
throw new HostedBlobAlreadyExistsException(blobPath);
var sasUri = await BuildUploadSasUriAsync(blobPath, expiresOn, cancellationToken);
return new HostedUploadSasResult
@@ -185,7 +208,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
// but the explicit separator keeps the invariant obvious and layout-proof).
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
var prefix = $"{tenantId}/{PackageNamePrefix}{sessionId}-";
var deleted = 0;
await foreach (var item in containerClient.GetBlobsAsync(
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
@@ -272,7 +295,17 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
}
/// <summary>
/// Builds the blob-scoped Write+Create-only SAS URI for upload. Branch on the
/// Test seam: whether a blob already exists at <paramref name="blobPath"/>. Production
/// issues a HEAD; tests override to simulate a taken name without Azurite.
/// </summary>
protected virtual async Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
{
var response = await GetContainerClient().GetBlobClient(blobPath).ExistsAsync(cancellationToken);
return response.Value;
}
/// <summary>
/// Builds the blob-scoped Create-only SAS URI for upload. Branch on the
/// auth mode: MI uses User Delegation SAS (no account key needed); connection
/// string uses the underlying StorageSharedKeyCredential via
/// <see cref="BlobClient.GenerateSasUri(BlobSasBuilder)"/>.
@@ -290,9 +323,10 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // clock-skew tolerance
ExpiresOn = expiresOn,
};
// Write + Create only — agent must not be able to Read other tenants' blobs,
// Delete anything, or List the container.
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
// Create only — the agent does a single Put Blob of a brand-new name. No Write
// (would let the holder replace an existing blob of the same tenant), no Read,
// no Delete, no List.
sasBuilder.SetPermissions(UploadSasPermissions);
if (UsesManagedIdentity)
{
@@ -337,6 +371,26 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
throw new ArgumentException("filename must not contain null bytes", nameof(filename));
}
/// <summary>
/// Binds an upload filename to the requesting session: it must be
/// <c>AgentDiagnostics-{sessionId}-{anything}.zip</c> (the shape the agent has always
/// produced, incl. the <c>-preprov</c>/<c>-server-requested</c> suffixes). Without this
/// an authenticated device could name any other session's package in its tenant.
/// Throws <see cref="ArgumentException"/> on rejection.
/// </summary>
internal static void EnsureFilenameBoundToSession(string filename, string sessionId)
{
var required = $"{PackageNamePrefix}{sessionId}-";
if (!filename.StartsWith(required, StringComparison.OrdinalIgnoreCase)
|| filename.Length <= required.Length
|| !filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"filename must be of the form 'AgentDiagnostics-{sessionId}-{timestamp}.zip' for the requesting session",
nameof(filename));
}
}
/// <summary>
/// Validates a stored blob path (<c>{tenantId}/{filename}</c>) on the read paths.
/// Defends the download proxy against path-traversal even if a row was tampered with.
@@ -358,6 +412,22 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
}
}
/// <summary>
/// Thrown by <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/> when the
/// requested blob name is already taken. Distinct from <see cref="ArgumentException"/>
/// (malformed input → 400) so the Function can answer 409.
/// </summary>
public sealed class HostedBlobAlreadyExistsException : InvalidOperationException
{
public HostedBlobAlreadyExistsException(string blobPath)
: base($"Hosted diagnostics blob already exists: {blobPath}")
{
BlobPath = blobPath;
}
public string BlobPath { get; }
}
/// <summary>
/// Return value of <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/>.
/// </summary>
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Storage.Blobs;
@@ -100,6 +101,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (string.IsNullOrEmpty(sourceBlobName))
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedNoDiagnostics, null);
// Destination is derived from request data (tenant/session ids). A non-flat name
// would let the SDK + System.Uri walk out of the session-reports container.
if (!BlobNameGuard.IsFlat(destinationBlobName))
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: rejecting non-flat destination blob name for tenant {TenantId}, session {SessionId}",
tenantId, sessionId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedInvalidBlobName, null);
}
try
{
var (destination, classifyErr) = DiagnosticsDownloadFunction.ClassifyBlobName(sourceBlobName, tenantId);
@@ -219,7 +230,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
{
var containerClient = _blobStorage.GetContainerClient(ReportsContainerName);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(destinationBlobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(destinationBlobName, nameof(destinationBlobName)));
await blobClient.UploadAsync(content, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Net;
namespace AutopilotMonitor.Functions.Services;
@@ -73,10 +74,15 @@ public class EmailTemplateService : IEmailTemplateProvider
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
/// <summary>
/// Substitutes the placeholder; an empty domain becomes "your organization". The value is
/// HTML-encoded at the point of substitution: the mail goes out under the product's signed
/// sender to a tenant-chosen recipient, so no writer of DomainName — current or future — may
/// ever be able to inject markup into it.
/// </summary>
public static string Render(string rawHtml, string? domainName)
{
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : WebUtility.HtmlEncode(domainName.Trim());
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
}
@@ -1,3 +1,4 @@
using System.Net;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Services;
@@ -26,7 +27,7 @@ public static class EmailTemplates
/// </summary>
public static string GetOffboardingFarewellHtml(string domainName)
{
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
return $@"
<!DOCTYPE html>
@@ -125,7 +126,7 @@ public static class EmailTemplates
/// </summary>
public static string GetPreviewApprovedHtml(string domainName)
{
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
return $@"
<!DOCTYPE html>
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Azure.Storage.Blobs.Models;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -60,6 +61,11 @@ namespace AutopilotMonitor.Functions.Services
SubmitSessionReportRequest request,
string submittedBy)
{
// Both identifiers shape blob names below; a non-GUID value could smuggle path
// separators / dot segments into the storage URI (cross-container write).
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
SecurityValidator.EnsureValidGuid(request.SessionId, nameof(request.SessionId));
var reportId = Guid.NewGuid().ToString("N")[..12];
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_{request.SessionId}_diag_request_{timestamp}.zip";
@@ -203,7 +209,7 @@ namespace AutopilotMonitor.Functions.Services
zipStream.Position = 0;
var containerClient = _blobStorage.GetContainerClient(ContainerName);
await containerClient.CreateIfNotExistsAsync();
var blobClient = containerClient.GetBlobClient(blobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -245,6 +251,8 @@ namespace AutopilotMonitor.Functions.Services
SubmitDiagFilesReportRequest request,
string submittedBy)
{
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
var reportId = Guid.NewGuid().ToString("N")[..12];
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
@@ -292,7 +300,7 @@ namespace AutopilotMonitor.Functions.Services
zipStream.Position = 0;
var containerClient = _blobStorage.GetContainerClient(ContainerName);
await containerClient.CreateIfNotExistsAsync();
var blobClient = containerClient.GetBlobClient(blobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -660,7 +660,7 @@ namespace AutopilotMonitor.Functions.Services
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
{
var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: snapshotTableClient,
@@ -838,7 +838,7 @@ namespace AutopilotMonitor.Functions.Services
private async Task<List<SessionSummary>> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
var sessionIds = new List<string>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
@@ -1437,6 +1437,26 @@ namespace AutopilotMonitor.Functions.Services
groups[pk] = g.Add(statusStr);
}
/// <summary>
/// SessionsIndex filter for the metrics summary: date-window bound plus optional single-tenant
/// scope. <paramref name="tenantId"/> may originate from the HTTP query string, so it is
/// escaped — a quote in the value must never alter the filter grammar.
/// </summary>
internal static string BuildMetricsSummaryFilter(string? tenantId, string cutoffRowKeyPrefix)
{
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
if (!string.IsNullOrEmpty(tenantId))
filterParts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
return string.Join(" and ", filterParts);
}
/// <summary>
/// DeviceSnapshot filter for the device-property search path: optional single-tenant scope.
/// Escaped for the same reason as <see cref="BuildMetricsSummaryFilter"/>.
/// </summary>
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
{
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
@@ -1446,10 +1466,7 @@ namespace AutopilotMonitor.Functions.Services
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
var cutoffRowKeyPrefix = ComputeCutoffRowKeyPrefix(days);
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
if (!string.IsNullOrEmpty(tenantId))
filterParts.Add($"PartitionKey eq '{tenantId}'");
var oDataFilter = string.Join(" and ", filterParts);
var oDataFilter = BuildMetricsSummaryFilter(tenantId, cutoffRowKeyPrefix);
// Tally every status into its own bucket so the buckets always reconcile to total
// (SessionStatusBuckets enforces this by construction — Pending/Stalled/Unknown no
@@ -1008,25 +1008,32 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Gets manufacturer whitelist as array
/// Gets manufacturer whitelist as array (empty/whitespace = allow all)
/// </summary>
public string[] GetManufacturerWhitelist()
{
if (string.IsNullOrEmpty(ManufacturerWhitelist))
return new[] { "*" };
return ManufacturerWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetManufacturerWhitelist() => ParseWhitelist(ManufacturerWhitelist);
/// <summary>
/// Gets model whitelist as array
/// Gets model whitelist as array (empty/whitespace = allow all)
/// </summary>
public string[] GetModelWhitelist()
public string[] GetModelWhitelist() => ParseWhitelist(ModelWhitelist);
/// <summary>
/// Splits a stored hardware whitelist CSV into individual match patterns: trimmed, empty
/// entries dropped. The CSV is the ONLY storage format, so a ',' can never be part of a
/// pattern — writers (web editor, <c>TenantConfigValidation</c>) must guarantee each
/// intended entry lands as exactly one CSV item.
/// </summary>
public static string[] ParseWhitelist(string? csv)
{
if (string.IsNullOrEmpty(ModelWhitelist))
if (string.IsNullOrWhiteSpace(csv))
return new[] { "*" };
return ModelWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var entries = csv!.Split(',')
.Select(e => e.Trim())
.Where(e => e.Length > 0)
.ToArray();
return entries.Length == 0 ? new[] { "*" } : entries;
}
/// <summary>
@@ -30,7 +30,8 @@ export default function DiagnosisPage() {
function DiagnosisContent() {
const searchParams = useSearchParams();
const router = useRouter();
const sessionId = searchParams?.get("id") as string;
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string;
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
@@ -108,7 +108,10 @@ export function useSessionDetail({
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
// Accept only a session whose id matches the one we asked for — a misrouted or
// unexpected 200 must never hydrate the page (defense in depth behind the GUID gate).
const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined;
if (foundSession) {
setSession(foundSession);
setSessionTenantId(foundSession.tenantId);
@@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { useAuth } from '@/contexts/AuthContext';
import { isGuid } from '@/utils/inputValidation';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
@@ -91,7 +92,8 @@ export default function NetworkTimelinePage() {
function Content() {
const searchParams = useSearchParams();
const sessionId = searchParams?.get('id') ?? null;
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null;
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
@@ -3,6 +3,7 @@
import { diagnosisUrl, inspectorUrl } from "@/lib/routes";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
@@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions';
import { useLatestVersions } from '@/lib/useLatestVersions';
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
import { api } from "@/lib/api";
import { isGuid } from "@/utils/inputValidation";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
@@ -59,7 +61,11 @@ function SessionDetailContent() {
const searchParams = useSearchParams();
// Query-string identity (`?id=`) — path params are gone with the static export;
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
const sessionId = searchParams?.get("id") as string;
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
// percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty
// sessionId keeps every data hook inert and the page renders the invalid-id branch below.
const rawSessionId = searchParams?.get("id") ?? "";
const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : "";
// Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a
// delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode.
const tenantIdOverride = searchParams?.get("tenantId") || undefined;
@@ -424,6 +430,19 @@ function SessionDetailContent() {
// so `loading` never flips and we'd render this branch forever — bypassing the
// auth gate and hanging on "Loading session details..." without ever triggering
// the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth.
if (!sessionId) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
<Link href="/dashboard" className="text-blue-600 hover:underline">Back to dashboard</Link>
</div>
</div>
</ProtectedRoute>
);
}
if (loading && !session && events.length === 0) {
return (
<ProtectedRoute>
@@ -3,6 +3,7 @@
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { joinList, parseList } from "../lib/hardwareWhitelist";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -16,14 +17,6 @@ interface HardwareWhitelistSectionProps {
readOnly?: boolean;
}
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
function joinList(items: string[]): string {
return items.join(",");
}
function WhitelistEditor({
label,
description,
@@ -38,14 +31,17 @@ function WhitelistEditor({
setItems: (items: string[]) => void;
}) {
const [newItem, setNewItem] = useState("");
const trimmed = newItem.trim();
const isDuplicate =
trimmed !== "" &&
items.some((a) => a.toLowerCase() === trimmed.toLowerCase());
// Admin-typed input is a LIST: "Dell*, *" adds two patterns (the placeholder invites
// exactly that). Only the insights buttons neutralize ',' — their values are untrusted.
const existing = new Set(items.map((a) => a.toLowerCase()));
const typed = parseList(newItem);
const fresh = typed.filter((t, i) =>
!existing.has(t.toLowerCase()) && typed.findIndex((u) => u.toLowerCase() === t.toLowerCase()) === i);
const isDuplicate = typed.length > 0 && fresh.length === 0;
const addItem = () => {
if (!trimmed || isDuplicate) return;
setItems([...items, trimmed]);
if (fresh.length === 0) return;
setItems([...items, ...fresh]);
setNewItem("");
};
@@ -110,7 +106,7 @@ function WhitelistEditor({
/>
<button
onClick={addItem}
disabled={!trimmed || isDuplicate}
disabled={fresh.length === 0}
className="px-4 py-1.5 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { addWhitelistEntry, parseList, toWhitelistEntry } from "../hardwareWhitelist";
describe("hardware whitelist CSV helpers", () => {
it("adds a plain value as one entry", () => {
expect(addWhitelistEntry("Dell*,HP*", "Lenovo")).toBe("Dell*,HP*,Lenovo");
});
it("never lets a delimiter-bearing value expand into extra patterns", () => {
// Attacker-controlled distress-signal string: would become [..., "Dell Inc.", "*"] verbatim.
const csv = addWhitelistEntry("Dell*", "Dell Inc.,*");
expect(parseList(csv)).toEqual(["Dell*", "Dell Inc.?*"]);
expect(parseList(csv)).not.toContain("*");
});
it("neutralizes a trailing comma instead of creating an empty/stray entry", () => {
expect(parseList(addWhitelistEntry("Dell*", "Dell Inc.,"))).toEqual(["Dell*", "Dell Inc.?"]);
});
it("ignores empty and duplicate values", () => {
expect(addWhitelistEntry("Dell*", " ")).toBe("Dell*");
// A lone delimiter becomes the single-character pattern "?" — one entry, never a split.
expect(parseList(addWhitelistEntry("Dell*", ","))).toEqual(["Dell*", "?"]);
expect(addWhitelistEntry("Dell*", "dell*")).toBe("Dell*");
});
it("toWhitelistEntry trims and replaces every comma", () => {
expect(toWhitelistEntry(" a,b,,c ")).toBe("a?b??c");
});
});
@@ -0,0 +1,37 @@
/**
* Hardware whitelist CSV helpers.
*
* The tenant whitelist is persisted as ONE comma-separated string and split on ',' at
* enforcement time (TenantConfiguration.GetManufacturerWhitelist). A comma inside a single
* entry therefore changes the LIST STRUCTURE: "Dell Inc.,*" appended as one "manufacturer"
* becomes two patterns, the second being the allow-all wildcard. Manufacturer/model strings
* shown in the rejection insights come from UNAUTHENTICATED distress signals, so every value
* that enters the list must round-trip as exactly one pattern.
*/
export function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
export function joinList(items: string[]): string {
return items.join(",");
}
/**
* Normalizes a value so it is stored as exactly one whitelist pattern. The delimiter is
* replaced by the single-character wildcard '?', which still matches the original string
* literally (and nothing structurally different) instead of splitting the list.
* Returns "" when nothing usable remains.
*/
export function toWhitelistEntry(value: string): string {
return value.replace(/,/g, "?").trim();
}
/** Appends `value` as one pattern unless an equal (case-insensitive) entry already exists. */
export function addWhitelistEntry(csv: string, value: string): string {
const entry = toWhitelistEntry(value);
if (!entry) return csv;
const items = parseList(csv);
if (items.some((i) => i.toLowerCase() === entry.toLowerCase())) return csv;
return joinList([...items, entry]);
}
@@ -7,14 +7,7 @@ import { TenantNotifications } from "../../TenantNotifications";
import HardwareWhitelistSection from "../../components/HardwareWhitelistSection";
import HardwareRejectionInsights from "../../components/HardwareRejectionInsights";
import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights";
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
function joinList(items: string[]): string {
return items.join(",");
}
import { addWhitelistEntry } from "../../lib/hardwareWhitelist";
export function SectionHardwareWhitelist() {
const { getAccessToken } = useAuth();
@@ -30,18 +23,14 @@ export function SectionHardwareWhitelist() {
const hasWebhook = notificationChannels.some((c) => c.enabled && (c.url ?? "").length > 0);
// Values come from UNAUTHENTICATED distress signals — addWhitelistEntry guarantees each
// click adds exactly one pattern (a ',' inside the value cannot split the list).
const handleAddManufacturer = useCallback((value: string) => {
const items = parseList(manufacturerWhitelist);
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
setManufacturerWhitelist(joinList([...items, value]));
}
setManufacturerWhitelist(addWhitelistEntry(manufacturerWhitelist, value));
}, [manufacturerWhitelist, setManufacturerWhitelist]);
const handleAddModel = useCallback((value: string) => {
const items = parseList(modelWhitelist);
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
setModelWhitelist(joinList([...items, value]));
}
setModelWhitelist(addWhitelistEntry(modelWhitelist, value));
}, [modelWhitelist, setModelWhitelist]);
return (
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
// API_BASE_URL is read at import time of lib/api — stub it before the import.
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiPromise = import("../api") as Promise<{ api: any }>;
/**
* Path-position ids must be percent-encoded so an attacker-controlled id
* (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the
* route: '/' and '..' would otherwise be resolved by the URL parser into a
* different API path, '#' would truncate the builder's suffix and query string.
*/
const HOSTILE = "x/../../global/y#";
const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23";
describe("api path ids are percent-encoded", () => {
it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => {
const { api } = await apiPromise;
const builders: Array<(id: string) => string> = [
(id) => api.sessions.get(id),
(id) => api.sessions.events(id),
(id) => api.sessions.delete(id, "t"),
(id) => api.sessions.analysis(id),
(id) => api.sessions.vulnerabilityReport(id),
(id) => api.sessions.timeAttribution(id),
(id) => api.sessions.markFailed(id),
(id) => api.sessions.markSucceeded(id),
(id) => api.sessions.queueAction(id),
(id) => api.sessions.report(id),
(id) => api.sessions.annotations(id),
(id) => api.sessions.annotation(id, "lane"),
(id) => api.inspector.signals(id),
(id) => api.inspector.decisionGraph(id),
(id) => api.inspector.reducerVerification(id),
];
for (const build of builders) {
const url = build(HOSTILE);
expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true);
expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true);
}
});
it("markFailed keeps its suffix after a '#'-bearing id", async () => {
const { api } = await apiPromise;
expect(api.sessions.markFailed(HOSTILE, "t")).toBe(
`https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`,
);
});
it("annotation lane and gather rule id are encoded too", async () => {
const { api } = await apiPromise;
expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb");
expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`);
});
it("no path builder lets an id escape its segment", async () => {
const { api } = await apiPromise;
type Builder = (...args: string[]) => string;
const walk = (node: unknown): Builder[] =>
typeof node === "function"
? [node as Builder]
: Object.values(node as Record<string, unknown>).flatMap(walk);
for (const fn of walk(api)) {
if (fn.length === 0) continue;
const url = fn(HOSTILE, HOSTILE);
// The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y";
// an encoded id survives verbatim and the fragment marker never appears.
expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/);
expect(url).not.toContain("#");
}
});
});
+50 -50
View File
@@ -33,44 +33,44 @@ export const api = {
continuation: opts?.continuation,
})}`,
get: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
events: (
sessionId: string,
tenantId?: string,
opts?: { pageSize?: number; continuation?: string },
) =>
`${API_BASE_URL}/api/sessions/${sessionId}/events${qs({
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/events${qs({
tenantId,
pageSize: opts?.pageSize?.toString(),
continuation: opts?.continuation,
})}`,
delete: (sessionId: string, tenantId: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
vulnerabilityReport: (sessionId: string, tenantId?: string, rescan?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
// F1 time attribution: pre-computed breakdown row (null for pre-feature / non-terminal /
// Incomplete sessions — the lane is simply omitted, nothing is re-derived client-side).
timeAttribution: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/time-attribution${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/time-attribution${qs({ tenantId })}`,
markFailed: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-failed${qs({ tenantId })}`,
markSucceeded: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-succeeded${qs({ tenantId })}`,
// POST { type, reason } — queues a ServerAction delivered with the agent's next ingest
// response (e.g. request_diagnostics for the "Collect Logs" button).
queueAction: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/actions${qs({ tenantId })}`,
report: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/report${qs({ tenantId })}`,
// GET — all annotation lanes visible to the caller (backend hides the globaladmin
// lane from callers without global scope).
annotations: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/annotations${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations${qs({ tenantId })}`,
// PUT { verdict, note } — upserts one lane; both fields null/empty clears it.
annotation: (sessionId: string, lane: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations/${encodeURIComponent(lane)}${qs({ tenantId })}`,
quickSearch: (q: string) =>
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
// Server-side free-text search (dashboard search box) — substring across the same
@@ -92,11 +92,11 @@ export const api = {
// ── Inspector v1 (global admin only — Plan §M6) ───────────────────────────
inspector: {
signals: (sessionId: string, opts?: { tenantId?: string; maxResults?: number }) =>
`${API_BASE_URL}/api/sessions/${sessionId}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
decisionGraph: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/decision-graph${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/decision-graph${qs({ tenantId })}`,
reducerVerification: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/reducer-verification${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/reducer-verification${qs({ tenantId })}`,
},
// ── Global Sessions (global admin) ────────────────────────────────────────
@@ -131,31 +131,31 @@ export const api = {
// ── Config ────────────────────────────────────────────────────────────────
config: {
all: () => `${API_BASE_URL}/api/config/all`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}`,
// Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend
// maps the intent to write source "portal-collect-logs" and emits the dedicated
// CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled.
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`,
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}?intent=collect-logs`,
/**
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
* verifies exactly those fields changed and rolls back on drift; every write snapshots
* to ConfigurationBackups first. The Settings sections save through this per section.
*/
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`,
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/fields`,
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/feature-flags`,
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/plan`,
/** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`,
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/trial`,
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
autopilotConsentStatus: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-status`,
autopilotConsentFailure: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-failure`,
autopilotConsentSuccess: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-success`,
/**
* Probe whether the app's core validation permission is already effectively granted in
* the tenant (pre-approved by someone with consent rights), so a rights-less admin can be
@@ -163,16 +163,16 @@ export const api = {
* { accessPresent, isTransient, requiredPermission }.
*/
autopilotAccessCheck: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/access-check`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/access-check`,
/**
* POST — dual app-reg homing flip: { target: "primary" | "legacy", force?: boolean }.
* Tenant admins: target "primary" only, no force, kill-switch flag on, consent probe must
* pass. Global Admins: both directions, force skips the probe.
*/
appHoming: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/app-homing`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/app-homing`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/test-notification`,
latestVersions: (opts?: { refresh?: boolean }) =>
`${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`,
},
@@ -181,13 +181,13 @@ export const api = {
graphPermissions: {
/** GET status of optional Graph add-on permissions for the tenant (admin-only). */
status: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/status`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/status`,
/** POST to invalidate the backend's cached roles after a grant script run. */
refresh: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/refresh`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/refresh`,
/** POST endpoint to resolve Intune script display names; body carries the refs array. */
scriptDisplayNames: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/scripts/display-names`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/scripts/display-names`,
},
// ── Global Config (global admin) ──────────────────────────────────────────
@@ -201,17 +201,17 @@ export const api = {
// ── Tenants ───────────────────────────────────────────────────────────────
tenants: {
admins: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins`,
admin: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}`,
adminAction: (tenantId: string, adminUpn: string, action: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`,
adminPermissions: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`,
offboard: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/offboard`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`,
offboardFeedback: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/offboard/feedback`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard/feedback`,
},
// ── Devices ───────────────────────────────────────────────────────────────
@@ -248,7 +248,7 @@ export const api = {
gather: (tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`,
gatherRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/rules/gather/${encodeURIComponent(ruleId)}${qs({ tenantId })}`,
globalGather: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`,
// Cross-tenant edit/toggle/delete of an existing gather rule (Global Admin override) — see globalAnalyzeRule.
@@ -406,7 +406,7 @@ export const api = {
lookup: (tenantId: string, search: string) =>
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`,
`${API_BASE_URL}/api/progress/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, serial })}`,
},
// ── Bootstrap ─────────────────────────────────────────────────────────────
@@ -414,7 +414,7 @@ export const api = {
sessions: (tenantId?: string) =>
`${API_BASE_URL}/api/bootstrap/sessions${qs({ tenantId })}`,
session: (code: string, tenantId: string) =>
`${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`,
`${API_BASE_URL}/api/bootstrap/sessions/${encodeURIComponent(code)}${qs({ tenantId })}`,
},
// ── Annotations (tenant-scoped overview list) ─────────────────────────────
@@ -467,7 +467,7 @@ export const api = {
downloadUrl: (blobName: string) =>
`${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`,
note: (reportId: string) =>
`${API_BASE_URL}/api/global/session-reports/${reportId}/note`,
`${API_BASE_URL}/api/global/session-reports/${encodeURIComponent(reportId)}/note`,
},
// ── Diag Files Reports (no session context) ───────────────────────────────
@@ -553,11 +553,11 @@ export const api = {
// ── Notifications ─────────────────────────────────────────────────────────
notifications: {
list: () => `${API_BASE_URL}/api/global/notifications`,
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${id}/dismiss`,
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${encodeURIComponent(id)}/dismiss`,
dismissAll: () => `${API_BASE_URL}/api/global/notifications/dismiss-all`,
// Tenant-scoped persistent notifications (bell). TenantId resolved server-side from JWT.
tenantList: () => `${API_BASE_URL}/api/notifications`,
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${id}/dismiss`,
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${encodeURIComponent(id)}/dismiss`,
tenantDismissAll: () => `${API_BASE_URL}/api/notifications/dismiss-all`,
},
@@ -608,12 +608,12 @@ export const api = {
preview: {
whitelist: () => `${API_BASE_URL}/api/preview/whitelist`,
whitelistTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/whitelist/${tenantId}`,
`${API_BASE_URL}/api/preview/whitelist/${encodeURIComponent(tenantId)}`,
sendWelcomeEmail: (tenantId: string) =>
`${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`,
`${API_BASE_URL}/api/preview/send-welcome-email/${encodeURIComponent(tenantId)}`,
notificationEmail: () => `${API_BASE_URL}/api/preview/notification-email`,
notificationEmailTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/notification-email/${tenantId}`,
`${API_BASE_URL}/api/preview/notification-email/${encodeURIComponent(tenantId)}`,
/** GA/Reader: every stored notification address, keyed by lowercased tenant id. */
notificationEmails: () => `${API_BASE_URL}/api/preview/notification-emails`,
},
@@ -721,13 +721,13 @@ export const api = {
// ── Email Templates (GA) ──────────────────────────────────────────────────
emailTemplates: {
get: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
save: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
reset: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
sendTest: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}/test`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}/test`,
},
// ── Maintenance ───────────────────────────────────────────────────────────