From d6bd7b5f545dfd0a3f2d6f25c67a833994650846 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sat, 5 Sep 2026 17:32:03 -0400 Subject: [PATCH] Fix endpoint user-context collection running in the wrong session Mapped drives reported Unavailable on endpoints where the operator could see them fine. The data path was intact; the collection was running under the wrong token. Mapped drives are per logon token, so an elevated probe enumerates a different drive table than the interactive user. Collection: - Pin the user-context spool path so the probe and service agree on one location, and persist/overwrite it instead of deleting on read. - Refuse to collect while elevated (exit code 2) rather than emitting a confident wrong answer, and stamp elevated/username on the spool. - Reject spools stamped elevated or from an unexpected account on read, so an admin run cannot masquerade as valid. - Log netuse/smbmapping/userlistener counts so each run leaves evidence of what it actually saw. Install and launch context: - Move collection to a 'ShopDB user-context probe' scheduled task under S-1-5-32-545 at RunLevel Limited, the only mechanism that reliably runs in the operator's own session. Verify registration and record it in the install ledger. - Stop starting the tray as a child of the elevated installer; explorer.exe de-elevates without changing ACCOUNT, so an admin install produced an admin tray. The Run key only fires at logon, which left the tray dead on a machine already signed in, so add a transient 'ShopDB tray first launch' task that starts it once in the existing session and unregisters itself. - Hide the console window during --collect-usercontext. The agent is a console exe; the tray used to mask this with CreateNoWindow, but the scheduled task invokes it directly and flashed a window every cycle. Installer edits are in _install_script() in plugins/endpoints/api/siteagent.py, which generates the install.ps1 that actually ships. The standalone agent/installer/install-shopdbagent.ps1 was never shipped once the package moved to the UI builder and is deleted here to stop the divergence that caused an earlier fix to land in a file no endpoint ever ran. Versions: agent 1.10.1, tray 1.3.0. These share the pinned spool path and must be deployed together. Pre-existing test failures unrelated to this work (8, identical on a clean tree): docs publishable/versions, plugin contract symlink scan, import guard tamper check, security headers upload sandbox, and three endpoint timestamp tests. --- .github/copilot-instructions.md | 4 + agent/README.md | 25 +- .../ShopDbEndpointAgent.Core/ConsoleWindow.cs | 51 ++ .../EndpointCollector.cs | 15 +- .../FileLoggerProvider.cs | 21 +- .../UserContextProbe.cs | 122 ++++- .../UserContextSpool.cs | 144 ++++-- .../ShopDbEndpointAgent.Tray.csproj | 30 +- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 104 ++++- agent/ShopDbEndpointAgent/Program.cs | 145 ++++-- .../ShopDbEndpointAgent.csproj | 43 +- agent/installer/install-shopdbagent.ps1 | 437 ------------------ plugins/endpoints/AGENT-CONTRACT.md | 94 +++- plugins/endpoints/api/siteagent.py | 201 +++++++- scripts/uninstall-shopdbagent.ps1 | 37 +- 15 files changed, 904 insertions(+), 569 deletions(-) create mode 100644 agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs delete mode 100644 agent/installer/install-shopdbagent.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2631a058..02d403b9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,10 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. - Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F `. - Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined. +## Installer Logic +- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers. +- The user's workflow: the agent hands over only the built EXEs (`shopdbagent.exe`, `shopdb-endpoint-tray.exe`); the user uploads them via the "Upload New/Legacy Agents" field in the Build Site Agent UI and builds the package with the newest version selected at the top. + ## Naming (LOCKED - the hook rejects violations) - **DB tables**: lowercase, concatenated, plural. No underscores, no dashes. diff --git a/agent/README.md b/agent/README.md index 0c43efe5..c54c7282 100644 --- a/agent/README.md +++ b/agent/README.md @@ -84,12 +84,23 @@ event channel and system WMI. ## Tray app (system-tray icon) The tray is a per-user process, not a service, so it starts at sign-in rather -than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies -`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart -(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and -launches it for the current user. Run the installer from the operator's own -session so the autostart lands in the right user hive. Pass `-NoTray` for a -headless, service-only install. +than under `sc.exe`. The generated `install.ps1` handles it: it copies +`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon +autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`, +64-bit view). The installer deliberately does NOT start the tray itself: it runs +elevated, so any process it starts inherits an admin token, and mapped drives are +per logon token - an elevated tray probes the wrong drive table. The Run key only +fires at logon, which would leave the tray dead until sign-out on a machine +someone is already using, so the installer also registers a transient +`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel +Limited`, runs it once, and unregisters it. That task is the only mechanism that +can reach into an existing interactive session without carrying the installer's +token. Pass `-NoTray` for a headless, service-only install. + +User-context collection does not depend on the tray. The installer registers a +`ShopDB user-context probe` scheduled task against the Users group at +`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the +operator's own session at logon and every 30 minutes. Right-click the tray icon for: @@ -143,7 +154,7 @@ logic itself changes. ## Diagnostics -- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log` +- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log` (14-day retention). - Windows Event Log: agent runtime failures and unhandled exceptions are written to the `Application` log under source `ShopDB Endpoint Agent`. The source is diff --git a/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs new file mode 100644 index 00000000..1c4f8e90 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// Hides the console window this process was given. +// +// shopdbagent.exe is a console executable, so anything that starts it in an +// interactive session gets a visible black window. That was invisible while the +// tray owned the probe - it launched with CreateNoWindow - but the user-context +// scheduled task runs the exe directly in the operator's session, where the +// window is very much visible and pops up on every collection cycle. +// +// The window is hidden rather than the exe rebuilt as a WinExe: the agent is +// also a service host and a command-line tool, and both of those want a real +// console for --once, --report and service debugging. +public static class ConsoleWindow +{ + private const int SW_HIDE = 0; + + [DllImport("kernel32.dll")] + private static extern nint GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint window, int command); + + // Best-effort: a process with no console (already detached, or redirected by + // a caller) simply has nothing to hide, and failing to hide a window must + // never stop the collection that window belongs to. + public static void Hide() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + var window = GetConsoleWindow(); + if (window != nint.Zero) + { + ShowWindow(window, SW_HIDE); + } + } + catch + { + // Nothing useful to do or say: there is no log sink yet at the point + // this runs, and a visible window is cosmetic next to losing data. + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 698b3126..08604605 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -272,12 +272,15 @@ public sealed class EndpointCollector : ICollector return lastUserContext; } - // Consume rather than peek: the file is removed once it has been read, so - // a tray that has died cannot keep this endpoint reporting the same - // mapped drives forever. lastUserContext still repeats the previous value - // between successful reads, because the tray collects on a much slower - // cadence than telemetry and dropping the key in between would read on - // the server as "the drives went away". + // Peek rather than consume: the probe overwrites the spool on every run, + // so the newest result is always the one on disk. The file deliberately + // survives a service restart, which the previous delete-on-read did not - + // lastUserContext is in-memory only, so a restart used to leave the agent + // with no drives to report until the tray's next probe up to 30 minutes + // later. lastUserContext still repeats the previous value between reads, + // because the tray collects on a slower cadence than telemetry and + // dropping the key in between would read on the server as "the drives + // went away". var collected = userContextSpool.ConsumeResult(resultPath); if (collected is not null) lastUserContext = collected; diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs index 6172a107..7ff794ed 100644 --- a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -15,7 +15,6 @@ public sealed class FileLoggerProvider : ILoggerProvider private readonly LogLevel minLevel; private readonly int retentionDays; private readonly string filePrefix; - private readonly bool stampFirst; private readonly object gate = new(); private readonly ConcurrentDictionary loggers = new(); private string currentDateStamp = ""; @@ -27,13 +26,12 @@ public sealed class FileLoggerProvider : ILoggerProvider public string? SinkProblem { get; private set; } public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, - int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false) + int retentionDays = 14, string filePrefix = "agent") { this.directory = directory; this.minLevel = minLevel; this.retentionDays = retentionDays; this.filePrefix = filePrefix; - this.stampFirst = stampFirst; // Best-effort: creating the log directory can fail on a locked-down host // or when a non-privileged user targets a SYSTEM-owned path. Never let // that abort construction; Write() degrades to a no-op if the sink is @@ -129,10 +127,13 @@ public sealed class FileLoggerProvider : ILoggerProvider writer?.Flush(); writer?.Dispose(); currentDateStamp = stamp; - // stampFirst gives "-trayagent.log"; the service keeps its - // established "agent-.log" so existing log collection still finds - // it. Both roll daily and prune on the same pattern. - var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{stamp}.log"; + // One convention for every log this codebase writes: + // "-.log", so agent, tray and installer logs sort + // chronologically when they share a directory and can be collected with + // a single date-leading glob. The service previously used the opposite + // order, which meant a support request for "the logs from the 5th" had to + // know which component wrote which shape. + var name = $"{stamp}-{filePrefix}.log"; var path = Path.Combine(directory, name); writer = new StreamWriter(new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) @@ -147,7 +148,11 @@ public sealed class FileLoggerProvider : ILoggerProvider try { var cutoff = DateTime.Now.AddDays(-retentionDays); - var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.log"; + // Both shapes: retention has to keep covering the "-" + // files written before the convention was unified, or an upgraded + // endpoint would keep its pre-upgrade logs forever. + string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"]; + foreach (var pattern in patterns) foreach (var file in Directory.EnumerateFiles(directory, pattern)) { if (File.GetLastWriteTime(file) < cutoff) diff --git a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs index 69105183..04a42006 100644 --- a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs +++ b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs @@ -28,13 +28,19 @@ namespace ShopDbEndpointAgent.Core; // from the agent binary or be tampered with on disk between cycles. public sealed class UserContextProbe { - private const string ProbeScript = """ + // The spool directory is injected by BuildProbeScript rather than derived in + // the script, so the writer cannot disagree with the reader. See + // UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong. + private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__"; + + private const string ProbeScriptTemplate = """ $ErrorActionPreference = 'Stop' $result = [ordered]@{} - # Must match UserContextSpool.TryGetResultPath on the reader side. Uses - # USERPROFILE, which for the shop-floor operator resolves to - # C:\Users\ShopFloor - the same fixed location the service reads. - $spool = Join-Path $env:USERPROFILE 'ShopDB' + # Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the + # writer and the reader resolve the same directory. Deliberately NOT + # $env:USERPROFILE: the tray can inherit another account's environment on a + # locked-down image, which silently wrote this file into the wrong profile. + $spool = '__SHOPDB_SPOOL_DIR__' $target = Join-Path $spool 'usercontext.json' $temporary = "$target.tmp" @@ -133,6 +139,20 @@ public sealed class UserContextProbe $result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o') $result['username'] = $env:USERNAME + # Stamp the token's elevation state. The username alone does not + # settle it: the SAME account elevated and unelevated gets two + # different logon tokens and therefore two different drive tables, + # so a spool from an elevated ShopFloor session passes the account + # check while still reporting every drive as broken. + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + $result['elevated'] = [bool] $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + $result['elevatederror'] = $_.Exception.Message + } + # Write to a temporary name and rename into place, so the reader can # never observe a half-written file and discard a good result. $result | ConvertTo-Json -Depth 4 -Compress | @@ -144,6 +164,14 @@ public sealed class UserContextProbe exit 0 """; + // Substitute the resolved spool directory into the template. The path goes in + // as a PowerShell single-quoted literal, so an embedded quote is doubled and + // nothing else in the path is interpreted - backslashes and $ are literal + // inside single quotes, which is exactly what a Windows path needs. + private static string BuildProbeScript(string spoolDirectory) + => ProbeScriptTemplate.Replace( + SpoolDirectoryToken, spoolDirectory.Replace("'", "''")); + // How long the probe is allowed to take. Generous: this no longer runs on // the telemetry path, so an unresponsive WMI call delays nothing but itself. private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60); @@ -160,15 +188,42 @@ public sealed class UserContextProbe } // Run the probe in THIS process's session and write the spool file. - // Returns true when the probe wrote a result. + // Returns true only when the spool file is present on disk afterwards. // // Called from the --collect-usercontext entry point, which the tray runs as // the signed-in operator. Never throws: this is background collection and a // failure must not take the tray or the agent process down with it. public bool Collect() { + var spoolDirectory = UserContextSpool.PinnedSpoolDirectory(); + var resultPath = UserContextSpool.PinnedResultPath(); + try { + // Logged before the launch, not after. When this goes wrong the + // interesting question is always "which directory did it target", + // and a failure later in this method must not take the answer with + // it. + logger.LogInformation( + "Running the user-context probe as {User}; spool target {Path}", + Environment.UserName, resultPath); + + // The probe writes a .tmp beside the target and moves it into place, + // so the directory has to exist before powershell.exe starts. The + // script creates it too, but doing it here means a permission problem + // is reported by the agent with a real exception rather than showing + // up as an opaque non-zero exit code. + try + { + Directory.CreateDirectory(spoolDirectory); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + logger.LogWarning(error, + "Could not create the user-context spool directory at {Directory}", + spoolDirectory); + return false; + } // Passed as -EncodedCommand rather than staged as a .ps1 and run with // -File. The script used to be written into %TEMP% and executed from // there, which put a dependency on that file still existing, and still @@ -187,7 +242,7 @@ public sealed class UserContextProbe // in the command line itself, so there is no file to lose and no // execution-policy or quarantine step between us and the probe. var encoded = Convert.ToBase64String( - System.Text.Encoding.Unicode.GetBytes(ProbeScript)); + System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory))); var startInfo = new System.Diagnostics.ProcessStartInfo { @@ -231,6 +286,28 @@ public sealed class UserContextProbe return false; } + // Exit code 0 is necessary but NOT sufficient. The probe used to be + // trusted on its exit code alone, which meant a run that wrote its + // file into a different profile entirely still reported success - the + // log claimed a spool had been written for weeks while nothing was + // ever there. Confirm the file exists where the reader will look. + if (!File.Exists(resultPath)) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Path}.", resultPath); + return false; + } + + // Byte count alone does not distinguish a healthy spool from a + // well-formed empty one, and "0 drives" is the exact symptom this + // whole path exists to diagnose. Summarising the contents means a + // single log line from any given task run answers whether the probe + // saw the operator's drives, without needing the spool itself. + var summary = SummariseSpool(resultPath); + logger.LogInformation( + "The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}", + new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary); return true; } catch (Exception exception) @@ -240,6 +317,37 @@ public sealed class UserContextProbe } } + // One-line description of what the probe actually captured, for the run log. + // Best-effort by design: this is diagnostic colour on an already-successful + // run, so a parse problem here must never turn that run into a failure. + private static string SummariseSpool(string resultPath) + { + try + { + using var document = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(resultPath)); + var root = document.RootElement; + + static int CountOf(System.Text.Json.JsonElement root, string name) => + root.TryGetProperty(name, out var value) + && value.ValueKind == System.Text.Json.JsonValueKind.Array + ? value.GetArrayLength() + : 0; + + var user = root.TryGetProperty("username", out var stamped) + ? stamped.GetString() + : "(unstamped)"; + + return $"user={user} netuse={CountOf(root, "netuse")} " + + $"smbmapping={CountOf(root, "smbmapping")} " + + $"listeners={CountOf(root, "userlisteners")}"; + } + catch (Exception) + { + return "(could not summarise)"; + } + } + private void TryKill(System.Diagnostics.Process process) { try diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs index a1988674..b036298c 100644 --- a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -25,20 +25,38 @@ public sealed class UserContextSpool public const string SpoolFolderName = "ShopDB"; public const string ResultFileName = "usercontext.json"; + // The one definition of where the spool lives. Both the probe (writer) and + // this class (reader) call it, so the two can no longer disagree. + // + // The writer used to build its own path from $env:USERPROFILE while the + // reader used the pinned ShopFloor profile. On a locked-down image those are + // not the same directory - the tray is launched through explorer.exe by an + // elevated installer and can inherit another account's environment block, as + // the tray already documents for %TEMP%. The probe then wrote a perfectly + // good file into the wrong profile, exited 0, and the service found nothing: + // no spool ever appeared and no mapped drive was ever reported. + public static string PinnedSpoolDirectory() + => Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName); + + public static string PinnedResultPath() + => Path.Combine(PinnedSpoolDirectory(), ResultFileName); + // A probe result is small - a handful of mapped drives and listeners. A file // larger than this is either corrupt or hostile, and we will not parse it. private const long MaxResultBytes = 512 * 1024; - // A result older than this is treated as absent. Sized against the server's - // default quiet-report window (endpoints_quietreporthours, 24h): an endpoint - // that has not reported inside that window is already shown as 'quiet', so - // session data that outlived it would be claiming freshness the rest of the - // page no longer claims. Well clear of the tray's collection interval, so an - // ordinary missed run does not blank the card. + // A result older than this is treated as absent. Sized at roughly three tray + // probe intervals (30 min each), so a single missed run never blanks the card + // but genuinely stale session data retires quickly. + // + // This was 24h when the reader deleted the file after each read; the delete + // was doing the real retiring and the age check was only a backstop. Now that + // the spool persists, this value IS the staleness bound, so it is sized + // against the writer's cadence rather than the server's quiet-report window. // // Not read from the server: this is an offline-safe local bound, and the // agent must retire stale session data even when it cannot reach ShopDB. - private static readonly TimeSpan MaxResultAge = TimeSpan.FromHours(24); + private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90); private readonly ILogger logger; @@ -67,7 +85,7 @@ public sealed class UserContextSpool if (Directory.Exists(pinned)) { reason = null; - return Path.Combine(pinned, SpoolFolderName, ResultFileName); + return PinnedResultPath(); } var profileDirectory = @@ -161,6 +179,12 @@ public sealed class UserContextSpool if (IsExpired(parsed, resultPath)) return null; + if (IsWrongAccount(parsed)) + return null; + + if (IsElevatedCollection(parsed)) + return null; + return parsed; } catch (JsonException exception) @@ -179,36 +203,92 @@ public sealed class UserContextSpool } } - // Read the spool and then REMOVE it, so the next report can only carry a - // result the tray produced after this read. Leaving a consumed file in place - // means a tray that has stopped running keeps re-reporting the same drives - // as though they were current, which is worse than reporting nothing: the - // card looks healthy while the data is frozen. + // Read the spool WITHOUT removing it. The probe overwrites the file on every + // run (write .tmp, move into place), so the newest result always replaces the + // previous one and there is nothing for the reader to clean up. // - // The removal is deliberately AFTER a successful parse. If the file was - // mid-write (truncated JSON) it is left alone rather than destroyed, so a - // read that raced the tray costs one cycle instead of a good result. The - // collectedat age check still applies as a second bound for the window - // between the tray writing and the service next waking up. + // This used to delete the file after a successful read, to stop a dead tray + // re-reporting the same drives forever. The collectedat age check already + // does that job, and deleting created a worse problem: lastUserContext in the + // collector is in-memory only, so a service restart wiped the cache while the + // spool had already been consumed. The agent then reported NO mapped drives + // until the tray's next probe - and the installer restarts the service and + // the tray on every install, so a fresh install was guaranteed to show an + // empty card at exactly the moment someone looked at it. + // + // The contents are the operator's own session data, visible to them in their + // own context, so leaving the file in place costs nothing. public Dictionary? ConsumeResult(string resultPath) + => TryReadResult(resultPath); + + // Reject a spool that some other account produced. + // + // Pinning the path guarantees the file lands where the reader looks, but it + // cannot guarantee WHO ran the probe. Mapped drives and idle time are per + // logon session, so a probe that ran as an administrator (for example a tray + // that inherited an elevated installer's environment) would enumerate that + // account's drive table and write it into the operator's spool. That is worse + // than reporting nothing, because the card would look populated and correct + // while showing drives the operator does not have. + // + // The probe stamps $env:USERNAME into the file, so the mismatch is checkable. + // Logged at warning, not debug: this is a misconfigured endpoint, not a quiet + // absence of data. + private bool IsWrongAccount(Dictionary parsed) { - var parsed = TryReadResult(resultPath); - if (parsed is null) - return null; - - try + if (!parsed.TryGetValue("username", out var raw) || raw is null) { - File.Delete(resultPath); - } - catch (Exception exception) - { - // Non-fatal: the value was already read. It does mean the next cycle - // may re-read the same result, which the age check will retire. - logger.LogWarning(exception, - "Read the user-context spool but could not remove it at {Path}", resultPath); + logger.LogWarning( + "The user-context spool has no username stamp; ignoring it because " + + "the session it describes cannot be confirmed."); + return true; } - return parsed; + var username = raw.ToString(); + if (string.Equals(username, FileLoggerProvider.ShopFloorUserName, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + logger.LogWarning( + "The user-context spool was written by {Actual}, not {Expected}; " + + "ignoring it. Session data is per logon session, so this would " + + "report the wrong account's mapped drives.", + username, FileLoggerProvider.ShopFloorUserName); + return true; + } + + // An elevated probe runs under a different logon token to the operator's + // interactive one and enumerates a different, usually empty, drive table. + // The agent refuses to collect while elevated, so a spool stamped elevated + // should not exist; this rejects any that predates that guard or arrives + // from a hand-run --collect-usercontext in an admin shell. + // + // Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here + // rather than rejected. An unstamped spool is simply one written by an older + // agent, and treating that as elevated would blank out session data across a + // fleet mid-upgrade. An unstamped spool has already had to pass the username + // check to reach this point. + private bool IsElevatedCollection(Dictionary parsed) + { + if (!parsed.TryGetValue("elevated", out var raw) || raw is null) + return false; + + var elevated = raw is JsonElement element + ? element.ValueKind == JsonValueKind.True + : string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase); + + if (!elevated) + return false; + + logger.LogWarning( + "The user-context spool was collected by an ELEVATED process; ignoring " + + "it. Mapped drives are per logon token, so an elevated probe reports " + + "the wrong drive table even under the right account. Collection should " + + "come from the 'ShopDB user-context probe' scheduled task, which runs " + + "at RunLevel Limited."); + return true; } // Age is taken from the stamp the probe wrote, not from the file's last diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index c78d85ef..a1e05a25 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -27,8 +27,34 @@ anywhere on the machine. The tray writes C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log (the operator's own profile; it cannot write to ProgramData or Program Files) covering - tray startup, agent-exe resolution, probe launch and probe exit code. --> - 1.1.1 + tray startup, agent-exe resolution, probe launch and probe exit code. --> + + + 1.3.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 3bd64554..f7b05997 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -42,6 +42,19 @@ internal sealed class TrayContext : ApplicationContext // picture while competing with everything else starting up. private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2); + // ...but that reasoning only applies at logon. When the tray starts into a + // session that has been up for a while - which is what an installer restart + // or a manual relaunch does - nothing is settling and the full delay just + // extends the window where the server has no session data. The installer + // kills and restarts the tray on every install, so the 2-minute delay was + // being paid at exactly the moment someone was watching the card to see + // whether the install had worked. + private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15); + + // How long the session must have been up to count as established. Comfortably + // past the logon storm, well short of any realistic install-to-check gap. + private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5); + private readonly System.Windows.Forms.Timer userContextTimer; private bool userContextStarted; private bool collectingUserContext; @@ -59,13 +72,14 @@ internal sealed class TrayContext : ApplicationContext { logProvider = new FileLoggerProvider( FileLoggerProvider.UserProfileLogDirectory(), - filePrefix: "trayagent", - stampFirst: true); + filePrefix: "trayagent"); logger = logProvider.CreateLogger("Tray"); + + var startupDelay = ResolveStartupDelay(); logger.LogInformation( "Tray {Version} started; user-context probe in {Delay}, then every {Interval}.", typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown", - UserContextStartupDelay, UserContextInterval); + startupDelay, UserContextInterval); var menu = new ContextMenuStrip(); menu.Items.Add("Send Status now", null, OnSendStatus); @@ -86,9 +100,25 @@ internal sealed class TrayContext : ApplicationContext }; trayIcon.DoubleClick += OnSendStatus; + // A dead log sink cannot report itself through the log. Complaining via + // the logger would write the one message that explains the silence into + // the sink that is swallowing messages, so the balloon is the only + // channel that survives. Shown once at startup and never repeated: this + // is a "your diagnostics are blind" notice for whoever is standing at the + // machine, not a recurring nag about something the operator cannot fix. + if (logProvider.SinkProblem is not null) + { + trayIcon.ShowBalloonTip( + 10000, + "ShopDB Endpoint Agent", + "Tray logging is unavailable, so tray diagnostics will not be " + + $"recorded. {logProvider.SinkProblem}", + ToolTipIcon.Warning); + } + userContextTimer = new System.Windows.Forms.Timer { - Interval = (int)UserContextStartupDelay.TotalMilliseconds, + Interval = (int)startupDelay.TotalMilliseconds, }; userContextTimer.Tick += OnUserContextTimer; userContextTimer.Start(); @@ -126,17 +156,44 @@ internal sealed class TrayContext : ApplicationContext { logger.LogInformation("Running the user-context probe: {Agent}", agentExe); var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext"); - if (exitCode == 0) + + // Check the disk rather than announcing what the exit code implies. + // This line used to print a hardcoded path whenever the child exited + // 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for + // weeks while the probe was actually writing into a different profile + // and the service was finding nothing. + var resultPath = UserContextSpool.PinnedResultPath(); + if (exitCode == 0 && File.Exists(resultPath)) { logger.LogInformation( - "The user-context probe finished; spool written to {Spool}.", - Path.Combine( - FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB")); + "The user-context probe finished; spool present at {Spool}.", + resultPath); + } + else if (exitCode == 0) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Spool}.", resultPath); + } + else if (exitCode == 2) + { + // The agent refused because it was elevated. Called out by name + // because as a bare exit code it reads like a probe fault and + // sends the reader looking for a drive or WMI problem that is + // not there - the fix is to start the tray unelevated, or let + // the scheduled task do the collecting. + logger.LogWarning( + "The user-context probe refused to run because this tray is " + + "ELEVATED. Mapped drives are per logon token, so an elevated " + + "probe would report the wrong drive table. Start the tray " + + "unelevated, or rely on the 'ShopDB user-context probe' " + + "scheduled task."); } else { logger.LogWarning( - "The user-context probe exited with code {ExitCode}.", exitCode); + "The user-context probe exited with code {ExitCode}; expected a " + + "spool at {Spool}.", exitCode, resultPath); } } catch (Exception exception) @@ -353,6 +410,35 @@ internal sealed class TrayContext : ApplicationContext startInfo.Environment.Remove(key); } + // Full delay at logon, short delay when the session is already established. + // + // Environment.TickCount64 is machine uptime, not session uptime, which is a + // deliberate simplification: the two differ only when someone signs out and + // back in without rebooting, and treating that as "established" is harmless - + // the profile is already built and the drives are already mapped in that + // case too. A failure to read it falls back to the conservative long delay. + private TimeSpan ResolveStartupDelay() + { + try + { + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + if (uptime >= EstablishedSessionUptime) + { + logger.LogInformation( + "Session has been up {Uptime}; using the established-session probe delay.", + uptime); + return UserContextEstablishedDelay; + } + } + catch (Exception exception) + { + logger.LogDebug(exception, + "Could not read system uptime; using the logon probe delay."); + } + + return UserContextStartupDelay; + } + // Same directory first (the installer ships the tray next to the agent), // then the known Program Files install directory as a fallback. private static string? ResolveAgentExe() diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index f8162d01..c3472cb0 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -29,10 +29,103 @@ var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; +// FIRST, ahead of everything else. This verb runs as the signed-in operator (the +// tray invokes it) and must not touch a single privileged resource on the way in. +// +// It used to sit below the bootstrap logger, the fault monitor, and the config +// load, which meant the operator-context process opened the SYSTEM-owned +// ProgramData log directory, attempted an EventLog source registration that only +// an administrator can perform, and read CollectorKey out of HKLM - all before it +// discovered it was only being asked to write a file into the user's own profile. +// On an EPM-managed host (CyberArk) those are exactly the calls a broker +// intercepts, and the registry read put the collector secret in a process that +// has no business holding it. +// +// It also has no config dependency by design: it only writes into the caller's +// own profile and never talks to the server, so it must still work on a machine +// whose CollectorKey has not been provisioned yet - otherwise session data would +// be missing on precisely the endpoints that are hardest to diagnose. +if (collectUserContext) +{ + // First statement in this branch. The scheduled task runs this exe directly + // in the operator's interactive session, and shopdbagent.exe is a console + // app, so without this a black window flashes up on every collection cycle. + // Done before any logging or probing so the window is gone immediately + // rather than lingering for the life of the probe. + ConsoleWindow.Hide(); + + // Logs to the OPERATOR's profile, not the service's ProgramData directory. + // A non-privileged user cannot write to the SYSTEM-owned log directory, and + // the file sink degrades to a no-op when it cannot create its directory, so + // pointing this at the service location meant every probe failure was + // discarded unread - which is exactly how a broken probe looked identical to + // a probe that was never invoked. + using var probeLogProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent"); + using var probeLoggerFactory = LoggerFactory.Create( + logging => logging.AddProvider(probeLogProvider)); + + var probeLogger = probeLoggerFactory.CreateLogger(); + ReportSinkProblem(probeLogProvider, "user-context probe"); + + // Refuse to collect from an elevated process. Drive letters are a property + // of the logon token, not the machine: an elevated process gets a different + // token to the operator's interactive one and enumerates a DIFFERENT, almost + // always empty, drive table. Measured on a live endpoint, the same two + // shares reported Unavailable from the elevated shell and OK from the + // operator's, in both Win32_NetworkConnection and Get-SmbMapping. + // + // Refusing here rather than filtering later is deliberate. An elevated probe + // does not fail - it succeeds and reports every mapped drive as broken, + // which is indistinguishable from a genuine outage at the far end. Better to + // write nothing and say why than to publish a confident wrong answer. + if (IsProcessElevated()) + { + probeLogger.LogError( + "Refusing to collect user context: this process is elevated. Mapped drives " + + "are per-logon-token, so an elevated probe reports the wrong drive table. " + + "Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " + + "--collect-usercontext from an admin shell.", + "ShopDB user-context probe"); + Console.Error.WriteLine( + "shopdbagent: refusing to collect user context from an elevated process; " + + "mapped drives would be wrong. Use the scheduled task."); + return 2; + } + + var probe = new UserContextProbe(probeLogger); + return probe.Collect() ? 0 : 1; +} + +// True when the current process holds the elevated (high-integrity) token. +static bool IsProcessElevated() +{ + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + } + catch + { + // Cannot tell. Treat as not elevated so an unreadable token does not + // suppress collection on a host where the probe would have worked; the + // spool's own account stamp is still checked on the way back in. + return false; + } +} + // Bootstrap logger first so config loading (including the registry read that // CyberArk/EPM may gate) is fully diagnosable before the host starts. using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup"); +ReportSinkProblem(bootstrapFileLogger, "service"); var faultMonitor = new AgentFaultMonitor(bootstrapLogger); faultMonitor.Install(); @@ -43,30 +136,6 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) ? FileLoggerProvider.DefaultDirectory() : config.LogPath; -// Deliberately ahead of the collector config gate. This verb only writes a file -// into the caller's own profile and never talks to the server, so it must still -// work on a machine whose CollectorKey has not been provisioned yet - otherwise -// session data would be missing on exactly the endpoints that are hardest to -// diagnose. -if (collectUserContext) -{ - // Logs to the OPERATOR's profile, not the service's ProgramData directory. - // This verb always runs as the signed-in user (the tray invokes it), and a - // non-privileged user cannot write to the SYSTEM-owned log directory. The - // file sink degrades to a no-op when it cannot create its directory, so - // pointing this at the service location meant every probe failure was - // discarded unread - which is exactly how a broken probe looked identical to - // a probe that was never invoked. - using var probeLoggerFactory = LoggerFactory.Create(logging => - logging.AddProvider(new FileLoggerProvider( - FileLoggerProvider.UserProfileLogDirectory(), - filePrefix: "trayagent", - stampFirst: true))); - var probe = new UserContextProbe( - probeLoggerFactory.CreateLogger()); - return probe.Collect() ? 0 : 1; -} - if (!config.IsUsable()) { bootstrapLogger.LogError( @@ -226,12 +295,15 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config // Session-scoped collection. The agent is LocalSystem, so mapped drives and // user-owned listeners are invisible to it. The tray, which already runs as // the operator, writes the spool file; the service only reads it. + // + // UserContextProbe is deliberately NOT registered here. The service must + // never run the probe: descending into the session with CreateProcessAsUser + // is the reverted design, and leaving a resolvable dependency in the + // container is an invitation to reintroduce it. The probe is constructed + // only on the --collect-usercontext path, which the tray invokes. services.AddSingleton(sp => new UserContextSpool( sp.GetRequiredService>())); - services.AddSingleton(sp => new UserContextProbe( - sp.GetRequiredService>())); - services.AddSingleton(sp => new EndpointCollector( sp.GetRequiredService>(), config, @@ -274,3 +346,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) logging.AddProvider(new FileLoggerProvider(logDirectory)); logging.SetMinimumLevel(LogLevel.Information); } + +// Say so, loudly, when a file sink could not open its directory. +// +// FileLoggerProvider never throws on construction and silently drops lines when +// its directory is unusable. That is the right behaviour - logging must not take +// the agent down - but it made two different failures indistinguishable: a probe +// that ran and failed, and a probe whose log had nowhere to go. The provider +// records the reason; this is the part that was missing, the caller actually +// asking for it. stderr is the only sink guaranteed to exist at this point, so +// that is where the complaint goes. +static void ReportSinkProblem(FileLoggerProvider provider, string which) +{ + if (provider.SinkProblem is null) + return; + + Console.Error.WriteLine( + $"shopdbagent: the {which} log sink is unusable and log lines will be " + + $"dropped - {provider.SinkProblem}"); +} diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 7c6fdb8e..62a24fea 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,6 +7,47 @@ enable ShopDbEndpointAgent shopdbagent + + + - 1.8.1 + 1.10.1 true win-x64 true diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 deleted file mode 100644 index 0185bb9f..00000000 --- a/agent/installer/install-shopdbagent.ps1 +++ /dev/null @@ -1,437 +0,0 @@ -# install-shopdbagent.ps1 -# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED -# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies -# the agent files to Program Files, runs a one-shot smoke test, then installs -# and starts the Windows service. -# -# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts -# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same -# values are also stamped into the installed appsettings.json as a fallback. -# -# The agent and tray are published as single-file self-contained executables, -# so copying the two .exe files plus appsettings.json is the complete payload. - -[CmdletBinding()] -param( - # ShopDB server root URL for this instance. REQUIRED - there is no default, - # because a default here means one site's hostname shipped to every site - # (ADR-015) and, worse, a working install pointed at somebody else's server. - # - # https is required. The collector key travels in the X-API-Key header and - # every telemetry payload follows it, so plain http puts both on the wire in - # clear. Pass -AllowInsecureHttp only for a local test instance. - [Parameter(Mandatory = $true)] - [string] $BaseUrl, - - # Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS. - # REQUIRED and never defaulted - a key in source is a key in git history, and - # this one is also the command channel's kill switch. - [Parameter(Mandatory = $true)] - [string] $CollectorKey, - - # Permit an http:// BaseUrl. For a local test instance only. - [switch] $AllowInsecureHttp, - - - [string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent", - [string] $ServiceName = "ShopDbEndpointAgent", - - # Interactive tray app that shows the system-tray icon and the on-demand - # "Send Status now" action. Separate process from the SYSTEM service (a - # service cannot draw a tray icon in a user session). Pass -NoTray to skip. - [string] $TrayExeName = "shopdb-endpoint-tray.exe", - [switch] $NoTray, - - # Add a Microsoft Defender path exclusion for the install directory. The - # agent and tray are unsigned single-file self-contained executables, which - # Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as - # a false positive and quarantine, killing the tray before its icon appears. - # Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions - # centrally or the binaries are code-signed). - [switch] $NoDefenderExclusion -) - -# Refuse cleartext unless it was asked for explicitly. The key is a bearer -# credential for the whole fleet's telemetry and command channel. -if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) { - throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance." -} - -$ErrorActionPreference = "Stop" -$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path - -function Assert-Admin { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw "This script must be run from an elevated (Run as administrator) PowerShell." - } -} - -Assert-Admin - -if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." -} - -# Write HKLM values through the explicit 64-bit registry view. If this script -# runs under 32-bit PowerShell (some management tooling launches x86), the -# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent -# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and -# see nothing, so config "goes missing" and the tray never autostarts. Pinning -# the view removes that dependency on the launching shell's bitness. -function Set-Hklm64Value { - param( - [Parameter(Mandatory)] [string] $SubKey, - [Parameter(Mandatory)] [string] $Name, - [Parameter(Mandatory)] [string] $Value - ) - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( - [Microsoft.Win32.RegistryHive]::LocalMachine, - [Microsoft.Win32.RegistryView]::Registry64) - try { - $key = $base.CreateSubKey($SubKey) - try { - $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) - } - finally { $key.Dispose() } - } - finally { $base.Dispose() } -} - -# Tear down any previously installed agent BEFORE touching files. The agent and -# tray are single-file self-contained exes; while the old service or tray is -# running it holds those .exe files open, so Copy-Item over them fails with a -# file-in-use error. Stopping the service, deleting it, and killing any lingering -# agent/tray processes up front makes the install a clean overwrite. Best-effort -# throughout: a missing service or process is the normal fresh-install case. -function Uninstall-PreviousAgent { - param( - [Parameter(Mandatory)] [string] $Service, - [Parameter(Mandatory)] [string] $TrayExe, - [Parameter(Mandatory)] [string] $TargetDir - ) - - $existing = Get-Service -Name $Service -ErrorAction SilentlyContinue - if ($existing) { - Write-Host " stopping and removing existing service $Service ..." - if ($existing.Status -ne "Stopped") { - try { Stop-Service -Name $Service -Force -ErrorAction Stop } - catch { Write-Warning " could not stop service: $($_.Exception.Message)" } - } - # sc.exe delete only marks the service for deletion; the SCM finishes the - # removal once every handle closes. Give it a moment so the subsequent - # sc.exe create does not hit a "marked for deletion" (1072) error. - sc.exe delete $Service | Out-Null - Start-Sleep -Seconds 2 - } - - # Kill any lingering worker or tray processes that would keep the exes - # locked. Match by the installed exe base names; the service host process is - # shopdbagent, the tray is the TrayExe base name. - $trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe) - foreach ($name in @("shopdbagent", $trayBase)) { - $procs = Get-Process -Name $name -ErrorAction SilentlyContinue - foreach ($p in $procs) { - Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Also kill anything still running out of the target install directory, - # regardless of exe name. A tray from an older build (different exe name) or - # one launched from a stale path would otherwise survive the name-based sweep - # above, keep its exe locked, and leave a duplicate icon once step 5 starts - # the fresh tray. Match on the executable path so we only touch our own dir. - $targetPrefix = $TargetDir.TrimEnd('\') + '\' - foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { - $path = $null - try { $path = $p.Path } catch { $path = $null } - if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Brief settle so file handles are released before the copy overwrites them. - Start-Sleep -Milliseconds 500 -} - -# Installer transcript. Deliberately SEPARATE from both the service log -# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-.log) and the tray log -# (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log): the installer runs -# elevated and one-shot, and mixing its output into the service's rolling log -# made an install indistinguishable from runtime behaviour when reading back a -# failure. This runs as admin, so ProgramData is writable here. -$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs' -try { - New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null - $installLog = Join-Path $installLogDir ( - '{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) - Start-Transcript -Path $installLog -Append | Out-Null - $transcriptStarted = $true -} -catch { - # Never let logging block an install. - $transcriptStarted = $false - Write-Warning "could not start the install transcript: $($_.Exception.Message)" -} - -# The shop-floor operator profile. The tray and the user-context probe run as -# this local account on every shop-floor endpoint, and it is the one place both -# the operator (writer) and the SYSTEM service (reader) can reach. Defined here -# so the install can provision the directories and report their location. -$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor' - -# Stop the transcript on ANY terminating error, not just the success path. The -# script throws on several validation and smoke-test failures, and those are -# precisely the installs worth reading back afterwards - an orphaned transcript -# would truncate exactly the failure it was started to capture. -trap { - if ($transcriptStarted) { - try { Stop-Transcript | Out-Null } catch { } - $script:transcriptStarted = $false - } - break -} - -Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan -Write-Host "BaseUrl : $BaseUrl" -Write-Host "InstallDir : $InstallDir" -Write-Host "ServiceName : $ServiceName" -if ($transcriptStarted) { Write-Host "InstallLog : $installLog" } - -# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator -# typically extracts installer.zip into Downloads, so every file carries a -# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with -# "Windows protected your PC" the first time they run - including the [3/5] -# smoke test below, which launches shopdbagent.exe out of $sourceDir before the -# installed copy exists. Unblocking the source folder up front keeps the smoke -# test (and any manual run from the extract folder) clean; the installed copies -# get unblocked again in step 2a after they land in Program Files. Best-effort: -# a file with no MotW is a no-op. -Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..." -Get-ChildItem -Path $sourceDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 0. Uninstall any previous agent first, so the file copy is a clean overwrite -# and does not fail on exes locked by a running service or tray. -Write-Host "`n[0/5] Removing any previously installed agent ..." -Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir - -# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value). -$regPath = "HKLM:\SOFTWARE\GE\ShopDB" -Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..." -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey - -# 1a. Provision the user-context spool and tray log directories in the operator -# profile, and grant the operator Modify on them. -# -# This is the handoff point between the two security contexts (AGENT-CONTRACT.md -# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM -# service READS and then removes the spool. The operator cannot write to -# Program Files or to the SYSTEM-owned ProgramData log directory, and the file -# log sink degrades to a silent no-op when it cannot create its directory - so -# without this step a tray-side failure leaves no trace anywhere on the machine. -# Creating them here, elevated, means the directories exist and are writable -# before the tray ever starts. -Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..." -if (Test-Path $shopFloorProfile) { - foreach ($dir in @( - (Join-Path $shopFloorProfile 'ShopDB'), - (Join-Path $shopFloorProfile 'ShopDB\Logs'))) { - try { - New-Item -ItemType Directory -Force -Path $dir | Out-Null - $acl = Get-Acl $dir - $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - 'ShopFloor', 'Modify', - 'ContainerInherit,ObjectInherit', 'None', 'Allow') - $acl.SetAccessRule($rule) - Set-Acl -Path $dir -AclObject $acl - Write-Host " provisioned $dir (ShopFloor: Modify)" - } - catch { - # Non-fatal: telemetry, commands and inventory are unaffected. Only - # user-context (mapped drives, idle time) depends on this path. - Write-Warning " could not provision $dir : $($_.Exception.Message)" - } - } -} -else { - Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once." -} - -# 2. Copy agent files to the install directory. The agent and tray are -# single-file self-contained executables, so the .exe plus appsettings.json is -# the whole payload; there are no side-by-side runtime DLLs to copy. -Write-Host "[2/5] Copying agent files to $InstallDir ..." -New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -$agentSource = Join-Path $sourceDir "shopdbagent.exe" -if (-not (Test-Path $agentSource)) { - throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder." -} -Copy-Item -Path $agentSource -Destination $InstallDir -Force -$appsettings = Join-Path $sourceDir "appsettings.json" -$installedSettings = Join-Path $InstallDir "appsettings.json" -if (Test-Path $appsettings) { - Copy-Item -Path $appsettings -Destination $InstallDir -Force -} - -# Copy the interactive tray app next to the agent so its same-dir resolution -# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a -# service-only (headless) build. -$trayInstalled = $null -if (-not $NoTray) { - $traySource = Join-Path $sourceDir $TrayExeName - if (Test-Path $traySource) { - Copy-Item -Path $traySource -Destination $InstallDir -Force - $trayInstalled = Join-Path $InstallDir $TrayExeName - Write-Host " copied tray app $TrayExeName" - } - else { - Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)" - } -} - -# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM -# hive so the agent cannot READ the values the registry step just wrote. Stamp -# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads -# appsettings first, then lets the registry override it, so this is a safe -# no-op where the registry read works and the rescue where it does not. -if (Test-Path $installedSettings) { - try { - $json = Get-Content $installedSettings -Raw | ConvertFrom-Json - $json.baseurl = $BaseUrl - $json.apikey = $CollectorKey - $json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8 - Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)" - } - catch { - Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)" - } -} -$exePath = Join-Path $InstallDir "shopdbagent.exe" - -# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files -# extracted from a downloaded zip with a Zone.Identifier alternate data stream; -# SmartScreen reads that and blocks the unsigned exe with "Windows protected -# your PC" the first time it runs, and the operator sees only a "Don't run" -# button. Unblock-File removes the ADS so the smoke test and the tray launch -# start clean. Best-effort: a file with no MotW is a no-op. -Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..." -Get-ChildItem -Path $InstallDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 2b. Defender path exclusion for the install dir. Unsigned single-file -# self-contained binaries (and the tray's icon P/Invoke) can trip Defender -# heuristics and get quarantined, which is why the tray dies before showing an -# icon. Excluding the fixed install directory is the least-privilege fix short -# of code-signing. Best-effort: Set-MpPreference is absent on servers without -# Defender and can be blocked by tamper protection; a failure is not fatal. -if (-not $NoDefenderExclusion) { - Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..." - if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) { - try { - Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop - Write-Host " excluded $InstallDir from Defender scanning." - } - catch { - Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)" - Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy." - } - } - else { - Write-Host " Defender cmdlets not present; skipping exclusion." - } -} - -# 3. One-shot smoke test (exact production collection path, then exits). -Write-Host "[3/5] Running one-shot smoke test (--once) ..." -$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow -Write-Host " smoke test exit code: $($proc.ExitCode)" - -$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs" -$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd")) -if (Test-Path $logFile) { - Write-Host " --- last log lines ---" -ForegroundColor DarkGray - Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -} -if ($proc.ExitCode -ne 0) { - throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service." -} - -# 4. Install the Windows service. Any previous instance was already stopped, -# deleted, and its processes killed in step 0, so this is a clean create. Guard -# against the rare case where the SCM has not finished a pending deletion yet. -Write-Host "[4/5] Installing service $ServiceName ..." -$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue -if ($existing) { - Write-Host " service still present (pending deletion); removing again ..." - if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force } - sc.exe delete $ServiceName | Out-Null - Start-Sleep -Seconds 2 -} -$binPath = '"' + $exePath + '"' -sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null -sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null -sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null - -Start-Service -Name $ServiceName -Write-Host " service started." - -# 5. Register the interactive tray app to launch at user logon (per-machine). -Write-Host "[5/5] Registering tray app for logon ..." -if ($trayInstalled -and (Test-Path $trayInstalled)) { - # Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A - # 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores. - Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` - -Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"') - Write-Host " tray app registered to start at logon (Registry64): $trayInstalled" - - # Start it now so the icon appears without waiting for the next logon. The - # Run key only fires at logon, so a fresh install would otherwise show no - # tray until the operator signs out and back in. - # - # The installer runs elevated, so a direct Start-Process would launch the - # tray ELEVATED too. That is wrong for a tray app (it should run at the - # user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an - # unsigned exe triggers a broker prompt that comes back as Win32 1223 - # "The operation was canceled by the user" - which is exactly what we hit. - # Launching through explorer.exe hands the exe to the already-running user - # shell, which spawns it de-elevated in the interactive session, sidestepping - # the elevation broker entirely. - $interactiveUser = $env:USERNAME - if ($interactiveUser -and $interactiveUser -ne "SYSTEM") { - try { - # explorer.exe re-parents the launch under the user shell at - # medium integrity; it returns immediately regardless of the tray's - # own lifetime, so there is nothing to wait on here. - Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null - Write-Host " tray app started for the current session (de-elevated via explorer)." - } - catch { - Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon." - } - } - else { - Write-Host " no interactive user session; tray will start at next logon." - } -} -else { - Write-Host " tray skipped (headless install)." -} - -Write-Host "`nInstall complete." -ForegroundColor Green -Write-Host "Service '$ServiceName' is running and set to auto-start." -# The three log destinations are separate on purpose; see AGENT-CONTRACT.md. -Write-Host "Service log : $logDir" -Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')" -if ($transcriptStarted) { - Write-Host "Install log : $installLog" - try { Stop-Transcript | Out-Null } catch { } -} diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index f9e045d1..21dc42d4 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -106,6 +106,8 @@ arrays. `adopt` is first-contact only. ], "idleseconds": 45, "username": "operator", + "elevated": false, // token elevation of the collecting process; + // a spool stamped true is REJECTED (see 5.3) "collectedat": "2025-01-14T06:12:00Z" }, "events": [ @@ -194,8 +196,9 @@ Notes: rather than a string match against a Windows enum the server would have to keep in sync. `null` means the drive did not answer: unknown, NOT healthy. `healthstatus` keeps the human-readable source value alongside it. -- `usercontext` is gathered by a probe the TRAY runs inside the signed-in - operator's session, because mapped drives, user-owned listeners and idle time +- `usercontext` is gathered by a probe run inside the signed-in operator's + session by the `ShopDB user-context probe` scheduled task (and, opportunistically, + by the tray), because mapped drives, user-owned listeners and idle time do not exist in the agent's own LocalSystem session 0. It is omitted when no one is signed in, which is a normal unattended state, not a fault. The security-context split that makes this work is normative; see section 5. @@ -302,9 +305,9 @@ Three processes run as three different principals. Confusing them is what makes | Process | Runs as | May write to | |---|---|---| -| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\agent-.log` | -| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log` | -| Installer (`install-shopdbagent.ps1`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\-shopdbinstalls.log` | +| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\-agent.log` | +| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session, UNELEVATED (task principal `S-1-5-32-545` at `RunLevel Limited`) | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log` | +| Installer (`install.ps1`, generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\-shopdbinstall.log` and the append-only ledger `%ProgramData%\ShopDB\Logs\shopdbagent-installs.log` | Rules: @@ -314,23 +317,76 @@ Rules: degrades to a silent no-op when it cannot create its directory, so violating this does not raise an error - it discards the log line and the failure becomes invisible. -2. **Only the tray collects user context**, because mapped drives, user-owned - listeners and idle time are per-session and do not exist in session 0. The - service MUST NOT try to descend into the session to collect them. -3. **The tray WRITES the spool; the service READS it and then REMOVES it.** - The service consumes the file after a successful parse, so a tray that has - stopped running cannot keep re-reporting stale mapped drives as current. A - file that fails to parse (a read that raced the write) is left in place. -4. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`.** - Every shop-floor endpoint uses the same local `ShopFloor` account. Resolving - it through `WTSQueryUserToken` is a fallback only: that call is privileged, - endpoint protection blocks it, and when it fails the writer and reader +2. **User context is collected in the operator's own logon session**, because + mapped drives, user-owned listeners and idle time are per-session and do not + exist in session 0. The service MUST NOT try to descend into the session to + collect them. The authoritative collector is the `ShopDB user-context probe` + scheduled task, registered by the installer against the `BUILTIN\Users` group + (`S-1-5-32-545`) at `RunLevel Limited`, triggered at logon and every 30 + minutes. The tray also probes on its own cadence and writes the same spool; + the two are belt-and-braces and the last writer wins. +3. **The probe MUST NOT run elevated, and a spool stamped `elevated` is + REJECTED.** Drive letters are a property of the logon token, not the machine: + an elevated process gets a different token to the operator's interactive one + and enumerates a different, usually empty, drive table. This was measured on a + live endpoint, where the same two shares reported `Unavailable` from an + elevated shell and `OK` from the operator's, in both `Win32_NetworkConnection` + and `Get-SmbMapping`. `--collect-usercontext` therefore refuses to run when + elevated (exit code 2) rather than producing a confident wrong answer, and the + installer MUST NOT start the tray or probe as a child process - it is + elevated, and `explorer.exe` de-elevates without changing ACCOUNT, so an + install run by an admin produced a probe running as that admin. It MUST + instead reach the operator's session through a scheduled task principal. + `RunLevel Limited` on the task is load-bearing for the same reason; a task + promoted to `Highest` reproduces the bug while still looking registered. +4. **The probe WRITES the spool; the service only READS it.** The spool PERSISTS + and is overwritten by each probe run. The service MUST NOT delete it: the + collector's last-known-good cache is in-memory only, so consuming the file + meant a service restart blanked mapped drives until the next probe cycle. + Staleness is bounded by the in-file `collectedat` stamp (max age 90 minutes), + not by deletion, so a probe that has stopped running still stops re-reporting + stale drives - it just does so on a timer instead of on a read. +5. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`, + and writer and reader MUST derive it from the same helper.** Every shop-floor + endpoint uses the same local `ShopFloor` account. Resolving the path through + `WTSQueryUserToken` or through `%USERPROFILE%` is forbidden: the former is + privileged and endpoint protection blocks it, the latter is wrong under a + brokered or re-parented tray launch, and in both cases the writer and reader disagree about the location with no error on either side. -5. **Losing user context is never fatal.** If the `ShopFloor` profile is absent +6. **The probe MUST verify the spool exists after writing it.** A zero exit code + from `powershell.exe` proves the interpreter started, not that a file was + produced; success is reported only when the file is present on disk. +7. **A spool not stamped `ShopFloor` is REJECTED.** + collecting `username` and the service discards any spool whose stamp is + missing or different. Drive mappings from the wrong session are worse than + no drive mappings, so this fails loudly rather than degrading quietly. +8. **Losing user context is never fatal.** If the `ShopFloor` profile is absent or unwritable, `usercontext` is omitted and telemetry, commands, inventory and the command channel continue unaffected. -6. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs` - subdirectory with `ShopFloor: Modify` before the tray first runs. +9. **`--collect-usercontext` MUST NOT touch privileged resources.** It runs as + the operator and only writes into that operator's own profile, so it is + handled before the service's bootstrap logger, fault monitor and config load. + It MUST NOT open the SYSTEM-owned log directory, register an EventLog source, + or read `CollectorKey` from HKLM - those are precisely the calls an EPM broker + intercepts, and the registry read would put the collector secret in a process + that has no need of it. It therefore also works on a host whose CollectorKey + has not been provisioned yet. +10. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs` + subdirectory with `ShopFloor: Modify` before the probe first runs. +11. **Every install appends one line to the install ledger**, including + reinstalls and repeat installs of the same version, recording timestamp, + host, installing user, service version, tray version, install directory and + the user-context task state (`ok:/`, `UNVERIFIED:...`, + `FAILED:...` or `skipped`). The task is the single point of failure for + session-scoped collection, so its registration outcome MUST be recorded + where it survives the install transcript. +12. **Task registration MUST be verified by reading the task back**, not inferred + from `Register-ScheduledTask` returning without error. Policy or a + management agent can rewrite or drop a task after registration, and a task + that exists but runs with the wrong principal or run level looks healthy in + the scheduler UI while producing wrong data. +13. **All log filenames use `-.log`** so service, tray and + installer logs sort chronologically and collect with one date-leading glob. The three log destinations are deliberately separate: an install is one-shot and elevated, the service log is a rolling runtime log, and the tray log is the only diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index edabe28d..1e2bf0f5 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -217,7 +217,14 @@ param( [string] $ServiceName = "ShopDbEndpointAgent", [string] $TrayExeName = "shopdb-endpoint-tray.exe", [string] $CertFileName = {certfile}, - [switch] $NoTray + [switch] $NoTray, + + # Scheduled task that runs the user-context probe INSIDE the operator's own + # logon session. Mapped drives are per logon token, so this is the only + # reliable way to collect them; see the registration block below. + [string] $UserContextTaskName = "ShopDB user-context probe", + [int] $UserContextTaskMinutes = 30, + [switch] $NoUserContextTask ) $ErrorActionPreference = "Stop" @@ -362,10 +369,150 @@ if (-not $NoTray) {{ }} catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }} - # Launch the tray once now so it appears without waiting for the next - # logon. Single-instance guard makes a duplicate launch a no-op. - try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }} - catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }} + # Deliberately NOT launched here. This installer runs elevated, so + # Start-Process would hand the tray an ADMIN token, and drive letters + # are per logon token: the probe would then enumerate the installing + # admin's drive table, not the operator's. Measured on a live endpoint, + # the same two shares reported Unavailable from the elevated shell and + # OK from the operator's own session, in both Win32_NetworkConnection + # and Get-SmbMapping. Launching via explorer.exe does not fix this + # either - it de-elevates but does NOT change account. + # + # The Run key above is the correct autostart: it fires at a genuine + # logon, in the operator's own session. Collection no longer depends on + # the tray at all - the scheduled task below owns it. + Write-Host " tray will start at next logon (not launched from this elevated install)." + }} +}} + +# 2b. Register the user-context probe as a scheduled task owned by the Users +# GROUP. This is what makes mapped drives, user listeners and idle time +# collectable: it is the only mechanism that starts a process in the operator's +# own session with the operator's own token. +# +# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated +# token is precisely what sees the operator's mapped drives. GroupId rather +# than UserId so the task runs as WHOEVER signs in, each in their own session. +$taskState = "skipped" +if (-not $NoUserContextTask) {{ + Write-Host " registering user-context probe task ..." + $taskAgentExe = Join-Path $InstallDir "shopdbagent.exe" + $taskRegistered = $false + $taskDetail = "no principal accepted" + try {{ + $taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext" + # AtLogOn covers the normal case. The repeating trigger matters just as + # much: AtLogOn does NOT fire for a session that is already signed in, + # which is every install onto a machine someone is currently using. + $taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn) + if ($UserContextTaskMinutes -gt 0) {{ + $taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes) + }} + $taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) + + # BUILTIN\\Users by SID first, then by name: the SID is locale + # independent (on non-English Windows the group is not called "Users"), + # but a hardened host can still reject it. + foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{ + try {{ + $taskPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop + Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null + $taskRegistered = $true + $taskDetail = $groupId + break + }} + catch {{ $taskDetail = $_.Exception.Message }} + }} + }} + catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }} + + if ($taskRegistered) {{ + # Read the task back rather than trusting Register-ScheduledTask to have + # returned cleanly: policy or a management agent can rewrite or drop a + # task after registration, and a task that exists but runs elevated + # looks healthy in the scheduler UI while producing wrong data. + $verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue + if (-not $verified) {{ + $taskState = "UNVERIFIED:task not found after registration" + Write-Warning " user-context task registered but cannot be read back." + }} + elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{ + $taskState = "UNVERIFIED:elevated" + Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token." + }} + elseif (-not $verified.Principal.GroupId) {{ + $taskState = "UNVERIFIED:no group principal" + Write-Warning " user-context task has no group principal; it would run as one specific account." + }} + else {{ + $taskState = "ok:$($verified.Principal.GroupId)/$($verified.Principal.RunLevel)" + Write-Host " user-context task registered and verified: $taskState" + }} + + # AtLogOn will not fire for an already-signed-in session, so kick it once + # rather than leaving a fresh install blind until the repeat interval. + try {{ + Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop + Write-Host " user-context task started once for the current session." + }} + catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }} + }} + else {{ + # Not fatal: the service still checks in, only the session-scoped fields + # are missing, and the ledger records why. + $taskState = "FAILED:$taskDetail" + Write-Warning " could not register the user-context task: $taskDetail" + Write-Warning " mapped drives and idle time will not be collected." + }} +}} + +# 2c. Start the tray in the operator's session. +# +# The Run key registered above only fires at LOGON, so installing onto a machine +# someone is already signed in to leaves no tray until they sign out and back in. +# The installer cannot start it directly - it is elevated, and the tray must run +# unelevated in the user's own session - so we borrow the same mechanism that +# solves this for the probe: a transient scheduled task with a Limited group +# principal, run once and then deleted. +# +# Registered without a trigger; it exists only to be invoked immediately. The +# tray is single-instance, so if one is somehow already running this is a no-op. +if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ + $launchTaskName = "ShopDB tray first launch" + try {{ + $launchExe = Join-Path $InstallDir $TrayExeName + $launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir + $launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $launched = $false + foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{ + try {{ + $launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop + Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null + Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop + $launched = $true + break + }} + catch {{ }} + }} + + # Give the task a moment to spawn the tray before removing it: deleting a + # task that is still starting can kill the process it just launched. + if ($launched) {{ + Start-Sleep -Seconds 3 + Write-Host " tray started in the operator's session." + }} + else {{ + Write-Warning " could not start the tray now; it will start at next logon via the Run key." + }} + + # Transient by design: this is a one-shot launcher, not an autostart. + # The Run key owns autostart, and leaving this behind would mean two + # things racing to start the same single-instance tray at every logon. + try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }} + catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }} + }} + catch {{ + Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon." }} }} @@ -397,15 +544,22 @@ Write-Host " service is running." -ForegroundColor Green $trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName) if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ - $trayOk = $false - for ($attempt = 1; $attempt -le 5; $attempt++) {{ - if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }} - Write-Host " tray not running yet (attempt $attempt/5); relaunching ..." - try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }} + # Report only; never relaunch from here. This block used to Start-Process the + # tray when it was not found running, which would hand it this installer's + # ADMIN token and reintroduce the wrong-drive-table bug. The launch task + # above is the only place the tray is started during install, and it starts + # it correctly - unelevated, in the operator's session. + $trayFound = $false + for ($attempt = 1; $attempt -le 3; $attempt++) {{ + if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }} Start-Sleep -Seconds 2 }} - if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }} - else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }} + if ($trayFound) {{ + Write-Host " tray is running." -ForegroundColor Green + }} + else {{ + Write-Warning "tray did not confirm running; it will start at next logon via the Run key." + }} }} # 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under @@ -432,6 +586,29 @@ if (Test-Path $installSelf) {{ }} Write-Host "[6/6] Done." -ForegroundColor Green + +# Append one line per install, including reinstalls and repeat installs of the +# same version. The user-context task state is recorded because it is the single +# point of failure for session-scoped collection: if mapped drives go missing +# later, this answers "was the task ever registered on this host, and as what" +# without needing the endpoint back. The ledger is a record, not a gate - a +# failure to append must never fail an otherwise-good install. +try {{ + $ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs" + New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null + $ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log" + $ledgerAgentVersion = "unknown" + if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }} + $ledgerTrayVersion = "not-installed" + $ledgerTrayPath = Join-Path $InstallDir $TrayExeName + if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }} + $ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState + Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8 + Write-Host "Install ledger: $ledgerPath" + Write-Host " $ledgerLine" -ForegroundColor DarkGray +}} +catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }} + if (Test-Path $uninstaller) {{ Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan Write-Host " To remove the agent, run that script from an elevated PowerShell." diff --git a/scripts/uninstall-shopdbagent.ps1 b/scripts/uninstall-shopdbagent.ps1 index 717a1a2c..ac14ae93 100644 --- a/scripts/uninstall-shopdbagent.ps1 +++ b/scripts/uninstall-shopdbagent.ps1 @@ -1,10 +1,11 @@ # uninstall-shopdbagent.ps1 # ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on -# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent -# generator's install.ps1 create: +# the endpoint. Reverses everything the site agent generator's install.ps1 +# creates: # - stops and deletes the Windows service # - kills any running agent/tray processes # - removes the tray logon Run key (64-bit and 32-bit views) +# - unregisters the ShopDB user-context probe scheduled task # - removes the credential hive HKLM\SOFTWARE\GE\ShopDB (both views) # - removes the Defender path exclusion for the install directory # - deletes the install directory and the ProgramData data/log tree @@ -20,6 +21,10 @@ param( [string] $ServiceName = "ShopDbEndpointAgent", [string] $TrayExeName = "shopdb-endpoint-tray.exe", + # Scheduled task that runs the user-context probe in the operator's session. + # Must match the installer's -UserContextTaskName. + [string] $UserContextTaskName = "ShopDB user-context probe", + # Also remove any server TLS certificate the site-agent installer imported # into the machine Trusted Root store. Off by default because a root cert is # shared trust material: only pull it when you are sure no other service on @@ -113,6 +118,34 @@ Start-Sleep -Seconds 1 # 3. Remove the tray logon Run key entry (both views) and the credential hive. Write-Host "[3/6] Removing registry entries ..." Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray" + +# Unregister the probe task. Left behind it would keep firing at every logon +# against a deleted exe, which shows up as a recurring task-failure history on a +# machine nobody believes has the agent on it any more. +try { + if (Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $UserContextTaskName -Confirm:$false -ErrorAction Stop + Write-Host " removed scheduled task '$UserContextTaskName'" + } + else { + Write-Host " scheduled task '$UserContextTaskName' not present." + } +} +catch { + Write-Warning " could not remove scheduled task '$UserContextTaskName': $($_.Exception.Message)" +} + +# The install's one-shot tray launcher is transient and normally removes itself, +# but an install that failed partway can leave it behind. +try { + if (Get-ScheduledTask -TaskName "ShopDB tray first launch" -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName "ShopDB tray first launch" -Confirm:$false -ErrorAction Stop + Write-Host " removed leftover scheduled task 'ShopDB tray first launch'" + } +} +catch { + Write-Warning " could not remove scheduled task 'ShopDB tray first launch': $($_.Exception.Message)" +} Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB" # Remove the All-Users Start Menu shortcut the site-agent installer created.