Collect user context from the tray instead of descending from the service
The service ran as SYSTEM and tried to launch the probe into the interactive user's session. That path is fragile under CyberArk and produced no spool file at all on a live endpoint: session-scoped data has to be gathered by something already in the session, not reached down into from outside it. Invert it. The tray already runs unelevated as the operator, so it invokes shopdbagent.exe with the user-context verb on a timer and the service only reads the resulting spool file. Nothing descends between security contexts. That makes the tray the writer and the service an independent reader, so the spool can no longer be consumed on read - deleting it would race the collector and drop data the tray had just gathered. It is now read non-destructively and aged out by its own collectedat value instead, which also means freshness is decided by when the data was collected rather than by when it happened to be read. The 60-minute read throttle goes with it; it delayed data that was already sitting on disk. Agent 1.8.0, tray 1.1.0. Both ship together - the tray invokes a verb this agent build is the first to understand.
This commit is contained in:
@@ -20,30 +20,28 @@ public sealed class EndpointCollector : ICollector
|
||||
// keep the payload bounded on a chatty Security log.
|
||||
private const int MaxEventsPerChannel = 25;
|
||||
|
||||
// The user-context probe spawns a process in the operator's session, so it
|
||||
// runs on its own slower sub-cadence rather than every telemetry tick.
|
||||
// Mapped drives change at logon, not minute to minute, and this keeps the
|
||||
// visible-session cost low on a shop floor PC.
|
||||
private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(60);
|
||||
// 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<EndpointCollector> logger;
|
||||
private readonly AgentConfig config;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
private readonly EventWatermarkStore? watermarks;
|
||||
private readonly UserContextProbe? userContextProbe;
|
||||
private readonly UserContextSpool? userContextSpool;
|
||||
|
||||
private DateTimeOffset lastUserContextAttempt = DateTimeOffset.MinValue;
|
||||
private object? lastUserContext;
|
||||
|
||||
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
|
||||
IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null,
|
||||
UserContextProbe? userContextProbe = null)
|
||||
UserContextSpool? userContextSpool = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.config = config;
|
||||
this.utilities = utilities;
|
||||
this.watermarks = watermarks;
|
||||
this.userContextProbe = userContextProbe;
|
||||
this.userContextSpool = userContextSpool;
|
||||
}
|
||||
|
||||
public EndpointReport Collect(string agentVersion, bool adopt)
|
||||
@@ -75,7 +73,7 @@ public sealed class EndpointCollector : ICollector
|
||||
Domain = GetDomain(),
|
||||
Applications = GetApplications(),
|
||||
PendingReboot = GetPendingReboot(),
|
||||
UserContext = GetUserContext(adopt),
|
||||
UserContext = GetUserContext(),
|
||||
};
|
||||
|
||||
if (report.LastBootTime is DateTimeOffset boot)
|
||||
@@ -251,23 +249,26 @@ public sealed class EndpointCollector : ICollector
|
||||
return names;
|
||||
}
|
||||
|
||||
// Session-scoped data on a slow sub-cadence. Adoption forces a fresh probe so
|
||||
// a newly enrolled machine reports its mapped drives immediately instead of
|
||||
// looking bare for the first hour. Between probes the previous result is
|
||||
// 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".
|
||||
private object? GetUserContext(bool force)
|
||||
// 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 (userContextProbe is null)
|
||||
if (userContextSpool is null)
|
||||
return null;
|
||||
|
||||
var due = force
|
||||
|| DateTimeOffset.UtcNow - lastUserContextAttempt >= UserContextInterval;
|
||||
if (!due)
|
||||
var resultPath = userContextSpool.TryGetResultPath(out var reason);
|
||||
if (resultPath is null)
|
||||
{
|
||||
logger.LogDebug("Skipping the user-context read: {Reason}", reason);
|
||||
return lastUserContext;
|
||||
}
|
||||
|
||||
lastUserContextAttempt = DateTimeOffset.UtcNow;
|
||||
var collected = userContextProbe.TryCollect();
|
||||
var collected = userContextSpool.TryReadResult(resultPath);
|
||||
if (collected is not null)
|
||||
lastUserContext = collected;
|
||||
|
||||
|
||||
@@ -3,22 +3,23 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Reads the result file that a user-context probe writes into the signed-in
|
||||
// user's ShopDB spool folder, then removes it.
|
||||
// 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 has to run
|
||||
// as the interactive user, and a process launched with CreateProcessAsUser gives
|
||||
// back an exit code, not stdout. A file in the user's own profile is the
|
||||
// handoff, and the user can always write there without elevation.
|
||||
// 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.
|
||||
//
|
||||
// Ordering matters here. The stale file is deleted BEFORE the probe runs, not
|
||||
// after it is read: if the probe fails to start, crashes, or is still running
|
||||
// when we look, an old file left on disk would be indistinguishable from a fresh
|
||||
// result and we would report yesterday's mapped drives as current. Deleting up
|
||||
// front means a file that exists at read time was necessarily produced by this
|
||||
// cycle.
|
||||
// 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";
|
||||
@@ -28,6 +29,17 @@ public sealed class UserContextSpool
|
||||
// larger than this is either corrupt or hostile, and we will not parse it.
|
||||
private const long MaxResultBytes = 512 * 1024;
|
||||
|
||||
// A result older than this is treated as absent. Sized against the server's
|
||||
// default quiet-report window (endpoints_quietreporthours, 24h): an endpoint
|
||||
// that has not reported inside that window is already shown as 'quiet', so
|
||||
// session data that outlived it would be claiming freshness the rest of the
|
||||
// page no longer claims. Well clear of the tray's collection interval, so an
|
||||
// ordinary missed run does not blank the card.
|
||||
//
|
||||
// Not read from the server: this is an offline-safe local bound, and the
|
||||
// agent must retire stale session data even when it cannot reach ShopDB.
|
||||
private static readonly TimeSpan MaxResultAge = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly ILogger<UserContextSpool> logger;
|
||||
|
||||
public UserContextSpool(ILogger<UserContextSpool> logger)
|
||||
@@ -52,8 +64,8 @@ public sealed class UserContextSpool
|
||||
return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName);
|
||||
}
|
||||
|
||||
// Remove any result file left over from a previous cycle. Call this before
|
||||
// launching the probe so a stale file can never be mistaken for a fresh one.
|
||||
// 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
|
||||
@@ -91,10 +103,11 @@ public sealed class UserContextSpool
|
||||
}
|
||||
}
|
||||
|
||||
// Read and consume the probe result. Returns null when the probe produced
|
||||
// nothing, which the caller reports as "no session data this cycle" rather
|
||||
// than as empty data - absent and empty mean different things here.
|
||||
public Dictionary<string, object?>? TryConsumeResult(string resultPath)
|
||||
// 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<string, object?>? TryReadResult(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -125,6 +138,9 @@ public sealed class UserContextSpool
|
||||
if (parsed is null || parsed.Count == 0)
|
||||
return null;
|
||||
|
||||
if (IsExpired(parsed, resultPath))
|
||||
return null;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
@@ -141,9 +157,35 @@ public sealed class UserContextSpool
|
||||
"Could not read the user-context spool file at {Path}", resultPath);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
}
|
||||
|
||||
// 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<string, object?> 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))
|
||||
{
|
||||
ClearStaleResult(resultPath);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
||||
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
|
||||
<!-- 1.1.0: the tray now collects session-scoped user context. It invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on a timer (once
|
||||
shortly after logon, then periodically) and the agent service reads the
|
||||
resulting spool file. The tray owns this because it is the only ShopDB
|
||||
process already running as the operator: mapped drives, user-owned
|
||||
listeners, and input idle time do not exist in the service's session 0.
|
||||
Requires agent 1.8.0 or newer: an older agent does not recognize the
|
||||
verb and falls through to starting a full host, so the tray and agent
|
||||
must be deployed together. -->
|
||||
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
|
||||
service version independently (they are separate exes and can be pushed
|
||||
apart), so the tray carries its own <Version> and the agent reports it
|
||||
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.1.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -25,6 +25,25 @@ 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);
|
||||
|
||||
private readonly System.Windows.Forms.Timer userContextTimer;
|
||||
private bool userContextStarted;
|
||||
private bool collectingUserContext;
|
||||
|
||||
public TrayContext()
|
||||
{
|
||||
var menu = new ContextMenuStrip();
|
||||
@@ -45,6 +64,47 @@ internal sealed class TrayContext : ApplicationContext
|
||||
ContextMenuStrip = menu,
|
||||
};
|
||||
trayIcon.DoubleClick += OnSendStatus;
|
||||
|
||||
userContextTimer = new System.Windows.Forms.Timer
|
||||
{
|
||||
Interval = (int)UserContextStartupDelay.TotalMilliseconds,
|
||||
};
|
||||
userContextTimer.Tick += OnUserContextTimer;
|
||||
userContextTimer.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;
|
||||
}
|
||||
|
||||
if (collectingUserContext)
|
||||
return;
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
return;
|
||||
|
||||
collectingUserContext = true;
|
||||
try
|
||||
{
|
||||
await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Intentionally silent. The agent logs the detail to its own file.
|
||||
}
|
||||
finally
|
||||
{
|
||||
collectingUserContext = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnReportIssue(object? sender, EventArgs e)
|
||||
@@ -316,6 +376,8 @@ internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
userContextTimer.Stop();
|
||||
userContextTimer.Dispose();
|
||||
trayIcon.Dispose();
|
||||
brandIcon.Dispose();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ 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()
|
||||
@@ -41,6 +43,20 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
||||
? FileLoggerProvider.DefaultDirectory()
|
||||
: config.LogPath;
|
||||
|
||||
// Deliberately ahead of the collector config gate. This verb only writes a file
|
||||
// into the caller's own profile and never talks to the server, so it must still
|
||||
// work on a machine whose CollectorKey has not been provisioned yet - otherwise
|
||||
// session data would be missing on exactly the endpoints that are hardest to
|
||||
// diagnose.
|
||||
if (collectUserContext)
|
||||
{
|
||||
using var probeLoggerFactory = LoggerFactory.Create(logging =>
|
||||
logging.AddProvider(new FileLoggerProvider(logDirectory)));
|
||||
var probe = new UserContextProbe(
|
||||
probeLoggerFactory.CreateLogger<UserContextProbe>());
|
||||
return probe.Collect() ? 0 : 1;
|
||||
}
|
||||
|
||||
if (!config.IsUsable())
|
||||
{
|
||||
bootstrapLogger.LogError(
|
||||
@@ -198,13 +214,12 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
sp.GetRequiredService<ILogger<EventWatermarkStore>>()));
|
||||
|
||||
// Session-scoped collection. The agent is LocalSystem, so mapped drives and
|
||||
// user-owned listeners are invisible to it; the probe runs a short script in
|
||||
// the operator's session and hands the result back through a spool file.
|
||||
// user-owned listeners are invisible to it. The tray, which already runs as
|
||||
// the operator, writes the spool file; the service only reads it.
|
||||
services.AddSingleton(sp => new UserContextSpool(
|
||||
sp.GetRequiredService<ILogger<UserContextSpool>>()));
|
||||
|
||||
services.AddSingleton(sp => new UserContextProbe(
|
||||
sp.GetRequiredService<UserContextSpool>(),
|
||||
sp.GetRequiredService<ILogger<UserContextProbe>>()));
|
||||
|
||||
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||
@@ -212,7 +227,7 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
config,
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<EventWatermarkStore>(),
|
||||
sp.GetRequiredService<UserContextProbe>()));
|
||||
sp.GetRequiredService<UserContextSpool>()));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.8.0: user-context collection is inverted. The service no longer
|
||||
descends into the operator's session with CreateProcessAsUser to run the
|
||||
probe; that path needs to duplicate a token, is what endpoint protection
|
||||
watches for, and produced no spool file at all on a live endpoint. The
|
||||
tray, which is already running as the operator, now invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on its own cadence and
|
||||
the service only reads the spool. Requires the 1.1.0 tray: an older tray
|
||||
the new verb, so usercontext stays null until the tray is updated.
|
||||
Spool freshness moved from delete-around-read to the in-file collectedat
|
||||
stamp with a max age, because writer and reader are now independent
|
||||
processes and a delete-then-read would race the tray and discard good
|
||||
results. -->
|
||||
<!-- 1.7.0: curated events are harvested against a persisted per-channel
|
||||
Windows record-id watermark and carry eventrecordid, so one real event
|
||||
is reported and stored exactly once instead of once per cadence tick
|
||||
@@ -62,7 +74,7 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.7.0</Version>
|
||||
<Version>1.8.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
Reference in New Issue
Block a user