mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d294415d29 | |||
| 76af1234ed | |||
| 0515523e49 | |||
| a47e94f9a5 | |||
| ecfa59d049 | |||
| 6c50a54249 | |||
| 6e3487756a | |||
| dbb154bae9 |
@@ -54,6 +54,12 @@ jobs:
|
||||
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
# guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
# auto-committed to main and shipped to the portal - the schema forbids control
|
||||
# and line-terminator characters so a "data" PR can never become code.
|
||||
- name: Validate Guardrails
|
||||
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
|
||||
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
|
||||
timestamp: 2026-08-20
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -86,6 +86,40 @@ with the process zone).
|
||||
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
|
||||
completion line-anchored) and are recognizable via provenance.
|
||||
|
||||
# Line parsing (bounded cost)
|
||||
|
||||
`CmTraceLogParser.TryParseLine` (Shared) is the single CMTrace line parser for the agent
|
||||
(`ImeLogTracker`, `LogParserCollector`, `StallProbeCollector`) and the backend
|
||||
(`TestLogPatternFunction`). Both feed it input an outsider influences: tenant-supplied
|
||||
sample lines (200 x 8 KB per request) on the backend, and on the agent every tailed log
|
||||
file, including entries that IME assembles from installer or script output. It is
|
||||
therefore not one regex. The former shape — a greedy `(?<message>.*)` in front of the
|
||||
`]LOG]!><time="` literal, unanchored, no match timeout — re-tried every `<![LOG[`
|
||||
occurrence as a fresh start and backtracked the message group across every later character
|
||||
for each: quadratic in line length (an 8 KB line of repeated prefixes and `]` filler cost
|
||||
~7 million engine steps; a 1 MB assembled entry would have spun the SYSTEM agent for
|
||||
hours and blacked out IME tracking for the rest of the enrollment).
|
||||
|
||||
The parser reproduces exactly what that regex returned, in linear time: the message begins
|
||||
after the first `<![LOG[` (a later start could only match when the first one does, since
|
||||
`.*` spans the difference) and ends at the last `]LOG]!><time="` occurrence whose trailer
|
||||
parses — earlier occurrences are tried in turn, which is the order greedy backtracking
|
||||
visited them. The trailer is checked with a `\G`-anchored regex that has no ambiguous
|
||||
quantifier and a 1-second match timeout as a backstop; a timeout means "not a CMTrace
|
||||
line" (callers already have that path: raw-text matching in the tracker, `parse_failed`
|
||||
on the backend), never an exception. `CmTraceLogParserTests` pins the equivalence against
|
||||
the legacy regex over the shapes that matter (multiline messages, bias suffix, trailer
|
||||
literal inside the message, a last trailer that fails to parse, BOM prefix) and the
|
||||
reported worst case at 200 lines.
|
||||
|
||||
On the agent, `ImeLogTracker` additionally caps an assembled multiline entry at 100 raw
|
||||
lines **and** 1 MB of characters (`MaxMultiLineBufferChars`). A capped entry is dropped
|
||||
and logged at **Warning** — visible at the default `Info` level, so a real IME entry that
|
||||
large shows up in the client log and can be acted on. The remaining physical lines of a
|
||||
dropped entry are skipped until it closes or a new `<![LOG[` entry begins; previously they
|
||||
fell through to raw-text pattern matching, so fragments of a discarded entry could fire
|
||||
patterns without CMTrace context.
|
||||
|
||||
# Backend regression tripwire
|
||||
|
||||
The backend watches for the failure mode this design eliminates. On every terminal ingest
|
||||
@@ -129,3 +163,6 @@ precisely so an anchoring regression can never surface as a customer finding.
|
||||
* `tasks/todo.md` (2026-08-20) — full evidence trail: field measurements, the revert,
|
||||
the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA
|
||||
mismatch was the known release race against the 5-minute AdminConfiguration cache).
|
||||
* `CmTraceLogParser.TryParseLine` + `CmTraceLogParserTests` — the linear parser and its
|
||||
legacy-regex equivalence oracle; `ImeLogTracker.LogProcessing.cs` multiline buffering
|
||||
(`MaxMultiLineBufferLines` / `MaxMultiLineBufferChars`, `skippingDroppedEntry`).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, the handle-validated reads that close the enumerate-then-open junction race, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal, security]
|
||||
timestamp: 2026-08-30
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -90,10 +90,50 @@ validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
the handle-validated read (next section), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Reads are validated on the handle, not the path
|
||||
|
||||
Every guard above judges a path string. Reading the bytes from that string later —
|
||||
`File.GetAttributes` on the candidate, then a `FileStream` open after earlier files have been
|
||||
compressed — leaves a window: under any folder a local user can write to (the user-profile
|
||||
token paths, `ProgramData` subtrees, `C:\Install\Log`) that user can turn a subdirectory into
|
||||
a junction in between. An attribute check on the file resolves through a junction in a
|
||||
*parent* component, and SYSTEM would copy the junction target into the package.
|
||||
`PinnedSourceFolder` (`Monitoring/Runtime/PinnedSourceFolder.cs`, Win32 declarations in
|
||||
`Monitoring/Interop/FileNativeMethods.cs`) closes the window by deciding on the handle that
|
||||
is read:
|
||||
|
||||
* `AddLogFiles` pins the section folder once. It is opened with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS`, must not be a reparse point
|
||||
itself, and its `GetFinalPathNameByHandle` result — every reparse point resolved — must
|
||||
equal the validated lexical path (8.3 names expanded through `GetLongPathName`, so a
|
||||
`%TEMP%`-style short name is not mistaken for a junction). Anything else skips the section
|
||||
with `FOLDER REJECTED (<reason>)`; a missing folder stays `FOLDER MISSING`. The handle is
|
||||
held, with `FILE_LIST_DIRECTORY` access and no delete share, until the section is done:
|
||||
NTFS refuses to rename or delete a directory while it or anything beneath it is open, so
|
||||
the validated chain cannot be swapped underneath the build either.
|
||||
* Every candidate file is opened by `PinnedSourceFolder.TryOpenFile` with
|
||||
`FILE_FLAG_OPEN_REPARSE_POINT` — a symlink at the final component is opened as itself and
|
||||
refused (`SKIPPED (reparse point)`) — and the handle's final path must be exactly
|
||||
`<canonical folder>\<relative path>`. A subdirectory that became a junction after
|
||||
enumeration resolves elsewhere: `SKIPPED (resolved outside validated folder)`, naming only
|
||||
the requested path in the manifest, never the resolved one. The length charged against the
|
||||
caps and the stream copied into the archive come from that same handle; any other open
|
||||
failure is `FAILED (open): <path>: <reason>`.
|
||||
* `CollectFilesNoReparseDirs` still skips reparse-point directories while enumerating — as a
|
||||
candidate filter and to keep the walk out of junction loops — but nothing it decides is
|
||||
trusted at copy time.
|
||||
* The `.evtx` channel export is the agent's own file under an unguessable name in SYSTEM's
|
||||
temp folder and is streamed as-is. Hard links are out of scope: Windows refuses to create
|
||||
one to a file the caller cannot write.
|
||||
|
||||
The race itself is pinned by `DiagnosticsPackageServiceTests`: the `BeforeSourceFileOpen`
|
||||
seam swaps a subdirectory for a junction after enumeration and before the open, and the
|
||||
archive must not carry the file.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
@@ -120,6 +160,8 @@ same-named folder share one ZIP folder.
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
`BeforeSourceFileOpen` fires with each candidate path after enumeration and before its open
|
||||
— the point at which the junction-race test swaps the directory.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
@@ -139,9 +181,11 @@ entry is removed.
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, `AddLogFiles` / `AddLogFile` / `OpenSource`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PinnedSourceFolder.cs` — pinned folder handle, `TryOpenFile`, `SourceFile`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/FileNativeMethods.cs` — `CreateFileW`, `GetFileInformationByHandle`, `GetFinalPathNameByHandleW`, `GetLongPathNameW`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure, junction swapped in after enumeration, junction as section folder), `PinnedSourceFolderTests` (final-path equality, short names, junction in the chain, symlink at the final component, rename refused while pinned), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
|
||||
@@ -36,6 +36,14 @@ same table) — the only reliable address: it is typed by the user on the activa
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
The PUT is `AuthenticatedUserWithRole`, not a member tier, because the signup user is still
|
||||
roleless until approval auto-promotes them to Admin. The function re-gates on the resolved
|
||||
role (`PreviewWhitelistFunction.MayWriteNotificationEmailAsync`): a member role or platform
|
||||
scope always may write; a roleless caller may write only while the tenant has **no enabled
|
||||
member** at all. Once any member exists the window is closed for good — the address feeds the
|
||||
welcome and farewell mails and is shown to operators as the tenant contact, so an ordinary
|
||||
employee with a JWT but no product role must not be able to redirect or clear it (403).
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
+2
-2
@@ -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.
|
||||
@@ -28,7 +28,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Zero User-Targeted Apps — the starved user-phase completion gate](agent/zero-user-apps-completion-gate.md) - Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate — IME skips its "Completed user session" line entirely in the zero-app branch — turning the 30-minute completion backstop into `esp_exit_without_completion_evidence` on a third of one tenant's enrollments, with the user at the desktop and RealmJoin still installing. The two fixes: read the line IME does write (IME-USER-SESSION-ZERO-APPS), and never fail a session while a completion gate with its own armed deadline is deliberately holding it.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable; plus the linear-cost CMTrace line parser (first open / last parseable trailer, timeout-backed) and the agent multiline caps.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
|
||||
|
||||
+16
@@ -1,5 +1,21 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (2)
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — new "Line parsing (bounded cost)" section: `CmTraceLogParser.TryParseLine` is no longer one greedy-`.*` regex (quadratic on hostile lines, no timeout) but a linear first-open / last-parseable-trailer search with a `\G`-anchored, timeout-backed trailer regex that returns exactly what the old regex did; the agent's multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://raw.githubusercontent.com/OliverKieselbach/Autopilot-Monitor/main/rules/schema/guardrails.schema.json",
|
||||
"title": "Autopilot Monitor Gather-Rule Guardrails",
|
||||
"description": "Agent-side collection allowlists/blocklists. Every string is emitted verbatim into generated TypeScript (web + MCP), so values are single-line and must not contain control characters or the U+2028/U+2029 line terminators.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": { "type": "string" },
|
||||
"registryPrefixes": { "$ref": "#/$defs/categorizedList" },
|
||||
"filePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"wmiQueryPrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"eventLogChannels": { "$ref": "#/$defs/categorizedList" },
|
||||
"blockedEventLogChannels": { "$ref": "#/$defs/stringList" },
|
||||
"allowedCommands": { "$ref": "#/$defs/categorizedList" },
|
||||
"diagnosticsPathPrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"blockedFilePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"blockedCommandPatterns": { "$ref": "#/$defs/stringList" },
|
||||
"blockedInterimTriggerEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"maxCommandLength": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
||||
},
|
||||
"required": [
|
||||
"registryPrefixes",
|
||||
"filePrefixes",
|
||||
"wmiQueryPrefixes",
|
||||
"eventLogChannels",
|
||||
"blockedEventLogChannels",
|
||||
"allowedCommands",
|
||||
"diagnosticsPathPrefixes",
|
||||
"blockedFilePrefixes",
|
||||
"blockedCommandPatterns",
|
||||
"maxCommandLength"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"safeString": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000,
|
||||
"pattern": "^[^\\u0000-\\u001F\\u007F\\u2028\\u2029]+$"
|
||||
},
|
||||
"stringList": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/safeString" }
|
||||
},
|
||||
"categorizedList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "$ref": "#/$defs/safeString" },
|
||||
"prefixes": { "$ref": "#/$defs/stringList" },
|
||||
"channels": { "$ref": "#/$defs/stringList" },
|
||||
"commands": { "$ref": "#/$defs/stringList" }
|
||||
},
|
||||
"required": ["category"],
|
||||
"minProperties": 2,
|
||||
"maxProperties": 2,
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,18 +97,71 @@ const guardrailsOutput = path.resolve(
|
||||
if (fs.existsSync(guardrailsPath)) {
|
||||
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
||||
|
||||
/** Escape a string for use inside a TS string literal (double-quoted). */
|
||||
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
// Every value below is emitted into a TypeScript module that ships in the
|
||||
// portal bundle (and is auto-committed + auto-deployed from main). A string
|
||||
// containing a raw newline / CR / U+2028 / U+2029 inside a naive "..." literal
|
||||
// would terminate the literal and turn the remainder of the value into CODE.
|
||||
// Two independent defences: (1) reject any control/line-terminator character
|
||||
// outright — no legitimate prefix, command or channel name contains one —
|
||||
// and (2) render every literal through a complete encoder (JSON.stringify),
|
||||
// so even a value that slipped past (1) stays an inert string constant.
|
||||
// rules/schema/guardrails.schema.json enforces the same constraints in CI
|
||||
// before this script runs; this in-process check keeps local runs honest.
|
||||
const UNSAFE_CHAR = /[\u0000-\u001F\u007F\u2028\u2029]/;
|
||||
const fail = (msg) => {
|
||||
console.error(`ERROR: guardrails.json ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
const assertSafeString = (value, where) => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
fail(`${where}: expected a non-empty string, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
if (UNSAFE_CHAR.test(value)) {
|
||||
fail(`${where}: control or line-terminator character in ${JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
const assertStringList = (list, where) => {
|
||||
if (!Array.isArray(list)) fail(`${where}: expected an array`);
|
||||
list.forEach((s, i) => assertSafeString(s, `${where}[${i}]`));
|
||||
};
|
||||
const assertCategorized = (groups, itemsKey, where) => {
|
||||
if (!Array.isArray(groups)) fail(`${where}: expected an array`);
|
||||
groups.forEach((g, i) => {
|
||||
assertSafeString(g && g.category, `${where}[${i}].category`);
|
||||
assertStringList(g[itemsKey], `${where}[${i}].${itemsKey}`);
|
||||
});
|
||||
};
|
||||
|
||||
assertCategorized(guardrails.registryPrefixes, 'prefixes', 'registryPrefixes');
|
||||
assertCategorized(guardrails.allowedCommands, 'commands', 'allowedCommands');
|
||||
assertCategorized(guardrails.eventLogChannels, 'channels', 'eventLogChannels');
|
||||
for (const key of [
|
||||
'filePrefixes', 'wmiQueryPrefixes', 'diagnosticsPathPrefixes', 'blockedFilePrefixes',
|
||||
'blockedEventLogChannels', 'blockedCommandPatterns'
|
||||
]) {
|
||||
assertStringList(guardrails[key], key);
|
||||
}
|
||||
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
|
||||
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
|
||||
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a string as a complete, self-contained TS double-quoted literal.
|
||||
* JSON.stringify escapes backslash, quote and every C0 control character;
|
||||
* U+2028/U+2029 are escaped on top because JSON does not require it.
|
||||
*/
|
||||
const lit = (s) => JSON.stringify(s).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
||||
|
||||
/** Render an array of strings as a TS readonly array. */
|
||||
const flatArray = (items) =>
|
||||
items.map((s) => ` "${esc(s)}",`).join('\n');
|
||||
items.map((s) => ` ${lit(s)},`).join('\n');
|
||||
|
||||
/** Render a categorized list as a TS readonly array of { category, items }. */
|
||||
const categorizedArray = (groups, itemsKey) =>
|
||||
groups.map((g) => {
|
||||
const items = g[itemsKey].map((s) => ` "${esc(s)}",`).join('\n');
|
||||
return ` {\n category: "${esc(g.category)}",\n items: [\n${items}\n ],\n },`;
|
||||
const items = g[itemsKey].map((s) => ` ${lit(s)},`).join('\n');
|
||||
return ` {\n category: ${lit(g.category)},\n items: [\n${items}\n ],\n },`;
|
||||
}).join('\n');
|
||||
|
||||
// Flatten categorized lists for validation
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Xunit;
|
||||
|
||||
@@ -137,5 +142,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(hugeMessage.Length, entry.Message.Length);
|
||||
}
|
||||
|
||||
// ── Hostile input: cost must be linear, never quadratic ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// The legacy single-regex parser (greedy <c>.*</c> message group before the trailer
|
||||
/// literal, unanchored, Singleline, no timeout). Kept here ONLY as the semantic oracle:
|
||||
/// the linear parser must return exactly what this regex returned for every line that
|
||||
/// matches, and false for every line it rejected.
|
||||
/// </summary>
|
||||
private static readonly Regex LegacyRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
public static IEnumerable<object[]> EquivalenceLines()
|
||||
{
|
||||
const string trailer = "]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"2\" thread=\"7\" file=\"\">";
|
||||
const string badTrailer = "]LOG]!><time=\"06:08:04\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"x\" thread=\"7\" file=\"\">";
|
||||
yield return new object[] { CmTraceLine("plain", "06:08:04.8834397", "2-8-2026") };
|
||||
yield return new object[] { CmTraceLine("multi\nline\r\nmessage", "06:08:04.8834397", "2-8-2026") };
|
||||
yield return new object[] { CmTraceLine("bias", "11:46:19.0226610+480", "7-29-2026") };
|
||||
yield return new object[] { CmTraceLine("", "06:08:04.8834397", "2-8-2026") };
|
||||
// The trailer literal INSIDE the message: greedy .* takes the LAST parseable trailer.
|
||||
yield return new object[] { "<![LOG[inner " + trailer + " outer" + trailer };
|
||||
// Last trailer occurrence does not parse (type="x") → the earlier one wins, and the
|
||||
// failing trailer text is part of the message.
|
||||
yield return new object[] { "<![LOG[first" + trailer + " second" + badTrailer };
|
||||
// Only a broken trailer → false.
|
||||
yield return new object[] { "<![LOG[msg" + badTrailer };
|
||||
// A nested "<![LOG[" inside the message: the FIRST open wins (message keeps the inner one).
|
||||
yield return new object[] { "<![LOG[outer <![LOG[inner" + trailer };
|
||||
// Whitespace variants the old regex tolerated (\s+ between attributes).
|
||||
yield return new object[] { "<![LOG[ws]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\"\tcomponent=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"x.cpp:12\">" };
|
||||
// BOM-prefixed first line of a fresh log.
|
||||
yield return new object[] { "\uFEFF" + CmTraceLine("bom", "06:08:04.8834397", "2-8-2026") };
|
||||
// Trailer text without its leading ']' → false on both.
|
||||
yield return new object[] { "<![LOG[" + trailer.Substring(1) };
|
||||
yield return new object[] { "<![LOG[truncated" };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(EquivalenceLines))]
|
||||
public void TryParseLine_is_semantically_identical_to_the_legacy_greedy_regex(string line)
|
||||
{
|
||||
var legacy = LegacyRegex.Match(line);
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.Equal(legacy.Success, ok);
|
||||
if (!legacy.Success) return;
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(legacy.Groups["message"].Value, entry.Message);
|
||||
Assert.Equal(legacy.Groups["component"].Value, entry.Component);
|
||||
Assert.Equal(int.Parse(legacy.Groups["type"].Value), entry.Type);
|
||||
Assert.Equal(legacy.Groups["thread"].Value, entry.Thread);
|
||||
var expectedBias = legacy.Groups["bias"].Success ? int.Parse(legacy.Groups["bias"].Value) : (int?)null;
|
||||
Assert.Equal(expectedBias, entry.BiasMinutes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reported worst case: 585 x "<![LOG[" (4095 chars) + 4097 x "]" — passes the StartsWith
|
||||
/// gate, never matches, and drove ~7 million backtracking steps through the legacy regex
|
||||
/// (k prefix restarts x m filler positions). The linear parser has no trailer literal to
|
||||
/// find and returns false in a handful of string searches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParseLine_rejects_reported_worst_case_in_bounded_time()
|
||||
{
|
||||
var line = string.Concat(Enumerable.Repeat("<![LOG[", 585)) + new string(']', 4097);
|
||||
Assert.Equal(8192, line.Length);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
Assert.False(CmTraceLogParser.TryParseLine(line, out _));
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// 200 lines = one maximal backend request. Generous bound for a slow CI box; the
|
||||
// legacy regex needed seconds here.
|
||||
Assert.True(sw.ElapsedMilliseconds < 500, $"200 worst-case lines took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The agent variant: a multi-megabyte assembled entry stuffed with prefixes, trailer
|
||||
/// literals whose attributes never parse, and filler — every occurrence is tried once,
|
||||
/// each trailer probe fails at its first attribute, total work stays linear.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParseLine_rejects_multi_megabyte_hostile_entry_in_bounded_time()
|
||||
{
|
||||
var sb = new StringBuilder(2 * 1024 * 1024);
|
||||
sb.Append("<![LOG[");
|
||||
while (sb.Length < 1024 * 1024)
|
||||
sb.Append("<![LOG[").Append("]LOG]!><time=\"x\" ").Append(new string(']', 40)).Append('\n');
|
||||
var line = sb.ToString();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
Assert.False(CmTraceLogParser.TryParseLine(line, out _));
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000, $"1 MB hostile entry took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
@@ -437,5 +438,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
}
|
||||
|
||||
private DateTime? _expectedForCurrentEntry;
|
||||
|
||||
// ── Multiline buffer caps ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 100 raw lines of unbounded length could still assemble a multi-megabyte entry. The
|
||||
/// char cap drops such an entry (its marker never fires) and says so at Warning, which
|
||||
/// is visible at the default Info level — a capped real entry would be news worth
|
||||
/// reacting to. A same-shaped entry under the cap still assembles and matches.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(2 * 1024 * 1024, false)] // over cap → dropped, warned
|
||||
[InlineData(64 * 1024, true)] // under cap → assembled, matched
|
||||
public async Task MultilineEntry_IsDroppedAndWarned_WhenItExceedsTheCharCap(int fillerChars, bool expectMatch)
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Append("AgentExecutor.log", Line("marker 0", h.Now, TimeSpan.Zero));
|
||||
await h.Pass();
|
||||
|
||||
var matched = new List<string>();
|
||||
h.Tracker.OnPatternMatched += id => matched.Add(id);
|
||||
|
||||
// One entry spanning 3 physical lines; the closing trailer sits on the last line.
|
||||
var filler = new string('x', fillerChars / 2);
|
||||
h.Append("AgentExecutor.log", "<![LOG[write output done. output = " + filler);
|
||||
h.Append("AgentExecutor.log", filler);
|
||||
h.Append("AgentExecutor.log", Line("marker 7", h.Now, TimeSpan.Zero).Substring("<![LOG[".Length));
|
||||
h.Now = T0.AddSeconds(10);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(expectMatch ? 1 : 0, matched.Count);
|
||||
var agentLog = string.Concat(Directory.GetFiles(h.PathOf(""), "*.log")
|
||||
.Where(f => !Path.GetFileName(f).Equals("AgentExecutor.log", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(File.ReadAllText));
|
||||
if (expectMatch)
|
||||
Assert.DoesNotContain("discarding multiline CMTrace buffer", agentLog);
|
||||
else
|
||||
Assert.Contains("discarding multiline CMTrace buffer in AgentExecutor.log", agentLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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"));
|
||||
}
|
||||
|
||||
+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(
|
||||
|
||||
+17
-3
@@ -123,12 +123,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// "write output done. output = ..." spans many lines)
|
||||
StringBuilder multiLineBuffer = null;
|
||||
int multiLineCount = 0;
|
||||
// Set after a capped entry is dropped: its remaining physical lines
|
||||
// are skipped instead of being matched as raw text, until the entry
|
||||
// closes or a new entry begins (so no real entry is lost).
|
||||
bool skippingDroppedEntry = false;
|
||||
|
||||
string line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
if (token.IsCancellationRequested) break;
|
||||
|
||||
if (skippingDroppedEntry)
|
||||
{
|
||||
if (line.Contains("]LOG]!>")) { skippingDroppedEntry = false; continue; }
|
||||
if (!line.StartsWith("<![LOG[")) continue;
|
||||
skippingDroppedEntry = false;
|
||||
}
|
||||
|
||||
// --- Multiline CMTrace buffering ---
|
||||
// CMTrace entries: <![LOG[message]LOG]!><time=...>
|
||||
// When message contains newlines, the entry spans multiple lines.
|
||||
@@ -146,12 +157,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
}
|
||||
else if (multiLineCount >= MaxMultiLineBufferLines)
|
||||
else if (multiLineCount >= MaxMultiLineBufferLines || multiLineBuffer.Length >= MaxMultiLineBufferChars)
|
||||
{
|
||||
// Safety limit — discard to prevent unbounded memory usage
|
||||
_logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)");
|
||||
// Safety limit — discard to bound memory and parser work. Warning
|
||||
// (not Debug) so a capped entry is visible in the client log at the
|
||||
// default level: a real IME entry this large would be news.
|
||||
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {Path.GetFileName(filePath)} after {multiLineCount} lines / {multiLineBuffer.Length} chars (cap {MaxMultiLineBufferLines} lines / {MaxMultiLineBufferChars} chars) — entry dropped");
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
skippingDroppedEntry = true;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -224,6 +224,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private const int MaxScriptOutputLength = 2048;
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
// Companion byte cap: 100 raw lines of unbounded length could still assemble a
|
||||
// multi-megabyte entry. Any process able to append to a tailed log can plant one; the
|
||||
// parser is linear, but memory and the per-entry work must stay bounded regardless.
|
||||
// Real IME entries (AgentExecutor script output is capped by IME itself) stay far below.
|
||||
internal const int MaxMultiLineBufferChars = 1024 * 1024;
|
||||
|
||||
// Counter for HS-NEW-RESULT JSON parse failures — visible to operators via tracker
|
||||
// metrics so we can detect IME log-format drift in production.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -33,18 +33,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// which is exactly the set the UI timeline groups on), plus the originating
|
||||
/// <see cref="EnrollmentEvent.Source"/> component label. The source lets subscribers that
|
||||
/// themselves emit timeline events (the gather-rule executor) recognise their own output
|
||||
/// and not re-trigger on it — see <see cref="GatherRuleExecutorHost"/>.
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
public event Action<string, EnrollmentPhase, string>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
internal void Publish(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
foreach (Action<string, EnrollmentPhase, string> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
try { subscriber(eventType, phase, source); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
|
||||
@@ -148,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase, evt.Source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -240,6 +240,45 @@ public class GatherRuleScopeFieldsTests
|
||||
ValidationRule(activePhases: new List<string> { "" })));
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string triggerEventType, string? outputEventType)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-VAL-EVT",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Contoso",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = outputEventType!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("gather_proxy", "gather_proxy")] // self-cycle
|
||||
[InlineData("GATHER_PROXY", "gather_proxy")] // case-insensitive (agent matches OrdinalIgnoreCase)
|
||||
[InlineData("gather_result", null)] // self-cycle through the default output type
|
||||
[InlineData("gather_result", "")]
|
||||
[InlineData("gather_result", "gather_other")] // gather output namespace never triggers
|
||||
public void Validate_RejectsOnEventRuleTriggeringOnGatherOutput(string trigger, string? output)
|
||||
{
|
||||
Assert.NotNull(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_complete", "gather_proxy")]
|
||||
[InlineData("app_install_failed", null)]
|
||||
[InlineData("", "gather_proxy")] // partial/toggle payload passes through
|
||||
public void Validate_AcceptsOnEventRuleOnNonGatherEvents(string trigger, string? output)
|
||||
{
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutputEqualsTrigger_IsOnlyCheckedForOnEventTrigger()
|
||||
{
|
||||
var rule = OnEventRule("gather_same", "gather_same");
|
||||
rule.Trigger = "startup";
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsBothScopeFieldsSet()
|
||||
{
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// BlockedDevices rows must be keyed case-insensitively: the enforcement cache
|
||||
/// (BlockedDeviceService) and Autopilot inventory validation both compare serials
|
||||
/// OrdinalIgnoreCase, so a case-variant serial must resolve to the same storage row.
|
||||
/// </summary>
|
||||
public class TableDeviceSecurityRepositoryKeyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ABC123", "ABC123")]
|
||||
[InlineData("abc123", "ABC123")]
|
||||
[InlineData(" aBc123 ", "ABC123")]
|
||||
[InlineData(null, "")]
|
||||
public void CanonicalizeSerial_TrimsAndUpperCases(string? input, string expected)
|
||||
=> Assert.Equal(expected, TableDeviceSecurityRepository.CanonicalizeSerial(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("ABC123", "abc123")]
|
||||
[InlineData("ABC123", "Abc123 ")]
|
||||
[InlineData("PF/1#2", "pf/1#2")]
|
||||
public void DeviceRowKey_IsIdenticalForCaseVariants(string a, string b)
|
||||
=> Assert.Equal(TableDeviceSecurityRepository.DeviceRowKey(a), TableDeviceSecurityRepository.DeviceRowKey(b));
|
||||
|
||||
[Fact]
|
||||
public void DeviceRowKey_EscapesReservedCharacters()
|
||||
=> Assert.Equal("PF%2F1%232", TableDeviceSecurityRepository.DeviceRowKey("pf/1#2"));
|
||||
|
||||
[Fact]
|
||||
public void LegacyDeviceRowKey_IsNull_WhenAlreadyCanonical()
|
||||
=> Assert.Null(TableDeviceSecurityRepository.LegacyDeviceRowKey("ABC123"));
|
||||
|
||||
[Fact]
|
||||
public void LegacyDeviceRowKey_IsVerbatimKey_WhenCaseDiffers()
|
||||
=> Assert.Equal("abc123", TableDeviceSecurityRepository.LegacyDeviceRowKey("abc123"));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
@@ -142,4 +144,26 @@ public class TestLogPatternFunctionTests
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== hostile sample lines: the built-in CMTrace parser must stay bounded =====
|
||||
|
||||
/// <summary>A maximal request (200 x 8192-char lines) built from the reported worst case:
|
||||
/// each line passes the CMTrace prefix gate and never matches. The built-in parser used to
|
||||
/// backtrack quadratically here (~1.4e9 steps per request) — before the tenant's own
|
||||
/// 1-second-timeout regex ever ran.</summary>
|
||||
[Fact]
|
||||
public void CmTraceMode_WorstCaseSampleLines_CompleteInBoundedTime()
|
||||
{
|
||||
var line = string.Concat(Enumerable.Repeat("<![LOG[", 585)) + new string(']', 4097);
|
||||
Assert.Equal(TestLogPatternFunction.MaxLineLength, line.Length);
|
||||
var lines = Enumerable.Repeat(line, TestLogPatternFunction.MaxSampleLines).ToArray();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = TestLogPatternFunction.EvaluatePattern(AgentRegex("error"), isTextMode: false, lines);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(TestLogPatternFunction.MaxSampleLines, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000, $"maximal worst-case request took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
}
|
||||
|
||||
+133
-43
@@ -43,24 +43,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
||||
return (false, null, "Block", null);
|
||||
|
||||
var entity = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
|
||||
if (entity == null)
|
||||
return (false, null, "Block", null);
|
||||
|
||||
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
|
||||
if (DateTime.UtcNow >= unblockAt)
|
||||
return (false, null, "Block", null);
|
||||
|
||||
var action = entity.GetString("Action") ?? "Block";
|
||||
var blockedSessionIds = entity.GetString("BlockedSessionIds");
|
||||
return (true, unblockAt, action, blockedSessionIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point-reads the block row for a serial under its canonical key, falling back to the
|
||||
/// legacy verbatim-case key for rows written before serials were canonicalized.
|
||||
/// Returns null when neither exists.
|
||||
/// </summary>
|
||||
private async Task<TableEntity?> GetDeviceBlockEntityAsync(string tenantId, string serialNumber)
|
||||
{
|
||||
var entity = await TryGetEntityAsync(tenantId, DeviceRowKey(serialNumber));
|
||||
if (entity != null)
|
||||
return entity;
|
||||
|
||||
var legacyKey = LegacyDeviceRowKey(serialNumber);
|
||||
return legacyKey == null ? null : await TryGetEntityAsync(tenantId, legacyKey);
|
||||
}
|
||||
|
||||
private async Task<TableEntity?> TryGetEntityAsync(string partitionKey, string rowKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
|
||||
var entity = response?.Value;
|
||||
if (entity == null)
|
||||
return (false, null, "Block", null);
|
||||
|
||||
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
|
||||
if (DateTime.UtcNow >= unblockAt)
|
||||
return (false, null, "Block", null);
|
||||
|
||||
var action = entity.GetString("Action") ?? "Block";
|
||||
var blockedSessionIds = entity.GetString("BlockedSessionIds");
|
||||
return (true, unblockAt, action, blockedSessionIds);
|
||||
var response = await _blockedDevicesTable.GetEntityAsync<TableEntity>(partitionKey, rowKey);
|
||||
return response?.Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return (false, null, "Block", null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +101,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
result.Add(MapToBlockedDeviceEntry(entity, tenantId, now));
|
||||
await MigrateLegacyRowKeyAsync(entity);
|
||||
}
|
||||
|
||||
// Clean up expired entries (fire-and-forget, best effort)
|
||||
@@ -106,6 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
result.Add(MapToBlockedDeviceEntry(entity, entity.PartitionKey, now));
|
||||
await MigrateLegacyRowKeyAsync(entity);
|
||||
}
|
||||
|
||||
// Clean up expired entries (fire-and-forget, best effort)
|
||||
@@ -123,37 +145,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var unblockAt = now.AddHours(durationHours);
|
||||
var canonicalSerial = CanonicalizeSerial(serialNumber);
|
||||
|
||||
// If a session-aware block already exists, merge session IDs
|
||||
string? blockedSessionIds = blockedSessionId;
|
||||
if (!string.IsNullOrEmpty(blockedSessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
|
||||
var existingSessionIds = existing?.Value?.GetString("BlockedSessionIds");
|
||||
var existing = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
|
||||
var existingSessionIds = existing?.GetString("BlockedSessionIds");
|
||||
|
||||
if (existingSessionIds == null)
|
||||
{
|
||||
// Existing whole-device block takes precedence — don't downgrade to session-aware
|
||||
if (existing?.Value != null)
|
||||
blockedSessionIds = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Merge: append new session ID if not already present
|
||||
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
if (existingSessionIds == null)
|
||||
{
|
||||
// No existing record — use single session ID
|
||||
// Existing whole-device block takes precedence — don't downgrade to session-aware
|
||||
if (existing != null)
|
||||
blockedSessionIds = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Merge: append new session ID if not already present
|
||||
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber))
|
||||
var entity = new TableEntity(tenantId, DeviceRowKey(serialNumber))
|
||||
{
|
||||
["SerialNumber"] = serialNumber,
|
||||
["SerialNumber"] = canonicalSerial,
|
||||
["BlockedAt"] = now,
|
||||
["UnblockAt"] = unblockAt,
|
||||
["BlockedByEmail"] = blockedByEmail ?? string.Empty,
|
||||
@@ -166,7 +182,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
entity["BlockedSessionIds"] = blockedSessionIds;
|
||||
|
||||
await _blockedDevicesTable.UpsertEntityAsync(entity);
|
||||
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber, action, durationHours }, tenantId);
|
||||
await DeleteLegacyRowAsync(tenantId, serialNumber);
|
||||
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber = canonicalSerial, action, durationHours }, tenantId);
|
||||
}
|
||||
|
||||
internal static string MergeSessionId(string? existingList, string newSessionId)
|
||||
@@ -189,16 +206,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _blockedDevicesTable.DeleteEntityAsync(tenantId, EncodeRowKey(serialNumber));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already removed
|
||||
}
|
||||
await DeleteIgnoringNotFoundAsync(tenantId, DeviceRowKey(serialNumber));
|
||||
await DeleteLegacyRowAsync(tenantId, serialNumber);
|
||||
|
||||
await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber }, tenantId);
|
||||
await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber = CanonicalizeSerial(serialNumber) }, tenantId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -396,6 +407,85 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serial numbers are matched case-insensitively everywhere else (agent inventory
|
||||
/// validation, BlockedDeviceService cache), so the storage key must not depend on the
|
||||
/// casing the admin typed or the agent reported: trim + upper-case invariant.
|
||||
/// </summary>
|
||||
internal static string CanonicalizeSerial(string? serialNumber)
|
||||
=> (serialNumber ?? string.Empty).Trim().ToUpperInvariant();
|
||||
|
||||
/// <summary>Canonical BlockedDevices RowKey for a serial number.</summary>
|
||||
internal static string DeviceRowKey(string? serialNumber)
|
||||
=> EncodeRowKey(CanonicalizeSerial(serialNumber));
|
||||
|
||||
/// <summary>
|
||||
/// RowKey a pre-canonicalization row would have been written under (verbatim case),
|
||||
/// or null when it is identical to the canonical key.
|
||||
/// </summary>
|
||||
internal static string? LegacyDeviceRowKey(string? serialNumber)
|
||||
{
|
||||
var legacy = EncodeRowKey(serialNumber ?? string.Empty);
|
||||
return string.Equals(legacy, DeviceRowKey(serialNumber), StringComparison.Ordinal) ? null : legacy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-keys a row written under a non-canonical RowKey to its canonical key (best effort).
|
||||
/// Runs from the list paths so the pre-canonicalization backlog drains over time.
|
||||
/// </summary>
|
||||
private async Task MigrateLegacyRowKeyAsync(TableEntity entity)
|
||||
{
|
||||
var serial = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey);
|
||||
var canonicalKey = DeviceRowKey(serial);
|
||||
if (string.Equals(entity.RowKey, canonicalKey, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var migrated = new TableEntity(entity.PartitionKey, canonicalKey);
|
||||
foreach (var kv in entity)
|
||||
{
|
||||
if (kv.Key is "PartitionKey" or "RowKey" or "Timestamp" or "odata.etag" or "ETag")
|
||||
continue;
|
||||
migrated[kv.Key] = kv.Value;
|
||||
}
|
||||
migrated["SerialNumber"] = CanonicalizeSerial(serial);
|
||||
|
||||
// Never clobber a canonical row that already exists (it is the newer write).
|
||||
await _blockedDevicesTable.AddEntityAsync(migrated);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
// Canonical row already present — just drop the legacy duplicate below.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to migrate legacy BlockedDevices row {PartitionKey}/{RowKey}", entity.PartitionKey, entity.RowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await DeleteIgnoringNotFoundAsync(entity.PartitionKey, entity.RowKey);
|
||||
}
|
||||
|
||||
private async Task DeleteLegacyRowAsync(string tenantId, string serialNumber)
|
||||
{
|
||||
var legacyKey = LegacyDeviceRowKey(serialNumber);
|
||||
if (legacyKey != null)
|
||||
await DeleteIgnoringNotFoundAsync(tenantId, legacyKey);
|
||||
}
|
||||
|
||||
private async Task DeleteIgnoringNotFoundAsync(string partitionKey, string rowKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _blockedDevicesTable.DeleteEntityAsync(partitionKey, rowKey);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already removed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Azure Table RowKey must not contain /\#? and must be <= 1KB. URL-encode to be safe.</summary>
|
||||
internal static string EncodeRowKey(string value)
|
||||
=> Uri.EscapeDataString(value);
|
||||
|
||||
@@ -49,6 +49,18 @@ public class MetricsSummaryFunction
|
||||
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Query value is interpolated into an OData filter downstream — GUID-gate it here
|
||||
// (belt) in addition to ODataSanitizer at the sink (braces).
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
var summary = await _metricsRepo.GetMetricsSummaryAsync(
|
||||
string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId,
|
||||
days);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -167,12 +168,33 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the phase-scope + emit-mode fields on create/update.
|
||||
/// Returns an error message for a 400 response, or null when valid.
|
||||
/// Validates the phase-scope + emit-mode fields and the on_event trigger wiring on
|
||||
/// create/update. Returns an error message for a 400 response, or null when valid.
|
||||
/// Toggle-style partial payloads carry none of these fields and pass through.
|
||||
/// <para>
|
||||
/// on_event: the agent never dispatches on_event rules for events the gather executor
|
||||
/// itself emitted (feedback-loop guard, <c>GatherRuleExecutorHost</c>), so a rule that
|
||||
/// triggers on its own output — or on the <c>gather_result</c> default output type —
|
||||
/// could never fire and is rejected here with an explanatory message instead of being
|
||||
/// stored as a dead rule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateScopeAndEmitMode(GatherRule rule)
|
||||
{
|
||||
if (string.Equals(rule.Trigger, "on_event", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(rule.TriggerEventType))
|
||||
{
|
||||
var outputEventType = string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? Constants.EventTypes.GatherResult
|
||||
: rule.OutputEventType;
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, outputEventType, StringComparison.OrdinalIgnoreCase))
|
||||
return "triggerEventType must differ from the rule's own outputEventType — gather rules never trigger on their own output.";
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, Constants.EventTypes.GatherResult, StringComparison.OrdinalIgnoreCase))
|
||||
return $"triggerEventType '{Constants.EventTypes.GatherResult}' is the gather-rule output type — gather rules never trigger on gather output.";
|
||||
}
|
||||
|
||||
var hasActivePhases = rule.ActivePhases != null && rule.ActivePhases.Count > 0;
|
||||
var hasFromPhase = !string.IsNullOrEmpty(rule.ActiveFromPhase);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD endpoints for managing the tenant-activation whitelist (table: PreviewWhitelist).
|
||||
/// All endpoints are Global Admin only (except notification-email which is AuthenticatedUser).
|
||||
/// All endpoints are Global Admin only, except PUT notification-email: that one is
|
||||
/// AuthenticatedUserWithRole and gated in-function (member role, or the tenant has no members yet).
|
||||
/// </summary>
|
||||
public class PreviewWhitelistFunction
|
||||
{
|
||||
@@ -19,6 +21,7 @@ public class PreviewWhitelistFunction
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantApprovalService _tenantApprovalService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
@@ -26,12 +29,14 @@ public class PreviewWhitelistFunction
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantApprovalService tenantApprovalService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IEmailService emailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantApprovalService = tenantApprovalService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
@@ -156,7 +161,8 @@ public class PreviewWhitelistFunction
|
||||
/// <summary>
|
||||
/// PUT /api/preview/notification-email
|
||||
/// Saves the caller's notification email for the activation notice.
|
||||
/// AuthenticatedUser policy — preview-blocked users can call this.
|
||||
/// AuthenticatedUserWithRole policy — preview-blocked users can call this, but only while the
|
||||
/// tenant has no members (see <see cref="MayWriteNotificationEmailAsync"/>).
|
||||
/// </summary>
|
||||
[Function("SavePreviewNotificationEmail")]
|
||||
[Authorize]
|
||||
@@ -174,6 +180,16 @@ public class PreviewWhitelistFunction
|
||||
return bad;
|
||||
}
|
||||
|
||||
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
|
||||
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = "A tenant member role is required to change the notification email" });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
|
||||
var email = body?.Email?.Trim();
|
||||
|
||||
@@ -208,6 +224,25 @@ public class PreviewWhitelistFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The notification email is tenant-shared state consumed by privileged flows (welcome mail,
|
||||
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
|
||||
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
|
||||
/// first-touch signup window, where the activating user is still roleless because the requester
|
||||
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
|
||||
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
|
||||
/// or suppress the tenant's correspondence.
|
||||
/// </summary>
|
||||
internal static async Task<bool> MayWriteNotificationEmailAsync(
|
||||
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
|
||||
{
|
||||
if (ctx.IsTenantMemberOrGlobalAdmin())
|
||||
return true;
|
||||
|
||||
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
return !members.Any(m => m.IsEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/preview/send-welcome-email/{tenantId}
|
||||
/// Sends (or resends) the activation welcome email. Global Admin only.
|
||||
|
||||
@@ -56,6 +56,17 @@ public class SearchSessionsFunction
|
||||
else
|
||||
{
|
||||
filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
// Interpolated into OData filters downstream — GUID-gate the raw query value.
|
||||
if (!Guid.TryParse(filterTenantId, out var parsedTenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a GUID" });
|
||||
return bad;
|
||||
}
|
||||
filterTenantId = parsedTenantId.ToString("D");
|
||||
}
|
||||
tenantId = filterTenantId;
|
||||
scope = "search:global";
|
||||
basePath = "/api/global/search/sessions";
|
||||
|
||||
@@ -223,7 +223,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ProgressPortalFunction; regression history c4dabeee).
|
||||
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
|
||||
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
|
||||
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
|
||||
// roleless during first-touch signup, so the tier must admit non-members — but the address is
|
||||
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
|
||||
// resolved role: roleless callers may only write while the tenant has no member yet.
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
@@ -660,7 +660,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
||||
{
|
||||
var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: snapshotTableClient,
|
||||
@@ -838,7 +838,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<List<SessionSummary>> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
||||
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
||||
var oDataFilter = BuildDeviceSnapshotTenantFilter(tenantId);
|
||||
|
||||
var sessionIds = new List<string>();
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
||||
@@ -1437,6 +1437,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
groups[pk] = g.Add(statusStr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SessionsIndex filter for the metrics summary: date-window bound plus optional single-tenant
|
||||
/// scope. <paramref name="tenantId"/> may originate from the HTTP query string, so it is
|
||||
/// escaped — a quote in the value must never alter the filter grammar.
|
||||
/// </summary>
|
||||
internal static string BuildMetricsSummaryFilter(string? tenantId, string cutoffRowKeyPrefix)
|
||||
{
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
|
||||
return string.Join(" and ", filterParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceSnapshot filter for the device-property search path: optional single-tenant scope.
|
||||
/// Escaped for the same reason as <see cref="BuildMetricsSummaryFilter"/>.
|
||||
/// </summary>
|
||||
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
|
||||
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
|
||||
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
{
|
||||
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
|
||||
@@ -1446,10 +1466,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var cutoffRowKeyPrefix = ComputeCutoffRowKeyPrefix(days);
|
||||
|
||||
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filterParts.Add($"PartitionKey eq '{tenantId}'");
|
||||
var oDataFilter = string.Join(" and ", filterParts);
|
||||
var oDataFilter = BuildMetricsSummaryFilter(tenantId, cutoffRowKeyPrefix);
|
||||
|
||||
// Tally every status into its own bucket so the buckets always reconcile to total
|
||||
// (SessionStatusBuckets enforces this by construction — Pending/Stalled/Unknown no
|
||||
|
||||
@@ -73,32 +73,84 @@ namespace AutopilotMonitor.Shared.Logging
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
|
||||
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
|
||||
// UTC = local + bias) — without the optional bias group such lines would not match
|
||||
// at all and their content would be invisible to every pattern.
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
/// <summary>
|
||||
/// Every CMTrace line has the shape <c><![LOG[message]LOG]!><time=... file=""></c>. The
|
||||
/// parser deliberately does NOT express that as one regex with a greedy <c>.*</c> message
|
||||
/// group in front of the <c>]LOG]!><time="</c> literal: on a line that never matches, such a
|
||||
/// regex re-tries every <c><![LOG[</c> occurrence as a fresh start and, for each, backtracks
|
||||
/// <c>.*</c> across every later character — Theta(k*m), quadratic in line length. Both sinks
|
||||
/// parse attacker-influenced input (tenant-supplied sample lines on the backend, tailed log
|
||||
/// files assembled into multi-line entries on the SYSTEM agent), so the cost must be linear.
|
||||
///
|
||||
/// <para>
|
||||
/// The greedy regex's semantics are reproduced exactly with string search: the message
|
||||
/// starts after the FIRST <c><![LOG[</c> (a later start can only match when the first one
|
||||
/// does too, since <c>.*</c> spans the difference), and the message ends at the LAST
|
||||
/// <c>]LOG]!><time="</c> occurrence whose trailer parses — earlier occurrences are tried in
|
||||
/// turn exactly as backtracking would. The trailer regex is anchored with <c>\G</c>, has no
|
||||
/// ambiguous quantifier, and additionally carries a match timeout as a backstop; a timeout
|
||||
/// is reported as "does not match", never thrown.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private const string LogOpen = "<![LOG[";
|
||||
private const string TrailerOpen = "]LOG]!><time=\"";
|
||||
|
||||
// The time field may carry a UTC-bias suffix in minutes ("06:08:04.8834397+480",
|
||||
// GetTimeZoneInformation convention: UTC = local + bias) — without the optional bias
|
||||
// group such lines would not match at all and their content would be invisible to
|
||||
// every pattern.
|
||||
private static readonly Regex TrailerRegex = new Regex(
|
||||
@"\G\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a single line of CMTrace-formatted log.
|
||||
/// Returns true if parsing succeeded, false if the line doesn't match the format.
|
||||
/// Cost is linear in the line length for arbitrary input; it never throws.
|
||||
/// </summary>
|
||||
public static bool TryParseLine(string? line, [NotNullWhen(true)] out CmTraceLogEntry? entry)
|
||||
{
|
||||
entry = null;
|
||||
|
||||
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)] — assert non-null after it.
|
||||
if (string.IsNullOrEmpty(line) || !line!.StartsWith("<![LOG["))
|
||||
// StartsWith is culture-sensitive on purpose: a leading BOM (U+FEFF) is ignorable
|
||||
// there, so a freshly created log's first line still parses.
|
||||
if (string.IsNullOrEmpty(line) || !line!.StartsWith(LogOpen))
|
||||
return false;
|
||||
|
||||
var match = CmTraceRegex.Match(line);
|
||||
if (!match.Success)
|
||||
var open = line.IndexOf(LogOpen, StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
return false;
|
||||
var messageStart = open + LogOpen.Length;
|
||||
|
||||
Match? match = null;
|
||||
var trailerPos = line.LastIndexOf(TrailerOpen, StringComparison.Ordinal);
|
||||
try
|
||||
{
|
||||
while (trailerPos >= messageStart)
|
||||
{
|
||||
var candidate = TrailerRegex.Match(line, trailerPos);
|
||||
if (candidate.Success)
|
||||
{
|
||||
match = candidate;
|
||||
break;
|
||||
}
|
||||
if (trailerPos == messageStart)
|
||||
break;
|
||||
trailerPos = line.LastIndexOf(TrailerOpen, trailerPos - 1, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match == null)
|
||||
return false;
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var message = line.Substring(messageStart, trailerPos - messageStart);
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var biasStr = match.Groups["bias"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function DiagnosisPage() {
|
||||
function DiagnosisContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string;
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
|
||||
|
||||
@@ -108,7 +108,10 @@ export function useSessionDetail({
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
// Accept only a session whose id matches the one we asked for — a misrouted or
|
||||
// unexpected 200 must never hydrate the page (defense in depth behind the GUID gate).
|
||||
const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
|
||||
const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined;
|
||||
if (foundSession) {
|
||||
setSession(foundSession);
|
||||
setSessionTenantId(foundSession.tenantId);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { isGuid } from '@/utils/inputValidation';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
|
||||
@@ -91,7 +92,8 @@ export default function NetworkTimelinePage() {
|
||||
|
||||
function Content() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams?.get('id') ?? null;
|
||||
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
|
||||
const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null;
|
||||
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
|
||||
|
||||
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { diagnosisUrl, inspectorUrl } from "@/lib/routes";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions';
|
||||
import { useLatestVersions } from '@/lib/useLatestVersions';
|
||||
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
|
||||
import { api } from "@/lib/api";
|
||||
import { isGuid } from "@/utils/inputValidation";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
|
||||
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
|
||||
@@ -59,7 +61,11 @@ function SessionDetailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
// Query-string identity (`?id=`) — path params are gone with the static export;
|
||||
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
|
||||
const sessionId = searchParams?.get("id") as string;
|
||||
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
|
||||
// percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty
|
||||
// sessionId keeps every data hook inert and the page renders the invalid-id branch below.
|
||||
const rawSessionId = searchParams?.get("id") ?? "";
|
||||
const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : "";
|
||||
// Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a
|
||||
// delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode.
|
||||
const tenantIdOverride = searchParams?.get("tenantId") || undefined;
|
||||
@@ -424,6 +430,19 @@ function SessionDetailContent() {
|
||||
// so `loading` never flips and we'd render this branch forever — bypassing the
|
||||
// auth gate and hanging on "Loading session details..." without ever triggering
|
||||
// the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth.
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-600">
|
||||
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
|
||||
<Link href="/dashboard" className="text-blue-600 hover:underline">Back to dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !session && events.length === 0) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// API_BASE_URL is read at import time of lib/api — stub it before the import.
|
||||
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiPromise = import("../api") as Promise<{ api: any }>;
|
||||
|
||||
/**
|
||||
* Path-position ids must be percent-encoded so an attacker-controlled id
|
||||
* (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the
|
||||
* route: '/' and '..' would otherwise be resolved by the URL parser into a
|
||||
* different API path, '#' would truncate the builder's suffix and query string.
|
||||
*/
|
||||
const HOSTILE = "x/../../global/y#";
|
||||
const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23";
|
||||
|
||||
describe("api path ids are percent-encoded", () => {
|
||||
it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
const builders: Array<(id: string) => string> = [
|
||||
(id) => api.sessions.get(id),
|
||||
(id) => api.sessions.events(id),
|
||||
(id) => api.sessions.delete(id, "t"),
|
||||
(id) => api.sessions.analysis(id),
|
||||
(id) => api.sessions.vulnerabilityReport(id),
|
||||
(id) => api.sessions.timeAttribution(id),
|
||||
(id) => api.sessions.markFailed(id),
|
||||
(id) => api.sessions.markSucceeded(id),
|
||||
(id) => api.sessions.queueAction(id),
|
||||
(id) => api.sessions.report(id),
|
||||
(id) => api.sessions.annotations(id),
|
||||
(id) => api.sessions.annotation(id, "lane"),
|
||||
(id) => api.inspector.signals(id),
|
||||
(id) => api.inspector.decisionGraph(id),
|
||||
(id) => api.inspector.reducerVerification(id),
|
||||
];
|
||||
for (const build of builders) {
|
||||
const url = build(HOSTILE);
|
||||
expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true);
|
||||
expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("markFailed keeps its suffix after a '#'-bearing id", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.markFailed(HOSTILE, "t")).toBe(
|
||||
`https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`,
|
||||
);
|
||||
});
|
||||
|
||||
it("annotation lane and gather rule id are encoded too", async () => {
|
||||
const { api } = await apiPromise;
|
||||
expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb");
|
||||
expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`);
|
||||
});
|
||||
|
||||
it("no path builder lets an id escape its segment", async () => {
|
||||
const { api } = await apiPromise;
|
||||
type Builder = (...args: string[]) => string;
|
||||
const walk = (node: unknown): Builder[] =>
|
||||
typeof node === "function"
|
||||
? [node as Builder]
|
||||
: Object.values(node as Record<string, unknown>).flatMap(walk);
|
||||
for (const fn of walk(api)) {
|
||||
if (fn.length === 0) continue;
|
||||
const url = fn(HOSTILE, HOSTILE);
|
||||
// The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y";
|
||||
// an encoded id survives verbatim and the fragment marker never appears.
|
||||
expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/);
|
||||
expect(url).not.toContain("#");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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