diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8892e585..64c6c533 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,16 @@ Follow these when suggesting code. They are enforced by a naming/style hook and by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. `CONTRIBUTING.md` is the full authority; this is the short version. +## General Guidelines +- Finish the in-flight task before starting new workstreams. +- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies. +- 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/CONTRIBUTING.md b/CONTRIBUTING.md index 83a739fe..5fddca00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,32 +171,15 @@ Tests are required for: ## Branches: what belongs on main, and what does not -`main` is the curated product other sites adopt, and what the publication -mirror exports. It is a release decision: something reaching `main` says other -sites should take it. **`main` is not what this site runs.** - -Each branch answers to one instance: - -| branch | runs on | contains | -|---|---|---| -| `dev` | the dev instance | work being validated | -| `wj` | this site's PRODUCTION | everything this site runs | -| `main` | nothing directly | the curated release for other sites | -| `wilm` | the other site | their work, on the corporate GitHub | - -So work flows `dev` -> `wj` -> `main`: land it on `dev`, validate it on the dev -instance, take it to `wj` for production, and promote to `main` what you are -happy for another site to run. Promoting first and back-filling the site branch -is the wrong way round - it puts unvalidated work in front of other sites and -leaves production behind. - -`wilm` is not visible from the dev box because that machine has no account on -the corporate GitHub, which is why `fetch-wilm-branch.cmd` exists to bring it -back for review. It sits on the publication lineage, so it shares no ancestor -with `main`/`wj`/`dev` and cannot be merged locally. +`main` is the product every site runs and the publication mirror exports. +`` branches hold work that is not ready for other sites to see. There are +two: `wj` here, and `wilm` on the corporate GitHub, where the other site +develops the .NET endpoint agent. A site branch is not a fork. Both feed `main`, and the sooner they do it the -cheaper it is. +cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not +visible from here because this machine has no GitHub account, which is why +`fetch-wilm-branch.cmd` on the share exists to bring it back for review. **Most work is product.** ADR-015 gives site differences three homes on `main`: a setting with a neutral default, seed data, or a site-namespaced directory 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/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 991d857e..92320022 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -89,38 +89,44 @@ public sealed class AgentConfig return bare; } - // Per-name allowlist of pre-staged scripts for the generic run_script - // action. Exactly like LaunchPaths, the wire never carries a script body or - // path - only a script NAME the agent already holds here. A name absent - // from this map fails closed. There are no shipped defaults: a script is a - // site-specific artifact, so an empty map means run_script does nothing - // until a site stages scripts and lists them in appsettings.json. Keyed by - // script name (case-insensitive, .ps1 optional). - [JsonPropertyName("scriptpaths")] - public Dictionary ScriptPaths { get; set; } = new(); + // REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths. + // + // This used to be a per-name map of PRE-STAGED scripts on the endpoint + // (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which + // run_script would look up by name and execute. It was superseded by + // server-hosted delivery and left behind as dead config: nothing read the + // map, so it was an execution path that appeared supported but was not. + // + // It is gone rather than merely unused because reviving it is a security + // regression, and dead code invites revival. That model had NO integrity + // check - no digest, no provenance - so whoever could write into the staging + // directory chose what the agent ran as SYSTEM. ProgramData is world- + // writable by default unless the tree is explicitly tightened, which made + // the gate "is this name in appsettings.json", not "is this content + // trusted". + // + // The supported path is run_script with a server-issued delivery descriptor: + // the body is fetched from ShopDB over the collector credential and verified + // against a server-pinned SHA-256 before it executes, so the content is + // authenticated and the action is attributable in the command log. If you + // need a script to run on an endpoint, publish it server-side and send it + // through that path. If you need to trigger something already installed on + // the box, use scheduledtask_run, which is far narrower. - // The effective script map: appsettings.json entries only (no defaults). - // Keys are normalized to bare names so a lookup tolerates 'reset' or - // 'reset.ps1'. - public IReadOnlyDictionary ResolveScriptPaths() - { - var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var pair in ScriptPaths) - { - if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) - continue; - resolved[NormalizeScript(pair.Key)] = pair.Value; - } - return resolved; - } - - private static string NormalizeScript(string name) - { - var bare = (name ?? "").Trim(); - if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) - bare = bare[..^4]; - return bare; - } + // Approved scheduled task paths for the scheduledtask_run action. The wire + // carries a free-text task path so the WebUI can expose it as a single + // field; ScheduledTaskPolicy validates it against this list and fails closed + // when it does not match. An entry matches that exact task path, or acts as + // a folder prefix for anything beneath it. + // + // Empty means "use the shipped Corporate Tools defaults" + // (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those + // defaults, so a site can narrow the set as well as widen it without an + // agent rebuild. Keep it tight: every entry here is something an operator + // holding endpoints.deploy can start, in whatever context the task itself + // registered. + [JsonPropertyName("approvedtaskpaths")] + public List ApprovedTaskPaths { get; set; } = new(); public bool IsUsable() => !string.IsNullOrWhiteSpace(BaseUrl) diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 7db7a198..99277899 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -64,6 +64,10 @@ public sealed class CommandExecutor // before they are honored. Local launchPaths always win over a server path. private readonly LaunchPathPolicy launchPathPolicy; + // Validates the free-text task path carried by scheduledtask_run before the + // agent will start anything. Fails closed: an unapproved path never runs. + private readonly ScheduledTaskPolicy scheduledTaskPolicy; + // Held for update_app: the agent joins InstallPath to BaseUrl and downloads // the pinned installer with its own collector credential (ApiKey), so the // installer body never rides the command wire. @@ -77,6 +81,7 @@ public sealed class CommandExecutor this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); + this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths); } public async Task ExecuteAsync( @@ -116,6 +121,16 @@ public sealed class CommandExecutor return RequireTarget(action, target) ?? await ProcessStart(target, token); + // Start an already-registered scheduled task by path. Deliberately + // narrow: the agent does not create, modify, or define tasks, it + // only starts one that already exists AND clears the approved-task + // policy. Prefer this over run_script whenever the work is already + // packaged as a task - it is a far smaller grant than shipping a + // script body, and the command log names exactly what ran. + case "scheduledtask_run": + return RequireTarget(action, target) + ?? await ScheduledTaskRun(target, token); + // Software delivery. run_script fetches a ShopDB-hosted script // pinned by SHA-256 and runs it in the script's configured context // (system or the interactive user); update_app fetches a ShopDB @@ -144,6 +159,14 @@ public sealed class CommandExecutor Message = "Fresh telemetry collection requested", }; + // Full TCP connection table, on demand only. Deliberately not part of + // the standard report: established connections churn every second, so + // sending them on cadence would bloat every payload and swamp the + // stable listener list. As a command the technician pays that cost + // once, at the moment they are actually diagnosing connectivity. + case "network_connections": + return NetworkConnections(); + default: logger.LogWarning("Rejected unknown command action {Action}", action); return new CommandResult @@ -154,6 +177,98 @@ public sealed class CommandExecutor } } + // Snapshot of every TCP connection with its owning process. Capped because a + // busy machine can hold thousands of sockets and an unbounded result would + // be rejected by the server's payload limit, returning nothing at all - + // a truncated answer is far more useful than a failed one. + private const int MaxReportedConnections = 500; + + private CommandResult NetworkConnections() + { + try + { + var rows = TcpTableReader.GetTcpTable(); + var names = GetProcessNameSnapshot(); + var connections = new List(); + var truncated = false; + + foreach (var row in rows) + { + if (connections.Count >= MaxReportedConnections) + { + truncated = true; + break; + } + + names.TryGetValue(row.ProcessId, out var processName); + connections.Add(new Dictionary + { + ["localaddress"] = row.LocalAddress, + ["localport"] = row.LocalPort, + ["remoteaddress"] = row.RemoteAddress, + ["remoteport"] = row.RemotePort, + ["state"] = row.State.ToString().ToLowerInvariant(), + ["processid"] = row.ProcessId, + ["processname"] = processName, + }); + } + + logger.LogInformation( + "network_connections returned {Count} connections (truncated: {Truncated})", + connections.Count, truncated); + + return new CommandResult + { + Succeeded = true, + Message = $"Collected {connections.Count} TCP connections" + + (truncated ? " (truncated)" : ""), + Detail = new Dictionary + { + ["connections"] = connections, + ["totalcount"] = rows.Count, + ["truncated"] = truncated, + }, + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "network_connections failed"); + return new CommandResult + { + Succeeded = false, + Message = "Could not read the TCP connection table: " + exception.Message, + }; + } + } + + private Dictionary GetProcessNameSnapshot() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception exception) + { + logger.LogDebug(exception, "Process name snapshot failed"); + } + return names; + } + // Guard: a generic action with no target has nothing to act on. Returns a // failed result to short-circuit, or null when the target is present. private CommandResult? RequireTarget(string action, string target) @@ -550,6 +665,139 @@ public sealed class CommandExecutor return found; } + // ---- Scheduled tasks ---- + + // Upper bound on how long a task start may block the command worker. This + // waits only for schtasks.exe to hand the request to the Task Scheduler + // service, NOT for the task's own work to finish - schtasks /run returns as + // soon as the task is launched. So success here means "the task was + // started", not "the task succeeded", and the timeout only needs to cover + // the handoff. + private static readonly TimeSpan ScheduledTaskStartTimeout = TimeSpan.FromSeconds(30); + + // Start an already-registered scheduled task by path, after the policy + // approves it. Uses schtasks.exe with ArgumentList (no shell, no string + // concatenation) so the task path is passed as a single argv entry and + // cannot break out into additional arguments. + // + // The task runs in whatever security context its own registered definition + // specifies - SYSTEM, an interactive user, whatever principal was set when + // the task was created. The agent does not and cannot change that, which is + // exactly why this action is narrower than run_script: the endpoint owns the + // definition of what happens, the server only asks for it to happen now. + private async Task ScheduledTaskRun(string target, CancellationToken token) + { + if (!scheduledTaskPolicy.TryValidate(target, out var taskPath, out var reason)) + { + logger.LogWarning( + "Rejected scheduledtask_run for {Target}: {Reason}", target, reason); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task '{target}' rejected: {reason}", + }; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "schtasks.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("/run"); + startInfo.ArgumentList.Add("/tn"); + startInfo.ArgumentList.Add(taskPath); + + using var process = Process.Start(startInfo); + if (process is null) + { + return new CommandResult + { + Succeeded = false, + Message = $"Failed to start schtasks.exe for task {taskPath}", + }; + } + + var stdout = ""; + var stderr = ""; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(ScheduledTaskStartTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + // Read only after exit. Reading to end first would block until + // the streams close - which IS process exit - so the timeout + // above could never fire. Safe here because schtasks emits a + // line or two, far below the pipe buffer that would deadlock a + // chattier child. + stdout = await process.StandardOutput.ReadToEndAsync(token); + stderr = await process.StandardError.ReadToEndAsync(token); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + TryKill(process); + return new CommandResult + { + Succeeded = false, + Message = $"Timed out starting scheduled task {taskPath}", + }; + } + + var exit = process.ExitCode; + var ok = exit == 0; + // schtasks writes the useful diagnosis (task not found, access + // denied, disabled) to its output, so surface it rather than making + // the operator guess from a bare exit code. + var detail = (stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim()); + if (ok) + { + logger.LogInformation("Started scheduled task {TaskPath}", taskPath); + } + else + { + logger.LogWarning( + "schtasks /run for {TaskPath} exited {Exit}: {Detail}", + taskPath, exit, detail); + } + + return new CommandResult + { + Succeeded = ok, + Message = ok + ? $"Started scheduled task {taskPath}" + : $"Scheduled task {taskPath} failed to start (exit {exit})" + + (detail.Length > 0 ? $": {detail}" : ""), + }; + } + catch (Exception error) + { + logger.LogWarning(error, "scheduledtask_run for {TaskPath} failed", taskPath); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task {taskPath} failed: {error.Message}", + }; + } + } + + private void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not kill timed-out schtasks.exe"); + } + } + // ---- Software delivery: run_script and update_app ---- // Run a ShopDB-hosted script pinned by the server's delivery descriptor. The @@ -559,6 +807,26 @@ public sealed class CommandExecutor // 'system' through the agent's own service account, or 'user' in the active // console session so the script sees the interactive user's resources (for // example mapped network drives). Anything missing or mismatched fails closed. + // + // SECURITY CONTRACT - read before changing this method. This is arbitrary + // code execution on the endpoint, by design, and the agent is a SYSTEM + // service: a script whose descriptor does not pin the user context runs with + // full machine privilege. Three properties make that acceptable, and all + // three are load-bearing: + // + // 1. The body is never carried on the command wire. The agent fetches it + // itself from ShopDB using its own collector credential, so queueing a + // command cannot smuggle in code. + // 2. The body is verified against the server-pinned SHA-256 before it + // runs. A tampered or swapped body fails closed. + // 3. The action is gated on endpoints.deploy server-side and is meant to + // sit behind an explicit UI confirmation, the same as system_restart. + // + // Do not add a path that runs a script the agent found locally rather than + // fetched and verified - that was the old pre-staged 'scriptpaths' model and + // it was removed on purpose (see the tombstone in AgentConfig). If the goal + // is to trigger something already installed on the endpoint, use + // scheduledtask_run instead: it is a much smaller grant. private async Task RunScript( string name, ScriptDeliveryClaim? script, CancellationToken token) { @@ -714,6 +982,12 @@ public sealed class CommandExecutor // to exit and returns its real exit code, so success here means the script // actually ran to a zero exit - not merely that it was launched. Fails closed // when no interactive user is signed in. + // Upper bound on how long a user-session script may block the command + // worker. Generous, because a staged maintenance script can legitimately run + // for minutes, but finite: before this existed the wait was Infinite and a + // hung script pinned the caller forever. + private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5); + private CommandResult RunScriptInUserSession( string name, ScriptDeliveryClaim script, string scriptPath) { @@ -723,7 +997,8 @@ public sealed class CommandExecutor var workingDirectory = Path.GetDirectoryName(scriptPath) ?? ""; var outcome = InteractiveProcessLauncher.LaunchInActiveSession( - "powershell.exe", arguments, workingDirectory, waitForExit: true); + "powershell.exe", arguments, workingDirectory, waitForExit: true, + waitTimeout: UserScriptWaitTimeout); if (!outcome.Launched) { logger.LogWarning( @@ -737,6 +1012,23 @@ public sealed class CommandExecutor }; } + if (outcome.TimedOut) + { + // The script is still running; we simply stopped waiting. Report it + // as unresolved rather than success, because we never saw an exit + // code and cannot claim it worked. + logger.LogWarning( + "run_script for {Name} still running after {Timeout}; stopped waiting", + name, UserScriptWaitTimeout); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} did not finish within " + + $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + }; + } + var exit = outcome.ExitCode; var ok = exit is null || exit == 0; return new CommandResult 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/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d1c18f0f..911ad397 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -44,11 +44,57 @@ public sealed class EndpointReport // collection: both Uninstall registry hives, {displayname, displayversion, // publisher}. Server logs it as an unpersisted key until the SBOM store lands. [JsonPropertyName("applications")] public object[]? Applications { get; set; } + + // True when Windows is holding a reboot. A machine that has been waiting to + // reboot for weeks explains stalled patching and "the fix did not take" + // reports, and on a shop floor PC nobody reboots voluntarily. + [JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; } + + // Which signal(s) drove pendingreboot, so the server can tell a box staged + // mid-servicing (pendingfilerename) from one with a stale servicing key. + // Empty array = checked, nothing pending. Omitted = the check failed. + [JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; } + + // hiberbootenabled (fast startup configured) and lastboottype (what the + // last boot actually did). Uptime is not trustworthy when these disagree. + [JsonPropertyName("bootinfo")] public object? BootInfo { get; set; } + + // Defender health. Persisted but deliberately not surfaced: known false + // positives in this environment. + [JsonPropertyName("defender")] public object? Defender { get; set; } + + // Physical drive health from two sources: MSFT_PhysicalDisk (the only one + // that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback). + // Each entry carries a precomputed predictfailure so an alert is a boolean + // test rather than a string match against an enum. Null predictfailure + // means the drive did not answer - unknown, not healthy. + [JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; } + + // Session-scoped data gathered by a probe running as the signed-in user: + // mapped drives and user-owned listeners. Null when nobody is signed in or + // the probe did not produce a result this cycle - absent means "not + // observed", which is deliberately distinct from an empty list. + [JsonPropertyName("usercontext")] public object? UserContext { get; set; } + + // Pending Dell Command | Update results, produced by the shopdb-dellupdates + // agent utility rather than collected in C#: the data comes from dcu-cli's + // own scan output, so the utility that parses it belongs next to the task + // that writes it. Null means no scan has ever run (or the utility is not + // installed), which is deliberately distinct from a scan that found nothing + // pending - the first is "unknown", the second is "known clean". + [JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; } } public sealed class EndpointEventRecord { [JsonPropertyName("eventid")] public int EventId { get; set; } + + // The Windows event record id: monotonic and unique within its channel on a + // given machine. It is both the agent's harvest watermark and the server's + // dedupe key, so the same record can never be stored twice. Null only when + // the source record did not expose one. + [JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; } + [JsonPropertyName("eventchannel")] public string? EventChannel { get; set; } [JsonPropertyName("eventsource")] public string? EventSource { get; set; } [JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; } diff --git a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs index 57b44d7f..143ce78d 100644 --- a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs +++ b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs @@ -27,7 +27,15 @@ public static class CuratedEventCatalog new(55, "System", "NTFS file system corruption"), new(7011, "System", "Service Control Manager timeout"), new(10016, "System", "DCOM permission error"), + // Windows Error Reporting on the Application channel: 1000 is the + // faulting-application record (app, version, faulting module, exception + // code), 1001 is the WER bucket record Windows pairs with it, and 1002 + // is an unresponsive app rather than a crash. The structured detail for + // all three arrives in the record's EventData, which the collector lifts + // into `fields`. + new(1000, "Application", "Application error / faulting module"), new(1001, "Application", "Application error / bugcheck report"), + new(1002, "Application", "Application hang"), }; // The distinct channels the collector must read. diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 2486a66e..08604605 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; using System.Management; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -19,16 +20,28 @@ public sealed class EndpointCollector : ICollector // keep the payload bounded on a chatty Security log. private const int MaxEventsPerChannel = 25; + // The spool file is read every cycle. Reading it is a small file read, and + // the tray - not this cadence - decides how often the data is refreshed, so + // throttling the read here would only delay already-collected data reaching + // the server without saving any real work. + private readonly ILogger logger; private readonly AgentConfig config; private readonly IAgentUtilityRunner? utilities; + private readonly EventWatermarkStore? watermarks; + private readonly UserContextSpool? userContextSpool; + + private object? lastUserContext; public EndpointCollector(ILogger logger, AgentConfig config, - IAgentUtilityRunner? utilities = null) + IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null, + UserContextSpool? userContextSpool = null) { this.logger = logger; this.config = config; this.utilities = utilities; + this.watermarks = watermarks; + this.userContextSpool = userContextSpool; } public EndpointReport Collect(string agentVersion, bool adopt) @@ -49,23 +62,232 @@ public sealed class EndpointCollector : ICollector MachineNumber = GetMachineNumber(), LastBootTime = GetLastBootTime(), Services = GetMonitoredTargets(), + Ports = GetListeningPorts(), Interfaces = GetInterfaces(), Events = GetCuratedEvents(), Disks = GetDisks(), + DiskHealth = GetDiskHealth(), Bios = GetBios(), Memory = GetMemory(), Processor = GetProcessor(), OsInfo = GetOsInfo(), Domain = GetDomain(), Applications = GetApplications(), + PendingReboot = GetPendingReboot(), + RebootReasons = GetRebootReasons(), + BootInfo = GetBootInfo(), + Defender = GetDefender(), + UserContext = GetUserContext(), }; if (report.LastBootTime is DateTimeOffset boot) report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds; + report.CpuPercent = GetCpuPercent(); + report.MemoryPercent = GetMemoryPercent(); + report.IdleSeconds = GetIdleSecondsFromUserContext(); + return report; } + // Instantaneous CPU load from the _Total performance instance. + // + // Uses the WMI formatted-data class rather than PerformanceCounter because a + // PerformanceCounter reads 0 on its first sample and needs a second sample a + // second later to mean anything. Sleeping a second inside the collect path + // would delay the heartbeat on every tick for one number. The formatted-data + // class is already averaged by the OS, so a single read is usable. + private double? GetCpuPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT PercentProcessorTime FROM " + + "Win32_PerfFormattedData_PerfOS_Processor WHERE Name='_Total'"); + foreach (var mo in searcher.Get()) + { + var value = ToLong(mo["PercentProcessorTime"]); + if (value is long percent) + return Math.Clamp(percent, 0, 100); + } + } + catch (Exception error) + { + logger.LogDebug(error, "CPU percent query failed"); + } + return null; + } + + // Percentage of physical memory in use. Reported alongside the absolute + // byte counts in the memory object because the percentage is what an alert + // threshold keys off, and deriving it server-side would break whenever the + // byte fields are absent. + private double? GetMemoryPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT TotalVisibleMemorySize, FreePhysicalMemory " + + "FROM Win32_OperatingSystem"); + foreach (var mo in searcher.Get()) + { + var totalKb = ToLong(mo["TotalVisibleMemorySize"]); + var freeKb = ToLong(mo["FreePhysicalMemory"]); + if (totalKb is long total && total > 0 && freeKb is long free) + { + var used = (double)(total - free) / total * 100.0; + return Math.Round(Math.Clamp(used, 0, 100), 1); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Memory percent query failed"); + } + return null; + } + + // Idle time is measured by the user-context probe, because GetLastInputInfo + // is per session and this service runs in session 0. Null when no probe + // result is available, which correctly reads as "not observed" rather than + // "the machine has been idle for zero seconds". + private long? GetIdleSecondsFromUserContext() + { + if (lastUserContext is not Dictionary context) + return null; + if (!context.TryGetValue("idleseconds", out var raw) || raw is null) + return null; + + try + { + if (raw is JsonElement element) + { + return element.ValueKind == JsonValueKind.Number + && element.TryGetInt64(out var parsed) ? parsed : null; + } + return Convert.ToInt64(raw); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not read idleseconds from the user context"); + return null; + } + } + + // Listening TCP ports with the owning process name. + // + // Only listeners, never established connections: listeners answer "what + // service is this machine offering" (the DNC port, the MTConnect adapter), + // which is stable and small. Established connections churn constantly and + // would make every report differ from the last for no diagnostic gain, so + // the full table is exposed as an on-demand command instead. + private const int MaxListeners = 100; + + private object[] GetListeningPorts() + { + try + { + var listeners = TcpTableReader.GetListeners(); + if (listeners.Count == 0) + return Array.Empty(); + + var names = GetProcessNames(); + var result = new List(); + + foreach (var listener in listeners) + { + if (result.Count >= MaxListeners) + { + logger.LogDebug( + "Listener list truncated at {Limit} entries.", MaxListeners); + break; + } + + names.TryGetValue(listener.ProcessId, out var processName); + result.Add(new + { + localaddress = listener.LocalAddress, + localport = listener.LocalPort, + processid = listener.ProcessId, + processname = processName, + protocol = "tcp", + }); + } + + return result.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Listening port enumeration failed"); + return Array.Empty(); + } + } + + // Snapshot the process table once and index by id. Resolving each listener + // individually would be an O(n) call per row and can race with process exit. + private Dictionary GetProcessNames() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Process name snapshot failed"); + } + return names; + } + + // Session-scoped data, read from the spool file the tray writes. The service + // only reads: it cannot collect this itself, because mapped drives and idle + // time do not exist in session 0. Between reads the previous result is + // repeated, because a mapped drive stays mapped and dropping the key would + // read on the server as "the drive went away". The spool's own age limit is + // what eventually retires a result, so a logged-off machine goes quiet + // instead of reporting the last operator forever. + private object? GetUserContext() + { + if (userContextSpool is null) + return null; + + var resultPath = userContextSpool.TryGetResultPath(out var reason); + if (resultPath is null) + { + logger.LogDebug("Skipping the user-context read: {Reason}", reason); + return lastUserContext; + } + + // 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; + + return lastUserContext; + } + // The interactive tray app ships and updates independently of this service, // so the agent reports the tray's file version as its own field. Resolve the // tray exe beside the agent first (the normal install layout), then the @@ -296,6 +518,7 @@ public sealed class EndpointCollector : ICollector private object[] GetInterfaces() { var result = new List(); + var linkstate = GetLinkState(); try { using var searcher = new ManagementObjectSearcher( @@ -321,6 +544,10 @@ public sealed class EndpointCollector : ICollector var gateway = gateways is null || gateways.Length == 0 ? null : gateways[0]; + // Paired by MAC from MSFT_NetAdapter. Absent when that class is + // unavailable, which must leave the rest of the entry intact. + linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link); + result.Add(new { alias = mo["Description"]?.ToString()?.Trim() ?? "", @@ -332,6 +559,10 @@ public sealed class EndpointCollector : ICollector gateway = gateway ?? "", subnet = subnet ?? "", dhcp = mo["DHCPEnabled"] as bool? ?? false, + // Null rather than 0/false when link state was not + // readable: an unknown duplex must not read as half. + linkspeedbps = link.Speed, + fullduplex = link.FullDuplex, }); } } @@ -451,40 +682,33 @@ public sealed class EndpointCollector : ICollector } } - // Walk each curated channel newest-first, keep only the curated IDs for - // that channel, cap per channel. Reads Security/System/Application - the - // old scaffold read System only and filtered by the wrong IDs. + // Walk each curated channel newest-first and report ONLY records newer than + // the stored watermark, so a record crosses the wire exactly once. + // + // Two things changed here versus the original scaffold. First, filtering by + // event id now happens inside the event log service through an XPath query + // instead of walking every entry in the channel from C#; on a busy Security + // log that is the difference between touching a handful of records and + // hundreds of thousands. Second, EventLogReader (not the legacy EventLog + // API) exposes RecordId, which is what makes exactly-once possible. + // + // First contact on a channel has no watermark, so it sends a bounded tail + // (MaxEventsPerChannel) for initial context rather than the whole backlog. + // The same cap also bounds a burst: if more than the cap arrived since the + // last cycle, the NEWEST are taken and the watermark still jumps to the + // newest, so the payload can never balloon. private EndpointEventRecord[] GetCuratedEvents() { var records = new List(); foreach (var channel in CuratedEventCatalog.Channels) { var wanted = CuratedEventCatalog.IdsForChannel(channel); + if (wanted.Count == 0) + continue; + try { - using var log = new EventLog(channel); - int kept = 0; - for (int i = log.Entries.Count - 1; - i >= 0 && kept < MaxEventsPerChannel; i--) - { - EventLogEntry entry; - try { entry = log.Entries[i]; } catch { continue; } - - int id = (int)(entry.InstanceId & 0xFFFF); - if (!wanted.Contains(id)) - continue; - - records.Add(new EndpointEventRecord - { - EventId = id, - EventChannel = channel, - EventSource = entry.Source, - EventTime = new DateTimeOffset( - entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero), - EventMessage = Truncate(entry.Message, 1000), - }); - kept++; - } + records.AddRange(ReadChannel(channel, wanted)); } catch (Exception error) { @@ -493,9 +717,172 @@ public sealed class EndpointCollector : ICollector logger.LogDebug(error, "Event channel {Channel} unreadable", channel); } } + + watermarks?.Save(); return records.ToArray(); } + private IEnumerable ReadChannel( + string channel, IReadOnlySet wanted) + { + long watermark = watermarks?.GetWatermark(channel) ?? 0; + bool seeding = watermarks is null || !watermarks.HasWatermark(channel); + + var query = new EventLogQuery(channel, PathType.LogName, BuildIdFilter(wanted)) + { + // Newest-first so the cap keeps the most recent records and we can + // stop as soon as we reach the watermark. + ReverseDirection = true, + }; + + var found = new List(); + long newestSeen = 0; + + using var reader = new EventLogReader(query); + while (found.Count < MaxEventsPerChannel) + { + EventRecord? entry; + try { entry = reader.ReadEvent(); } + catch (Exception error) + { + logger.LogDebug(error, "Event read failed on {Channel}", channel); + break; + } + + if (entry is null) + break; + + using (entry) + { + long recordId = entry.RecordId ?? 0; + if (recordId > newestSeen) + newestSeen = recordId; + + // Reverse order means everything from here down is older than + // the watermark and has already been reported. + if (!seeding && recordId != 0 && recordId <= watermark) + break; + + found.Add(ToEventRecord(entry, channel, recordId)); + } + } + + // A cleared log restarts record ids low; reseed rather than go silent. + if (!seeding && newestSeen > 0 + && watermarks is not null && watermarks.ResetIfStale(channel, newestSeen)) + { + return found; + } + + if (newestSeen > 0) + watermarks?.Advance(channel, newestSeen); + + return found; + } + + private EndpointEventRecord ToEventRecord( + EventRecord entry, string channel, long recordId) + { + string? message = null; + try { message = entry.FormatDescription(); } + catch (Exception error) + { + // A provider whose message DLL is missing cannot render text; the + // id, time, and source are still worth reporting. + logger.LogDebug(error, "Event message not renderable on {Channel}", channel); + } + + return new EndpointEventRecord + { + EventId = entry.Id, + EventRecordId = recordId == 0 ? null : recordId, + EventChannel = channel, + EventSource = entry.ProviderName, + EventTime = entry.TimeCreated is DateTime created + ? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero) + : null, + EventMessage = Truncate(message, 1000), + Fields = GetEventFields(entry, channel), + }; + } + + // Bounds on the structured field extraction. The rendered message is often + // a paragraph of prose while the useful specifics (which account, which + // logon type, which faulting module) live in EventData, so it is worth + // carrying - but a single record can declare a lot of elements and some + // providers emit large blobs, so both the count and each value are capped. + private const int MaxEventFields = 30; + private const int MaxEventFieldLength = 512; + + // Pull the provider's structured payload out of the event XML into the + // existing `fields` dictionary, which the server already stores as + // payloadjson. Most providers use v; + // some use with an arbitrary provider-defined subtree, so both + // are walked. Unnamed elements keep their positional index as the + // key, because for several catalog IDs position IS the meaning. + // + // Never throws: a provider with malformed or unreadable XML degrades to no + // fields rather than losing the whole record. + private Dictionary? GetEventFields(EventRecord entry, string channel) + { + try + { + var xml = entry.ToXml(); + if (string.IsNullOrWhiteSpace(xml)) + return null; + + var document = System.Xml.Linq.XDocument.Parse(xml); + var root = document.Root; + if (root is null) + return null; + + var container = + root.Elements().FirstOrDefault(e => e.Name.LocalName == "EventData") + ?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "UserData"); + if (container is null) + return null; + + var fields = new Dictionary(); + int unnamed = 0; + foreach (var element in container.Descendants()) + { + if (fields.Count >= MaxEventFields) + break; + if (element.HasElements) + continue; + + var value = element.Value; + if (string.IsNullOrWhiteSpace(value)) + continue; + + var name = element.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + name = element.Name.LocalName == "Data" + ? "data" + unnamed++ + : element.Name.LocalName; + } + + fields[name!] = Truncate(value, MaxEventFieldLength) ?? string.Empty; + } + + return fields.Count > 0 ? fields : null; + } + catch (Exception error) + { + logger.LogDebug(error, "Event XML not parseable on {Channel}", channel); + return null; + } + } + + // An XPath the event log service evaluates itself, e.g. + // *[System[(EventID=4624 or EventID=4625)]] + private static string BuildIdFilter(IReadOnlySet wanted) + { + var clauses = string.Join(" or ", wanted.Select(id => "EventID=" + id)); + return "*[System[(" + clauses + ")]]"; + } + // Installed-software SBOM. Mirrors the Assess-Orchestrator applications // collection exactly: enumerate both Uninstall registry hives (64-bit and // WOW6432Node), keep only entries with a DisplayName, and emit @@ -566,6 +953,243 @@ public sealed class EndpointCollector : ICollector } } + // Whether Windows is waiting on a reboot. + // + // There is no single API for this, so we check the three signals Windows + // itself uses: the CBS servicing flag, the Windows Update auto-update flag, + // and a queued file-rename operation. Any one of them is sufficient. Checked + // by key EXISTENCE rather than value, which is how these flags work - the + // key is created when a reboot is needed and deleted once it happens. + // Link speed and duplex, keyed by MAC so the caller can pair them onto the + // matching GetInterfaces() entry. Deliberately a separate query: the + // IPEnabled config class above does not carry link state, and a NIC that + // negotiated 100Mb half-duplex on a gigabit switch port is a cable/port + // fault that otherwise presents as "the machine is slow sometimes". + private Dictionary GetLinkState() + { + var byMac = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + // MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It + // lives in the same Storage-style optional namespace family, so a + // failure here must not take the interface list with it. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\StandardCimv2", + "SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " + + "FROM MSFT_NetAdapter"); + foreach (var mo in searcher.Get()) + { + var mac = NormalizeMac(mo["PermanentAddress"]?.ToString()); + if (mac is null) + continue; + byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Net adapter link state query failed"); + } + + return byMac; + } + + // WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex + // so the two sources can be matched at all. + private static string? NormalizeMac(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit)); + return hex.Length == 12 ? hex : null; + } + + // Whether the last start was a true cold boot or a fast-startup resume. + // + // This matters because uptime LIES under fast startup: the box reports days + // of uptime after what the operator experienced as a shutdown, and a + // "reboot" that was really a hybrid resume does not clear the driver or + // kernel state that a support call is usually trying to clear. + // + // Two distinct facts, deliberately not collapsed: + // hiberbootenabled - fast startup is CONFIGURED (registry, always readable) + // lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27) + // The registry alone cannot tell you what the last boot did, and the event + // may have aged out of the log, so neither substitutes for the other. + private object? GetBootInfo() + { + bool? hiberboot = null; + try + { + using var power = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager\Power"); + if (power?.GetValue("HiberbootEnabled") is int raw) + hiberboot = raw != 0; + } + catch (Exception error) + { + logger.LogDebug(error, "Hiberboot registry read failed"); + } + + string? lastboottype = null; + try + { + // Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup), + // 2 resume from hibernate. + var query = new EventLogQuery( + "System", PathType.LogName, + "*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " + + "and (EventID=27)]]") + { ReverseDirection = true }; + using var reader = new EventLogReader(query); + using var record = reader.ReadEvent(); + if (record?.Properties.Count > 0) + { + lastboottype = ToInt(record.Properties[0].Value) switch + { + 0 => "Cold", + 1 => "Fast startup", + 2 => "Resume", + _ => null, + }; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Boot type event read failed"); + } + + if (hiberboot is null && lastboottype is null) + return null; + + return new + { + hiberbootenabled = hiberboot, + lastboottype = lastboottype ?? "Unknown", + }; + } + + // Defender status. Collected and persisted but NOT surfaced: this + // environment is known to produce false positives, so wiring it to a card + // or an alert now would train operators to ignore the card. Stored so the + // signal is already there when someone is ready to interpret it. + private object? GetDefender() + { + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Defender", + "SELECT AMServiceEnabled, RealTimeProtectionEnabled, " + + "AntivirusSignatureAge, AntispywareSignatureAge, " + + "QuickScanAge FROM MSFT_MpComputerStatus"); + foreach (var mo in searcher.Get()) + { + return new + { + serviceenabled = mo["AMServiceEnabled"] as bool?, + realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?, + // Ages in days. Null when Defender did not report rather + // than 0, which would read as "updated today". + signatureagedays = ToLong(mo["AntivirusSignatureAge"]), + antispywareagedays = ToLong(mo["AntispywareSignatureAge"]), + quickscanagedays = ToLong(mo["QuickScanAge"]), + }; + } + } + catch (Exception error) + { + // A third-party AV box or a stripped image has no Defender WMI + // provider at all. That is normal here, not a fault. + logger.LogDebug(error, "Defender status query failed"); + } + return null; + } + + // Which signal(s) drove pendingreboot=true, in report order. + // + // pendingreboot is a single bool OR-ed from three sources, so it cannot say + // WHY. That distinction is the point: PendingFileRenameOperations is the + // stronger signal - files are staged to be replaced on next boot, so the + // box is mid-servicing - whereas a lone CBS or Windows Update key can sit + // set for weeks on a machine nobody has rebooted. Same boolean, very + // different urgency. + // + // Empty array = checked, nothing pending. Null = the check failed, matching + // pendingreboot's own null-is-unknown rule. + private string[]? GetRebootReasons() + { + try + { + var reasons = new List(); + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")) + { + reasons.Add("componentbasedservicing"); + } + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired")) + { + reasons.Add("windowsupdate"); + } + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + reasons.Add("pendingfilerename"); + } + + return reasons.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Reboot reason check failed"); + return null; + } + } + + private bool? GetPendingReboot() + { + try + { + const string cbs = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"; + const string windowsUpdate = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"; + + if (LocalMachineKeyExists(cbs) || LocalMachineKeyExists(windowsUpdate)) + return true; + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + return true; + } + + return false; + } + catch (Exception error) + { + // Null, not false: an unreadable registry means we do not know, and + // reporting a confident "no reboot pending" would be worse than + // reporting nothing. + logger.LogDebug(error, "Pending reboot check failed"); + return null; + } + } + + private static bool LocalMachineKeyExists(string path) + { + using var key = Registry.LocalMachine.OpenSubKey(path); + return key is not null; + } + // Fixed local disks with capacity/free so the server can flag a PC that is // running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk; // network/removable drives are skipped. Bytes stay as longs; the server can @@ -591,6 +1215,14 @@ public sealed class EndpointCollector : ICollector freebytes = free, usedbytes = size is long total && free is long available ? total - available : (long?)null, + // Precomputed so an alert threshold is a direct comparison. + // A 40GB free reading means nothing without the capacity it + // came from, and the server would have to guard against a + // null or zero size on every evaluation to derive it. + freepercent = size is long capacity && capacity > 0 + && free is long remaining + ? Math.Round((double)remaining / capacity * 100.0, 1) + : (double?)null, }); } } @@ -601,6 +1233,129 @@ public sealed class EndpointCollector : ICollector return result.ToArray(); } + // Physical drive health, so a failing disk is caught before it takes the + // machine and whatever was local on it. + // + // Two sources, because neither covers the fleet alone: + // + // MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe. + // The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA + // only and returns NOTHING on an NVMe box - verified on a two-NVMe machine + // here, where it yielded zero instances while the drives were present and + // healthy. Collecting only that class would have reported an empty disk + // health list on every modern endpoint and looked like "no problems". + // + // Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and + // covers anything the Storage namespace does not, including machines where + // that namespace is missing or blocked. + // + // Keyed by DeviceId so the server can pair the two views per drive the same + // way mapped drives are paired. + private object[] GetDiskHealth() + { + var result = new List(); + + // MSFT_PhysicalDisk returns raw enum INTEGERS through + // ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk + // prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe. + // Mapping them here keeps the wire self-describing; shipping bare + // integers would make the server guess at an undocumented enum. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Storage", + "SELECT DeviceId, FriendlyName, MediaType, BusType, " + + "HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk"); + foreach (var mo in searcher.Get()) + { + var health = ToInt(mo["HealthStatus"]); + result.Add(new + { + deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "", + model = mo["FriendlyName"]?.ToString()?.Trim() ?? "", + mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])), + bustype = PhysicalDiskBusType(ToInt(mo["BusType"])), + healthstatus = PhysicalDiskHealth(health), + // The actionable bit, precomputed so an alert is a direct + // boolean test rather than a string comparison against an + // enum the server would have to keep in sync with this file. + // Null when health is unreadable: unknown is not healthy, + // but it is not a confident failure either. + predictfailure = health is null ? (bool?)null : health != 0, + source = "physicaldisk", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Physical disk health query failed"); + } + + // Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain + // string here, no enum mapping needed. + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT DeviceID, Model, Status FROM Win32_DiskDrive"); + foreach (var mo in searcher.Get()) + { + var status = mo["Status"]?.ToString()?.Trim() ?? ""; + result.Add(new + { + deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "", + model = mo["Model"]?.ToString()?.Trim() ?? "", + mediatype = "", + bustype = "", + healthstatus = status, + // "OK" is the only healthy value this class reports; + // "Pred Fail" is the SMART trip. An empty status means the + // drive did not answer, which is unknown rather than failed. + predictfailure = status.Length == 0 + ? (bool?)null + : !status.Equals("OK", StringComparison.OrdinalIgnoreCase), + source = "diskdrive", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Disk drive status query failed"); + } + + return result.ToArray(); + } + + // MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy. + private static string PhysicalDiskHealth(int? value) => value switch + { + 0 => "Healthy", + 1 => "Warning", + 2 => "Unhealthy", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM. + private static string PhysicalDiskMediaType(int? value) => value switch + { + 3 => "HDD", + 4 => "SSD", + 5 => "SCM", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk BusType. Only the values that actually appear on an + // endpoint are named; anything else stays numeric rather than guessed at. + private static string PhysicalDiskBusType(int? value) => value switch + { + 3 => "ATA", + 7 => "USB", + 8 => "RAID", + 9 => "iSCSI", + 11 => "SATA", + 17 => "NVMe", + _ => value is null ? "Unknown" : value.Value.ToString(), + }; + // Firmware identity from Win32_BIOS: vendor, version string, and release // date. Lets the server track BIOS/UEFI levels for a fleet update campaign. private object? GetBios() @@ -737,6 +1492,14 @@ public sealed class EndpointCollector : ICollector catch { return null; } } + private static int? ToInt(object? value) + { + if (value is null) + return null; + try { return Convert.ToInt32(value); } + catch { return null; } + } + // Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC // string; anything unparseable passes through untouched so nothing is lost. private string? ToBiosDate(string? raw) diff --git a/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs new file mode 100644 index 00000000..ba4cfdd4 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Remembers the newest Windows event record id already reported for each +// curated channel, so a collect cycle harvests ONLY records the server has not +// seen. +// +// Why this exists: the collector used to re-read the newest N records of every +// channel on every cycle, and the server ingest appends event rows without a +// dedupe check. A single crash therefore landed in the database once per +// cadence tick for as long as it stayed inside that window - up to 96 identical +// rows a day per event. Carrying a watermark makes the steady state "only what +// is new", which is also what makes the full event XML affordable to send. +// +// The watermark is persisted under %ProgramData% (not in memory) because a +// service restart would otherwise re-report the whole window again, which is +// exactly the crash-loop case where duplicate events are most likely. +// +// Record ids are per channel and monotonic on a given machine. A cleared or +// rebuilt event log restarts them at 1; that is handled by treating a stored +// watermark HIGHER than the newest available record as stale and resetting it, +// otherwise the channel would go permanently silent. +public sealed class EventWatermarkStore +{ + private const string StoreFileName = "eventwatermark.json"; + + // %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log + // lives under, so all agent-owned state sits in one place. + public static string DefaultStateDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "EndpointAgent"); + + private readonly string storePath; + private readonly ILogger logger; + private readonly Dictionary watermarks; + + public EventWatermarkStore(ILogger logger, + string? stateDirectory = null) + { + this.logger = logger; + var directory = string.IsNullOrWhiteSpace(stateDirectory) + ? DefaultStateDirectory + : stateDirectory; + storePath = Path.Combine(directory, StoreFileName); + watermarks = Load(); + } + + // The last record id reported for this channel, or 0 when the channel has + // never been harvested. 0 means "first contact": the caller sends a bounded + // tail for initial context rather than the entire backlog. + public long GetWatermark(string channel) + => watermarks.TryGetValue(channel, out var value) ? value : 0; + + public bool HasWatermark(string channel) + => watermarks.ContainsKey(channel); + + // Advance the channel forward only. Never moves backwards, so an + // out-of-order read cannot cause the same record to be reported twice. + public void Advance(string channel, long recordId) + { + if (recordId <= 0) + return; + if (watermarks.TryGetValue(channel, out var current) && current >= recordId) + return; + watermarks[channel] = recordId; + } + + // A cleared or rebuilt log restarts record ids near 1. When the newest + // record available is below the stored watermark the store is stale, so + // drop it and let the channel re-seed instead of going silent forever. + public bool ResetIfStale(string channel, long newestAvailableRecordId) + { + if (newestAvailableRecordId <= 0) + return false; + if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId) + return false; + + logger.LogInformation( + "Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding", + channel, current, newestAvailableRecordId); + watermarks.Remove(channel); + return true; + } + + // Best-effort persist. A failure here costs at most a repeated report of + // records already sent, so it must never abort a collection. + public void Save() + { + try + { + var directory = Path.GetDirectoryName(storePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + // Write-then-rename so a crash mid-write cannot leave a truncated + // file that would reseed every channel on the next start. + var temporaryPath = storePath + ".tmp"; + File.WriteAllText(temporaryPath, + JsonSerializer.Serialize(watermarks)); + File.Move(temporaryPath, storePath, overwrite: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath); + } + } + + private Dictionary Load() + { + try + { + if (!File.Exists(storePath)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var loaded = JsonSerializer.Deserialize>( + File.ReadAllText(storePath)); + return loaded is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); + } + catch (Exception error) + { + // A corrupt store reseeds every channel, which costs one bounded + // tail per channel - acceptable versus failing the collection. + logger.LogWarning(error, + "Event watermark store unreadable at {Path}; reseeding channels", storePath); + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs index fa374105..7ff794ed 100644 --- a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -14,32 +14,74 @@ public sealed class FileLoggerProvider : ILoggerProvider private readonly string directory; private readonly LogLevel minLevel; private readonly int retentionDays; + private readonly string filePrefix; private readonly object gate = new(); private readonly ConcurrentDictionary loggers = new(); private string currentDateStamp = ""; private StreamWriter? writer; + // Why the sink can be unusable, or null when it opened normally. The + // constructor cannot throw (see below), but a caller that has a second, + // known-writable place to complain to needs to be able to ask. + public string? SinkProblem { get; private set; } + public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, - int retentionDays = 14) + int retentionDays = 14, string filePrefix = "agent") { this.directory = directory; this.minLevel = minLevel; this.retentionDays = retentionDays; + this.filePrefix = filePrefix; // 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 // unusable. + // + // The reason is KEPT rather than discarded. A silently dead sink is what + // made the user-context probe undiagnosable: running as the operator it + // could not write into the SYSTEM-owned ProgramData log directory, so a + // probe that failed and a probe that never ran produced identical + // silence. Callers that have a writable fallback can now surface this. try { Directory.CreateDirectory(directory); } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + SinkProblem = $"{directory}: {error.Message}"; + } } // The default log directory when config leaves LogPath empty: // %ProgramData%\ShopDB\EndpointAgent\logs + // + // SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot + // write to it, so anything running in the user's session must use + // UserProfileLogDirectory() instead. public static string DefaultDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "ShopDB", "EndpointAgent", "logs"); + // The shop-floor operator account. Every shop-floor endpoint uses this same + // local account name, so the path is fixed rather than resolved through the + // interactive token: the token lookup is the part that fails on a hardened + // endpoint, and when it fails the writer and the reader silently disagree + // about where the spool lives. If a site ever uses a different account only + // user-context is lost - telemetry, commands and inventory are unaffected. + public const string ShopFloorUserName = "ShopFloor"; + + public static string ShopFloorProfileDirectory() + => Path.Combine(DrivesRoot, "Users", ShopFloorUserName); + + private static string DrivesRoot => + Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + ?? @"C:\"; + + // Where the TRAY and the user-context probe log: + // C:\Users\ShopFloor\ShopDB\Logs + // The operator owns this directory, so it is writable from the user session + // where ProgramData is not. + public static string UserProfileLogDirectory() + => Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs"); + public ILogger CreateLogger(string categoryName) => loggers.GetOrAdd(categoryName, name => new FileLogger(this, name)); @@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider writer?.Flush(); writer?.Dispose(); currentDateStamp = stamp; - var path = Path.Combine(directory, $"agent-{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)) { @@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider try { var cutoff = DateTime.Now.AddDays(-retentionDays); - foreach (var file in Directory.EnumerateFiles(directory, "agent-*.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) File.Delete(file); diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs index 326bdc07..b9c45755 100644 --- a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Runtime.InteropServices; +using System.Text; namespace ShopDbEndpointAgent.Core; @@ -18,7 +19,11 @@ namespace ShopDbEndpointAgent.Core; // Only process_start uses this. Stop/status/service actions stay as SYSTEM. public static class InteractiveProcessLauncher { - public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null); + // TimedOut=true means the process launched fine but was still running when + // the caller's wait budget expired. It is deliberately NOT killed, so the + // work still completes; the caller just stops blocking on it. + public readonly record struct LaunchOutcome( + bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false); private const int TokenDuplicate = 0x0002; private const int TokenQuery = 0x0008; @@ -34,6 +39,10 @@ public static class InteractiveProcessLauncher private const uint Infinite = 0xFFFFFFFF; + // WaitForSingleObject returns this when the budget expires with the handle + // still unsignalled. + private const uint WaitTimeout = 0x00000102; + // Returns Launched=true when CreateProcessAsUser succeeds. Never throws; // failures come back as Launched=false with a descriptive Error so the // caller can surface a clean CommandResult. @@ -51,9 +60,17 @@ public static class InteractiveProcessLauncher // needs the actual outcome (run_script in the user session) can report // completed/failed instead of fire-and-forget "launched". GUI callers // (process_start) leave it false and return as soon as the window is up. + // + // waitTimeout bounds that wait. This matters because the caller may be the + // telemetry loop, and telemetry IS the heartbeat: a script that hangs in the + // user session used to block here forever (the wait was hardcoded to + // Infinite), which would stall reporting and make a perfectly healthy PC + // read as offline in the WebUI. On expiry the child is left running and the + // outcome comes back with TimedOut=true. Null means wait forever and should + // only be used by a caller that is not on a timed path. public static LaunchOutcome LaunchInActiveSession( string applicationPath, string? arguments, string? workingDirectory, - bool waitForExit = false) + bool waitForExit = false, TimeSpan? waitTimeout = null) { var sessionId = WTSGetActiveConsoleSessionId(); if (sessionId == InvalidSessionId) @@ -129,11 +146,25 @@ public static class InteractiveProcessLauncher } int? exitCode = null; + bool timedOut = false; if (waitForExit && processInformation.hProcess != IntPtr.Zero) { - WaitForSingleObject(processInformation.hProcess, Infinite); - if (GetExitCodeProcess(processInformation.hProcess, out var code)) + uint milliseconds = waitTimeout is TimeSpan budget + ? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1) + : Infinite; + + if (WaitForSingleObject(processInformation.hProcess, milliseconds) + == WaitTimeout) + { + // Still running. Leave it alone - killing it would abandon + // half-done work, and a file-spooling probe will still write + // its result for the next cycle to pick up. + timedOut = true; + } + else if (GetExitCodeProcess(processInformation.hProcess, out var code)) + { exitCode = unchecked((int)code); + } } if (processInformation.hProcess != IntPtr.Zero) @@ -141,7 +172,7 @@ public static class InteractiveProcessLauncher if (processInformation.hThread != IntPtr.Zero) CloseHandle(processInformation.hThread); - return new LaunchOutcome(true, null, exitCode); + return new LaunchOutcome(true, null, exitCode, timedOut); } finally { @@ -154,6 +185,57 @@ public static class InteractiveProcessLauncher } } + // Profile directory of the active console user, for example + // C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding + // %USERPROFILE% here would return SYSTEM's own profile under + // config\systemprofile rather than the operator's. The interactive user's + // token is the only reliable source, and this class already knows how to + // obtain it. Returns null with a reason when nobody is signed in. + public static string? TryGetActiveUserProfileDirectory(out string? error) + { + var sessionId = WTSGetActiveConsoleSessionId(); + if (sessionId == InvalidSessionId) + { + error = "No active console session (no interactive user is signed in)."; + return null; + } + + if (!WTSQueryUserToken(sessionId, out var userToken)) + { + error = "Could not obtain the interactive user token: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + try + { + uint size = 0; + GetUserProfileDirectory(userToken, null, ref size); + if (size == 0) + { + error = "Could not size the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + var buffer = new StringBuilder((int)size); + if (!GetUserProfileDirectory(userToken, buffer, ref size)) + { + error = "Could not resolve the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + error = null; + return buffer.ToString(); + } + finally + { + if (userToken != IntPtr.Zero) + CloseHandle(userToken); + } + } + private enum SecurityImpersonationLevel { SecurityAnonymous, @@ -222,6 +304,10 @@ public static class InteractiveProcessLauncher [DllImport("userenv.dll", SetLastError = true)] private static extern bool DestroyEnvironmentBlock(IntPtr environment); + [DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool GetUserProfileDirectory( + IntPtr token, StringBuilder? profileDirectory, ref uint size); + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CreateProcessAsUser( IntPtr token, diff --git a/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs new file mode 100644 index 00000000..0a328267 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs @@ -0,0 +1,159 @@ +namespace ShopDbEndpointAgent.Core; + +// Guards which registered scheduled tasks the scheduledtask_run action may +// start. The wire carries a free-text task path (so the WebUI can expose it as +// one field, and a site can widen the set without an agent rebuild); this +// policy is the safeguard that makes that free text safe to honor. +// +// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an +// INDIRECTION: the task path names a definition, and that definition chooses +// what actually runs. So "start the task named X" is only as safe as the set of +// X the agent will accept - an unconstrained task name would be a general- +// purpose "run anything already registered on this box, as whatever context it +// asked for" primitive. That is a materially bigger grant than any other +// allowlisted action, and it would be invisible in the audit trail (the command +// log would read "scheduledtask_run \Foo" no matter what \Foo does). +// +// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or +// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the +// threat it actually addresses, and it is the realistic one. It does NOT stop an +// attacker who can already register or rewrite task definitions on the endpoint: +// such an attacker can define a task under an approved prefix and have the agent +// start it. That is accepted, because anyone who can write task definitions on a +// box already has the privilege the task would grant them. Local task definition +// ACLs, not this policy, are the control for that. +// +// The approved-prefix list ships with the Corporate Tools set and is overridable +// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy +// pattern so the future Site Agent Configuration tool can narrow or widen it +// without a rebuild. +public sealed class ScheduledTaskPolicy +{ + // Shipped defaults: the Corporate Tools task set. Prefix entries end with a + // backslash and match a whole folder; entries without one match that exact + // task path only. Kept deliberately tight - widen per site, not here. + // + // The ShopDB-* entries are tasks ShopDB itself owns. They are separate task + // definitions from any corporate-initiated equivalent ON PURPOSE: a + // ShopDB-initiated Dell Command scan must write its results somewhere the + // agent's collector can attribute to ShopDB, rather than into whatever + // location a corporate-managed task already writes. Keeping the definitions + // distinct is what makes "who asked for this scan" answerable at all - see + // the note in commands.py about the DCU output path convention. + public static IReadOnlyList DefaultApprovedTaskPaths { get; } = new[] + { + @"\ReportIP", + @"\SFLD-ApplyDSCConfig", + @"\SFLD - Consume Credentials", + @"\ShopDB-DellCommandScan", + @"\ShopDB-DellCommandUpdate", + }; + + private readonly IReadOnlyList approvedPaths; + + public ScheduledTaskPolicy(IEnumerable? approvedPathsOverride = null) + { + var paths = (approvedPathsOverride ?? Enumerable.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeTaskPath) + .ToList(); + approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths; + } + + // Validates a candidate task path. On success taskPath is the normalized + // path to hand to Task Scheduler; on failure reason explains the rejection + // so the command result tells the operator WHY rather than just "denied". + public bool TryValidate(string? candidate, out string taskPath, out string reason) + { + taskPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty task path"; + return false; + } + + var normalized = NormalizeTaskPath(candidate); + + // Traversal in a task path is meaningless to Task Scheduler but very + // meaningful to a prefix check: "\Approved\..\Evil" starts with an + // approved prefix as a STRING while naming something else entirely. + // Reject rather than try to canonicalize it away. + if (normalized.Contains("..")) + { + reason = "task path contains a traversal segment"; + return false; + } + + // Control characters and quotes have no place in a task path and are the + // shape of an attempt to break out of the argument. The invocation uses + // ArgumentList (no shell, no string concatenation) so this is defense in + // depth rather than the primary control, but a task path that needs + // these is not a task path we should be starting. + foreach (var ch in normalized) + { + if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|') + { + reason = "task path contains an illegal character"; + return false; + } + } + + if (!IsApproved(normalized)) + { + reason = "task path is not in the approved task list"; + return false; + } + + taskPath = normalized; + return true; + } + + // Task Scheduler paths are rooted and backslash-separated. Accept the + // operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the + // rooted form, so an allowlist entry does not hinge on how it was typed. + private static string NormalizeTaskPath(string path) + { + var trimmed = (path ?? "").Trim().Replace('/', '\\'); + if (trimmed.Length == 0) + return ""; + if (!trimmed.StartsWith('\\')) + trimmed = "\\" + trimmed; + // Collapse a trailing separator so "\Folder\" and "\Folder" compare + // equal as exact entries; prefix semantics are handled in IsApproved. + while (trimmed.Length > 1 && trimmed.EndsWith('\\')) + trimmed = trimmed[..^1]; + return trimmed; + } + + // An entry ending in a separator is a FOLDER prefix and matches anything + // beneath it; any other entry must match the whole task path exactly. The + // exact-match case is why "\Approved" does not silently authorize + // "\ApprovedEvil". + private bool IsApproved(string candidate) + { + foreach (var entry in approvedPaths) + { + if (entry.Length == 0) + continue; + + var isFolderPrefix = entry.EndsWith('\\'); + if (isFolderPrefix) + { + if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase)) + return true; + continue; + } + + if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase)) + return true; + + // Allow the folder form without its trailing separator to still act + // as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil". + if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj index 013080b0..ee711845 100644 --- a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -17,6 +17,10 @@ + + diff --git a/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs new file mode 100644 index 00000000..26af6257 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// TCP connection state with owning process ids, via GetExtendedTcpTable. +// +// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text +// output means spawning a process on every telemetry tick and depending on the +// column layout of a localized command. GetExtendedTcpTable is the API netstat +// itself calls, returns process ids directly, and costs a single allocation. +// +// Listeners are the cheap, bounded half of this (a handful of rows, stable over +// time) and ride the normal cadence. The full connection table is unbounded and +// churns constantly, so it stays on-demand. +public static class TcpTableReader +{ + private const int AF_INET = 2; + private const uint ERROR_INSUFFICIENT_BUFFER = 122; + private const int TCP_TABLE_OWNER_PID_ALL = 5; + + public enum TcpState + { + Closed = 1, + Listen = 2, + SynSent = 3, + SynReceived = 4, + Established = 5, + FinWait1 = 6, + FinWait2 = 7, + CloseWait = 8, + Closing = 9, + LastAck = 10, + TimeWait = 11, + DeleteTcb = 12, + } + + public readonly record struct TcpRow( + string LocalAddress, + int LocalPort, + string RemoteAddress, + int RemotePort, + TcpState State, + int ProcessId); + + // Full IPv4 TCP table. Throws only on catastrophic interop failure; callers + // treat an empty list as "not observed". + public static List GetTcpTable() + { + var rows = new List(); + var buffer = IntPtr.Zero; + var size = 0; + + try + { + // First call sizes the buffer. It is expected to fail with + // INSUFFICIENT_BUFFER; anything else is a real error. + var result = GetExtendedTcpTable( + IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != ERROR_INSUFFICIENT_BUFFER && result != 0) + return rows; + + buffer = Marshal.AllocHGlobal(size); + result = GetExtendedTcpTable( + buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != 0) + return rows; + + var entryCount = Marshal.ReadInt32(buffer); + var rowSize = Marshal.SizeOf(); + var cursor = IntPtr.Add(buffer, 4); + + for (var index = 0; index < entryCount; index++) + { + var row = Marshal.PtrToStructure(cursor); + rows.Add(new TcpRow( + FormatAddress(row.localAddress), + FormatPort(row.localPort), + FormatAddress(row.remoteAddress), + FormatPort(row.remotePort), + (TcpState)row.state, + (int)row.owningProcessId)); + cursor = IntPtr.Add(cursor, rowSize); + } + } + finally + { + if (buffer != IntPtr.Zero) + Marshal.FreeHGlobal(buffer); + } + + return rows; + } + + public static List GetListeners() + => GetTcpTable().FindAll(row => row.State == TcpState.Listen); + + private static string FormatAddress(uint address) + => new IPAddress(BitConverter.GetBytes(address)).ToString(); + + // The port arrives as a big-endian value packed into the low two bytes of a + // host-order uint, so the bytes must be swapped rather than simply cast. + private static int FormatPort(uint port) + { + var bytes = BitConverter.GetBytes(port); + return (bytes[0] << 8) | bytes[1]; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPROW_OWNER_PID + { + public uint state; + public uint localAddress; + public uint localPort; + public uint remoteAddress; + public uint remotePort; + public uint owningProcessId; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable( + IntPtr tcpTable, + ref int size, + bool order, + int addressFamily, + int tableClass, + int reserved); +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 2efa847d..97b94bb4 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -15,6 +15,11 @@ namespace ShopDbEndpointAgent.Core; // cadence tick rather than hammering the server. public sealed class TelemetryWorker : BackgroundService { + // The one agent utility whose output is forwarded to the server today. The + // name is the .ps1 filename without its extension, which is how + // PowerShellUtilityRunner keys its results. + public const string DellUpdatesUtilityName = "shopdb-dellupdates"; + private readonly ICollector collector; private readonly CollectorClient client; private readonly IAgentUtilityRunner? utilities; @@ -22,11 +27,31 @@ public sealed class TelemetryWorker : BackgroundService private readonly string agentVersion; private readonly ILogger logger; private readonly CollectSignal? collectSignal; + private readonly UserContextSpool? userContextSpool; + private readonly UserContextRefreshChannel? userContextRefresh; private readonly Random jitter = new(); + // How long to wait for the tray to answer a refresh request. + // + // Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a + // report that arrives with slightly stale mapped drives is a working + // endpoint, while a report that never arrives because we blocked on an + // absent tray is an endpoint that looks dead. The probe itself is a single + // short PowerShell run, so a tray that is alive answers well inside this; + // anything longer means nobody is home. + private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12); + + // Poll interval while waiting for the stamp to advance. There is no + // completion signal coming back the other way - the tray's answer IS the + // rewritten spool file - so this is a poll by design, kept coarse enough to + // be free and fine enough not to add visible latency. + private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400); + public TelemetryWorker(ICollector collector, CollectorClient client, AgentConfig config, string agentVersion, ILogger logger, - IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null) + IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null, + UserContextSpool? userContextSpool = null, + UserContextRefreshChannel? userContextRefresh = null) { this.collector = collector; this.client = client; @@ -35,6 +60,77 @@ public sealed class TelemetryWorker : BackgroundService this.logger = logger; this.utilities = utilities; this.collectSignal = collectSignal; + this.userContextSpool = userContextSpool; + this.userContextRefresh = userContextRefresh; + } + + // Request a tray-side re-probe and wait, briefly, for the spool to be + // rewritten. Never throws and never fails the run. + // + // The service does NOT collect session data itself and must not start doing + // so: mapped drives belong to the operator's logon token, and a LocalSystem + // probe reads a different, near-empty drive table. See the tombstone in + // Program.cs. This method is the entire service-side involvement - ask, then + // wait for the answer to appear on disk. + private async Task RefreshUserContextAsync(CancellationToken token) + { + if (userContextSpool is null || userContextRefresh is null + || !OperatingSystem.IsWindows()) + { + return; + } + + var resultPath = userContextSpool.TryGetResultPath(out _); + if (resultPath is null) + return; + + // Captured BEFORE the request so "advanced" is measured against what we + // would otherwise have reported. A missing spool reads as null, and any + // stamp at all then counts as progress - that is the first-probe case. + var before = userContextSpool.TryReadCollectedAt(resultPath); + + if (!userContextRefresh.RequestRefresh()) + { + // Could not signal at all. Note that the inverse does NOT mean a tray + // is listening: the channel opens-or-creates and does not hold the + // handle, so signalling into an empty machine succeeds by creating + // the event and finding nobody waiting on it. Detecting an absent + // tray is the timeout's job below, not this return value's. + logger.LogDebug( + "Could not request a user-context refresh; reporting the existing spool."); + return; + } + + var deadline = DateTime.UtcNow + UserContextRefreshTimeout; + while (DateTime.UtcNow < deadline) + { + try + { + await Task.Delay(UserContextPollInterval, token); + } + catch (OperationCanceledException) + { + return; + } + + var after = userContextSpool.TryReadCollectedAt(resultPath); + if (after is not null && (before is null || after > before)) + { + logger.LogDebug( + "User-context spool refreshed by the tray (collectedat={Stamp}).", + after); + return; + } + } + + // Timed out. Proceed anyway - deliberately. Session data is one card on + // one page; telemetry carries heartbeat, inventory, events and command + // results, and holding all of that hostage to a tray that did not answer + // in twelve seconds trades a small inaccuracy for a total outage. + logger.LogDebug( + "The tray did not refresh the user-context spool within {Timeout}; " + + "reporting with the existing session data.", + UserContextRefreshTimeout); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -111,6 +207,15 @@ public sealed class TelemetryWorker : BackgroundService config.Monitors.Processes.Length, config.Monitors.Services.Length); } + // Ask the tray to re-probe session data and WAIT for it, before + // collecting. Ordering is the whole point: the collector reads the spool + // file, so a refresh that lands after Collect() is a refresh the server + // does not see until the NEXT cycle. That was the bug - "Send Status" + // and "Refresh Status" both re-sent a spool nobody had rewritten, so a + // drive the operator had just unmapped kept reporting OK for up to half + // an hour and no amount of clicking changed it. + await RefreshUserContextAsync(token); + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) @@ -118,6 +223,17 @@ public sealed class TelemetryWorker : BackgroundService var extra = utilities.RunAll(token); if (extra.Count > 0) logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count); + + // NARROW ON PURPOSE. The runner executes every .ps1 in the utilities + // directory, but only this one known key is attached to the report. + // Forwarding the whole dictionary would mean any script dropped into + // that directory starts uploading its output to the server on every + // cadence - see the signing caveat in PowerShellUtilityRunner's + // header. Widening this to the full map is the intended end state of + // the IAgentUtilityRunner seam, but it needs script signing or an + // allowlist first. Until then, add a key here per utility. + if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates)) + report.DellUpdates = dellUpdates; } logger.LogInformation( diff --git a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs new file mode 100644 index 00000000..04a42006 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs @@ -0,0 +1,362 @@ +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Collects the session-scoped data that does not exist in the agent's own +// context, by running a short PowerShell probe IN THE CALLING PROCESS'S +// SESSION and writing the result to the user's spool file. +// +// Collects only data that genuinely does not exist in the agent's own context: +// netuse - mapped drives are per logon session; "net use" from +// LocalSystem returns SYSTEM's (empty) mapping table, never the +// operator's Z: to the file server. +// userlisteners- TCP listeners owned by the signed-in user's processes, which +// is what identifies an operator-launched DNC or CMM listener. +// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer +// it. Sampled at probe time only, so treat it as a coarse +// "was anyone at the machine" signal, not live presence. +// +// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and +// only the tray - invokes, because the tray is already running as the signed-in +// operator. The service does NOT call this: it previously tried to descend into +// the session with CreateProcessAsUser, which is the direction that has to +// duplicate a token, is what endpoint protection watches for, and produced +// nothing on a live endpoint. Nothing here crosses a privilege boundary now; +// the probe simply runs as whoever the tray is. +// +// The script is embedded rather than shipped as a .ps1 so the probe cannot drift +// from the agent binary or be tampered with on disk between cycles. +public sealed class UserContextProbe +{ + // 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]@{} + # 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" + + try { + New-Item -ItemType Directory -Path $spool -Force | Out-Null + + $netuse = @() + try { + Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop | + ForEach-Object { + $netuse += [ordered]@{ + localname = $_.LocalName + remotename = $_.RemoteName + status = $_.Status + username = $_.UserName + persistent = [bool]$_.Persistent + } + } + } catch { + $result['netuseerror'] = $_.Exception.Message + } + $result['netuse'] = $netuse + + # Second, independent view of the same drives. Win32_NetworkConnection + # is the "net use" table and Get-SmbMapping is the SMB client's own, + # and they disagree: a share can be Unavailable in one and absent or + # OK in the other. Neither is authoritative, so both are reported as + # collected and the server decides what to show. Normalising them + # here would discard exactly the disagreement that is diagnostic. + $smbmapping = @() + try { + Get-SmbMapping -ErrorAction Stop | + ForEach-Object { + $smbmapping += [ordered]@{ + localpath = $_.LocalPath + remotepath = $_.RemotePath + status = [string]$_.Status + } + } + } catch { + # Absent on older builds and in some session types. A missing + # source is not a drive fault, so it is recorded and left out + # rather than reported as a failed mapping. + $result['smbmappingerror'] = $_.Exception.Message + } + $result['smbmapping'] = $smbmapping + + $listeners = @() + try { + $ownprocesses = @{} + Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + $ownprocesses[$_.Id] = $_.ProcessName + } + Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } | + ForEach-Object { + $listeners += [ordered]@{ + localaddress = $_.LocalAddress + localport = $_.LocalPort + processid = $_.OwningProcess + processname = $ownprocesses[$_.OwningProcess] + } + } + } catch { + $result['userlistenerserror'] = $_.Exception.Message + } + $result['userlisteners'] = $listeners + + # Idle time must be measured inside the user's session: + # GetLastInputInfo is per session, so asking from the agent's + # session 0 would report the service's own idle time, which is + # meaningless. This is what distinguishes "powered on" from + # "someone is actually working at this machine". + try { + # Built as a single-quoted string rather than a here-string: a + # here-string terminator must sit at column 0, which cannot be + # expressed inside an indented C# raw string literal. + $signature = 'using System; using System.Runtime.InteropServices; ' + + 'public class ShopDbIdle { ' + + '[StructLayout(LayoutKind.Sequential)] ' + + 'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' + + '[DllImport("user32.dll")] ' + + 'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' + + 'public static long IdleSeconds() { ' + + 'LASTINPUTINFO info = new LASTINPUTINFO(); ' + + 'info.cbSize = (uint)Marshal.SizeOf(info); ' + + 'if (!GetLastInputInfo(ref info)) return -1; ' + + 'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }' + Add-Type -TypeDefinition $signature -ErrorAction Stop + $idle = [ShopDbIdle]::IdleSeconds() + if ($idle -ge 0) { $result['idleseconds'] = $idle } + } catch { + $result['idlesecondserror'] = $_.Exception.Message + } + + $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 | + Set-Content -Path $temporary -Encoding UTF8 -NoNewline + Move-Item -Path $temporary -Destination $target -Force + } catch { + exit 1 + } + 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); + + // powershell.exe's exit code when it rejects its command line and prints + // usage instead of running anything. Named so the failure is recognisable. + private const int PowerShellStartupFailure = -196608; + + private readonly ILogger logger; + + public UserContextProbe(ILogger logger) + { + this.logger = logger; + } + + // Run the probe in THIS process's session and write the spool file. + // 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 + // being readable, at the moment powershell.exe started. On a hardened + // endpoint that window is not safe: endpoint protection quarantines a + // freshly written script in the temp directory between the write and + // the launch, and roaming/redirected profiles can make the path + // unusable outright. + // + // When powershell.exe cannot use its -File argument it prints its usage + // banner and exits -196608 (0xFFFD0000) WITHOUT running a single + // statement. That is a startup failure, not a script failure, so the + // script's own try/catch never runs, nothing is logged about drives, + // and the operator sees a silent "no mapped drives reported" while the + // log only shows the bare exit code. -EncodedCommand carries the script + // 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(BuildProbeScript(spoolDirectory))); + + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = + "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand " + + encoded, + WorkingDirectory = Path.GetTempPath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process is null) + { + logger.LogWarning("The user-context probe could not be started."); + return false; + } + + if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds)) + { + logger.LogWarning( + "The user-context probe did not finish within {Seconds} seconds.", + ProbeTimeout.TotalSeconds); + TryKill(process); + return false; + } + + if (process.ExitCode != 0) + { + // 0xFFFD0000 is powershell.exe refusing its own command line and + // printing usage: the probe never ran. Called out by name because + // as a bare number it reads like a script error and sends the + // reader looking for a WMI or drive fault that is not there. + var detail = process.ExitCode == PowerShellStartupFailure + ? " (powershell.exe rejected its command line; the probe did not run)" + : string.Empty; + logger.LogWarning( + "The user-context probe exited with code {ExitCode}.{Detail}", + process.ExitCode, detail); + 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) + { + logger.LogWarning(exception, "The user-context probe failed."); + return false; + } + } + + // 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 + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Could not stop the timed-out user-context probe."); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs new file mode 100644 index 00000000..af07004d --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs @@ -0,0 +1,179 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; + +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// The service-to-tray request channel for an on-demand user-context refresh. +// +// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon +// token, not of the machine. The agent is a session-0 LocalSystem service, so it +// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not +// by starting the probe task itself. That approach was built once and reverted; +// see the tombstone in Program.cs where UserContextProbe is deliberately kept +// out of the service's DI container. The only process on the box that can see +// the right drive table is one already running inside the operator's session: +// the tray. +// +// So the service does not probe. It ASKS. This channel is the ask, and the +// spool file remains the answer. +// +// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data +// - it is a single bit, "please re-probe now" - and the reply already has a +// transport (the spool file, whose collectedat stamp the service watches +// advance). A kernel event is the smallest thing that crosses the session-0 +// boundary, needs no serialization, no port, no firewall rule, and no cleanup if +// either side dies. A file-watcher trigger would also work but leaves litter on +// disk when the service is killed mid-request. +// +// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the +// caller's session, so the service (session 0) and the tray (session 1+) would +// create two unrelated events with the same name and never see each other. That +// failure is silent: both sides "work", nothing is ever refreshed. +// +// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem, +// which by default produces an object a non-elevated user cannot even open. The +// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by +// the probe, because it would read the wrong drive table). So the creator grants +// Users explicit Synchronize + Modify rights, or the tray gets access-denied on +// open and the refresh silently never happens. +public sealed class UserContextRefreshChannel +{ + // Session-global so session 0 and the interactive session share one object. + public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh"; + + private readonly ILogger logger; + + // Takes the base ILogger rather than ILogger: both the service (generic + // host) and the tray (its own file-backed logger named "Tray") construct + // this, and the tray has no DI container to resolve a typed logger from. + public UserContextRefreshChannel(ILogger logger) + { + this.logger = logger; + } + + // Signal the tray that a fresh probe is wanted. + // + // Returns false when the event could not be opened or created, which is the + // normal state on a machine with nobody signed in and no tray running. The + // caller treats that as "no refresh available" and reports what it already + // has - a stale mapped-drive list is worth strictly more than a missed + // heartbeat, because telemetry IS the liveness signal. + // + // AutoReset, so exactly one waiting tray is released per request and the + // event does not latch on for every subsequent listener iteration. + [SupportedOSPlatform("windows")] + public bool RequestRefresh() + { + try + { + using var handle = OpenOrCreate(out var created); + if (handle is null) + return false; + + handle.Set(); + logger.LogDebug( + "Requested a user-context refresh on {Event} (created={Created})", + EventName, created); + return true; + } + catch (Exception error) + { + // Never throws to the caller. This sits directly on the telemetry + // path, and a refresh is an enhancement to the report, not a + // precondition for sending it. + logger.LogDebug(error, + "Could not request a user-context refresh on {Event}", EventName); + return false; + } + } + + // Tray side: block until the service asks for a refresh, the timeout expires, + // or the token is cancelled. Returns true only for an actual request. + [SupportedOSPlatform("windows")] + public bool WaitForRequest(TimeSpan timeout, CancellationToken token) + { + try + { + using var handle = OpenOrCreate(out _); + if (handle is null) + return false; + + var index = WaitHandle.WaitAny( + new[] { handle, token.WaitHandle }, timeout); + + // Index 1 is cancellation, WaitTimeout is the idle path; only index 0 + // is a genuine request. + return index == 0; + } + catch (Exception error) + { + logger.LogDebug(error, + "Could not wait on the user-context refresh channel {Event}", EventName); + return false; + } + } + + // Open the shared event, creating it if this process is first. + // + // Either side may start first - the service can boot before anyone signs in, + // and the tray can start before the service finishes initializing - so both + // sides use the same create-or-open call rather than designating an owner. + // Whoever wins applies the DACL; the loser opens the existing object. + [SupportedOSPlatform("windows")] + private EventWaitHandle? OpenOrCreate(out bool created) + { + created = false; + try + { + var security = new EventWaitHandleSecurity(); + + // BUILTIN\Users by SID, not by name: on a non-English Windows the + // group is not called "Users", and this agent ships to whatever + // locale the site runs. Same reasoning as the task registration. + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + users, + EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify, + AccessControlType.Allow)); + + // FullControl for SYSTEM and Administrators so the service retains + // ownership semantics regardless of which side created the object. + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + system, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + var admins = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + admins, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + return EventWaitHandleAcl.Create( + initialState: false, + mode: EventResetMode.AutoReset, + name: EventName, + createdNew: out created, + eventSecurity: security); + } + catch (UnauthorizedAccessException) + { + // The object exists with a DACL that does not admit this caller. + // Retry read-side-only rights: the tray only ever needs to wait, and + // an event created by an older agent build may not grant Modify. + try + { + return EventWaitHandle.OpenExisting(EventName); + } + catch (Exception error) + { + logger.LogDebug(error, + "The user-context refresh event {Event} exists but is not accessible", + EventName); + return null; + } + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs new file mode 100644 index 00000000..8bb5e8a3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -0,0 +1,389 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Reads the result file that the tray-run user-context probe writes into the +// signed-in user's ShopDB spool folder. +// +// Why a file and not a return value: the agent is a session-0 LocalSystem +// service, so anything session-scoped (mapped drives from "net use", per-user +// network state) simply does not exist in its own context. The probe runs as the +// operator under the tray, in a completely separate process with no pipe back to +// the service. A file in the user's own profile is the handoff, and the user can +// always write there without elevation. +// +// Freshness is judged from the 'collectedat' stamp inside the file, NOT by +// deleting the file around each read. Writer and reader are now independent +// processes on unrelated schedules, so a delete-then-read would race the tray +// and usually destroy a perfectly good result before it was ever reported. The +// file is therefore left alone and simply ignored once it ages out, which also +// means a logged-off machine stops reporting session data instead of pinning the +// last operator's mapped drives forever. +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 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.FromMinutes(90); + + private readonly ILogger logger; + + public UserContextSpool(ILogger logger) + { + this.logger = logger; + } + + // Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because + // every shop-floor endpoint signs in as the same local ShopFloor account. + // + // This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory, + // and that lookup is precisely what fails on a hardened endpoint: querying + // another session's token is a privileged operation that endpoint protection + // blocks. When it failed the service simply reported "no session data" while + // the tray was writing a perfectly good file, so writer and reader silently + // disagreed about the location and the operator saw an empty card. + // + // The token lookup is kept ONLY as a fallback for a machine whose operator + // account is named something else. If both are unavailable there is no + // session data, which costs mapped drives and idle time but never affects + // telemetry, commands or inventory. + public string? TryGetResultPath(out string? reason) + { + var pinned = FileLoggerProvider.ShopFloorProfileDirectory(); + if (Directory.Exists(pinned)) + { + reason = null; + return PinnedResultPath(); + } + + var profileDirectory = + InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error); + if (string.IsNullOrWhiteSpace(profileDirectory)) + { + reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, " + + "and the interactive profile could not be resolved either: " + + (error ?? "unknown error"); + return null; + } + + reason = null; + return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName); + } + + // Remove a result file. Only used to discard one that is corrupt or has aged + // out; a valid result is left in place for the next read. + public void ClearStaleResult(string resultPath) + { + try + { + if (File.Exists(resultPath)) + { + File.Delete(resultPath); + logger.LogDebug( + "Removed a stale user-context spool file at {Path}", resultPath); + } + } + catch (Exception exception) + { + // Not fatal on its own, but it does mean we cannot trust whatever we + // read next, so the caller must know. + logger.LogWarning(exception, + "Could not remove the stale user-context spool file at {Path}", resultPath); + } + } + + public void EnsureSpoolDirectory(string resultPath) + { + var directory = Path.GetDirectoryName(resultPath); + if (string.IsNullOrWhiteSpace(directory)) + return; + + try + { + Directory.CreateDirectory(directory); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not create the user-context spool directory at {Directory}", directory); + } + } + + // Read the probe result without consuming it. Returns null when there is no + // usable, sufficiently recent result, which the caller reports as "no session + // data this cycle" rather than as empty data - absent and empty mean + // different things here. + public Dictionary? TryReadResult(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0) + { + logger.LogDebug("The user-context spool file was empty."); + return null; + } + + if (info.Length > MaxResultBytes) + { + logger.LogWarning( + "The user-context spool file was {Bytes} bytes, above the {Limit} byte " + + "limit; discarding it.", + info.Length, MaxResultBytes); + return null; + } + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null || parsed.Count == 0) + return null; + + if (IsExpired(parsed, resultPath)) + return null; + + if (IsWrongAccount(parsed)) + return null; + + if (IsElevatedCollection(parsed)) + return null; + + return parsed; + } + catch (JsonException exception) + { + // A truncated file is the expected shape of this failure: the probe + // was still writing when we looked. Next cycle gets a clean one. + logger.LogWarning(exception, + "The user-context spool file was not valid JSON; discarding it."); + return null; + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not read the user-context spool file at {Path}", resultPath); + return null; + } + } + + // 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. + // + // 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); + + // The collectedat stamp on disk, or null when there is no readable stamp. + // + // This is how the service PROVES a refresh actually happened rather than + // assuming it. Signalling the tray is asynchronous and the probe takes a + // second or two, so a caller that simply slept would either send the old + // spool anyway or pad the delay and hope. Watching this value advance past + // the pre-request stamp is the only honest way to know the file in front of + // us is the answer to OUR request and not the previous one. + // + // Deliberately NOT routed through TryReadResult: this must report the stamp + // of whatever is on disk, including a spool that is expired, elevated, or + // from the wrong account. Those are all rejected later for reporting, but + // for ordering we only care whether the file was rewritten - conflating "not + // usable" with "not fresh" would make the service wait out the full timeout + // on every cycle for a machine whose spool is being rejected for some other + // reason. + // + // Also deliberately NOT the file's LastWriteTime, for the reason IsExpired + // gives: a spool can be copied or restored with a roaming profile and carry + // a misleading filesystem timestamp. The stamp inside the file is written by + // the probe itself. + public DateTimeOffset? TryReadCollectedAt(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0 || info.Length > MaxResultBytes) + return null; + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null + || !parsed.TryGetValue("collectedat", out var raw) + || raw is null) + { + return null; + } + + if (DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + return collectedat; + } + + return null; + } + catch (Exception exception) + { + // A torn read while the probe is mid-write is the expected failure + // here and is not worth a warning: the caller polls, so the next + // attempt a moment later sees the completed file. + logger.LogDebug(exception, + "Could not read the user-context collectedat stamp at {Path}", resultPath); + return null; + } + } + + // 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) + { + if (!parsed.TryGetValue("username", out var raw) || raw is null) + { + logger.LogWarning( + "The user-context spool has no username stamp; ignoring it because " + + "the session it describes cannot be confirmed."); + return true; + } + + 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 + // write time, because the file can be copied or restored with a roaming + // profile and carry a misleading timestamp. A result with no readable stamp + // is treated as expired: we cannot prove it is current, so we will not claim + // it is. + private bool IsExpired(Dictionary parsed, string resultPath) + { + if (!parsed.TryGetValue("collectedat", out var raw) || raw is null + || !DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + logger.LogDebug( + "The user-context spool file has no usable collectedat stamp; ignoring it."); + return true; + } + + var age = DateTimeOffset.UtcNow - collectedat; + if (age <= MaxResultAge) + return false; + + logger.LogDebug( + "The user-context spool file is {Hours} hours old, past the {Limit} hour limit.", + Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours); + ClearStaleResult(resultPath); + return true; + } +} diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs index bee73158..ddaaee67 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs +++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs @@ -92,6 +92,57 @@ else var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe"); Check("8.3 short name resolving into the approved root", true, shortRoot, policy); +// --- ScheduledTaskPolicy ---------------------------------------------------- +// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the +// Task Scheduler namespace, not a file, so there is no disk state to plant. The +// cases that matter are the ones where a candidate LOOKS like an approved entry +// as a string but is not the same task. + +void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy policy) +{ + var ok = policy.TryValidate(candidate, out var full, out var reason); + var pass = ok == expectAccept; + if (!pass) failures++; + var verdict = pass ? "PASS" : "FAIL"; + var got = ok ? $"accepted -> {full}" : $"rejected ({reason})"; + Console.WriteLine($"{verdict} {label}"); + Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}"); +} + +Console.WriteLine(); +Console.WriteLine("--- ScheduledTaskPolicy ---"); + +var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" }); + +CheckTask("exact approved task", true, @"\ReportIP", taskPolicy); +// Task Scheduler is case-insensitive, so the policy must be too, or the same +// task typed differently would be refused. +CheckTask("approved task, different case", true, @"\reportip", taskPolicy); +// The operator should not have to know the rooted form. +CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy); +CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy); +// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must +// NOT authorize "\ReportIPEvil". +CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy); +CheckTask("unapproved task", false, @"\Evil", taskPolicy); +CheckTask("empty task path", false, "", taskPolicy); +// Folder entries authorize everything beneath them, but not a sibling folder +// whose name merely starts the same way. +CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy); +CheckTask("folder itself", true, @"\SFLD", taskPolicy); +CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy); +// Traversal would let an approved prefix string-match something else entirely. +CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy); +// Defense in depth: the invocation uses ArgumentList, but a task path with +// these characters is not one we should be starting. +CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy); +CheckTask("control character", false, "\\Report\u0000IP", taskPolicy); +// An empty override must fall back to the shipped defaults, never to "allow +// everything". +var defaultPolicy = new ScheduledTaskPolicy(Array.Empty()); +CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy); +CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy); + Console.WriteLine(); Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET"); return failures == 0 ? 0 : 1; diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj index 9112673f..1736af61 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj +++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj @@ -16,5 +16,9 @@ + + diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 322eb69c..90a0532c 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -8,11 +8,70 @@ true ShopDbEndpointAgent.Tray shopdb-endpoint-tray + - 1.0.0 + + + + + 1.4.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index f6b80d72..41039414 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Reflection; +using Microsoft.Extensions.Logging; + using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -25,8 +27,83 @@ internal sealed class TrayContext : ApplicationContext private readonly Icon brandIcon; private bool sending; + // Session-scoped collection. The service is LocalSystem and cannot see the + // operator's mapped drives, user-owned listeners, or input idle time, so the + // tray - which is already the operator - collects them and leaves the result + // in a spool file the service reads on its own cadence. + // + // Deliberately longer than the telemetry cadence: this data changes rarely + // (a mapped drive is mapped at logon and stays mapped), and each run spawns + // a short PowerShell process on a machine an operator is actively using. + private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30); + + // Logon is the worst moment to add work: drive mappings and profile setup + // are still settling, and a probe fired now would capture an incomplete + // 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; + + // Lets the service ask this tray to re-probe on demand, so a "Refresh + // Status" click in the WebUI produces genuinely current session data instead + // of whatever the last 30-minute tick happened to capture. The service + // cannot collect this itself at any price - mapped drives live on the + // operator's logon token - so the only way for an on-demand refresh to be + // accurate is for the request to end up here, in the operator's session. + private readonly UserContextRefreshChannel? refreshChannel; + private readonly CancellationTokenSource refreshCancellation = new(); + + // How long each wait blocks before looping. The wait returns false on + // timeout and the loop simply waits again, so this is not a deadline for + // anything - it just bounds how long the handle stays open, which lets a + // channel that was somehow broken get re-opened on the next pass instead of + // leaving the tray deaf until it restarts. + private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5); + + // The UI thread's context, captured in the constructor. The listener runs on + // a background thread, but every probe attempt is marshalled back here so + // that collectingUserContext is only ever read or written from one thread. + // Without this the timer tick and a service request could both pass the + // guard and launch two probes at the same spool file. + private readonly SynchronizationContext? uiContext; + + // The tray logs into the operator's own profile + // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the + // service's ProgramData directory. The operator cannot write there, and the + // file sink fails silently when it cannot create its directory, so tray-side + // failures previously left no trace anywhere. Keeping the two apart means a + // tray problem is diagnosable from a file the operator can actually reach. + private readonly FileLoggerProvider logProvider; + private readonly ILogger logger; + public TrayContext() { + logProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + 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", + startupDelay, UserContextInterval); + var menu = new ContextMenuStrip(); menu.Items.Add("Send Status now", null, OnSendStatus); menu.Items.Add("Report an issue...", null, OnReportIssue); @@ -45,6 +122,218 @@ internal sealed class TrayContext : ApplicationContext ContextMenuStrip = menu, }; 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)startupDelay.TotalMilliseconds, + }; + userContextTimer.Tick += OnUserContextTimer; + userContextTimer.Start(); + + uiContext = SynchronizationContext.Current; + + // Best-effort. A tray that cannot open the channel still probes on its + // timer, so the failure mode is "refresh is not immediate", not "session + // data stops working" - which is why this is caught and logged rather + // than allowed to take the tray down at startup. + try + { + refreshChannel = new UserContextRefreshChannel( + logProvider.CreateLogger(nameof(UserContextRefreshChannel))); + StartRefreshListener(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not open the user-context refresh channel; on-demand " + + "refresh requests from the service will be ignored and session " + + "data will only update on the {Interval} timer.", + UserContextInterval); + } + } + + // Waits for service refresh requests for the life of the tray. + // + // A dedicated background thread rather than a Task: this blocks on a kernel + // wait handle essentially forever, which is exactly the workload that should + // not sit on a thread-pool thread. IsBackground keeps it from holding the + // process open at exit. + private void StartRefreshListener() + { + var thread = new Thread(() => + { + var token = refreshCancellation.Token; + while (!token.IsCancellationRequested) + { + try + { + if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token)) + continue; + + logger.LogInformation( + "The service requested a user-context refresh."); + + // Marshalled, not awaited: the service is not waiting on a + // reply through this channel, it is watching the spool file's + // collectedat stamp. Our only job is to get the probe started + // on the UI thread and go back to waiting. + var post = uiContext; + if (post is not null) + post.Post(_ => _ = CollectUserContextAsync(), null); + else + _ = CollectUserContextAsync(); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + // Never let a bad wait kill the listener - if this thread + // exits, on-demand refresh is silently dead for the rest of + // the session with no other symptom. + logger.LogWarning(exception, + "The user-context refresh listener failed; retrying."); + try + { + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + return; + } + } + } + }) + { + IsBackground = true, + Name = "ShopDB user-context refresh listener", + }; + + thread.Start(); + } + + // Fires once after the startup delay, then re-arms at the steady-state + // interval. Failures are swallowed on purpose: session data is a + // nice-to-have, and an operator must never see a dialog or a crashed tray + // because a probe could not read a drive mapping. + private async void OnUserContextTimer(object? sender, EventArgs e) + { + if (!userContextStarted) + { + userContextStarted = true; + userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds; + } + + await CollectUserContextAsync(); + } + + // Runs the probe once and returns when the spool has been rewritten. + // + // Shared by all three callers - the cadence timer, an on-demand service + // request, and "Send Status now" - so there is exactly one place that knows + // how to invoke the probe and how to interpret its exit codes. Returns true + // only when a spool file is actually on disk afterwards, which is what lets + // Send Status decide whether it is worth waiting on. + private async Task CollectUserContextAsync() + { + if (collectingUserContext) + { + // Another probe is already in flight against the same spool path. + // Its result is at most a second or two old and satisfies whatever + // this caller wanted, so coalescing is correct here, not a shortcut. + logger.LogDebug( + "A user-context probe is already running; skipping this request."); + return false; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + // Previously a bare return. This is the single most likely reason for + // "no mapped drives" with nothing at all in any log, so it must say so. + logger.LogWarning( + "Skipping the user-context probe: {Agent} was not found beside the tray " + + "({Beside}) or at {Installed}.", + AgentExeName, AppContext.BaseDirectory, DefaultInstallDir); + return false; + } + + collectingUserContext = true; + try + { + logger.LogInformation("Running the user-context probe: {Agent}", agentExe); + var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext"); + + // 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 present at {Spool}.", + resultPath); + return true; + } + 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}; expected a " + + "spool at {Spool}.", exitCode, resultPath); + } + + return false; + } + catch (Exception exception) + { + // No longer silent. The agent logs its own detail, but if the process + // could not even be started the agent never ran and never logged. + logger.LogWarning(exception, "Could not run the user-context probe."); + return false; + } + finally + { + collectingUserContext = false; + } } private async void OnReportIssue(object? sender, EventArgs e) @@ -169,6 +458,18 @@ internal sealed class TrayContext : ApplicationContext try { + // Probe BEFORE reporting, and await it. The operator clicking this + // has just changed something - remapped a drive, signed in - and + // expects the result to show up. --once reads the spool file, so a + // probe started after it, or not awaited, would publish the previous + // probe's answer and the operator would click again and see the same + // stale card. + // + // Not fatal if it fails: a report with older session data still + // carries the heartbeat, inventory and events, which is most of what + // this button is for. + await CollectUserContextAsync(); + int exitCode = await RunAgentAsync(agentExe, "--once"); if (exitCode == 0) ShowBalloon("Send Status", "Status sent to ShopDB.", @@ -249,6 +550,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() @@ -316,6 +646,15 @@ internal sealed class TrayContext : ApplicationContext { if (disposing) { + userContextTimer.Stop(); + userContextTimer.Dispose(); + + // Cancel and let the listener unwind on its own. The channel opens + // and closes its handle inside each wait rather than holding one, so + // there is nothing here to dispose. + refreshCancellation.Cancel(); + refreshCancellation.Dispose(); + trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index b003d9f6..ffa770bd 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -22,15 +22,110 @@ using ShopDbEndpointAgent.Core; var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var collectUserContext = args.Any(a => + string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase)); 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(); @@ -191,10 +286,37 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetRequiredService>())); } + // Persisted per-channel event watermark so each Windows event record is + // reported exactly once, instead of re-sending the newest window of every + // channel on every cadence tick. + services.AddSingleton(sp => new EventWatermarkStore( + sp.GetRequiredService>())); + + // 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>())); + + // The ask-the-tray side of the same split. Registering this is NOT a + // loophole around the paragraph above: the service still cannot collect + // session data, it can only request that the process which can does so, and + // then read the file that appears. The probe stays out of this container. + services.AddSingleton(sp => new UserContextRefreshChannel( + sp.GetRequiredService>())); + services.AddSingleton(sp => new EndpointCollector( sp.GetRequiredService>(), config, - sp.GetService())); + sp.GetService(), + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(sp => new CollectorClient( config, sp.GetRequiredService>())); @@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config agentVersion, sp.GetRequiredService>(), sp.GetService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); // Command channel: outbound long-poll for allowlisted WebUI commands, run // through the fail-closed executor. Hosted alongside telemetry but on its @@ -231,3 +355,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 63e33106..ec456c3b 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,88 @@ enable ShopDbEndpointAgent shopdbagent - + + + + + - 1.6.3 + + 1.11.0 true win-x64 true diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 deleted file mode 100644 index 5a08d3db..00000000 --- a/agent/installer/install-shopdbagent.ps1 +++ /dev/null @@ -1,355 +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 -} - -Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan -Write-Host "BaseUrl : $BaseUrl" -Write-Host "InstallDir : $InstallDir" -Write-Host "ServiceName : $ServiceName" - -# 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 - -# 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." -Write-Host "Logs: $logDir" diff --git a/agent/utilities/shopdb-dellupdates.ps1 b/agent/utilities/shopdb-dellupdates.ps1 new file mode 100644 index 00000000..98451c6b --- /dev/null +++ b/agent/utilities/shopdb-dellupdates.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + ShopDB agent utility: reports pending Dell Command | Update items. + +.DESCRIPTION + Discovered and executed by PowerShellUtilityRunner on the telemetry cadence. + Emits a single JSON object on stdout, which the agent attaches to the report + as `dellupdates`. Never throws and never writes anything but JSON: a failure + here must not break a telemetry run, so every error path still emits a valid + object with a `status` explaining why the list is empty. + + THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The + scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is + what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart + is deliberate: a telemetry collector that shelled out to dcu-cli would block + the cadence for minutes and hammer Dell's servers every 15 minutes. + + OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py): + status unknown | ok | error - "unknown" means no scan has ever run, + which is NOT the same as "ok" with an empty list (known clean). + scannedat ISO-8601 UTC timestamp of the scan log's last write, or null. + count total pending updates found. + updates up to $MaxUpdates entries of { name, severity, version }. + truncated true when count exceeded $MaxUpdates. + + Dell's own long description text is deliberately NOT carried: it is + sentence-length marketing prose per update, which would add roughly an order + of magnitude to every report for no operational value. Name plus severity is + what an operator decides on. +#> + +$ErrorActionPreference = 'Stop' + +# Cap the list rather than the payload. A machine that is years behind can have +# dozens of pending updates, and an unbounded list would bloat every report on +# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor: +# a truncated answer is more useful than a rejected payload. +$MaxUpdates = 25 + +# Must match the -outputLog path set in the \ShopDB-DellCommandScan task +# definition. ShopDB-initiated scans write HERE rather than to Dell's default +# location, which is what keeps them distinguishable from the corporate-managed +# DCU tasks that also run on these machines. +$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml' + +function Write-Result { + param($Status, $ScannedAt, $Count, $Updates, $Truncated) + # Compress so the payload carries no pretty-printing whitespace. Depth 3 + # covers the nested updates array. + [pscustomobject]@{ + status = $Status + scannedat = $ScannedAt + count = $Count + updates = @($Updates) + truncated = [bool]$Truncated + } | ConvertTo-Json -Depth 3 -Compress +} + +try { + if (-not (Test-Path -LiteralPath $ScanLog)) { + # No scan has run yet. This is the normal state for a freshly adopted + # endpoint, not an error, but it must stay distinct from "scanned and + # found nothing" or the UI would claim a machine is up to date when + # nobody has ever checked. + Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false + return + } + + $logFile = Get-Item -LiteralPath $ScanLog + $scannedAt = $logFile.LastWriteTimeUtc.ToString('o') + + [xml]$report = Get-Content -LiteralPath $ScanLog -Raw + + # dcu-cli's scan report shape has varied across DCU versions, so select the + # update nodes by name anywhere in the tree rather than by a fixed path. + $nodes = @($report.SelectNodes('//update')) + if ($nodes.Count -eq 0) { + $nodes = @($report.SelectNodes('//Update')) + } + + $updates = foreach ($node in $nodes) { + # Attribute casing also varies by version; take whichever is present and + # fall back rather than emitting a null-filled row. + $name = $node.name + if (-not $name) { $name = $node.Name } + if (-not $name) { $name = $node.display } + if (-not $name) { continue } + + $severity = $node.urgency + if (-not $severity) { $severity = $node.Urgency } + if (-not $severity) { $severity = $node.criticality } + + $version = $node.version + if (-not $version) { $version = $node.Version } + + [pscustomobject]@{ + name = [string]$name + severity = if ($severity) { [string]$severity } else { 'unknown' } + version = if ($version) { [string]$version } else { $null } + } + } + + $updates = @($updates) + $count = $updates.Count + $truncated = $count -gt $MaxUpdates + if ($truncated) { + $updates = $updates[0..($MaxUpdates - 1)] + } + + Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count ` + -Updates $updates -Truncated $truncated +} +catch { + # Emit a valid object rather than failing: the runner would log a non-JSON + # utility and drop it, which looks identical to "never ran" on the server. + # An explicit error status tells an operator the scan log exists but could + # not be read, which is a different problem from no scan at all. + Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false +} diff --git a/deploy/windows/web.config b/deploy/windows/web.config index f365fcc1..513cca50 100644 --- a/deploy/windows/web.config +++ b/deploy/windows/web.config @@ -101,6 +101,17 @@ SHOPDB-CLIENTIP-END --> + + + + + + +