Compare commits

..

5 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
36 changed files with 1711 additions and 225 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).
+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.
+12
View File
@@ -1,5 +1,17 @@
# 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.
+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(
@@ -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>
@@ -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.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;
}
}
@@ -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));
}
}
@@ -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,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";
@@ -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),
@@ -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
@@ -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>
@@ -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 ───────────────────────────────────────────────────────────