mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a47e94f9a5 | |||
| ecfa59d049 | |||
| 6c50a54249 | |||
| 6e3487756a | |||
| dbb154bae9 | |||
| c41c2327ab | |||
| 83effea592 | |||
| 1a4a37b77a | |||
| f56851fc9f | |||
| cbe410cbbc | |||
| 43ddc60111 | |||
| 82b26b4ab9 | |||
| 1733f4fd48 |
@@ -216,7 +216,12 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
|
||||
- `SignalIngress` — bounded queue (256, back-pressure) + **one** worker thread. Per
|
||||
signal, in order: assign ordinals → `SignalLog.Append` (durable, *before* reducing;
|
||||
append failure ⇒ reducer does not run, no ordinal gap) → `engine.Reduce` →
|
||||
`DecisionStepProcessor.ApplyStep`.
|
||||
`DecisionStepProcessor.ApplyStep`. A `Post` issued *by the worker thread itself*
|
||||
(the `EffectRunner` posting `ClassifierVerdictIssued` from inside `ApplyStep`) never
|
||||
enters the bounded queue: it goes to a worker-local follow-up queue that is drained,
|
||||
ahead of the channel, right after the current item — the worker is the queue's only
|
||||
consumer, so blocking on a full channel there would freeze the pipeline for the rest
|
||||
of the run.
|
||||
- `DecisionStepProcessor.ApplyStep` (same thread, no locks): journal append (the only
|
||||
hard-throwing path; 3 consecutive failures escalate to quarantine) → effects run
|
||||
synchronously via `EffectRunner` → best-effort snapshot → advance `CurrentState` →
|
||||
|
||||
@@ -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_*`
|
||||
|
||||
@@ -109,7 +109,9 @@ telemetry directly:
|
||||
```
|
||||
Collector callback
|
||||
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
|
||||
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer;
|
||||
posts from the worker thread itself bypass the
|
||||
queue via a worker-local follow-up queue)
|
||||
→ single worker thread:
|
||||
assign ordinals → SignalLog.Append (durable) ← source of truth
|
||||
→ DecisionEngine.Reduce → apply step/effects
|
||||
|
||||
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
|
||||
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
|
||||
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
|
||||
scope always may write; a roleless caller may write only while the tenant has **no enabled
|
||||
member** at all. Once any member exists the window is closed for good — the address feeds the
|
||||
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
|
||||
employee with a JWT but no product role must not be able to redirect or clear it (403).
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
@@ -107,6 +115,13 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* The domain is HTML-encoded at the point of substitution (`EmailTemplateService.Render` and the
|
||||
built-in template builders) — the mail goes out under the product's signed sender to a
|
||||
tenant-chosen recipient, so no writer of `DomainName` may ever inject markup into it.
|
||||
`DomainName` itself is tenant identity: seeded once at first login from the UPN domain
|
||||
(`AuthFunction`, only when it passes `TenantConfigValidation.IsValidDomainName` — a strict
|
||||
dotted host name), preserved from the stored row on the full-model config PUT for every
|
||||
caller, and "never writable" in the field-patch flow.
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+20
@@ -1,5 +1,25 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (1)
|
||||
|
||||
* **Update**: `agent/diagnostics-package.md` — reads are validated on the handle, not the path: `PinnedSourceFolder` pins each section folder (its final path must equal the validated path; held open so the chain cannot be renamed) and opens every file with `FILE_FLAG_OPEN_REPARSE_POINT`, requiring the handle's final path to be `<canonical folder>\<relative path>`. A subdirectory swapped for a junction between enumeration and open is skipped (`SKIPPED (resolved outside validated folder)`) instead of copied into the package.
|
||||
|
||||
## 2026-08-29 (12)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — `PUT preview/notification-email` moved to `AuthenticatedUserWithRole`; roleless callers may write the address only while the tenant has no enabled member (signup window), afterwards a member role is required.
|
||||
|
||||
## 2026-08-29 (11)
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
|
||||
|
||||
## 2026-08-29 (10)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
|
||||
|
||||
## 2026-08-29 (9)
|
||||
|
||||
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
|
||||
|
||||
## 2026-08-29 (8)
|
||||
|
||||
* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+234
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-10
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -498,6 +498,207 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(5, observedCount);
|
||||
}
|
||||
|
||||
// ============================================================ Worker re-entrancy (2026-08-29)
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_with_full_channel_does_not_deadlock_and_is_processed_next()
|
||||
{
|
||||
// Channel capacity 1, the worker is parked inside ApplyStep on a gate while a
|
||||
// second producer keeps the channel full. When the gate opens, ApplyStep posts a
|
||||
// verdict from the worker thread. Before the fix, Post would block in
|
||||
// BlockingCollection.Add forever (the worker is the only consumer). After the fix,
|
||||
// the verdict goes to the worker-local queue, is processed BEFORE the queued channel
|
||||
// item, and the worker keeps draining.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var gate = new ManualResetEventSlim(false);
|
||||
var inApply = new ManualResetEventSlim(false);
|
||||
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At),
|
||||
channelCapacity: 1);
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
// Item 0 — worker takes it and parks inside ApplyStep on the gate.
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
Assert.True(inApply.Wait(5000));
|
||||
|
||||
// Item 1 fills the single slot; item 2 blocks a foreign producer (channel full).
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
|
||||
var blockedProducer = Task.Run(() =>
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(2), "Collector", RawEvidence("s2")));
|
||||
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1));
|
||||
Assert.False(blockedProducer.Wait(200)); // genuinely blocked by back-pressure
|
||||
|
||||
// Release the worker: ApplyStep(s0) now posts the verdict from the worker thread
|
||||
// into a full channel.
|
||||
gate.Set();
|
||||
|
||||
Assert.True(blockedProducer.Wait(5000), "foreign producer must be released — worker must keep draining");
|
||||
Assert.True(WaitFor(() => processor.CallCount == 4));
|
||||
ing.Stop();
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
DecisionSignalKind.SessionStarted,
|
||||
DecisionSignalKind.ClassifierVerdictIssued, // follow-up drained BEFORE the next channel item
|
||||
DecisionSignalKind.DesktopArrived,
|
||||
DecisionSignalKind.DesktopArrived,
|
||||
},
|
||||
processor.Kinds);
|
||||
Assert.Equal(1, ing.ReentrantPostCount);
|
||||
Assert.Equal(0, ing.PendingSignalCount);
|
||||
// Same worker thread for every step — single-writer invariant intact.
|
||||
Assert.Single(processor.ThreadIds.Distinct());
|
||||
// Follow-up went through the normal path: ordinal assigned + durably logged.
|
||||
var logged = log.ReadAll();
|
||||
Assert.Equal(4, logged.Count);
|
||||
Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, logged[1].Kind);
|
||||
Assert.Equal(1, logged[1].SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_chain_is_drained_iteratively_in_order()
|
||||
{
|
||||
// A follow-up whose own ApplyStep posts another follow-up: both are processed on
|
||||
// the worker, in order, before the next channel item, without recursion.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var processor = new ChainingProcessor(() => ingRef!);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At));
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
|
||||
ing.Stop();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Collector", "chain:1", "chain:2", "Collector" },
|
||||
processor.Origins);
|
||||
Assert.Equal(2, ing.ReentrantPostCount);
|
||||
Assert.Equal(4, log.ReadAll().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_thread_post_during_Stop_drain_is_still_processed()
|
||||
{
|
||||
// Stop() completes adding, but a verdict posted by the worker while it finishes
|
||||
// the last channel item must not be dropped — it belongs to that step.
|
||||
using var tmp = new TempDirectory();
|
||||
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
|
||||
SignalIngress? ingRef = null;
|
||||
var gate = new ManualResetEventSlim(false);
|
||||
var inApply = new ManualResetEventSlim(false);
|
||||
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
|
||||
var ing = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: log,
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: processor,
|
||||
clock: new VirtualClock(At));
|
||||
ingRef = ing;
|
||||
ing.Start();
|
||||
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
|
||||
Assert.True(inApply.Wait(5000));
|
||||
var stopTask = Task.Run(() => ing.Stop());
|
||||
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0 && !stopTask.IsCompleted));
|
||||
gate.Set();
|
||||
Assert.True(stopTask.Wait(5000));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { DecisionSignalKind.SessionStarted, DecisionSignalKind.ClassifierVerdictIssued },
|
||||
processor.Kinds);
|
||||
Assert.Equal(2, log.ReadAll().Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parks inside ApplyStep(SessionStarted) on a gate, then posts a verdict back into the
|
||||
/// ingress from the worker thread — the EffectRunner/RunClassifier shape.
|
||||
/// </summary>
|
||||
private sealed class GatedReentrantProcessor : IDecisionStepProcessor
|
||||
{
|
||||
private readonly Func<ISignalIngressSink> _sink;
|
||||
private readonly ManualResetEventSlim _gate;
|
||||
private readonly ManualResetEventSlim _inApply;
|
||||
private readonly List<(DecisionSignalKind Kind, int ThreadId)> _calls = new List<(DecisionSignalKind, int)>();
|
||||
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
|
||||
|
||||
public GatedReentrantProcessor(Func<ISignalIngressSink> sink, ManualResetEventSlim gate, ManualResetEventSlim inApply)
|
||||
{
|
||||
_sink = sink;
|
||||
_gate = gate;
|
||||
_inApply = inApply;
|
||||
}
|
||||
|
||||
public int CallCount { get { lock (_calls) { return _calls.Count; } } }
|
||||
public DecisionSignalKind[] Kinds { get { lock (_calls) { return _calls.Select(c => c.Kind).ToArray(); } } }
|
||||
public int[] ThreadIds { get { lock (_calls) { return _calls.Select(c => c.ThreadId).ToArray(); } } }
|
||||
|
||||
public DecisionCore.State.DecisionState CurrentState => _state;
|
||||
|
||||
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
|
||||
{
|
||||
lock (_calls) { _calls.Add((signal.Kind, Thread.CurrentThread.ManagedThreadId)); }
|
||||
_state = step.NewState;
|
||||
if (signal.Kind == DecisionSignalKind.SessionStarted)
|
||||
{
|
||||
_inApply.Set();
|
||||
_gate.Wait();
|
||||
_sink().Post(
|
||||
DecisionSignalKind.ClassifierVerdictIssued,
|
||||
signal.OccurredAtUtc,
|
||||
"effectrunner:classifier:test",
|
||||
new Evidence(EvidenceKind.Synthetic, "classifier:test:h", "Strong/90"),
|
||||
new Dictionary<string, string> { ["level"] = "Strong" });
|
||||
}
|
||||
return EffectRunResult.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ChainingProcessor : IDecisionStepProcessor
|
||||
{
|
||||
private readonly Func<ISignalIngressSink> _sink;
|
||||
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
|
||||
public List<string> Origins { get; } = new List<string>();
|
||||
|
||||
public ChainingProcessor(Func<ISignalIngressSink> sink) { _sink = sink; }
|
||||
|
||||
public DecisionCore.State.DecisionState CurrentState => _state;
|
||||
|
||||
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
|
||||
{
|
||||
Origins.Add(signal.SourceOrigin);
|
||||
_state = step.NewState;
|
||||
string? next = signal.Kind == DecisionSignalKind.SessionStarted ? "chain:1"
|
||||
: signal.SourceOrigin == "chain:1" ? "chain:2"
|
||||
: null;
|
||||
if (next != null)
|
||||
{
|
||||
_sink().Post(
|
||||
DecisionSignalKind.ClassifierVerdictIssued,
|
||||
signal.OccurredAtUtc,
|
||||
next,
|
||||
new Evidence(EvidenceKind.Synthetic, next, next));
|
||||
}
|
||||
return EffectRunResult.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_via_ISignalIngressSink_reaches_reducer_like_any_signal()
|
||||
{
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+37
@@ -50,5 +50,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
|
||||
// The branding URL is tenant config; the dialog parses arguments last-occurrence-wins, so
|
||||
// an embedded quote must never terminate the argument and inject extra flags (CWE-88).
|
||||
|
||||
private const string BaseArgs = "--status-file \"C:\\x\\final-status.json\" --timeout 30 --cleanup";
|
||||
|
||||
[Fact]
|
||||
public void BuildDialogArguments_omits_branding_when_empty()
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, null, null);
|
||||
|
||||
Assert.Equal(BaseArgs, args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDialogArguments_appends_clean_https_url_as_single_quoted_argument()
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, "https://cdn.example/logo.png?v=2", null);
|
||||
|
||||
Assert.Equal(BaseArgs + " --branding-url \"https://cdn.example/logo.png?v=2\"", args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0")]
|
||||
[InlineData("https://cdn.example/logo.png\" --cleanup")]
|
||||
[InlineData("https://cdn.example/logo.png --timeout 0")]
|
||||
[InlineData("https://cdn.example/lo'go.png")]
|
||||
[InlineData("https://cdn.example/logo.png\r\n")]
|
||||
[InlineData("http://cdn.example/logo.png")]
|
||||
[InlineData("file:///C:/Windows/x.png")]
|
||||
[InlineData("cdn.example/logo.png")]
|
||||
public void BuildDialogArguments_drops_unsafe_branding_url(string url)
|
||||
{
|
||||
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, url, null);
|
||||
|
||||
Assert.Equal(BaseArgs, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
|
||||
/// without following a reparse point at its final component, and ask the handle where it
|
||||
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
|
||||
/// </summary>
|
||||
internal static class FileNativeMethods
|
||||
{
|
||||
public const uint GENERIC_READ = 0x80000000;
|
||||
public const uint FILE_LIST_DIRECTORY = 0x0001;
|
||||
public const uint FILE_READ_ATTRIBUTES = 0x0080;
|
||||
|
||||
public const uint FILE_SHARE_READ = 0x1;
|
||||
public const uint FILE_SHARE_WRITE = 0x2;
|
||||
|
||||
public const uint OPEN_EXISTING = 3;
|
||||
|
||||
public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
|
||||
public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||
public const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
|
||||
|
||||
public const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
||||
public const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
|
||||
|
||||
/// <summary>GetFinalPathNameByHandle flags: FILE_NAME_NORMALIZED | VOLUME_NAME_DOS (both zero).</summary>
|
||||
public const uint FINAL_PATH_NORMALIZED_DOS = 0;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct BY_HANDLE_FILE_INFORMATION
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern SafeFileHandle CreateFileW(
|
||||
string lpFileName,
|
||||
uint dwDesiredAccess,
|
||||
uint dwShareMode,
|
||||
IntPtr lpSecurityAttributes,
|
||||
uint dwCreationDisposition,
|
||||
uint dwFlagsAndAttributes,
|
||||
IntPtr hTemplateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle hFile,
|
||||
out BY_HANDLE_FILE_INFORMATION lpFileInformation);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle hFile,
|
||||
StringBuilder lpszFilePath,
|
||||
uint cchFilePath,
|
||||
uint dwFlags);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern uint GetLongPathNameW(
|
||||
string lpszShortPath,
|
||||
StringBuilder lpszLongPath,
|
||||
uint cchBuffer);
|
||||
}
|
||||
}
|
||||
+152
-123
@@ -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><canonical folder>\<relative path></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><canonical folder>\<relative path></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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
});
|
||||
|
||||
+2
-2
@@ -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
|
||||
});
|
||||
|
||||
+1
-1
@@ -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
|
||||
});
|
||||
|
||||
+38
-1
@@ -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
|
||||
});
|
||||
|
||||
@@ -88,9 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot
|
||||
private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch
|
||||
|
||||
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the
|
||||
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixed L8: the
|
||||
// tripwire post no longer runs on the ingress worker, so a full channel cannot
|
||||
// self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep.
|
||||
// self-deadlock on it. (Since 2026-08-29 SignalIngress.Post itself is re-entrancy-safe
|
||||
// — worker-thread posts go to a worker-local queue — so the EffectRunner's
|
||||
// ClassifierVerdictIssued post from inside ApplyStep is covered too.) Guarded by
|
||||
// _parkedLock because the callback races ApplyStep.
|
||||
private readonly object _parkedLock = new object();
|
||||
private readonly TimeSpan _parkedTripwireDwell;
|
||||
private Timer? _parkedDwellTimer;
|
||||
|
||||
+13
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Die Back-Pressure-Emission läuft NICHT durch den Signal-Channel (würde deadlocken).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Worker re-entrancy</b>: a <see cref="Post"/> issued ON the worker thread itself
|
||||
/// (EffectRunner posting a <c>ClassifierVerdictIssued</c> from inside
|
||||
/// <c>ApplyStep</c>) must never touch the bounded channel — the worker is the channel's
|
||||
/// only consumer, so a full channel would block the consumer as a producer and freeze
|
||||
/// the whole pipeline for the rest of the run. Such posts go to a worker-local
|
||||
/// follow-up queue that the worker drains (ahead of the channel) right after the
|
||||
/// current item finishes. Same processing path, same ordinal/log/reduce semantics —
|
||||
/// just never blocking.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shutdown</b>: <see cref="Stop"/> ruft <see cref="BlockingCollection{T}.CompleteAdding"/>,
|
||||
/// wartet auf das Drain-Ende via <c>Thread.Join</c>. Nach <c>Stop</c> wirft jeder
|
||||
/// weitere <see cref="Post"/> <see cref="InvalidOperationException"/>.
|
||||
@@ -79,6 +89,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly object _backPressureLock = new object();
|
||||
|
||||
private Thread? _worker;
|
||||
// Worker-thread-only: signals posted re-entrantly from ApplyStep (EffectRunner
|
||||
// classifier verdicts). Drained by WorkerLoop after every channel item, before the
|
||||
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
|
||||
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
|
||||
private long _reentrantPostCount;
|
||||
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
|
||||
private int _started; // 0 = not started, 1 = running
|
||||
private int _stopRequested; // 0 = live, 1 = Stop called
|
||||
@@ -135,6 +150,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public long PendingSignalCount => Interlocked.Read(ref _unprocessedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that were posted from the worker thread itself and routed through the
|
||||
/// worker-local follow-up queue instead of the bounded channel. Observability/tests.
|
||||
/// </summary>
|
||||
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
|
||||
|
||||
/// <summary>
|
||||
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
|
||||
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
|
||||
@@ -218,6 +239,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Reicht ein Raw-/Derived-/Synthetic-Signal in den Ingress ein. Thread-safe; darf von
|
||||
/// beliebig vielen Collector-Threads (und vom <see cref="EffectRunner"/>) parallel
|
||||
/// gerufen werden. Blockiert, wenn der Channel voll ist — Plan §2.1a Back-Pressure.
|
||||
/// Ausnahme: Aufrufe vom Worker-Thread selbst blockieren nie (worker-local queue).
|
||||
/// </summary>
|
||||
public void Post(
|
||||
DecisionSignalKind kind,
|
||||
@@ -228,10 +250,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int kindSchemaVersion = 1,
|
||||
object? typedPayload = null)
|
||||
{
|
||||
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
|
||||
{
|
||||
throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
}
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(evidence));
|
||||
@@ -243,6 +261,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
var item = new IngressItem(kind, kindSchemaVersion, occurredAtUtc, sourceOrigin, evidence, payload, typedPayload);
|
||||
|
||||
// Worker re-entrancy (see class doc): a post from the worker thread itself goes to
|
||||
// the worker-local follow-up queue, never into the bounded channel. Checked before
|
||||
// the stop gate on purpose — while Stop() drains the channel the worker may still
|
||||
// produce verdicts for the items it is finishing, and those must not be lost.
|
||||
if (ReferenceEquals(Thread.CurrentThread, _worker))
|
||||
{
|
||||
Interlocked.Increment(ref _unprocessedCount);
|
||||
_workerLocalQueue.Enqueue(item);
|
||||
Interlocked.Increment(ref _reentrantPostCount);
|
||||
if (_workerLocalQueue.Count > 1)
|
||||
{
|
||||
// One follow-up per step is the expected shape (one RunClassifier effect).
|
||||
// More means effects are chaining on the worker — worth seeing in the log.
|
||||
_logger?.Debug($"SignalIngress: worker-local follow-up queue depth={_workerLocalQueue.Count} kind={kind} origin={sourceOrigin}");
|
||||
}
|
||||
RaiseSignalPosted(kind, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
|
||||
{
|
||||
throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
}
|
||||
|
||||
// Codex Finding 2 (2026-04-30): bump the pending-counter BEFORE handing the item
|
||||
// to the channel. Decrement in three places: (a) when TryAdd throws because
|
||||
// CompleteAdding raced between the post-stop check and the enqueue, (b) when
|
||||
@@ -353,6 +395,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
foreach (var item in _channel.GetConsumingEnumerable())
|
||||
{
|
||||
ProcessItem(item);
|
||||
DrainWorkerLocalQueue();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -366,6 +409,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes signals the worker posted to itself during the previous item (see class
|
||||
/// doc, worker re-entrancy). Iterative, not recursive: a follow-up that produces
|
||||
/// another follow-up is simply appended and handled in the same loop.
|
||||
/// </summary>
|
||||
private void DrainWorkerLocalQueue()
|
||||
{
|
||||
while (_workerLocalQueue.Count > 0)
|
||||
{
|
||||
ProcessItem(_workerLocalQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessItem(IngressItem item)
|
||||
{
|
||||
// Codex Finding 2 (2026-04-30): match the Interlocked.Increment that Post()
|
||||
|
||||
@@ -33,18 +33,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// which is exactly the set the UI timeline groups on), plus the originating
|
||||
/// <see cref="EnrollmentEvent.Source"/> component label. The source lets subscribers that
|
||||
/// themselves emit timeline events (the gather-rule executor) recognise their own output
|
||||
/// and not re-trigger on it — see <see cref="GatherRuleExecutorHost"/>.
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
public event Action<string, EnrollmentPhase, string>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
internal void Publish(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
foreach (Action<string, EnrollmentPhase, string> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
try { subscriber(eventType, phase, source); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
|
||||
@@ -148,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase, evt.Source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -132,10 +132,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
// 4) Compose args. Legacy contract: --status-file <path> --timeout <s> --cleanup [--branding-url <url>].
|
||||
var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName);
|
||||
var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
|
||||
if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl))
|
||||
args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\"";
|
||||
var args = BuildDialogArguments(
|
||||
Path.Combine(tempDir, FinalStatusFileName),
|
||||
configuration.EnrollmentSummaryTimeoutSeconds,
|
||||
configuration.EnrollmentSummaryBrandingImageUrl,
|
||||
logger);
|
||||
|
||||
// 5) Launch in user session with retry.
|
||||
var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0
|
||||
@@ -151,6 +152,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
return launched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the dialog command line. The branding URL is tenant-supplied config and the
|
||||
/// dialog parses arguments last-occurrence-wins, so a value carrying a double quote could
|
||||
/// terminate its argument and override <c>--status-file</c>/<c>--timeout</c>/<c>--cleanup</c>
|
||||
/// (CWE-88). The backend rejects such values, but the agent is the last line of defence:
|
||||
/// only a plain absolute HTTPS URL without quotes, whitespace or control characters is
|
||||
/// ever placed on the command line — anything else is dropped (the dialog then simply
|
||||
/// shows no branding image).
|
||||
/// </summary>
|
||||
internal static string BuildDialogArguments(string statusFilePath, int timeoutSeconds, string brandingImageUrl, AgentLogger logger)
|
||||
{
|
||||
var args = $"--status-file \"{statusFilePath}\" --timeout {timeoutSeconds} --cleanup";
|
||||
if (string.IsNullOrWhiteSpace(brandingImageUrl))
|
||||
return args;
|
||||
|
||||
if (IsSafeBrandingImageUrl(brandingImageUrl))
|
||||
return args + $" --branding-url \"{brandingImageUrl}\"";
|
||||
|
||||
logger?.Warning("SummaryDialogLauncher: EnrollmentSummaryBrandingImageUrl is not a plain absolute HTTPS URL — ignoring it.");
|
||||
return args;
|
||||
}
|
||||
|
||||
internal static bool IsSafeBrandingImageUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url) || url.Length > 2048)
|
||||
return false;
|
||||
|
||||
foreach (var ch in url)
|
||||
{
|
||||
if (ch == '"' || ch == '\'' || char.IsWhiteSpace(ch) || char.IsControl(ch))
|
||||
return false;
|
||||
}
|
||||
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& uri.Scheme == Uri.UriSchemeHttps
|
||||
&& !string.IsNullOrEmpty(uri.Host);
|
||||
}
|
||||
|
||||
private static void TryCopy(string source, string destination)
|
||||
{
|
||||
try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); }
|
||||
|
||||
@@ -123,6 +123,22 @@ public class AuthFunctionSideEffectTests
|
||||
// HandleNewTenantDomainAsync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
config.OnboardedBy = null;
|
||||
|
||||
// The seed is rendered into transactional mails: anything but a strict host name is refused.
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "user@<b>contoso.com</b>");
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenDomainEmpty_ExtractsAndSaves()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Request-derived blob names must be flat. Azure.Storage.Blobs keeps '/' in the blob URI and
|
||||
/// System.Uri collapses '..' segments, so "x/../../other-container/y" would resolve outside the
|
||||
/// intended container. The guard is the last line at every GetBlobClient sink.
|
||||
/// </summary>
|
||||
public class BlobNameGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_diag_request_20260829_120000.zip")]
|
||||
[InlineData("report.zip")]
|
||||
[InlineData("a.b.c.zip")]
|
||||
public void IsFlat_AcceptsFlatNames(string name)
|
||||
{
|
||||
Assert.True(BlobNameGuard.IsFlat(name));
|
||||
Assert.Equal(name, BlobNameGuard.EnsureFlat(name, "name"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("TA_x/../../hosted-diagnostics/victim/payload_diag_request_1.zip")]
|
||||
[InlineData("a/b.zip")]
|
||||
[InlineData("a\\b.zip")]
|
||||
[InlineData("..zip")]
|
||||
[InlineData("a%2F..%2Fb.zip")]
|
||||
[InlineData("a?b.zip")]
|
||||
[InlineData("a#b.zip")]
|
||||
public void IsFlat_RejectsPathShapedNames(string? name)
|
||||
{
|
||||
Assert.False(BlobNameGuard.IsFlat(name));
|
||||
Assert.Throws<ArgumentException>(() => BlobNameGuard.EnsureFlat(name, "name"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The enrollment-summary branding image URL is delivered verbatim to every enrolling device,
|
||||
/// fetched from the signed-in user's session and spliced into the summary-dialog command line.
|
||||
/// The server must therefore only ever persist a plain absolute HTTPS URL — a value with an
|
||||
/// embedded quote or a non-HTTPS scheme must fail the config PUT/PATCH.
|
||||
/// </summary>
|
||||
public class BrandingImageUrlValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://cdn.example/logo.png")]
|
||||
[InlineData("https://cdn.example:8443/assets/logo.png?v=2&x=y")]
|
||||
[InlineData("https://cdn.example/logo%20one.png")]
|
||||
public void ValidateBrandingImageUrl_WellFormed_ReturnsNull(string? url)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateBrandingImageUrl(url));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0", "quotes")]
|
||||
[InlineData("https://cdn.example/logo.png\"", "quotes")]
|
||||
[InlineData("https://cdn.example/lo'go.png", "quotes")]
|
||||
[InlineData("https://cdn.example/logo.png --cleanup", "whitespace")]
|
||||
[InlineData("https://cdn.example/logo.png\r\n", "whitespace")]
|
||||
[InlineData("http://cdn.example/logo.png", "HTTPS")]
|
||||
[InlineData("file:///C:/Windows/logo.png", "HTTPS")]
|
||||
[InlineData("ftp://cdn.example/logo.png", "HTTPS")]
|
||||
[InlineData("cdn.example/logo.png", "absolute URL")]
|
||||
[InlineData("https://localhost/logo.png", "localhost")]
|
||||
[InlineData("https://10.0.0.1/logo.png", "IP address")]
|
||||
public void ValidateBrandingImageUrl_Malformed_Rejected(string url, string expectedFragment)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateBrandingImageUrl(url);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedFragment, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBrandingImageUrl_Overlong_Rejected()
|
||||
{
|
||||
var url = "https://cdn.example/" + new string('a', TenantConfigValidation.MaxBrandingImageUrlLength);
|
||||
Assert.Contains("characters", TenantConfigValidation.ValidateBrandingImageUrl(url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_RejectsInjectedBrandingUrl()
|
||||
{
|
||||
var existing = new TenantConfiguration();
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
EnrollmentSummaryBrandingImageUrl = "x\" --status-file \"C:\\some\\file\" --timeout \"0",
|
||||
};
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.StartsWith("Invalid enrollment summary branding image URL", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,18 @@ public sealed class EmailServiceTests
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInTemplates_HtmlEncodeDomain()
|
||||
{
|
||||
const string hostile = "<a href=\"https://evil.invalid\">x</a>";
|
||||
|
||||
foreach (var html in new[] { EmailTemplates.GetOffboardingFarewellHtml(hostile), EmailTemplates.GetPreviewApprovedHtml(hostile) })
|
||||
{
|
||||
Assert.DoesNotContain(hostile, html);
|
||||
Assert.Contains("<a href="https://evil.invalid">x</a>", html);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,30 @@ public sealed class EmailTemplateServiceTests
|
||||
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<b>x</b>", "<b>x</b>")]
|
||||
[InlineData("a\"b&c", "a"b&c")]
|
||||
public void Render_HtmlEncodesDomain_ForOverrideTemplates(string domain, string expected)
|
||||
{
|
||||
var html = EmailTemplateService.Render("<p>{{domainName}}</p>", domain);
|
||||
|
||||
Assert.Equal($"<p>{expected}</p>", html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EmailTemplateKind.Welcome)]
|
||||
[InlineData(EmailTemplateKind.Farewell)]
|
||||
public async Task BuiltIn_HtmlEncodesDomain_NoDoubleEncoding(EmailTemplateKind kind)
|
||||
{
|
||||
var (sut, _) = Build();
|
||||
|
||||
var html = await sut.GetHtmlAsync(kind, "<img src=x onerror=alert(1)>&\"");
|
||||
|
||||
Assert.DoesNotContain("<img", html);
|
||||
Assert.Contains("<img src=x onerror=alert(1)>&"", html);
|
||||
Assert.DoesNotContain("&lt;", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_WinsAndRendersPlaceholder()
|
||||
{
|
||||
|
||||
@@ -240,6 +240,45 @@ public class GatherRuleScopeFieldsTests
|
||||
ValidationRule(activePhases: new List<string> { "" })));
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string triggerEventType, string? outputEventType)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-VAL-EVT",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Contoso",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = outputEventType!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("gather_proxy", "gather_proxy")] // self-cycle
|
||||
[InlineData("GATHER_PROXY", "gather_proxy")] // case-insensitive (agent matches OrdinalIgnoreCase)
|
||||
[InlineData("gather_result", null)] // self-cycle through the default output type
|
||||
[InlineData("gather_result", "")]
|
||||
[InlineData("gather_result", "gather_other")] // gather output namespace never triggers
|
||||
public void Validate_RejectsOnEventRuleTriggeringOnGatherOutput(string trigger, string? output)
|
||||
{
|
||||
Assert.NotNull(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_complete", "gather_proxy")]
|
||||
[InlineData("app_install_failed", null)]
|
||||
[InlineData("", "gather_proxy")] // partial/toggle payload passes through
|
||||
public void Validate_AcceptsOnEventRuleOnNonGatherEvents(string trigger, string? output)
|
||||
{
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutputEqualsTrigger_IsOnlyCheckedForOnEventTrigger()
|
||||
{
|
||||
var rule = OnEventRule("gather_same", "gather_same");
|
||||
rule.Trigger = "startup";
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsBothScopeFieldsSet()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The hardware whitelist is stored as one CSV and split at enforcement time. A value that
|
||||
/// carries the delimiter (e.g. an attacker-chosen distress-signal manufacturer "Dell Inc.,*")
|
||||
/// must never be able to widen the gate: the server rejects malformed lists and the parser
|
||||
/// yields exactly the trimmed, non-empty items.
|
||||
/// </summary>
|
||||
public class HardwareWhitelistCsvIntegrityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("*")]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation")]
|
||||
[InlineData("Dell Inc.?*")]
|
||||
[InlineData(" Dell* , HP* ")]
|
||||
public void ValidateHardwareWhitelist_WellFormed_ReturnsNull(string? csv)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateHardwareWhitelist(csv));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.,")]
|
||||
[InlineData(",Dell*")]
|
||||
[InlineData("Dell*,,HP*")]
|
||||
[InlineData("Dell*, ,HP*")]
|
||||
[InlineData(",")]
|
||||
public void ValidateHardwareWhitelist_BlankItem_Rejected(string csv)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateHardwareWhitelist(csv);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("must not be empty", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateHardwareWhitelist_ControlCharacter_Rejected()
|
||||
{
|
||||
var csv = "Dell*" + (char)1 + ",HP*";
|
||||
Assert.Contains("control characters", TenantConfigValidation.ValidateHardwareWhitelist(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateHardwareWhitelist_OverlongEntry_Rejected()
|
||||
{
|
||||
Assert.Contains("128 characters",
|
||||
TenantConfigValidation.ValidateHardwareWhitelist(new string('a', 129)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateHardwareWhitelist_TooManyEntries_Rejected()
|
||||
{
|
||||
var csv = string.Join(",", Enumerable.Repeat("x", 201));
|
||||
Assert.Contains("200 entries", TenantConfigValidation.ValidateHardwareWhitelist(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_RejectsMalformedWhitelists_WithFieldSpecificMessage()
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant");
|
||||
|
||||
var badMfr = TenantConfiguration.CreateDefault("tenant");
|
||||
badMfr.ManufacturerWhitelist = "Dell*,Dell Inc.,";
|
||||
Assert.StartsWith("Invalid manufacturer whitelist:",
|
||||
TenantConfigValidation.ValidateModel(badMfr, existing, isGlobalAdmin: false));
|
||||
|
||||
var badModel = TenantConfiguration.CreateDefault("tenant");
|
||||
badModel.ModelWhitelist = "Latitude*,,";
|
||||
Assert.StartsWith("Invalid model whitelist:",
|
||||
TenantConfigValidation.ValidateModel(badModel, existing, isGlobalAdmin: false));
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(
|
||||
TenantConfiguration.CreateDefault("tenant"), existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWhitelist_TrimsEntries_SoEditorViewMatchesEnforcement()
|
||||
{
|
||||
// The web editor displays trimmed items; enforcement must match the same patterns.
|
||||
Assert.Equal(new[] { "Dell*", "HP*" }, TenantConfiguration.ParseWhitelist("Dell*, HP*"));
|
||||
Assert.Equal(new[] { "*" }, TenantConfiguration.ParseWhitelist(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeutralizedDistressValue_MatchesOnlyItself_NotEveryManufacturer()
|
||||
{
|
||||
// "Dell Inc.,*" neutralized by the web helper to the single pattern "Dell Inc.?*".
|
||||
var whitelist = TenantConfiguration.ParseWhitelist("Dell*,Dell Inc.?*");
|
||||
Assert.DoesNotContain("*", whitelist);
|
||||
|
||||
Assert.False(HardwareWhitelistValidator.ValidateHardware("Contoso", "X1", whitelist, new[] { "*" }).IsValid);
|
||||
Assert.True(HardwareWhitelistValidator.ValidateHardware("Dell Inc.,*", "X1", whitelist, new[] { "*" }).IsValid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
|
||||
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
private const string SessionB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
private const string Filename = "AgentDiagnostics-" + SessionA + "-20260519T120000.zip";
|
||||
|
||||
// ── Path build + validation ────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,7 +91,15 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync("not-a-guid", Filename));
|
||||
() => svc.GenerateUploadSasAsync("not-a-guid", SessionA, Filename));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_RejectsNonGuidSessionId()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync(TenantA, "not-a-guid", Filename));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,14 +107,64 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync(TenantA, "../other-tenant/escape.zip"));
|
||||
() => svc.GenerateUploadSasAsync(TenantA, SessionA, "../other-tenant/escape.zip"));
|
||||
}
|
||||
|
||||
// ── Session binding — one device must not be able to name another session's package ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("AgentDiagnostics-" + SessionB + "-20260519T120000.zip")] // foreign session
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "20260519T120000.zip")] // missing separator
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "-")] // nothing after the session
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.txt")] // not a zip
|
||||
[InlineData("x-AgentDiagnostics-" + SessionA + "-20260519T120000.zip")] // wrong prefix
|
||||
[InlineData("other.zip")]
|
||||
public async Task GenerateUploadSas_RejectsFilenameNotBoundToSession(string filename)
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync(TenantA, SessionA, filename));
|
||||
Assert.Empty(svc.CapturedSasBuilders);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.zip")]
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000-preprov.zip")]
|
||||
[InlineData("AgentDiagnostics-" + SessionA + "-20260724170902-server-requested.zip")]
|
||||
[InlineData("agentdiagnostics-" + SessionA + "-20260519T120000.ZIP")] // case-insensitive
|
||||
public async Task GenerateUploadSas_AcceptsEveryAgentProducedShapeForOwnSession(string filename)
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, filename);
|
||||
Assert.Equal($"{TenantA}/{filename}", result.BlobPath);
|
||||
}
|
||||
|
||||
// ── Create-only — an existing blob is never re-issued ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_ExistingBlob_ThrowsAlreadyExists_AndMintsNothing()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantA}/{Filename}" };
|
||||
var ex = await Assert.ThrowsAsync<HostedBlobAlreadyExistsException>(
|
||||
() => svc.GenerateUploadSasAsync(TenantA, SessionA, Filename));
|
||||
Assert.Equal($"{TenantA}/{Filename}", ex.BlobPath);
|
||||
Assert.Empty(svc.CapturedSasBuilders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_ExistingBlobInOtherTenant_DoesNotBlockSameName()
|
||||
{
|
||||
// Existence is checked on the tenant-prefixed path, not on the bare filename.
|
||||
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantB}/{Filename}" };
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_ReturnsTenantPrefixedBlobPath()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
|
||||
}
|
||||
|
||||
@@ -112,9 +172,9 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
public async Task GenerateUploadSas_EnsuresContainerOnce()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantB, "other.zip");
|
||||
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
|
||||
Assert.Equal(1, svc.EnsureContainerCallCount);
|
||||
}
|
||||
|
||||
@@ -124,7 +184,7 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var requested = TimeSpan.FromHours(24);
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename, requested);
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename, requested);
|
||||
var maxAllowed = before.Add(HostedDiagnosticsBlobService.MaxUploadSasTtl).AddMinutes(1); // tolerance
|
||||
Assert.True(result.ExpiresAt <= maxAllowed,
|
||||
$"ExpiresAt {result.ExpiresAt:O} exceeded MaxUploadSasTtl-bounded {maxAllowed:O}");
|
||||
@@ -135,16 +195,16 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
var delta = result.ExpiresAt - before;
|
||||
Assert.InRange(delta.TotalMinutes, 14, 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_PassesBlobScopedResourceAndWriteCreateOnlyPermsToBuilder()
|
||||
public async Task GenerateUploadSas_PassesBlobScopedResourceAndCreateOnlyPermsToBuilder()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
|
||||
Assert.Single(svc.CapturedSasBuilders);
|
||||
var builder = svc.CapturedSasBuilders.Single();
|
||||
@@ -152,24 +212,26 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
Assert.Equal($"{TenantA}/{Filename}", builder.BlobName);
|
||||
Assert.Equal("diagnostics", builder.BlobContainerName);
|
||||
|
||||
// Reconstruct the permissions string to ensure ONLY Write + Create are set —
|
||||
// ONLY Create — no Write (would allow replacing an existing same-tenant blob),
|
||||
// no Read (would expose other-tenant blobs in cross-tenant scenarios),
|
||||
// no Delete (cleanup is service-side via DeleteIfExistsAsync),
|
||||
// no List (agent has no enumeration need).
|
||||
var permsString = builder.Permissions ?? string.Empty;
|
||||
Assert.Contains("w", permsString);
|
||||
Assert.Contains("c", permsString);
|
||||
Assert.DoesNotContain("r", permsString);
|
||||
Assert.DoesNotContain("d", permsString);
|
||||
Assert.DoesNotContain("l", permsString);
|
||||
Assert.Equal("c", builder.Permissions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadSasPermissions_IsCreateOnly()
|
||||
{
|
||||
// Pinned: Write must never come back — it is what turns a create into an overwrite.
|
||||
Assert.Equal(BlobSasPermissions.Create, HostedDiagnosticsBlobService.UploadSasPermissions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_DifferentTenants_GetDifferentBlobPaths()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var a = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
var b = await svc.GenerateUploadSasAsync(TenantB, Filename);
|
||||
var a = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
|
||||
var b = await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
|
||||
Assert.NotEqual(a.BlobPath, b.BlobPath);
|
||||
Assert.StartsWith(TenantA + "/", a.BlobPath);
|
||||
Assert.StartsWith(TenantB + "/", b.BlobPath);
|
||||
@@ -177,8 +239,6 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
|
||||
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
|
||||
|
||||
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-guid", SessionA)]
|
||||
[InlineData(TenantA, "not-a-guid")]
|
||||
@@ -310,6 +370,9 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
public int EnsureContainerCallCount { get; private set; }
|
||||
public List<BlobSasBuilder> CapturedSasBuilders { get; } = new();
|
||||
|
||||
/// <summary>Blob path that the fake reports as already existing (null = nothing exists).</summary>
|
||||
public string? ExistingBlobPath { get; set; }
|
||||
|
||||
public FakeHostedDiagnosticsBlobService()
|
||||
: base(
|
||||
new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
@@ -324,6 +387,9 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(ExistingBlobPath == blobPath);
|
||||
|
||||
protected override Task<Uri> BuildUploadSasUriAsync(
|
||||
string blobPath, DateTimeOffset expiresOn, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -338,7 +404,7 @@ public class HostedDiagnosticsBlobServiceTests
|
||||
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
ExpiresOn = expiresOn,
|
||||
};
|
||||
builder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
|
||||
builder.SetPermissions(HostedDiagnosticsBlobService.UploadSasPermissions);
|
||||
CapturedSasBuilders.Add(builder);
|
||||
|
||||
return Task.FromResult(new Uri($"https://fake.blob.core.windows.net/diagnostics/{blobPath}?fake=sas"));
|
||||
|
||||
@@ -1308,4 +1308,119 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
// ── Tenant suspension gate (TenantConfiguration.Disabled / DisabledUntil) ──────────
|
||||
// The operator kill-switch must revoke API access on the shared authorization path, not only
|
||||
// on the SPA's auth/me probe — a direct REST caller with a valid token must get 403 too.
|
||||
|
||||
private static void AsSuspended(Harness h, string tenantId, DateTime? until = null, string? reason = null) =>
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Disabled = true,
|
||||
DisabledUntil = until,
|
||||
DisabledReason = reason,
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_HomeTenant_ReadRoute_IsForbidden_WithTenantSuspendedCode()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddDays(30), reason: "Abuse investigation");
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
Assert.Equal("Abuse investigation", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_HomeTenant_WriteRoute_IsForbidden()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_ExpiredDisabledUntil_IsAllowed()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddMinutes(-1)); // suspension window elapsed
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_GlobalAdmin_BypassesGate_ToAdministerTenant()
|
||||
{
|
||||
const string upn = "ga@platform.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
AsSuspended(h, TenantA);
|
||||
AsSuspended(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DelegatedReader_TargetTenantSuspended_IsForbidden()
|
||||
{
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsSuspended(h, TenantB); // home tenant A is fine, managed target B is suspended
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DelegatedReader_HomeTenantSuspended_LosesDelegatedReach()
|
||||
{
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsSuspended(h, TenantA); // the MSP's own home tenant is suspended
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DeviceRoute_NoPrincipal_GateNotApplied()
|
||||
{
|
||||
// Anonymous/device routes carry no principal — SecurityValidator owns their tenant gate. The
|
||||
// middleware must not touch tenant config here (no tenant to key on).
|
||||
var h = BuildHarness();
|
||||
AsSuspended(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/health", null, null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -55,6 +55,23 @@ public class SessionReportDiagnosticsArchiveCopierTests
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x/../../hosted-diagnostics/22222222-2222-2222-2222-222222222222/payload_diag_archive_1.zip")]
|
||||
[InlineData("sub/dir_diag_archive_1.zip")]
|
||||
[InlineData("..\\escape_diag_archive_1.zip")]
|
||||
public async Task CopyAsync_NonFlatDestination_Rejected_WithoutReadOrWrite(string destination)
|
||||
{
|
||||
// A path-shaped destination would let the SDK + System.Uri resolve the write outside
|
||||
// the session-reports container. Reject before touching any storage.
|
||||
var copier = new RecordingCopier();
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, destination);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
|
||||
{
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -386,4 +388,40 @@ public class TenantContactEmailTests
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IsValidDomainName — the only shape DomainName may be persisted in
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso.com")]
|
||||
[InlineData("sub.domain.org")]
|
||||
[InlineData("x-y.example")]
|
||||
[InlineData("A1.B2.c3")]
|
||||
public void IsValidDomainName_AcceptsHostNames(string domain)
|
||||
=> Assert.True(TenantConfigValidation.IsValidDomainName(domain));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("localhost")]
|
||||
[InlineData(".contoso.com")]
|
||||
[InlineData("contoso.com.")]
|
||||
[InlineData("con..toso.com")]
|
||||
[InlineData("-contoso.com")]
|
||||
[InlineData("contoso-.com")]
|
||||
[InlineData("con toso.com")]
|
||||
[InlineData("<b>contoso.com</b>")]
|
||||
[InlineData("contoso.com\"onerror=\"x")]
|
||||
[InlineData("contoso.com/path")]
|
||||
[InlineData("user@contoso.com")]
|
||||
public void IsValidDomainName_RejectsNonHostNames(string? domain)
|
||||
=> Assert.False(TenantConfigValidation.IsValidDomainName(domain));
|
||||
|
||||
[Fact]
|
||||
public void IsValidDomainName_RejectsOverlongNamesAndLabels()
|
||||
{
|
||||
Assert.False(TenantConfigValidation.IsValidDomainName(new string('a', 64) + ".com"));
|
||||
Assert.False(TenantConfigValidation.IsValidDomainName(string.Join(".", Enumerable.Repeat(new string('a', 60), 5))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that the caller-influenced tenantId interpolated into the metrics-summary (SessionsIndex)
|
||||
/// and device-snapshot search (DeviceSnapshot) OData filters is escaped, so a quote in the value
|
||||
/// cannot rewrite the filter grammar (e.g. <c>x' or PartitionKey ne '</c> dissolving the
|
||||
/// single-tenant scope and the RowKey date-window bound).
|
||||
/// </summary>
|
||||
public class TenantFilterEscapingTests
|
||||
{
|
||||
private const string Injection = "x' or PartitionKey ne '";
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryFilter_EscapesQuotesInTenantId()
|
||||
{
|
||||
var filter = TableStorageService.BuildMetricsSummaryFilter(Injection, "cutoff");
|
||||
|
||||
Assert.Equal("RowKey lt 'cutoff' and PartitionKey eq 'x'' or PartitionKey ne '''", filter);
|
||||
Assert.DoesNotContain(" or ", filter.Replace("'x'' or PartitionKey ne '''", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryFilter_NoTenant_KeepsWindowBoundOnly()
|
||||
{
|
||||
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter(null, "cutoff"));
|
||||
Assert.Equal("RowKey lt 'cutoff'", TableStorageService.BuildMetricsSummaryFilter("", "cutoff"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSnapshotTenantFilter_EscapesQuotesInTenantId()
|
||||
{
|
||||
Assert.Equal("PartitionKey eq 'x'' or PartitionKey ne '''",
|
||||
TableStorageService.BuildDeviceSnapshotTenantFilter(Injection));
|
||||
Assert.Null(TableStorageService.BuildDeviceSnapshotTenantFilter(null));
|
||||
}
|
||||
}
|
||||
+6
@@ -110,6 +110,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// admins are served the FULL config so this is normally a no-op for them.)
|
||||
config.RestoreRedactedSecretsFrom(existingConfig);
|
||||
|
||||
// DomainName is tenant identity: seeded once from the first login's UPN (AuthFunction)
|
||||
// and never client-writable — for ANY caller, GA included — mirroring the patch flow
|
||||
// ("identity — never writable"). Without this preserve the full-model PUT persisted an
|
||||
// arbitrary string here, which is rendered into the welcome/farewell mails.
|
||||
config.DomainName = existingConfig.DomainName;
|
||||
|
||||
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
|
||||
// headers, notification channels, diagnostics SAS, retention cap) — single source
|
||||
// with the transactional field-patch flow (TenantConfigValidation).
|
||||
|
||||
+66
-9
@@ -2,6 +2,7 @@
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -19,8 +20,10 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
/// container SAS URL stored in <see cref="TenantConfiguration.DiagnosticsBlobSasUrl"/>.
|
||||
/// Data stays in the customer's own storage account.</item>
|
||||
/// <item><c>Hosted</c> (opt-in): mints a freshly-stamped, 15-min, blob-scoped,
|
||||
/// Write+Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
|
||||
/// own diagnostics container. Requires explicit admin opt-in via the UI; never
|
||||
/// Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
|
||||
/// own diagnostics container. The filename must carry the requesting session id
|
||||
/// and the blob must not exist yet, so one device can neither name nor replace
|
||||
/// another session's package. Requires explicit admin opt-in via the UI; never
|
||||
/// silent.</item>
|
||||
/// </list>
|
||||
/// Called by the agent just before uploading — never at startup, never cached.
|
||||
@@ -56,6 +59,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
private readonly ISessionDeletionInventoryReader _sessionRowReader;
|
||||
private readonly SessionOwnerBindingObserver _ownerBinding;
|
||||
|
||||
public GetDiagnosticsUploadUrlFunction(
|
||||
ILogger<GetDiagnosticsUploadUrlFunction> logger,
|
||||
@@ -68,8 +73,12 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics)
|
||||
HostedDiagnosticsBlobService hostedDiagnostics,
|
||||
ISessionDeletionInventoryReader sessionRowReader,
|
||||
SessionOwnerBindingObserver ownerBinding)
|
||||
{
|
||||
_sessionRowReader = sessionRowReader;
|
||||
_ownerBinding = ownerBinding;
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -145,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
|
||||
if (destination == DestinationHosted)
|
||||
{
|
||||
return await IssueHostedSasAsync(req, requestBody);
|
||||
return await IssueHostedSasAsync(req, requestBody, validation);
|
||||
}
|
||||
|
||||
if (destination == DestinationCustomerSas)
|
||||
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> IssueHostedSasAsync(
|
||||
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody)
|
||||
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody, SecurityValidationResult validation)
|
||||
{
|
||||
if (string.IsNullOrEmpty(requestBody.FileName))
|
||||
{
|
||||
@@ -250,18 +259,36 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// The blob name is bound to the session below, so the session id has to be a real
|
||||
// one — and it is the coordinate for the owner-binding observation.
|
||||
if (!SecurityValidator.IsValidGuid(requestBody.SessionId))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "sessionId is required for hosted destination"
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: same observation as the session-scoped writes, so
|
||||
// stage-2 enforcement covers this endpoint with the same data. Never rejects here.
|
||||
await ObserveSessionOwnerAsync(req, requestBody.TenantId, requestBody.SessionId, validation);
|
||||
|
||||
HostedUploadSasResult sasResult;
|
||||
try
|
||||
{
|
||||
sasResult = await _hostedDiagnostics.GenerateUploadSasAsync(
|
||||
requestBody.TenantId, requestBody.FileName);
|
||||
requestBody.TenantId, requestBody.SessionId, requestBody.FileName);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// Filename validation failed (path separator, traversal, oversize, etc.)
|
||||
// Filename validation failed (path separator, traversal, oversize, not bound
|
||||
// to the requesting session, etc.)
|
||||
_logger.LogWarning(
|
||||
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, file {FileName}: {Reason}",
|
||||
requestBody.TenantId, requestBody.FileName, ex.Message);
|
||||
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}, file {FileName}: {Reason}",
|
||||
requestBody.TenantId, requestBody.SessionId, requestBody.FileName, ex.Message);
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
@@ -270,6 +297,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
catch (HostedBlobAlreadyExistsException ex)
|
||||
{
|
||||
// A legitimate agent never re-requests an existing name (every package is
|
||||
// freshly timestamped); this is either a replay or an overwrite attempt.
|
||||
_logger.LogWarning(
|
||||
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}: blob {BlobPath} already exists",
|
||||
requestBody.TenantId, requestBody.SessionId, ex.BlobPath);
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "A diagnostics package with this name already exists"
|
||||
});
|
||||
return conflict;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"GetDiagnosticsUploadUrl: Issuing Hosted upload URL for tenant {TenantId}, session {SessionId}, blob {BlobPath}, SAS expires {ExpiresAt}",
|
||||
@@ -288,6 +330,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task ObserveSessionOwnerAsync(
|
||||
HttpRequestData req, string tenantId, string sessionId, SecurityValidationResult validation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId);
|
||||
_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"GetDiagnosticsUploadUrl: session-owner observation skipped for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the destination string from TenantConfiguration. Null/empty becomes
|
||||
/// CustomerSas (preserves existing behaviour for tenants that predate this field).
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -336,6 +337,14 @@ public class AuthFunction
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return;
|
||||
|
||||
// The seeded value is rendered into transactional mails — persist only a strict host name,
|
||||
// regardless of what the identity provider's UPN charset happens to allow.
|
||||
if (!TenantConfigValidation.IsValidDomainName(domain))
|
||||
{
|
||||
_logger.LogWarning("Refusing to seed DomainName for tenant {TenantId}: UPN domain is not a valid host name", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
tenantConfig.DomainName = domain;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
|
||||
@@ -49,6 +49,18 @@ public class MetricsSummaryFunction
|
||||
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Query value is interpolated into an OData filter downstream — GUID-gate it here
|
||||
// (belt) in addition to ODataSanitizer at the sink (braces).
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
var summary = await _metricsRepo.GetMetricsSummaryAsync(
|
||||
string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId,
|
||||
days);
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -44,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if (blobName.Contains("..") || blobName.Contains("/") || blobName.Contains("\\"))
|
||||
if (!BlobNameGuard.IsFlat(blobName))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
|
||||
|
||||
+9
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -106,6 +107,14 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
return bad;
|
||||
}
|
||||
|
||||
// TenantId becomes part of the blob name — must be a GUID, never path-shaped.
|
||||
if (!SecurityValidator.IsValidGuid(request.TenantId))
|
||||
{
|
||||
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId." });
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var metadata = await _sessionReportService.SubmitDiagFilesReportAsync(request, userIdentifier);
|
||||
|
||||
// Audit log — skip for Global Admins (their actions are not tenant-scoped)
|
||||
|
||||
+14
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -105,6 +106,19 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
return bad;
|
||||
}
|
||||
|
||||
// Identifier format gate: both values become part of the report blob name.
|
||||
// Route values are percent-decoded, so an encoded '/' or '..' would otherwise
|
||||
// reach the storage layer as a path segment.
|
||||
if (!SecurityValidator.IsValidGuid(request.SessionId) || !SecurityValidator.IsValidGuid(request.TenantId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SubmitSessionReport: BLOCKED non-GUID identifier user={User} tenant={TenantId} sessionIdLength={SessionIdLength}",
|
||||
userIdentifier, request.TenantId, request.SessionId?.Length ?? 0);
|
||||
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid sessionId or tenantId." });
|
||||
return invalid;
|
||||
}
|
||||
|
||||
// Submit report
|
||||
var metadata = await _sessionReportService.SubmitReportAsync(request, userIdentifier);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -167,12 +168,33 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the phase-scope + emit-mode fields on create/update.
|
||||
/// Returns an error message for a 400 response, or null when valid.
|
||||
/// Validates the phase-scope + emit-mode fields and the on_event trigger wiring on
|
||||
/// create/update. Returns an error message for a 400 response, or null when valid.
|
||||
/// Toggle-style partial payloads carry none of these fields and pass through.
|
||||
/// <para>
|
||||
/// on_event: the agent never dispatches on_event rules for events the gather executor
|
||||
/// itself emitted (feedback-loop guard, <c>GatherRuleExecutorHost</c>), so a rule that
|
||||
/// triggers on its own output — or on the <c>gather_result</c> default output type —
|
||||
/// could never fire and is rejected here with an explanatory message instead of being
|
||||
/// stored as a dead rule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateScopeAndEmitMode(GatherRule rule)
|
||||
{
|
||||
if (string.Equals(rule.Trigger, "on_event", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(rule.TriggerEventType))
|
||||
{
|
||||
var outputEventType = string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? Constants.EventTypes.GatherResult
|
||||
: rule.OutputEventType;
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, outputEventType, StringComparison.OrdinalIgnoreCase))
|
||||
return "triggerEventType must differ from the rule's own outputEventType — gather rules never trigger on their own output.";
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, Constants.EventTypes.GatherResult, StringComparison.OrdinalIgnoreCase))
|
||||
return $"triggerEventType '{Constants.EventTypes.GatherResult}' is the gather-rule output type — gather rules never trigger on gather output.";
|
||||
}
|
||||
|
||||
var hasActivePhases = rule.ActivePhases != null && rule.ActivePhases.Count > 0;
|
||||
var hasFromPhase = !string.IsNullOrEmpty(rule.ActiveFromPhase);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
|
||||
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
|
||||
/// All endpoints are Global Admin only, except PUT notification-email: that one is
|
||||
/// AuthenticatedUserWithRole and gated in-function (member role, or the tenant has no members yet).
|
||||
/// </summary>
|
||||
public class PreviewWhitelistFunction
|
||||
{
|
||||
@@ -19,6 +21,7 @@ public class PreviewWhitelistFunction
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantApprovalService _tenantApprovalService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
@@ -26,12 +29,14 @@ public class PreviewWhitelistFunction
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantApprovalService tenantApprovalService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IEmailService emailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantApprovalService = tenantApprovalService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
@@ -156,7 +161,8 @@ public class PreviewWhitelistFunction
|
||||
/// <summary>
|
||||
/// PUT /api/preview/notification-email
|
||||
/// Saves the caller's notification email for the activation notice.
|
||||
/// AuthenticatedUser policy — preview-blocked users can call this.
|
||||
/// AuthenticatedUserWithRole policy — preview-blocked users can call this, but only while the
|
||||
/// tenant has no members (see <see cref="MayWriteNotificationEmailAsync"/>).
|
||||
/// </summary>
|
||||
[Function("SavePreviewNotificationEmail")]
|
||||
[Authorize]
|
||||
@@ -174,6 +180,16 @@ public class PreviewWhitelistFunction
|
||||
return bad;
|
||||
}
|
||||
|
||||
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
|
||||
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = "A tenant member role is required to change the notification email" });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
|
||||
var email = body?.Email?.Trim();
|
||||
|
||||
@@ -208,6 +224,25 @@ public class PreviewWhitelistFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The notification email is tenant-shared state consumed by privileged flows (welcome mail,
|
||||
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
|
||||
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
|
||||
/// first-touch signup window, where the activating user is still roleless because the requester
|
||||
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
|
||||
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
|
||||
/// or suppress the tenant's correspondence.
|
||||
/// </summary>
|
||||
internal static async Task<bool> MayWriteNotificationEmailAsync(
|
||||
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
|
||||
{
|
||||
if (ctx.IsTenantMemberOrGlobalAdmin())
|
||||
return true;
|
||||
|
||||
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
return !members.Any(m => m.IsEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview/send-welcome-email/{tenantId}
|
||||
/// Sends (or resends) the activation welcome email. Global Admin only.
|
||||
|
||||
@@ -56,6 +56,17 @@ public class SearchSessionsFunction
|
||||
else
|
||||
{
|
||||
filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Interpolated into OData filters downstream — GUID-gate the raw query value.
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
tenantId = filterTenantId;
|
||||
scope = "search:global";
|
||||
basePath = "/api/global/search/sessions";
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
@@ -21,6 +22,120 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
// RFC 5321 caps a forward path at 254 characters.
|
||||
internal const int MaxContactEmailLength = 254;
|
||||
|
||||
// RFC 1035: a host name is at most 253 characters, labels at most 63.
|
||||
internal const int MaxDomainNameLength = 253;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="domainName"/> is a strict DNS host name: dotted labels of
|
||||
/// ASCII letters, digits and inner hyphens only. This is the ONLY shape DomainName may
|
||||
/// ever be persisted in — it is rendered into transactional mails and admin views.
|
||||
/// </summary>
|
||||
internal static bool IsValidDomainName(string? domainName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(domainName) || domainName.Length > MaxDomainNameLength)
|
||||
return false;
|
||||
|
||||
var labels = domainName.Split('.');
|
||||
if (labels.Length < 2)
|
||||
return false;
|
||||
|
||||
foreach (var label in labels)
|
||||
{
|
||||
if (label.Length == 0 || label.Length > 63)
|
||||
return false;
|
||||
if (label[0] == '-' || label[label.Length - 1] == '-')
|
||||
return false;
|
||||
foreach (var ch in label)
|
||||
{
|
||||
var ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-';
|
||||
if (!ok)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private const int MaxWhitelistEntries = 200;
|
||||
private const int MaxWhitelistEntryLength = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a hardware whitelist CSV (<see cref="TenantConfiguration.ManufacturerWhitelist"/> /
|
||||
/// <see cref="TenantConfiguration.ModelWhitelist"/>). The CSV is split on ',' at enforcement
|
||||
/// time, so this is the last line of defence against a value that was meant as ONE entry but
|
||||
/// alters the list structure: blank items (",,", ", ,") are rejected rather than silently
|
||||
/// dropped, and every item must be a printable, bounded pattern. Returns a user-facing
|
||||
/// reason, or null when the list is well-formed. Empty/whitespace = allow all (documented).
|
||||
/// </summary>
|
||||
internal static string? ValidateHardwareWhitelist(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv))
|
||||
return null;
|
||||
|
||||
var items = csv!.Split(',');
|
||||
if (items.Length > MaxWhitelistEntries)
|
||||
return $"at most {MaxWhitelistEntries} entries are allowed.";
|
||||
|
||||
foreach (var raw in items)
|
||||
{
|
||||
var entry = raw.Trim();
|
||||
if (entry.Length == 0)
|
||||
return "entries must not be empty (check for a stray or doubled comma).";
|
||||
if (entry.Length > MaxWhitelistEntryLength)
|
||||
return $"each entry must be at most {MaxWhitelistEntryLength} characters.";
|
||||
foreach (var ch in entry)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "entries must not contain control characters.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generous for a CDN asset URL; bounds the command line the agent composes from it.
|
||||
internal const int MaxBrandingImageUrlLength = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="TenantConfiguration.EnrollmentSummaryBrandingImageUrl"/>. The value
|
||||
/// is delivered verbatim to every enrolling device, fetched from the signed-in user's
|
||||
/// session and spliced into the summary-dialog command line, so it is the one piece of
|
||||
/// tenant config that is both a device egress target and a process argument. It must be a
|
||||
/// plain absolute HTTPS URL to a DNS host — no quotes, whitespace or control characters
|
||||
/// that could terminate a quoted argument. Returns an error message, or null when
|
||||
/// valid/empty (empty = no branding image).
|
||||
/// </summary>
|
||||
internal static string? ValidateBrandingImageUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
if (url!.Length > MaxBrandingImageUrlLength)
|
||||
return $"must be at most {MaxBrandingImageUrlLength} characters.";
|
||||
|
||||
foreach (var ch in url)
|
||||
{
|
||||
if (char.IsControl(ch) || char.IsWhiteSpace(ch))
|
||||
return "must not contain whitespace or control characters.";
|
||||
if (ch == '"' || ch == '\'')
|
||||
return "must not contain quotes.";
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return "is not a valid absolute URL.";
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttps)
|
||||
return "must use HTTPS.";
|
||||
|
||||
if (uri.IsLoopback || string.IsNullOrEmpty(uri.Host))
|
||||
return "must not target localhost.";
|
||||
|
||||
if (System.Net.IPAddress.TryParse(uri.Host, out _))
|
||||
return "must use a DNS hostname, not an IP address.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a candidate configuration against the stored one. Returns a
|
||||
/// user-facing error message (same wording the PUT endpoint always produced),
|
||||
@@ -58,6 +173,18 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (channelsError != null)
|
||||
return $"Invalid notification channels: {channelsError}";
|
||||
|
||||
var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl);
|
||||
if (brandingUrlError != null)
|
||||
return $"Invalid enrollment summary branding image URL: {brandingUrlError}";
|
||||
|
||||
var manufacturerWhitelistError = ValidateHardwareWhitelist(candidate.ManufacturerWhitelist);
|
||||
if (manufacturerWhitelistError != null)
|
||||
return $"Invalid manufacturer whitelist: {manufacturerWhitelistError}";
|
||||
|
||||
var modelWhitelistError = ValidateHardwareWhitelist(candidate.ModelWhitelist);
|
||||
if (modelWhitelistError != null)
|
||||
return $"Invalid model whitelist: {modelWhitelistError}";
|
||||
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually selected
|
||||
// the CustomerSas destination — a stale value left over from a prior CustomerSas
|
||||
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -212,6 +213,29 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant");
|
||||
}
|
||||
|
||||
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
|
||||
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
|
||||
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
|
||||
// richer client payload). Both the caller's HOME tenant (JWT tid) and the request's TARGET tenant are
|
||||
// checked: a suspended tenant's members lose everything, including delegated reach into other tenants,
|
||||
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
|
||||
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
|
||||
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId);
|
||||
if (suspended != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(suspended.TenantId), logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "TenantSuspended",
|
||||
!string.IsNullOrEmpty(suspended.DisabledReason)
|
||||
? suspended.DisabledReason
|
||||
: "Your tenant has been suspended. Please contact support for more information.",
|
||||
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
|
||||
}
|
||||
}
|
||||
|
||||
// Merge delegated state from the two delegated admission paths:
|
||||
// • scoped-route (RouteParam/QueryParam): a single-tenant cross-tenant read — delegatedRole +
|
||||
// allowedTenantIds were set above.
|
||||
@@ -259,6 +283,26 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and
|
||||
/// the request target (null when neither is suspended). <see cref="TenantConfigurationService.TryGetConfigurationAsync"/>
|
||||
/// is cached (5 min TTL, same as the agent gate) and never persists a default row.
|
||||
/// <see cref="TenantConfiguration.IsCurrentlyDisabled"/> already treats an elapsed DisabledUntil as
|
||||
/// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login.
|
||||
/// </summary>
|
||||
private async Task<TenantConfiguration?> FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId)
|
||||
{
|
||||
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId);
|
||||
if (homeConfig.IsCurrentlyDisabled())
|
||||
return homeConfig;
|
||||
|
||||
if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId);
|
||||
return targetConfig.IsCurrentlyDisabled() ? targetConfig : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
|
||||
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Last-line guard for request-derived blob names. Azure.Storage.Blobs appends the name to
|
||||
/// the container URI preserving '/' and System.Uri collapses '..' segments, so a name such as
|
||||
/// "x/../../other-container/y" resolves OUTSIDE the intended container. Every blob name that
|
||||
/// contains caller-influenced material must be flat: no separators, no dot segments.
|
||||
/// </summary>
|
||||
public static class BlobNameGuard
|
||||
{
|
||||
public static bool IsFlat(string? blobName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(blobName)) return false;
|
||||
if (blobName.Contains('/') || blobName.Contains('\\')) return false;
|
||||
if (blobName.Contains("..")) return false;
|
||||
if (blobName.Contains('%') || blobName.Contains('?') || blobName.Contains('#')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="ArgumentException"/> when the name is not flat.</summary>
|
||||
public static string EnsureFlat(string? blobName, string parameterName)
|
||||
{
|
||||
if (!IsFlat(blobName))
|
||||
throw new ArgumentException("Blob name must be a flat name without path separators or dot segments.", parameterName);
|
||||
return blobName!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ProgressPortalFunction; regression history c4dabeee).
|
||||
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
|
||||
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
|
||||
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
|
||||
// roleless during first-touch signup, so the tier must admit non-members — but the address is
|
||||
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
|
||||
// resolved role: roleless callers may only write while the tenant has no member yet.
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
+81
-11
@@ -27,8 +27,8 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
/// (<c>AzureStorageAccountName</c>) or connection string (<c>AzureBlobStorageConnectionString</c>).
|
||||
/// SAS issuance for the agent upload-url endpoint differs by auth path — User
|
||||
/// Delegation SAS under MI, account-key SAS under connection-string — both produce
|
||||
/// a blob-scoped, Write+Create-only, 15-min token that cannot reach other tenants'
|
||||
/// prefixes.
|
||||
/// a blob-scoped, Create-only, 15-min token that cannot reach other tenants'
|
||||
/// prefixes and cannot replace an existing blob.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class HostedDiagnosticsBlobService
|
||||
@@ -40,6 +40,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
// requesting hour-long tokens.
|
||||
public static readonly TimeSpan MaxUploadSasTtl = TimeSpan.FromMinutes(60);
|
||||
|
||||
/// <summary>
|
||||
/// Permissions on the upload SAS. Create-only by design: the agent PUTs one new block
|
||||
/// blob per package (fresh timestamped name), so Write — which would allow replacing an
|
||||
/// existing blob under the same tenant prefix — is never needed.
|
||||
/// </summary>
|
||||
public const BlobSasPermissions UploadSasPermissions = BlobSasPermissions.Create;
|
||||
|
||||
/// <summary>Canonical package-name prefix; the session id follows immediately after.</summary>
|
||||
internal const string PackageNamePrefix = "AgentDiagnostics-";
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<HostedDiagnosticsBlobService> _logger;
|
||||
private readonly bool _usesManagedIdentity;
|
||||
@@ -94,16 +104,21 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
// -------- Public API --------
|
||||
|
||||
/// <summary>
|
||||
/// Mints a blob-scoped, Write+Create-only SAS URI pinned to the EXACT path
|
||||
/// Mints a blob-scoped, Create-only SAS URI pinned to the EXACT path
|
||||
/// <c>{tenantId}/{filename}</c> for an agent's diagnostics-package upload. Cannot
|
||||
/// be redirected to other tenants' prefixes. TTL is capped at
|
||||
/// <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
|
||||
/// be redirected to other tenants' prefixes, and the filename must carry the
|
||||
/// caller's <paramref name="sessionId"/> (<c>AgentDiagnostics-{sessionId}-…zip</c>)
|
||||
/// so one device cannot name another session's package. An already-existing blob
|
||||
/// is never re-issued (<see cref="HostedBlobAlreadyExistsException"/>). TTL is
|
||||
/// capped at <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
|
||||
/// </summary>
|
||||
public virtual async Task<HostedUploadSasResult> GenerateUploadSasAsync(
|
||||
string tenantId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
string tenantId, string sessionId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
ValidateFilename(filename);
|
||||
EnsureFilenameBoundToSession(filename, sessionId);
|
||||
|
||||
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(15);
|
||||
if (effectiveTtl > MaxUploadSasTtl)
|
||||
@@ -116,6 +131,14 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
|
||||
await EnsureContainerAsync(cancellationToken);
|
||||
|
||||
// Create-only semantics, enforced twice: the SAS carries no Write permission (an
|
||||
// existing blob cannot be replaced with it), and issuance itself refuses a name
|
||||
// that is already taken so the conflict surfaces here as a 409 instead of as an
|
||||
// opaque 403 from storage. Every package gets a fresh timestamped name, so a
|
||||
// legitimate agent never asks for an existing one.
|
||||
if (await BlobExistsAsync(blobPath, cancellationToken))
|
||||
throw new HostedBlobAlreadyExistsException(blobPath);
|
||||
|
||||
var sasUri = await BuildUploadSasUriAsync(blobPath, expiresOn, cancellationToken);
|
||||
|
||||
return new HostedUploadSasResult
|
||||
@@ -185,7 +208,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
|
||||
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
|
||||
// but the explicit separator keeps the invariant obvious and layout-proof).
|
||||
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
|
||||
var prefix = $"{tenantId}/{PackageNamePrefix}{sessionId}-";
|
||||
var deleted = 0;
|
||||
await foreach (var item in containerClient.GetBlobsAsync(
|
||||
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
|
||||
@@ -272,7 +295,17 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the blob-scoped Write+Create-only SAS URI for upload. Branch on the
|
||||
/// Test seam: whether a blob already exists at <paramref name="blobPath"/>. Production
|
||||
/// issues a HEAD; tests override to simulate a taken name without Azurite.
|
||||
/// </summary>
|
||||
protected virtual async Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await GetContainerClient().GetBlobClient(blobPath).ExistsAsync(cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the blob-scoped Create-only SAS URI for upload. Branch on the
|
||||
/// auth mode: MI uses User Delegation SAS (no account key needed); connection
|
||||
/// string uses the underlying StorageSharedKeyCredential via
|
||||
/// <see cref="BlobClient.GenerateSasUri(BlobSasBuilder)"/>.
|
||||
@@ -290,9 +323,10 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // clock-skew tolerance
|
||||
ExpiresOn = expiresOn,
|
||||
};
|
||||
// Write + Create only — agent must not be able to Read other tenants' blobs,
|
||||
// Delete anything, or List the container.
|
||||
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
|
||||
// Create only — the agent does a single Put Blob of a brand-new name. No Write
|
||||
// (would let the holder replace an existing blob of the same tenant), no Read,
|
||||
// no Delete, no List.
|
||||
sasBuilder.SetPermissions(UploadSasPermissions);
|
||||
|
||||
if (UsesManagedIdentity)
|
||||
{
|
||||
@@ -337,6 +371,26 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
throw new ArgumentException("filename must not contain null bytes", nameof(filename));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds an upload filename to the requesting session: it must be
|
||||
/// <c>AgentDiagnostics-{sessionId}-{anything}.zip</c> (the shape the agent has always
|
||||
/// produced, incl. the <c>-preprov</c>/<c>-server-requested</c> suffixes). Without this
|
||||
/// an authenticated device could name any other session's package in its tenant.
|
||||
/// Throws <see cref="ArgumentException"/> on rejection.
|
||||
/// </summary>
|
||||
internal static void EnsureFilenameBoundToSession(string filename, string sessionId)
|
||||
{
|
||||
var required = $"{PackageNamePrefix}{sessionId}-";
|
||||
if (!filename.StartsWith(required, StringComparison.OrdinalIgnoreCase)
|
||||
|| filename.Length <= required.Length
|
||||
|| !filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"filename must be of the form 'AgentDiagnostics-{sessionId}-{timestamp}.zip' for the requesting session",
|
||||
nameof(filename));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a stored blob path (<c>{tenantId}/{filename}</c>) on the read paths.
|
||||
/// Defends the download proxy against path-traversal even if a row was tampered with.
|
||||
@@ -358,6 +412,22 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/> when the
|
||||
/// requested blob name is already taken. Distinct from <see cref="ArgumentException"/>
|
||||
/// (malformed input → 400) so the Function can answer 409.
|
||||
/// </summary>
|
||||
public sealed class HostedBlobAlreadyExistsException : InvalidOperationException
|
||||
{
|
||||
public HostedBlobAlreadyExistsException(string blobPath)
|
||||
: base($"Hosted diagnostics blob already exists: {blobPath}")
|
||||
{
|
||||
BlobPath = blobPath;
|
||||
}
|
||||
|
||||
public string BlobPath { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return value of <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/>.
|
||||
/// </summary>
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
@@ -100,6 +101,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
if (string.IsNullOrEmpty(sourceBlobName))
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedNoDiagnostics, null);
|
||||
|
||||
// Destination is derived from request data (tenant/session ids). A non-flat name
|
||||
// would let the SDK + System.Uri walk out of the session-reports container.
|
||||
if (!BlobNameGuard.IsFlat(destinationBlobName))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsArchiveCopier: rejecting non-flat destination blob name for tenant {TenantId}, session {SessionId}",
|
||||
tenantId, sessionId);
|
||||
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedInvalidBlobName, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (destination, classifyErr) = DiagnosticsDownloadFunction.ClassifyBlobName(sourceBlobName, tenantId);
|
||||
@@ -219,7 +230,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(ReportsContainerName);
|
||||
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
|
||||
var blobClient = containerClient.GetBlobClient(destinationBlobName);
|
||||
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(destinationBlobName, nameof(destinationBlobName)));
|
||||
await blobClient.UploadAsync(content, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
|
||||
|
||||
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
@@ -73,10 +74,15 @@ public class EmailTemplateService : IEmailTemplateProvider
|
||||
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
|
||||
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
|
||||
|
||||
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
|
||||
/// <summary>
|
||||
/// Substitutes the placeholder; an empty domain becomes "your organization". The value is
|
||||
/// HTML-encoded at the point of substitution: the mail goes out under the product's signed
|
||||
/// sender to a tenant-chosen recipient, so no writer of DomainName — current or future — may
|
||||
/// ever be able to inject markup into it.
|
||||
/// </summary>
|
||||
public static string Render(string rawHtml, string? domainName)
|
||||
{
|
||||
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
|
||||
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : WebUtility.HtmlEncode(domainName.Trim());
|
||||
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
@@ -26,7 +27,7 @@ public static class EmailTemplates
|
||||
/// </summary>
|
||||
public static string GetOffboardingFarewellHtml(string domainName)
|
||||
{
|
||||
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
|
||||
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
|
||||
|
||||
return $@"
|
||||
<!DOCTYPE html>
|
||||
@@ -125,7 +126,7 @@ public static class EmailTemplates
|
||||
/// </summary>
|
||||
public static string GetPreviewApprovedHtml(string domainName)
|
||||
{
|
||||
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
|
||||
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
|
||||
|
||||
return $@"
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -60,6 +61,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SubmitSessionReportRequest request,
|
||||
string submittedBy)
|
||||
{
|
||||
// Both identifiers shape blob names below; a non-GUID value could smuggle path
|
||||
// separators / dot segments into the storage URI (cross-container write).
|
||||
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
|
||||
SecurityValidator.EnsureValidGuid(request.SessionId, nameof(request.SessionId));
|
||||
|
||||
var reportId = Guid.NewGuid().ToString("N")[..12];
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var blobName = $"{request.TenantId}_{request.SessionId}_diag_request_{timestamp}.zip";
|
||||
@@ -203,7 +209,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
zipStream.Position = 0;
|
||||
var containerClient = _blobStorage.GetContainerClient(ContainerName);
|
||||
await containerClient.CreateIfNotExistsAsync();
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
|
||||
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
|
||||
@@ -245,6 +251,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SubmitDiagFilesReportRequest request,
|
||||
string submittedBy)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
|
||||
|
||||
var reportId = Guid.NewGuid().ToString("N")[..12];
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
|
||||
@@ -292,7 +300,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
zipStream.Position = 0;
|
||||
var containerClient = _blobStorage.GetContainerClient(ContainerName);
|
||||
await containerClient.CreateIfNotExistsAsync();
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
|
||||
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
|
||||
|
||||
@@ -660,7 +660,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
||||
{
|
||||
var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: snapshotTableClient,
|
||||
@@ -838,7 +838,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<List<SessionSummary>> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var sessionIds = new List<string>();
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
||||
@@ -1437,6 +1437,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
groups[pk] = g.Add(statusStr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SessionsIndex filter for the metrics summary: date-window bound plus optional single-tenant
|
||||
/// scope. <paramref name="tenantId"/> may originate from the HTTP query string, so it is
|
||||
/// escaped — a quote in the value must never alter the filter grammar.
|
||||
/// </summary>
|
||||
internal static string BuildMetricsSummaryFilter(string? tenantId, string cutoffRowKeyPrefix)
|
||||
{
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
|
||||
return string.Join(" and ", filterParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceSnapshot filter for the device-property search path: optional single-tenant scope.
|
||||
/// Escaped for the same reason as <see cref="BuildMetricsSummaryFilter"/>.
|
||||
/// </summary>
|
||||
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
|
||||
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
|
||||
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
{
|
||||
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
|
||||
@@ -1446,10 +1466,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var cutoffRowKeyPrefix = ComputeCutoffRowKeyPrefix(days);
|
||||
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{tenantId}'");
|
||||
var oDataFilter = string.Join(" and ", filterParts);
|
||||
var oDataFilter = BuildMetricsSummaryFilter(tenantId, cutoffRowKeyPrefix);
|
||||
|
||||
// Tally every status into its own bucket so the buckets always reconcile to total
|
||||
// (SessionStatusBuckets enforces this by construction — Pending/Stalled/Unknown no
|
||||
|
||||
@@ -1008,25 +1008,32 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manufacturer whitelist as array
|
||||
/// Gets manufacturer whitelist as array (empty/whitespace = allow all)
|
||||
/// </summary>
|
||||
public string[] GetManufacturerWhitelist()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ManufacturerWhitelist))
|
||||
return new[] { "*" };
|
||||
|
||||
return ManufacturerWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
public string[] GetManufacturerWhitelist() => ParseWhitelist(ManufacturerWhitelist);
|
||||
|
||||
/// <summary>
|
||||
/// Gets model whitelist as array
|
||||
/// Gets model whitelist as array (empty/whitespace = allow all)
|
||||
/// </summary>
|
||||
public string[] GetModelWhitelist()
|
||||
public string[] GetModelWhitelist() => ParseWhitelist(ModelWhitelist);
|
||||
|
||||
/// <summary>
|
||||
/// Splits a stored hardware whitelist CSV into individual match patterns: trimmed, empty
|
||||
/// entries dropped. The CSV is the ONLY storage format, so a ',' can never be part of a
|
||||
/// pattern — writers (web editor, <c>TenantConfigValidation</c>) must guarantee each
|
||||
/// intended entry lands as exactly one CSV item.
|
||||
/// </summary>
|
||||
public static string[] ParseWhitelist(string? csv)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ModelWhitelist))
|
||||
if (string.IsNullOrWhiteSpace(csv))
|
||||
return new[] { "*" };
|
||||
|
||||
return ModelWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var entries = csv!.Split(',')
|
||||
.Select(e => e.Trim())
|
||||
.Where(e => e.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
return entries.Length == 0 ? new[] { "*" } : entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function DiagnosisPage() {
|
||||
function DiagnosisContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string;
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
|
||||
|
||||
@@ -108,7 +108,10 @@ export function useSessionDetail({
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
// Accept only a session whose id matches the one we asked for — a misrouted or
|
||||
// unexpected 200 must never hydrate the page (defense in depth behind the GUID gate).
|
||||
const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined;
|
||||
if (foundSession) {
|
||||
setSession(foundSession);
|
||||
setSessionTenantId(foundSession.tenantId);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { isGuid } from '@/utils/inputValidation';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
|
||||
@@ -91,7 +92,8 @@ export default function NetworkTimelinePage() {
|
||||
|
||||
function Content() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams?.get('id') ?? null;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null;
|
||||
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
|
||||
|
||||
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { diagnosisUrl, inspectorUrl } from "@/lib/routes";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions';
|
||||
import { useLatestVersions } from '@/lib/useLatestVersions';
|
||||
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
|
||||
import { api } from "@/lib/api";
|
||||
import { isGuid } from "@/utils/inputValidation";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
|
||||
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
|
||||
@@ -59,7 +61,11 @@ function SessionDetailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
// Query-string identity (`?id=`) — path params are gone with the static export;
|
||||
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
|
||||
// percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty
|
||||
// sessionId keeps every data hook inert and the page renders the invalid-id branch below.
|
||||
const rawSessionId = searchParams?.get("id") ?? "";
|
||||
const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : "";
|
||||
// Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a
|
||||
// delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode.
|
||||
const tenantIdOverride = searchParams?.get("tenantId") || undefined;
|
||||
@@ -424,6 +430,19 @@ function SessionDetailContent() {
|
||||
// so `loading` never flips and we'd render this branch forever — bypassing the
|
||||
// auth gate and hanging on "Loading session details..." without ever triggering
|
||||
// the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth.
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-600">
|
||||
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
|
||||
<Link href="/dashboard" className="text-blue-600 hover:underline">Back to dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !session && events.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
|
||||
+11
-15
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import SaveResetBar from "./SaveResetBar";
|
||||
import ReadOnlyFieldset from "./ReadOnlyFieldset";
|
||||
import { joinList, parseList } from "../lib/hardwareWhitelist";
|
||||
|
||||
interface HardwareWhitelistSectionProps {
|
||||
manufacturerWhitelist: string;
|
||||
@@ -16,14 +17,6 @@ interface HardwareWhitelistSectionProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function parseList(csv: string): string[] {
|
||||
return csv.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function joinList(items: string[]): string {
|
||||
return items.join(",");
|
||||
}
|
||||
|
||||
function WhitelistEditor({
|
||||
label,
|
||||
description,
|
||||
@@ -38,14 +31,17 @@ function WhitelistEditor({
|
||||
setItems: (items: string[]) => void;
|
||||
}) {
|
||||
const [newItem, setNewItem] = useState("");
|
||||
const trimmed = newItem.trim();
|
||||
const isDuplicate =
|
||||
trimmed !== "" &&
|
||||
items.some((a) => a.toLowerCase() === trimmed.toLowerCase());
|
||||
// Admin-typed input is a LIST: "Dell*, *" adds two patterns (the placeholder invites
|
||||
// exactly that). Only the insights buttons neutralize ',' — their values are untrusted.
|
||||
const existing = new Set(items.map((a) => a.toLowerCase()));
|
||||
const typed = parseList(newItem);
|
||||
const fresh = typed.filter((t, i) =>
|
||||
!existing.has(t.toLowerCase()) && typed.findIndex((u) => u.toLowerCase() === t.toLowerCase()) === i);
|
||||
const isDuplicate = typed.length > 0 && fresh.length === 0;
|
||||
|
||||
const addItem = () => {
|
||||
if (!trimmed || isDuplicate) return;
|
||||
setItems([...items, trimmed]);
|
||||
if (fresh.length === 0) return;
|
||||
setItems([...items, ...fresh]);
|
||||
setNewItem("");
|
||||
};
|
||||
|
||||
@@ -110,7 +106,7 @@ function WhitelistEditor({
|
||||
/>
|
||||
<button
|
||||
onClick={addItem}
|
||||
disabled={!trimmed || isDuplicate}
|
||||
disabled={fresh.length === 0}
|
||||
className="px-4 py-1.5 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
Add
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addWhitelistEntry, parseList, toWhitelistEntry } from "../hardwareWhitelist";
|
||||
|
||||
describe("hardware whitelist CSV helpers", () => {
|
||||
it("adds a plain value as one entry", () => {
|
||||
expect(addWhitelistEntry("Dell*,HP*", "Lenovo")).toBe("Dell*,HP*,Lenovo");
|
||||
});
|
||||
|
||||
it("never lets a delimiter-bearing value expand into extra patterns", () => {
|
||||
// Attacker-controlled distress-signal string: would become [..., "Dell Inc.", "*"] verbatim.
|
||||
const csv = addWhitelistEntry("Dell*", "Dell Inc.,*");
|
||||
expect(parseList(csv)).toEqual(["Dell*", "Dell Inc.?*"]);
|
||||
expect(parseList(csv)).not.toContain("*");
|
||||
});
|
||||
|
||||
it("neutralizes a trailing comma instead of creating an empty/stray entry", () => {
|
||||
expect(parseList(addWhitelistEntry("Dell*", "Dell Inc.,"))).toEqual(["Dell*", "Dell Inc.?"]);
|
||||
});
|
||||
|
||||
it("ignores empty and duplicate values", () => {
|
||||
expect(addWhitelistEntry("Dell*", " ")).toBe("Dell*");
|
||||
// A lone delimiter becomes the single-character pattern "?" — one entry, never a split.
|
||||
expect(parseList(addWhitelistEntry("Dell*", ","))).toEqual(["Dell*", "?"]);
|
||||
expect(addWhitelistEntry("Dell*", "dell*")).toBe("Dell*");
|
||||
});
|
||||
|
||||
it("toWhitelistEntry trims and replaces every comma", () => {
|
||||
expect(toWhitelistEntry(" a,b,,c ")).toBe("a?b??c");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Hardware whitelist CSV helpers.
|
||||
*
|
||||
* The tenant whitelist is persisted as ONE comma-separated string and split on ',' at
|
||||
* enforcement time (TenantConfiguration.GetManufacturerWhitelist). A comma inside a single
|
||||
* entry therefore changes the LIST STRUCTURE: "Dell Inc.,*" appended as one "manufacturer"
|
||||
* becomes two patterns, the second being the allow-all wildcard. Manufacturer/model strings
|
||||
* shown in the rejection insights come from UNAUTHENTICATED distress signals, so every value
|
||||
* that enters the list must round-trip as exactly one pattern.
|
||||
*/
|
||||
|
||||
export function parseList(csv: string): string[] {
|
||||
return csv.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function joinList(items: string[]): string {
|
||||
return items.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a value so it is stored as exactly one whitelist pattern. The delimiter is
|
||||
* replaced by the single-character wildcard '?', which still matches the original string
|
||||
* literally (and nothing structurally different) instead of splitting the list.
|
||||
* Returns "" when nothing usable remains.
|
||||
*/
|
||||
export function toWhitelistEntry(value: string): string {
|
||||
return value.replace(/,/g, "?").trim();
|
||||
}
|
||||
|
||||
/** Appends `value` as one pattern unless an equal (case-insensitive) entry already exists. */
|
||||
export function addWhitelistEntry(csv: string, value: string): string {
|
||||
const entry = toWhitelistEntry(value);
|
||||
if (!entry) return csv;
|
||||
const items = parseList(csv);
|
||||
if (items.some((i) => i.toLowerCase() === entry.toLowerCase())) return csv;
|
||||
return joinList([...items, entry]);
|
||||
}
|
||||
+5
-16
@@ -7,14 +7,7 @@ import { TenantNotifications } from "../../TenantNotifications";
|
||||
import HardwareWhitelistSection from "../../components/HardwareWhitelistSection";
|
||||
import HardwareRejectionInsights from "../../components/HardwareRejectionInsights";
|
||||
import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights";
|
||||
|
||||
function parseList(csv: string): string[] {
|
||||
return csv.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function joinList(items: string[]): string {
|
||||
return items.join(",");
|
||||
}
|
||||
import { addWhitelistEntry } from "../../lib/hardwareWhitelist";
|
||||
|
||||
export function SectionHardwareWhitelist() {
|
||||
const { getAccessToken } = useAuth();
|
||||
@@ -30,18 +23,14 @@ export function SectionHardwareWhitelist() {
|
||||
|
||||
const hasWebhook = notificationChannels.some((c) => c.enabled && (c.url ?? "").length > 0);
|
||||
|
||||
// Values come from UNAUTHENTICATED distress signals — addWhitelistEntry guarantees each
|
||||
// click adds exactly one pattern (a ',' inside the value cannot split the list).
|
||||
const handleAddManufacturer = useCallback((value: string) => {
|
||||
const items = parseList(manufacturerWhitelist);
|
||||
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
|
||||
setManufacturerWhitelist(joinList([...items, value]));
|
||||
}
|
||||
setManufacturerWhitelist(addWhitelistEntry(manufacturerWhitelist, value));
|
||||
}, [manufacturerWhitelist, setManufacturerWhitelist]);
|
||||
|
||||
const handleAddModel = useCallback((value: string) => {
|
||||
const items = parseList(modelWhitelist);
|
||||
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
|
||||
setModelWhitelist(joinList([...items, value]));
|
||||
}
|
||||
setModelWhitelist(addWhitelistEntry(modelWhitelist, value));
|
||||
}, [modelWhitelist, setModelWhitelist]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// API_BASE_URL is read at import time of lib/api — stub it before the import.
|
||||
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiPromise = import("../api") as Promise<{ api: any }>;
|
||||
|
||||
/**
|
||||
* Path-position ids must be percent-encoded so an attacker-controlled id
|
||||
* (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the
|
||||
* route: '/' and '..' would otherwise be resolved by the URL parser into a
|
||||
* different API path, '#' would truncate the builder's suffix and query string.
|
||||
*/
|
||||
const HOSTILE = "x/../../global/y#";
|
||||
const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23";
|
||||
|
||||
describe("api path ids are percent-encoded", () => {
|
||||
it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
const builders: Array<(id: string) => string> = [
|
||||
(id) => api.sessions.get(id),
|
||||
(id) => api.sessions.events(id),
|
||||
(id) => api.sessions.delete(id, "t"),
|
||||
(id) => api.sessions.analysis(id),
|
||||
(id) => api.sessions.vulnerabilityReport(id),
|
||||
(id) => api.sessions.timeAttribution(id),
|
||||
(id) => api.sessions.markFailed(id),
|
||||
(id) => api.sessions.markSucceeded(id),
|
||||
(id) => api.sessions.queueAction(id),
|
||||
(id) => api.sessions.report(id),
|
||||
(id) => api.sessions.annotations(id),
|
||||
(id) => api.sessions.annotation(id, "lane"),
|
||||
(id) => api.inspector.signals(id),
|
||||
(id) => api.inspector.decisionGraph(id),
|
||||
(id) => api.inspector.reducerVerification(id),
|
||||
];
|
||||
for (const build of builders) {
|
||||
const url = build(HOSTILE);
|
||||
expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true);
|
||||
expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("markFailed keeps its suffix after a '#'-bearing id", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.markFailed(HOSTILE, "t")).toBe(
|
||||
`https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`,
|
||||
);
|
||||
});
|
||||
|
||||
it("annotation lane and gather rule id are encoded too", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb");
|
||||
expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`);
|
||||
});
|
||||
|
||||
it("no path builder lets an id escape its segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
type Builder = (...args: string[]) => string;
|
||||
const walk = (node: unknown): Builder[] =>
|
||||
typeof node === "function"
|
||||
? [node as Builder]
|
||||
: Object.values(node as Record<string, unknown>).flatMap(walk);
|
||||
for (const fn of walk(api)) {
|
||||
if (fn.length === 0) continue;
|
||||
const url = fn(HOSTILE, HOSTILE);
|
||||
// The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y";
|
||||
// an encoded id survives verbatim and the fragment marker never appears.
|
||||
expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/);
|
||||
expect(url).not.toContain("#");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user