Merge origin/wilm into wj

Brings in agent upgrade and log collection features:
- One-click agent self-upgrade from endpoint detail page
- Auto-register staged agent builds as pushable applications
- Log collection and script output capture
- Endpoint log collections plugin integration

Resolved conflict in ShopDbEndpointAgent.csproj by taking wilm version 1.16.0
This commit is contained in:
Proudlock
2026-09-10 09:24:08 -04:00
30 changed files with 4167 additions and 59 deletions

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.ServiceProcess;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
@@ -167,6 +169,11 @@ public sealed class CommandExecutor
case "network_connections":
return NetworkConnections();
// Read a plaintext log off this machine on demand. Read-only and
// bounded: see CollectLog for why each refusal is there.
case "collect_log":
return RequireTarget(action, target) ?? CollectLog(target);
default:
logger.LogWarning("Rejected unknown command action {Action}", action);
return new CommandResult
@@ -241,6 +248,248 @@ public sealed class CommandExecutor
}
}
// ---- Log collection ----
// Extensions this agent will read. An allowlist, not a blocklist: the
// question is not "is this file dangerous" but "will this render as text",
// and only a closed set answers that. Mirrors LOG_PATH_EXTENSIONS on the
// server; that copy gives a fast error at enqueue, THIS copy is the one
// guarding the disk and must never be removed on the assumption the server
// already checked.
private static readonly HashSet<string> AllowedLogExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
// The extension the server also omits (see LOG_PATH_EXTENSIONS) is
// omitted here too. The two copies must agree, or the server
// accepts a path the agent then refuses and the operator gets a
// failure with no actionable cause.
".log", ".txt", ".xml", ".json", ".csv", ".ini", ".conf",
".yml", ".yaml", ".trace", ".out", ".err", ".md",
};
// Ceiling on what crosses the wire. Logs grow without bound; a multi-GB
// file would exhaust memory here and be refused by the server's payload
// limit anyway, returning nothing. Sending the last 2 MB is far more useful
// than failing, because the end of a log is where the recent failure is.
private const int MaxLogBytes = 2 * 1024 * 1024;
// Read a plaintext log file and return its tail.
//
// Strictly read-only: opens for reading, never creates, writes or deletes.
// FileShare.ReadWrite|Delete is essential rather than lax - the interesting
// logs are the ones a running application currently holds open, and a
// stricter share mode would fail on exactly those with a sharing violation.
private CommandResult CollectLog(string target)
{
var path = (target ?? string.Empty).Trim();
try
{
// Re-validate independently of the server. The agent runs as SYSTEM
// and this path arrived over the network; trusting an upstream check
// would make the whole guard depend on a component this process
// cannot verify.
var rejection = ValidateLogPath(ref path);
if (rejection != null)
{
logger.LogWarning("collect_log refused {Path}: {Reason}", path, rejection);
return new CommandResult { Succeeded = false, Message = rejection };
}
var info = new FileInfo(path);
if (!info.Exists)
{
return new CommandResult
{
Succeeded = false,
Message = $"No file found at {path}",
};
}
var totalBytes = info.Length;
var truncated = totalBytes > MaxLogBytes;
string content;
using (var stream = new FileStream(
path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete))
{
if (truncated)
{
// Seek to the tail. This can land mid-line, so the partial
// first line is dropped below rather than shown as a
// fragment the reader would misread as a real entry.
stream.Seek(-MaxLogBytes, SeekOrigin.End);
}
using var reader = new StreamReader(stream, Encoding.UTF8, true);
content = reader.ReadToEnd();
}
if (truncated)
{
var firstBreak = content.IndexOf('\n');
if (firstBreak >= 0 && firstBreak < content.Length - 1)
content = content[(firstBreak + 1)..];
}
// A file whose bytes are not text will have produced replacement
// characters above. Returning that would fill the operator's pane
// with noise, so it is refused with a reason instead.
if (LooksBinary(content))
{
return new CommandResult
{
Succeeded = false,
Message =
$"{path} does not appear to be a plaintext file; " +
"only text logs can be collected",
};
}
var lineCount = CountLines(content);
logger.LogInformation(
"collect_log read {Path} ({Bytes} bytes on disk, truncated: {Truncated})",
path, totalBytes, truncated);
return new CommandResult
{
Succeeded = true,
Message = truncated
? $"Collected the last {content.Length:N0} characters of {info.Name} "
+ $"({totalBytes:N0} bytes on disk)"
: $"Collected {info.Name} ({totalBytes:N0} bytes)",
Detail = new Dictionary<string, object>
{
// The RESOLVED path, which may differ from what was typed
// once environment variables are expanded.
["path"] = info.FullName,
["content"] = content,
["filesizebytes"] = totalBytes,
["linecount"] = lineCount,
["truncated"] = truncated,
["modifiedat"] = info.LastWriteTimeUtc.ToString(
"o", CultureInfo.InvariantCulture),
},
};
}
catch (UnauthorizedAccessException exception)
{
// Called out separately because the remedy is different from a
// generic failure: the file exists but the agent cannot read it.
logger.LogWarning(exception, "collect_log denied for {Path}", path);
return new CommandResult
{
Succeeded = false,
Message = $"Access denied reading {path}",
};
}
catch (IOException exception)
{
logger.LogWarning(exception, "collect_log io failure for {Path}", path);
return new CommandResult
{
Succeeded = false,
Message = $"Could not read {path}: {exception.Message}",
};
}
catch (Exception exception)
{
logger.LogWarning(exception, "collect_log failed for {Path}", path);
return new CommandResult
{
Succeeded = false,
Message = $"Could not collect {path}: {exception.Message}",
};
}
}
// Shape validation, run before anything is opened. Returns a reason to
// refuse, or null to proceed. Expands environment variables first so the
// checks apply to the path that will ACTUALLY be opened - validating the
// literal string and then expanding it would let %VAR% smuggle past every
// rule here.
private static string? ValidateLogPath(ref string path)
{
if (string.IsNullOrWhiteSpace(path))
return "A log file path is required";
path = Environment.ExpandEnvironmentVariables(path).Trim();
var normalized = path.Replace('/', '\\');
if (normalized.StartsWith(@"\\", StringComparison.Ordinal))
return "UNC/network paths are not supported";
// Reject '..' before calling GetFullPath. Normalising first would
// resolve the traversal and hide it, so the check has to see the raw
// segments.
if (normalized.Split('\\').Any(part => part == ".."))
return "Path must not contain \"..\" segments";
if (normalized.Contains('*') || normalized.Contains('?'))
return "Wildcards are not supported; name a single log file";
if (!Path.IsPathRooted(normalized) || normalized.Length < 3
|| normalized[1] != ':')
{
return "Enter a full local path including the drive letter";
}
var extension = Path.GetExtension(normalized);
if (string.IsNullOrEmpty(extension)
|| !AllowedLogExtensions.Contains(extension))
{
return "Only plaintext log files can be collected "
+ $"(allowed: {string.Join(", ", AllowedLogExtensions.OrderBy(e => e))})";
}
// A directory reached through a path that passes every check above is
// still not a file, and Directory.Exists is cheap insurance.
if (Directory.Exists(normalized))
return "That path is a directory, not a log file";
path = Path.GetFullPath(normalized);
return null;
}
// Heuristic: a NUL byte, or many replacement characters, means the decode
// produced garbage rather than text. Sampling the head is enough - a binary
// file betrays itself immediately, and scanning megabytes to be sure would
// cost more than it is worth.
private static bool LooksBinary(string content)
{
if (string.IsNullOrEmpty(content))
return false;
var sample = Math.Min(content.Length, 8000);
var replacements = 0;
for (var index = 0; index < sample; index++)
{
var character = content[index];
if (character == '\0')
return true;
if (character == '\uFFFD')
replacements++;
}
return replacements > sample / 100;
}
private static int CountLines(string content)
{
if (string.IsNullOrEmpty(content))
return 0;
var lines = 1;
foreach (var character in content)
{
if (character == '\n')
lines++;
}
return lines;
}
private Dictionary<int, string> GetProcessNameSnapshot()
{
var names = new Dictionary<int, string>();
@@ -1016,7 +1265,7 @@ public sealed class CommandExecutor
// The endpoint log gets the output ALWAYS, success or failure. This is the
// copy that stays on the box: it never leaves the endpoint, so it costs no
// exposure, and it is the only record when a script reports success while
// doing nothing useful. The server copy is failure-only (see ScriptDetail).
// doing nothing useful. The server copy is success-gated (see ShouldReturnOutput).
private void LogScriptOutput(string name, string output, int? exitCode)
{
if (string.IsNullOrWhiteSpace(output))
@@ -1166,7 +1415,7 @@ public sealed class CommandExecutor
? $"Script {name} completed (system context)"
: $"Script {name} exited with code {exit}",
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
exit == 0 ? null : output),
ShouldReturnOutput(script, succeeded: exit == 0) ? output : null),
};
}
@@ -1282,10 +1531,22 @@ public sealed class CommandExecutor
? $"Script {name} completed (user context)"
: $"Script {name} exited with code {exit}",
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
ok ? null : output),
ShouldReturnOutput(script, succeeded: ok) ? output : null),
};
}
// Whether this run's output should travel back to the server.
//
// A FAILED run always returns it: the whole point of the field is making a
// failure diagnosable from the UI rather than by remoting to the box. A
// SUCCESSFUL run returns it only when the script's author opted in, because
// output carries whatever the body printed - hostnames, share paths, a
// token echoed while debugging - and a routine green run is not worth that
// exposure by default. A claim from a server too old to send the field
// leaves CaptureOutput false, so the behaviour is unchanged.
private static bool ShouldReturnOutput(ScriptDeliveryClaim? script, bool succeeded)
=> !succeeded || (script?.CaptureOutput ?? false);
private static Dictionary<string, object> ScriptDetail(
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode,
string? workingDirectory = null, string? output = null)
@@ -1313,12 +1574,12 @@ public sealed class CommandExecutor
detail["workingdirectory"] = workingDirectory!;
if (exitCode is not null)
detail["exitcode"] = exitCode;
// FAILURE ONLY. Callers pass output only when the command failed, so a
// successful run's stdout never leaves the endpoint. Script output can
// carry whatever the body happened to print - hostnames, paths, tokens
// an author echoed while debugging - and there is no reason to accept
// that exposure for a run nobody needs to diagnose. The endpoint log
// keeps the full copy either way.
// Always present on failure; on success only when the script opted in
// via captureoutput (see ShouldReturnOutput). Script output can carry
// whatever the body happened to print - hostnames, paths, tokens an
// author echoed while debugging - so a successful run stays silent
// unless someone decided otherwise after reading the body. The endpoint
// log keeps the full copy either way.
if (!string.IsNullOrWhiteSpace(output))
detail["output"] = output!;
return detail;

View File

@@ -197,6 +197,13 @@ public sealed class AppDeliveryClaim
// written to tolerate a failing cleanup or to probe with a cmdlet that errors on
// absence would start aborting halfway. An absent or unrecognised value is
// treated as default, so an old server that never sends the field is safe.
// CaptureOutput decides whether a SUCCESSFUL run returns its output to the
// server. Failures always return it, because a failure has to be diagnosable
// without walking to the machine. Success is opt-in per script: output carries
// whatever the body printed, and that is not worth persisting server-side for a
// routine green run. The endpoint log keeps the full copy regardless, so this
// only governs what crosses the wire. Absent means false, which is the same
// behaviour as before the field existed.
public sealed class ScriptDeliveryClaim
{
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
@@ -206,6 +213,7 @@ public sealed class ScriptDeliveryClaim
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
[JsonPropertyName("errormode")] public string? ErrorMode { get; set; }
[JsonPropertyName("captureoutput")] public bool CaptureOutput { get; set; }
}
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.

View File

@@ -186,38 +186,54 @@
member of that group, so Start-ScheduledTask returned access denied and
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
is the side that listens, so an older tray simply never answers. -->
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.5: coordinated release with tray 1.6.6 (no tray changes).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. No tray change - tray
stays 1.4.0. -->
<!-- 1.14.1: coordinated release with tray 1.6.7 (merged wj 1.14.1).
Merge wilm 1.14.0 changes with wj tray coordination releases. -->
<!-- 1.14.2: merge wilm fleet alert scope fixes and operator report enhancements.
Brings in wilm's alertscope targeting and _alert_targets_endpoint function,
along with wj's operator report email improvements (FQDN, machine relationship). -->
<Version>1.14.2</Version>
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
code changes. Tray is at 1.6.5 from wj. -->
<!-- 1.15.0: successful runs can now return their output to the server, gated
by a per-script captureoutput flag on the claim. Failures still always
return output; success previously never did, which left a green badge as
the only evidence a run produced and no way to tell "did the work" from
"PowerShell exited cleanly". Opt-in rather than always-on because output
carries whatever the body printed, and that is not worth persisting
server-side for every routine run. Minor because the claim gains a field
that defaults false: an agent or server too old to know it keeps today's
withholding behaviour, which is the safe direction to fail. -->
<!-- 1.16.0: adds collect_log, which reads a plaintext log file from a
caller-supplied path and returns its contents for the Log Collector tab.
Strictly read-only: opened for reading with FileShare.ReadWrite|Delete,
because the logs worth collecting are the ones a running application
currently holds open and a stricter share mode fails on exactly those.
Bounded rather than unlimited - only the last 2 MB is returned, and only
for an allowlisted set of text extensions; a binary or oversized file is
refused rather than mangled into an unreadable pane. The path is
re-validated here even though the server already checked it, since this
process runs as SYSTEM and the path arrived over the network: environment
variables are expanded BEFORE the checks so %VAR% cannot smuggle a
traversal or UNC path past them. Minor because it is a new action name -
an older agent asked for it fails closed on the unknown-action default. -->
<Version>1.16.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -228,9 +228,44 @@ export const endpointsApi = {
}
return api.post(`/endpoints/${assetId}/commands`, body)
},
// Pushes the published agent build to one endpoint. Separate from
// enqueueCommand because the server prepares the delivery (packages the
// build, seeds the upgrade script) before queueing it as a pinned
// run_script; the caller has no script id to pass and must not invent one.
enqueueAgentUpgrade(assetId, opts = {}) {
const body = {}
if (opts.policyoverride) {
body.policyoverride = true
body.overridereason = opts.overridereason
}
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
},
listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`)
},
// Recent run_script commands only, with the agent-reported detail the plain
// command list omits. Feeds the run history under the Run Scripts tab.
listScriptRuns(assetId, limit = 5) {
return api.get(`/endpoints/${assetId}/scriptruns`, { params: { limit } })
},
// Collected-log history for one endpoint. Metadata only - the log bodies are
// fetched one at a time, because a 20-row history of multi-hundred-KB logs
// would make opening the tab far more expensive than reading the one log the
// operator actually wants.
listLogCollections(assetId) {
return api.get(`/endpoints/${assetId}/logcollections`)
},
// One collected log including its content, for the scrollable pane.
getLogCollection(collectionId) {
return api.get(`/endpoints/logcollections/${collectionId}`)
},
// Blob, not JSON: the server sends this as a text/plain attachment, and the
// caller turns it into a save dialog.
downloadLogCollection(collectionId) {
return api.get(`/endpoints/logcollections/${collectionId}/download`, {
responseType: 'blob',
})
},
// Active break-glass holds. Readable by any endpoints.view holder, since a
// suppressed action is drift an operator must be able to explain.
listPolicyHolds(assetId) {

View File

@@ -309,8 +309,13 @@ import {
// unmount so the blobs are not leaked.
const reportImageUrls = ref({})
// Operator reports carry a full timestamp, and the time of day is the useful
// part: several reports from one shift all landed on the same date, so a
// date-only label made them indistinguishable and un-orderable. formatValue's
// 'date' mode renders via toLocaleDateString and discards the clock, so this
// asks for 'datetime'.
function formatDate(value) {
return formatValue(value, { format: 'date' })
return formatValue(value, { format: 'datetime' })
}
// Called from the template, so it runs on EVERY render. Without the seen-set it

View File

@@ -0,0 +1,48 @@
"""Add scripts captureoutput
Backs the per-script "may a successful run send its output back" field.
The agent already captures a script's full output and always writes it to the
endpoint log. What this controls is narrower: whether that output also crosses
to the server and is persisted in the command's resultjson, where the Run
Scripts history can show it.
Failures have always sent output and still do - a failed run has to be
diagnosable without walking to the machine. Success is the case in question,
and it is opt-in because output carries whatever the body happened to print:
hostnames, share paths, an account name, a token an author echoed while
debugging. Accepting that exposure for every routine green run is a poor
trade, so the author decides per script, after reading the body.
BOOLEAN NOT NULL defaulting FALSE, so every script row written before this
migration keeps exactly today's behaviour: metadata on success, output only on
failure. Same rule as errormode - never retroactive, because a library-wide
flip would start persisting output from bodies nobody re-read with this
question in mind.
Revision ID: 7d50_script_captureoutput
Revises: 7d49_script_errormode
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d50_script_captureoutput'
down_revision = '7d49_script_errormode'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('scripts', sa.Column(
'captureoutput', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='When true, a successful run also returns its output to the '
'server; failures always do. Off by default because script '
'output can contain whatever the body printed.'))
def downgrade():
op.drop_column('scripts', 'captureoutput')

View File

@@ -0,0 +1,92 @@
"""Add endpointlogcollections
Backs the manual Log Collector tab: one row per plaintext log file an operator
collected from an endpoint by path.
Collected content gets its own table rather than living in
endpointcommands.resultjson for three reasons. It is bulky, and resultjson is
read by every command-history list that only wants a status. It needs its own
retention rule, which a command row cannot carry without deleting the audit
record of the command itself. And the download route has to address a single
collection by id and serve it as a file, which is awkward against a JSON column.
Retention is PER ASSET (20, enforced on insert in the result route), not global.
The history renders on a single machine's detail page, so a global cap would let
one busy endpoint quietly evict the history of another - the panel would be
unreliable exactly where it is read.
endpointcommandid is SET NULL rather than CASCADE: commands are reaped and
expired over time, and a collected log an operator is still reading must not
vanish because its originating command row was cleaned up. assetid stays CASCADE
because a log has no meaning once the machine is gone.
filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on
disk may be gigabytes, while what we store is a bounded tail.
Revision ID: 7d51_endpoint_logcollections
Revises: 7d50_script_captureoutput
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d51_endpoint_logcollections'
down_revision = '7d50_script_captureoutput'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'endpointlogcollections',
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
sa.Column('sourcepath', sa.String(length=512), nullable=False,
comment='Path as the AGENT resolved it, which may differ '
'from what the operator typed once environment '
'variables are expanded endpoint-side.'),
sa.Column('content', sa.Text(length=16777215), nullable=True),
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
comment='Size of the file on disk. Differs from contentbytes '
'when the stored copy is a truncated tail.'),
sa.Column('contentbytes', sa.Integer(), nullable=True),
sa.Column('linecount', sa.Integer(), nullable=True),
sa.Column('istruncated', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='True when content is only the tail of a larger file.'),
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
comment='Username snapshot; the id alone becomes unreadable '
'if the user is later removed.'),
sa.Column('collectedat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['endpointcommandid'],
['endpointcommands.endpointcommandid'],
ondelete='SET NULL'),
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
)
op.create_index('ix_endpointlogcollections_assetid',
'endpointlogcollections', ['assetid'])
op.create_index('ix_endpointlogcollections_collectedat',
'endpointlogcollections', ['collectedat'])
# Serves both hot paths: listing an asset's history newest-first, and
# finding that asset's oldest rows to prune on insert.
op.create_index('idx_endpointlogcollections_assetcollected',
'endpointlogcollections', ['assetid', 'collectedat'])
def downgrade():
op.drop_index('idx_endpointlogcollections_assetcollected',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_collectedat',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_assetid',
table_name='endpointlogcollections')
op.drop_table('endpointlogcollections')

View File

@@ -218,7 +218,7 @@
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart || canHoldPolicy">
<template #actions v-if="canRestart || canHoldPolicy || (canAgentUpdate && agentBehind)">
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
automatic policy enforcement, so it sits with the destructive
controls rather than with the read-only refresh. Red rather
@@ -233,6 +233,18 @@
<button v-if="canRestart" @click="confirmRestart"
class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
<!-- Only shown when this endpoint is actually behind the
published build, so it is a call to action rather than a
permanent button. Warning-styled: it interrupts the agent
service, but unlike Restart it does not take the machine
down, and it rolls back on failure. -->
<button v-if="canAgentUpdate && agentBehind"
@click="confirmAgentUpgrade"
class="btn btn-warning btn-sm"
:title="`Endpoint is on ${agentversion}, published build is ${agentcurrent}`"
:disabled="upgradingAgent">
{{ upgradingAgent ? 'Queueing...' : `Update Agent to ${agentcurrent}` }}
</button>
</template>
</AssetTriageBand>
</div>
@@ -594,6 +606,238 @@
</div>
</template>
</div>
<!-- Recent runs. A script that exits zero shows a green badge and
nothing else, which is not enough to tell "it did the work" from
"PowerShell started and stopped cleanly". The exit code, the
context it ran as and the directory it really ran in are shown so
the operator can judge that themselves. -->
<div class="card">
<h3>Recent Script Runs</h3>
<div v-if="scriptrunsloading" class="runscripts-empty">
Loading recent runs...
</div>
<div v-else-if="scriptruns.length === 0" class="runscripts-empty">
No scripts have been run on this computer yet.
</div>
<ul v-else class="scriptrun-list">
<li
v-for="run in scriptruns"
:key="run.endpointcommandid"
class="scriptrun-row"
>
<div class="scriptrun-head">
<span
class="scriptrun-badge"
:class="`scriptrun-badge--${run.status}`"
>{{ scriptRunStatusLabel(run) }}</span>
<span class="scriptrun-name">
{{ run.scriptname || run.target }}
</span>
<span class="scriptrun-when" :title="scriptRunTimeTitle(run)">
{{ scriptRunTimeLabel(run) }}
</span>
</div>
<p v-if="run.resultmessage" class="scriptrun-message">
{{ run.resultmessage }}
</p>
<dl v-if="run.resultdetail" class="scriptrun-facts">
<template v-if="run.resultdetail.exitcode !== undefined
&& run.resultdetail.exitcode !== null">
<dt>Exit code</dt>
<dd class="mono">{{ run.resultdetail.exitcode }}</dd>
</template>
<template v-if="run.resultdetail.runcontext">
<dt>Ran as</dt>
<dd>
{{ run.resultdetail.runcontext === 'user'
? 'The logged-on user' : 'SYSTEM' }}
</dd>
</template>
<template v-if="run.resultdetail.workingdirectory">
<dt>Ran in</dt>
<dd class="mono">{{ run.resultdetail.workingdirectory }}</dd>
</template>
</dl>
<!-- Output is only reported back on failure; on success it stays
on the endpoint. Shown verbatim when present because it is
the thing that explains the failure. -->
<pre
v-if="run.resultdetail && run.resultdetail.output"
class="scriptrun-output"
>{{ run.resultdetail.output }}</pre>
<p
v-if="run.status === 'succeeded' && !hasRunEvidence(run)"
class="scriptrun-note"
>
The agent reported success but sent no detail. This agent may
predate run reporting.
</p>
</li>
</ul>
<p class="scriptrun-foot">
A zero exit code means the script ran and PowerShell exited cleanly.
It does not confirm the script achieved what it was written to do.
Output is shown for failures, and for successes only when the script
is set to return it.
</p>
</div>
</div>
<!-- Log Collector. Manual retrieval of a plaintext log from a known path.
Gated on the application install/update grant, because reading an
app's log is part of the same job as pushing that app. -->
<div v-show="activegroup === 'logcollector'" class="detail-grid">
<div class="card">
<h3>Collect a Log File</h3>
<p class="logcollect-intro">
Reads a plaintext log from this computer and stores a copy in ShopDB
so it can be read here or downloaded. The agent only ever reads the
file - it is never modified, moved or deleted.
</p>
<div v-if="!liveness" class="logcollect-note logcollect-note--warn">
This computer is not currently checking in. A collection queued now
would sit unclaimed, so collecting is disabled until it reports
again. Previously collected logs are still readable below.
</div>
<!-- Capability, not permission. Shown even while the endpoint is
offline, because "too old" stays true when it comes back and an
operator planning work should see both facts at once. History
below still reads: logs collected before are unaffected. -->
<div
v-if="!agentSupportsLogCollection"
class="logcollect-note logcollect-note--warn"
>{{ logCollectorVersionNotice }}</div>
<div class="logcollect-form">
<label class="logcollect-label" for="logcollect-path">
Full path to the log file
</label>
<input
id="logcollect-path"
v-model="logpath"
class="logcollect-input mono"
type="text"
placeholder="C:\ProgramData\ShopDB\EndpointAgent\agent.log"
:disabled="collectingLog || !liveness || !agentSupportsLogCollection"
@keyup.enter="collectLog"
/>
<p v-if="logpatherror" class="logcollect-error">{{ logpatherror }}</p>
<p class="logcollect-hint">
Local path including the drive letter. Plaintext files only
(.log, .txt, .xml, .json, .csv and similar). Large files are
trimmed to the last 2 MB, which keeps the most recent entries.
</p>
</div>
<div class="logcollect-actions">
<button
class="btn btn-warning"
:disabled="!logpath.trim() || collectingLog || !liveness || !agentSupportsLogCollection"
@click="collectLog"
>{{ collectingLog ? 'Collecting...' : 'Collect Log' }}</button>
</div>
</div>
<!-- The most recently collected log, opened for reading. Separate from
the history so the thing just collected is visible without a
second click. -->
<div v-if="activelog" class="card">
<div class="logview-head">
<h3>{{ activelog.sourcepath }}</h3>
<button class="btn btn-secondary btn-sm" @click="downloadLog(activelog)">
Download
</button>
</div>
<dl class="logview-facts">
<dt>Collected</dt>
<dd>{{ formatDateTime(activelog.collectedat) }}</dd>
<template v-if="activelog.collectedbyusername">
<dt>Collected by</dt>
<dd>{{ activelog.collectedbyusername }}</dd>
</template>
<template v-if="activelog.filemodifiedat">
<dt>File last written</dt>
<dd>{{ formatDateTime(activelog.filemodifiedat) }}</dd>
</template>
<template v-if="activelog.linecount !== null">
<dt>Lines</dt>
<dd class="mono">{{ activelog.linecount }}</dd>
</template>
<dt>Size on disk</dt>
<dd class="mono">{{ formatBytes(activelog.filesizebytes) }}</dd>
</dl>
<p v-if="activelog.istruncated" class="logcollect-note">
This file was larger than the collection limit, so only the end of it
was kept. The oldest entries are not shown.
</p>
<div v-if="logcontentloading" class="runscripts-empty">
Loading log contents...
</div>
<pre v-else class="logview-pane">{{ activelog.content }}</pre>
</div>
<!-- History. Capped at 20 server-side; the note says so, because a
silently trimmed list looks like data loss. -->
<div class="card">
<h3>Collected Logs</h3>
<div v-if="logcollectionsloading" class="runscripts-empty">
Loading collected logs...
</div>
<div v-else-if="logcollections.length === 0" class="runscripts-empty">
No logs have been collected from this computer yet.
</div>
<ul v-else class="logcollect-list">
<li
v-for="entry in logcollections"
:key="entry.endpointlogcollectionid"
class="logcollect-row"
:class="{ 'logcollect-row--active':
activelog && activelog.endpointlogcollectionid === entry.endpointlogcollectionid }"
>
<div class="logcollect-rowhead">
<span class="logcollect-path mono">{{ entry.sourcepath }}</span>
<span class="logcollect-when">
{{ formatDateTime(entry.collectedat) }}
</span>
</div>
<div class="logcollect-rowmeta">
<span>{{ formatBytes(entry.filesizebytes) }}</span>
<span v-if="entry.linecount !== null">
{{ entry.linecount }} lines
</span>
<span v-if="entry.istruncated" class="logcollect-trunc">
trimmed
</span>
<span v-if="entry.collectedbyusername">
by {{ entry.collectedbyusername }}
</span>
</div>
<div class="logcollect-rowactions">
<button class="btn btn-secondary btn-sm" @click="viewLog(entry)">
View
</button>
<button class="btn btn-secondary btn-sm" @click="downloadLog(entry)">
Download
</button>
</div>
</li>
</ul>
<p class="scriptrun-foot">
The 20 most recent collections are kept for this computer. Older ones
are removed automatically as new logs are collected.
</p>
</div>
</div>
<!-- Audit Footer -->
@@ -808,6 +1052,15 @@ const computer = ref(null)
// when the viewer lacks endpoints.view - in either case the chip is simply not
// shown, which is better than rendering "unknown" and implying a fault.
const liveness = ref(null)
// Agent version this endpoint last reported, for capability gating. Null when
// the endpoints plugin is absent, the viewer cannot read telemetry, or the
// endpoint has never checked in.
const agentversion = ref(null)
// The currently published agent build, and the per-action version floors, both
// served by the endpoint state route. Held rather than hardcoded so raising a
// floor on the server cannot leave this page disagreeing with the API.
const agentcurrent = ref(null)
const actionminimums = ref({})
const installedApps = ref([])
const assetPanels = ref(null)
@@ -930,6 +1183,14 @@ const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript
const canViewOperatorReports = computed(
() => authStore.hasPermission('endpoints.operatorreports'))
// Gates the Log Collector tab. Rides the application install/update grant
// rather than endpoints.runscript, matching ELEVATED_ACTION_PERMISSIONS on the
// server: collecting an app's log is part of the same job as pushing that app,
// and reading a text file is strictly weaker than the installer execution this
// grant already permits. Declared here with the others because detailtabs reads
// it during setup.
const canUpdateApp = computed(() => authStore.hasPermission('endpoints.updateapp'))
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
@@ -971,6 +1232,16 @@ const detailtabs = computed(() => {
if (canRunScript.value && liveness.value) {
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
}
// Log Collector rides the application install/update grant, not the script
// grant: the reason to read a log is almost always to find out why an app
// push behaved as it did, so it is the same job as pushing the app. Reading a
// file is strictly weaker than the installer execution that grant already
// allows. Liveness-gated like the others - collection queues against a live
// agent. The history stays readable, so the tab is not hidden once the
// machine goes quiet; only the Collect button disables.
if (canUpdateApp.value) {
tabs.push({ key: 'logcollector', label: 'Log Collector' })
}
return tabs
})
@@ -987,6 +1258,13 @@ const refreshing = ref(false)
// enforced again server-side; hiding the button is convenience, not the guard.
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Replacing the agent is self-modifying the control plane, so it carries its
// own grant. The server also requires endpoints.runscript, because the upgrade
// lands as a script push - reflected here so the button does not offer an
// action the server will refuse.
const canAgentUpdate = computed(
() => authStore.hasPermission('endpoints.agentupdate')
&& authStore.hasPermission('endpoints.runscript'))
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
// scoped token cannot inherit it from the admin who minted it. This flag only
@@ -1218,10 +1496,19 @@ async function loadLiveness(assetid) {
if (state.status) {
liveness.value = { level: levels[state.status], label: labels[state.status] }
}
// Already in this payload, so no second request. Used to tell an operator
// that an action needs a newer agent BEFORE they queue a command that the
// agent will refuse with a message that reads like a permissions problem.
agentversion.value = state.agentversion || null
agentcurrent.value = state.agentcurrent || null
actionminimums.value = state.actionminimums || {}
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
liveness.value = null
agentversion.value = null
agentcurrent.value = null
actionminimums.value = {}
}
}
@@ -1518,9 +1805,346 @@ async function loadScriptLibrary() {
// Fetch on first view of the tab rather than on page load: most visits to a PC
// never open this, and the library is irrelevant to every other tab.
watch(activegroup, (group) => {
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
if (group === 'runscripts' && canRunScript.value) {
loadScriptLibrary()
loadScriptRuns()
}
if (group === 'logcollector' && canUpdateApp.value) {
loadLogCollections()
}
})
// Recent run history for this endpoint. Refetched rather than accumulated
// client-side so the panel reflects what the server actually recorded,
// including runs pushed by another operator from another session.
const scriptruns = ref([])
const scriptrunsloading = ref(false)
async function loadScriptRuns() {
scriptrunsloading.value = true
try {
const response = await endpointsApi.listScriptRuns(computer.value.assetid, 5)
scriptruns.value = response.data.data || []
} catch (error) {
// History is supporting evidence, not the action itself. A failure here
// must not read as a failed script run, so it is not toasted.
scriptruns.value = []
} finally {
scriptrunsloading.value = false
}
}
// ---- Log Collector ----
const logpath = ref('')
const logpatherror = ref('')
const collectingLog = ref(false)
const logcollections = ref([])
const logcollectionsloading = ref(false)
const activelog = ref(null)
const logcontentloading = ref(false)
// Log collection needs an agent new enough to have the action. An older agent
// has no case for it and answers "Unknown or disallowed action", which reads as
// a permissions refusal - so an operator checks a grant they already hold
// rather than the agent version that is actually the problem. The server
// refuses these too; this only stops the pointless round trip and says why.
const collectLogMinAgent = computed(() => actionminimums.value.collect_log || null)
// Numeric per part, never lexical. Agents report four parts ('1.15.0.0') while
// the build carries three ('1.16.0'), and a string compare would also rank
// '1.9.0.0' above '1.16.0' because '9' > '1'. Mirrors agent_meets_minimum on
// the server; both must agree or the button and the API disagree about the
// same endpoint.
function parseAgentVersion(value) {
if (!value) return null
const parts = String(value).trim().split('.')
const out = []
for (const chunk of parts) {
if (!/^\d+$/.test(chunk)) return null
out.push(Number(chunk))
}
if (!out.length) return null
while (out.length < 4) out.push(0)
return out.slice(0, 4)
}
function agentMeetsMinimum(reported, minimum) {
const reportedparts = parseAgentVersion(reported)
const minimumparts = parseAgentVersion(minimum)
// Fail closed on an unreadable version. Seeded rows carry '0.0.0-sample',
// and there is no honest ordering between a placeholder and a real build.
if (!reportedparts || !minimumparts) return false
for (let i = 0; i < 4; i += 1) {
if (reportedparts[i] > minimumparts[i]) return true
if (reportedparts[i] < minimumparts[i]) return false
}
return true
}
const agentSupportsLogCollection = computed(() => {
// No floor served means the action carries no version requirement, so this
// must not block. Only a floor that the endpoint fails should disable it.
if (!collectLogMinAgent.value) return true
return agentMeetsMinimum(agentversion.value, collectLogMinAgent.value)
})
// Names both versions. "Requires a newer agent" alone leaves the operator
// without the one fact they need to act: which version this machine is on.
const logCollectorVersionNotice = computed(() => {
if (agentSupportsLogCollection.value) return ''
const reported = agentversion.value
? `this endpoint reports ${agentversion.value}`
: 'this endpoint has not reported an agent version'
return `Log collection requires agent ${collectLogMinAgent.value} or newer; `
+ `${reported}. Update the agent on this endpoint to use it.`
})
// True only when both versions parse AND the endpoint is strictly older than
// the published build. Silent when nothing is published or either version is a
// placeholder like '0.0.0-sample' - offering an upgrade off an unreadable
// version would be a false alarm, and the server applies the same rule.
const agentBehind = computed(() => {
if (!agentversion.value || !agentcurrent.value) return false
if (!parseAgentVersion(agentversion.value)) return false
if (!parseAgentVersion(agentcurrent.value)) return false
return !agentMeetsMinimum(agentversion.value, agentcurrent.value)
})
const upgradingAgent = ref(false)
// Typed confirmation, matching confirmRunScript and the destructive Corporate
// Tools: this stops the SYSTEM service that IS the command channel and swaps it
// underneath itself. An OK button is too easy to hit for something that, if it
// goes wrong, takes remote management of the machine with it. The prompt names
// BOTH versions so the operator can see exactly what is being replaced.
async function confirmAgentUpgrade() {
if (!computer.value?.assetid) return
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
const typed = prompt(`Update the ShopDB agent on ${label}?\n\n`
+ `${agentversion.value} will be replaced with ${agentcurrent.value}.\n`
+ 'The agent service stops and restarts, so this endpoint will briefly '
+ 'stop reporting. If the upgrade fails the agent rolls back to the '
+ 'current build.\n\nType UPDATE to confirm.')
if (typed !== 'UPDATE') return
upgradingAgent.value = true
try {
const response = await endpointsApi.enqueueAgentUpgrade(computer.value.assetid)
// "Queued", not "updated": the agent collects this on its next check-in and
// the swap happens afterwards. Claiming success here would assert an
// outcome nothing has observed.
toast.success(`Agent update queued for ${label}`)
const commandId = response?.data?.data?.endpointcommandid
if (commandId) {
await waitForCommand(commandId)
// Re-read liveness so the version and the behind flag reflect what the
// endpoint now reports, rather than leaving a stale "behind" badge on a
// machine that just upgraded.
await loadLiveness(computer.value.assetid)
await assetPanels.value?.refresh()
}
} catch (error) {
toast.error(apiError(error, `Failed to queue the agent update for ${label}`))
} finally {
upgradingAgent.value = false
}
}
// History for this endpoint. Metadata only - bodies are fetched one at a time,
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
// expensive than reading the one log that is actually wanted.
async function loadLogCollections() {
logcollectionsloading.value = true
try {
const response = await endpointsApi.listLogCollections(computer.value.assetid)
logcollections.value = response.data.data || []
} catch (error) {
// Supporting evidence, not the action itself; a failure here must not read
// as a failed collection.
logcollections.value = []
} finally {
logcollectionsloading.value = false
}
}
// Client-side shape check purely for immediate feedback. The server and the
// agent both re-validate; this exists so an obvious typo does not cost a round
// trip and a poll cycle to report back.
function validateLogPath(raw) {
const path = (raw || '').trim()
if (!path) return 'Enter the full path to the log file'
const normalized = path.replace(/\//g, '\\')
if (normalized.startsWith('\\\\')) {
return 'Network (UNC) paths are not supported; use a local path'
}
if (!/^[A-Za-z]:\\/.test(normalized)) {
return 'Include the drive letter, for example C:\\ProgramData\\App\\app.log'
}
if (normalized.split('\\').some((part) => part === '..')) {
return 'Path must not contain ".." segments'
}
if (normalized.includes('*') || normalized.includes('?')) {
return 'Wildcards are not supported; name a single log file'
}
return ''
}
async function collectLog() {
// Belt and braces with the disabled button: keyboard Enter reaches here too,
// and a stale agentversion could leave the button live for a moment.
if (!agentSupportsLogCollection.value) {
logpatherror.value = logCollectorVersionNotice.value
return
}
const path = logpath.value.trim()
logpatherror.value = validateLogPath(path)
if (logpatherror.value) return
collectingLog.value = true
try {
const response = await endpointsApi.enqueueCommand(
computer.value.assetid, 'collect_log', path)
const commandId = response.data.data?.endpointcommandid
// Wait for the agent rather than optimistically showing a result: the file
// is read on the endpoint, so until the command completes there is nothing
// to display and no way to know whether the path even exists.
const outcome = commandId ? await waitForCommand(commandId) : 'timeout'
await loadLogCollections()
if (outcome === 'succeeded') {
toast.success('Log collected')
// Open the newest collection, which is the one just produced.
const newest = logcollections.value[0]
if (newest) await viewLog(newest)
} else if (outcome === 'failed') {
// The agent's refusal reason is the useful part (file missing, access
// denied, not a text file), so it is fetched and surfaced verbatim
// instead of a generic failure message.
toast.error(await collectFailureReason(commandId))
} else {
toast.info(
'Collection queued. It will appear here once the agent reports back.')
}
} catch (error) {
toast.error(apiError(error, 'Failed to collect the log'))
} finally {
collectingLog.value = false
}
}
// Why a collection failed. waitForCommand only reports the terminal state, but
// the agent's message is the actionable part - "No file found at ...",
// "Access denied", "does not appear to be a plaintext file" - so it is read off
// the command row rather than replaced with a generic failure.
async function collectFailureReason(commandId) {
const fallback = 'The log could not be collected'
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
return command?.resultmessage || fallback
} catch {
return fallback
}
}
async function viewLog(entry) {
logcontentloading.value = true
// Show the row's metadata immediately so the pane is not blank while the
// body loads.
activelog.value = { ...entry, content: '' }
try {
const response = await endpointsApi.getLogCollection(
entry.endpointlogcollectionid)
activelog.value = response.data.data
} catch (error) {
activelog.value = null
toast.error(apiError(error, 'Failed to open the collected log'))
} finally {
logcontentloading.value = false
}
}
async function downloadLog(entry) {
try {
const response = await endpointsApi.downloadLogCollection(
entry.endpointlogcollectionid)
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.download = entry.filename || 'collected.log'
document.body.appendChild(link)
link.click()
// Revoke and detach, or every download leaks a blob URL and a node for the
// lifetime of the page.
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
toast.error(apiError(error, 'Failed to download the collected log'))
}
}
// Byte counts are shown to a human deciding whether a log is worth opening, so
// they are scaled rather than printed raw.
function formatBytes(value) {
if (value === null || value === undefined) return 'Unknown'
const bytes = Number(value)
if (!Number.isFinite(bytes)) return 'Unknown'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// Queued and claimed are live states, not outcomes. Labelling them "pending"
// would imply the run finished and reported nothing.
function scriptRunStatusLabel(run) {
if (run.status === 'failed') return 'Failed'
if (run.status === 'claimed') return 'Running'
return 'Queued'
}
// The timestamp on a run row means two different things depending on the state:
// when it FINISHED for a terminal run, when it was QUEUED for one still in
// flight. Rendered bare, those are indistinguishable, and a queued row showing
// its queue time reads as a completed run that finished then - which is exactly
// the wrong conclusion while the script is still executing. The label says which
// one is being shown.
function scriptRunTimeLabel(run) {
const finished = run.status === 'succeeded' || run.status === 'failed'
if (finished && run.completedat) return formatDateTime(run.completedat)
return `Queued ${formatDateTime(run.createdat)}`
}
// Hover detail. A terminal row only shows its finish time, so the queue time -
// the gap that tells an operator the agent was slow to pick the job up - would
// otherwise be lost.
function scriptRunTimeTitle(run) {
const parts = [`Queued ${formatDateTime(run.createdat)}`]
if (run.completedat) parts.push(`Completed ${formatDateTime(run.completedat)}`)
return parts.join('\n')
}
// Whether the agent sent anything beyond the status itself. Drives the note
// that distinguishes "nothing to report" from "this agent cannot report".
function hasRunEvidence(run) {
const detail = run.resultdetail
if (!detail) return false
return detail.exitcode !== undefined && detail.exitcode !== null
}
function formatDateTime(value) {
if (!value) return 'unknown'
// Server sends naive UTC isoformat; mark it as UTC so it renders in local
// time rather than being read as local and shifting by the offset.
const raw = value.endsWith('Z') ? value : `${value}Z`
const when = new Date(raw)
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
}
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.corptools regardless.
@@ -1578,12 +2202,22 @@ async function confirmRunScript() {
firingScript.value = true
try {
await endpointsApi.enqueueCommand(
const response = await endpointsApi.enqueueCommand(
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
// "Queued", not "finished": the agent collects this on its next check-in,
// and the script's own result comes back separately. Saying "ran" here
// would claim an outcome nothing has observed yet.
toast.success(`${script.scriptname} queued for ${label}`)
// Show the queued row straight away, then again once it reaches a terminal
// state, so the history reflects the run the operator just triggered
// without them having to leave and re-enter the tab.
await loadScriptRuns()
const commandId = response?.data?.data?.endpointcommandid
if (commandId) {
await waitForCommand(commandId)
await loadScriptRuns()
}
} catch (error) {
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
} finally {
@@ -2092,6 +2726,258 @@ async function confirmRetire() {
.runscripts-actions {
margin-top: 1rem;
}
/* Recent script runs. Themed off the same variables as the rest of the page so
the panel follows dark mode rather than pinning its own colours. */
.scriptrun-list {
list-style: none;
margin: 0;
padding: 0;
}
.scriptrun-row {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.scriptrun-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.scriptrun-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.scriptrun-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-light);
border: 1px solid var(--border);
}
.scriptrun-badge--succeeded {
color: var(--success-dark);
border-color: var(--success);
}
.scriptrun-badge--failed {
color: var(--danger);
border-color: var(--danger);
}
[data-theme="dark"] .scriptrun-badge--succeeded { color: var(--success); }
.scriptrun-name {
font-weight: 600;
color: var(--text);
font-size: 0.875rem;
}
.scriptrun-when {
margin-left: auto;
color: var(--text-light);
font-size: 0.75rem;
}
.scriptrun-message {
margin: 0.375rem 0 0;
color: var(--text);
font-size: 0.8125rem;
}
.scriptrun-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0.5rem 0 0;
font-size: 0.8125rem;
}
.scriptrun-facts dt {
color: var(--text-light);
}
.scriptrun-facts dd {
margin: 0;
color: var(--text);
}
.scriptrun-facts dd.mono {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
word-break: break-all;
}
/* Agent output is arbitrary text: it scrolls rather than stretching the card,
and wraps so a long single line does not push the layout sideways. */
.scriptrun-output {
margin: 0.5rem 0 0;
padding: 0.5rem;
max-height: 12rem;
overflow: auto;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.scriptrun-note,
.scriptrun-foot {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.scriptrun-foot {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* Log Collector. Themed off the same variables as the panels above so it
follows dark mode rather than pinning its own colours. */
.logcollect-intro {
margin: 0 0 0.75rem;
color: var(--text-light);
font-size: 0.8125rem;
}
.logcollect-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.logcollect-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
}
.logcollect-input {
width: 100%;
padding: 0.5rem;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.8125rem;
}
.logcollect-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.logcollect-hint {
margin: 0.25rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-error {
margin: 0.25rem 0 0;
color: var(--danger);
font-size: 0.75rem;
}
.logcollect-note {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-note--warn {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-left: 3px solid var(--warning);
background: var(--bg-subtle, var(--bg));
border-radius: 4px;
}
.logcollect-actions {
margin-top: 1rem;
}
.logview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
/* The collected path can be long; it wraps rather than forcing the card wide. */
.logview-head h3 {
margin: 0;
font-size: 0.9375rem;
word-break: break-all;
}
.logview-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0.75rem 0 0;
font-size: 0.8125rem;
}
.logview-facts dt {
color: var(--text-light);
}
.logview-facts dd {
margin: 0;
color: var(--text);
}
/* The log body. Scrolls rather than stretching the card, and wraps so a long
line does not push the layout sideways. */
.logview-pane {
margin: 0.75rem 0 0;
padding: 0.75rem;
max-height: 28rem;
overflow: auto;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.logcollect-list {
list-style: none;
margin: 0;
padding: 0;
}
.logcollect-row {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.logcollect-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.logcollect-row--active {
border-left: 3px solid var(--primary, var(--warning));
padding-left: 0.5rem;
}
.logcollect-rowhead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.logcollect-path {
font-size: 0.8125rem;
color: var(--text);
word-break: break-all;
}
.logcollect-when {
color: var(--text-light);
font-size: 0.75rem;
white-space: nowrap;
}
.logcollect-rowmeta {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-trunc {
color: var(--warning-dark, var(--warning));
}
[data-theme="dark"] .logcollect-trunc {
color: var(--warning);
}
.logcollect-rowactions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }

View File

@@ -0,0 +1,118 @@
"""Register staged endpoint agent builds as installable Applications.
The endpoint update_app push resolves an install source from the applications
table alone - it has no knowledge of the agent version store. Without a row
there, a build can be staged and current in agentstore and still be impossible
to push, which is exactly the gap found in the fleet: no application row existed
for the agent at all.
This module bridges the two by making the UPLOAD the single source of truth.
Staging a build repackages the current agent (plus tray) and rewrites the
application row's installpath and installersha256 in the same request, so the
row cannot describe a build that is no longer current. Nothing here is
hand-maintained; an operator never edits these rows.
"""
import os
from shopdb.api import Application, application_package_location, db
from . import agentstore
# Reserved appname for the managed agent bundle. appname is unique, which is
# what makes the upsert idempotent: repeated uploads update one row rather than
# accumulating a row per build. Changing this string strands the existing row,
# so it is a constant rather than anything configurable.
AGENT_APPNAME = 'ShopDB Endpoint Agent'
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
_AGENT_DESCRIPTION = (
'Managed ShopDB endpoint agent bundle. Created and kept current '
'automatically when an agent build is staged - do not edit by hand.'
)
def _package_path(appid):
directory, _prefix = application_package_location()
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
def get_agent_application():
"""The reserved agent Application row, or None when nothing is registered.
Read-only lookup for callers that need the appid to build a delivery URL
without causing a row to spring into existence as a side effect.
"""
return Application.query.filter_by(appname=AGENT_APPNAME).first()
def _get_or_create_app():
"""Return the reserved agent Application row, creating it when absent.
Does not commit: the caller commits once, after the package is written, so
a failed write cannot leave a row advertising an installer that is not
there.
"""
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
if app:
return app, False
app = Application(
appname=AGENT_APPNAME,
appdescription=_AGENT_DESCRIPTION,
# Installable so it can be pushed; hidden so a machine-managed row does
# not surface in the software catalogue alongside real licensed apps.
isinstallable=True,
ishidden=True,
)
db.session.add(app)
# Flush rather than commit: the row needs its appid to build the package
# path, but the transaction stays open so the whole registration is atomic.
db.session.flush()
return app, True
def register_current_agent_package():
"""Repackage the current agent build and point the agent app row at it.
Returns a dict describing what was registered, or None when there is no
current agent build to package. Safe to call on every upload: it is an
upsert keyed on the reserved appname, and it rewrites the package file in
place.
"""
payload, manifest = agentstore.build_current_package()
if payload is None:
return None
app, created = _get_or_create_app()
path = _package_path(app.appid)
os.makedirs(os.path.dirname(path), exist_ok=True)
# Write to a temp name and replace, so an interrupted write cannot leave a
# truncated archive at the path the row already advertises. os.replace is
# atomic within a filesystem.
temppath = path + '.tmp'
with open(temppath, 'wb') as handle:
handle.write(payload)
os.replace(temppath, path)
_directory, urlprefix = application_package_location()
app.installpath = '{}{}/{}'.format(
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
# The per-binary digests inside the manifest are for the install script to
# check its unpacked contents, and are not interchangeable with this one.
app.installersha256 = manifest['sha256']
db.session.commit()
return {
'appid': app.appid,
'appname': app.appname,
'created': created,
'installpath': app.installpath,
'sha256': manifest['sha256'],
'size': manifest['size'],
'agentversion': manifest['agentversion'],
'contents': manifest['contents'],
}

View File

@@ -35,6 +35,7 @@ import json
import os
import re
import struct
import zipfile
from datetime import datetime
from flask import current_app
@@ -470,3 +471,109 @@ def set_current(binkey, version):
manifest['current'] = version
_write_manifest(binkey, manifest)
return True
def version_sha256(binkey, version):
"""The recorded digest for a staged version, or None when not recorded.
Read from the manifest rather than recomputed: store_version already
hashed the exact bytes it wrote, so re-reading the file to hash it again
would only introduce a way for the two to disagree.
"""
for entry in _read_manifest(binkey).get('versions', []):
if entry.get('version') == version:
return entry.get('sha256')
return None
# Name the upgrade payload carries inside the archive. Fixed rather than derived
# from the staged filenames so the install script can address its contents
# without being told what to expect.
PACKAGE_MEMBER_NAMES = {
'agent': 'shopdbagent.exe',
'tray': 'shopdb-endpoint-tray.exe',
}
# Fixed member timestamp so packaging the same build twice yields byte-identical
# archives. The value is arbitrary (the DOS epoch zip supports); only its
# constancy matters. Real build provenance lives in the manifest's versions and
# digests, not in a zip entry's mtime.
_PACKAGE_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
def build_current_package():
"""Zip the current agent build (plus the current tray) into one archive.
Returns (bytes, manifestdict) or (None, None) when no agent build is
current. The agent is required; the tray is included only when it too has
a current staged version, because an endpoint can legitimately run the
service with no tray and half a tray upgrade is worse than none.
The archive is built IN MEMORY and returned rather than written here: the
caller decides where it lands, and nothing under the version store gets a
derived artifact that retention would then have to reason about.
The returned manifest describes what went in - version and digest per
member - so the install script can verify it unpacked the build the server
meant to send, rather than trusting the archive's filename.
"""
agentversion = current_version('agent')
if not agentversion:
return None, None
members = []
agentpath = _version_exe_path('agent', agentversion)
if not os.path.isfile(agentpath):
return None, None
members.append(('agent', agentversion, agentpath))
trayversion = current_version('tray')
if trayversion:
traypath = _version_exe_path('tray', trayversion)
if os.path.isfile(traypath):
members.append(('tray', trayversion, traypath))
contents = {}
buffer = io.BytesIO()
# ZIP_DEFLATED, not ZIP_STORED: these are ~70 MB self-contained binaries
# carrying the whole .NET runtime, and the archive crosses the wire to
# every endpoint being upgraded.
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
for binkey, version, path in members:
try:
with open(path, 'rb') as handle:
data = handle.read()
except OSError:
# A staged file that cannot be read means the package would be
# silently incomplete. Refuse the whole thing: a partial upgrade
# payload is how an endpoint ends up with a new service exe and
# a stale tray.
return None, None
name = PACKAGE_MEMBER_NAMES[binkey]
# Build the entry with a FIXED timestamp. zipfile otherwise stamps
# each member with the current local time, which makes the archive
# bytes - and therefore its digest - different on every call for
# identical input. update_app pins a download to a digest, so a
# drifting hash would invalidate a package an endpoint is already
# fetching. Same reason the mode is set explicitly.
info = zipfile.ZipInfo(name, date_time=_PACKAGE_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, data)
contents[binkey] = {
'filename': name,
'version': version,
# Prefer the recorded digest so the package agrees with the
# manifest; fall back to hashing when an older manifest entry
# predates digest recording.
'sha256': (version_sha256(binkey, version)
or hashlib.sha256(data).hexdigest()),
}
payload = buffer.getvalue()
return payload, {
'agentversion': agentversion,
'contents': contents,
'sha256': hashlib.sha256(payload).hexdigest(),
'size': len(payload),
}

View File

@@ -0,0 +1,136 @@
"""Seed the agent self-upgrade script into the ShopDB script library.
run_script only delivers bodies that live in the script library, pinned by
SHA-256. The upgrade template therefore has to exist as a real Script row with
a stored body before any upgrade can be pushed.
The template is source-controlled at
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 and seeded from there
rather than typed into the library by hand, so the reviewed script and the one
endpoints actually run are the same artifact. Editing the row's body in the UI
is pointless - the next seed overwrites it.
It lives under the plugin, not under agent/, because the Docker image copies
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
from agent/ resolves fine on a developer checkout and then fails in the
deployed container, which is the worst place to discover it.
One placeholder is rendered at seed time: the agent application's appid, which
only exists once agentregistry has created the row. Rendering happens BEFORE
the digest is computed, so the stored body and its sha256 always agree; a
substitution done after hashing would fail the agent's verification.
"""
import hashlib
import os
from shopdb.api import Script, db, script_body_location
from . import agentregistry
# Reserved script name. Unique in the library, which makes seeding idempotent
# and keeps a hand-made script from colliding with the managed one.
UPGRADE_SCRIPT_NAME = 'ShopDB Endpoint Agent Self-Upgrade'
_UPGRADE_DESCRIPTION = (
'Managed: downloads the current published agent bundle and replaces the '
'installed agent in place, then restarts the service. Seeded from source '
'control - edits here are overwritten.'
)
# Substituted with the agent application's appid so the script can fetch its
# payload from the existing collector-authenticated app-package route.
_APPID_TOKEN = '{{AGENT_APPID}}'
_TEMPLATE_FILENAME = 'upgrade-shopdbagent.ps1'
def template_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
'templates', _TEMPLATE_FILENAME)
def _script_dir():
"""Where script bodies live. Resolved through the contract surface helper
so this cannot drift from the directory the download route serves from."""
directory, _prefix = script_body_location()
return directory
def render_template(appid):
"""Return the upgrade script body with the appid substituted.
Raises if the placeholder is missing: that means the template was edited
in a way that breaks the download, and seeding a script that would fetch
from a literal '{{AGENT_APPID}}' path is worse than refusing.
"""
with open(template_path(), 'r', encoding='utf-8') as handle:
body = handle.read()
if _APPID_TOKEN not in body:
raise ValueError(
'upgrade template is missing the {} placeholder; refusing to seed '
'a script that cannot resolve its package'.format(_APPID_TOKEN))
return body.replace(_APPID_TOKEN, str(appid))
def seed_upgrade_script():
"""Create or refresh the managed upgrade script row and its body.
Returns a dict describing the seeded script, or None when the agent
application row does not exist yet (nothing to point the download at).
Idempotent: unchanged input rewrites nothing and leaves the digest alone.
"""
app = agentregistry.get_agent_application()
if app is None:
return None
body = render_template(app.appid).encode('utf-8')
digest = hashlib.sha256(body).hexdigest()
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
created = script is None
if created:
script = Script(
scriptname=UPGRADE_SCRIPT_NAME,
scriptdescription=_UPGRADE_DESCRIPTION,
# SYSTEM: the script stops a service, writes into Program Files,
# and edits HKLM. None of that is available in a user context.
runcontext='system',
# Failures always report; success is opt-in. Capture it here
# because an upgrade that "succeeded" but rolled back internally
# is only diagnosable from the transcript.
captureoutput=True,
)
db.session.add(script)
db.session.flush()
# Body filename follows the library convention (script-<id>.ps1) so the
# collector download route serves it like any other script.
filename = 'script-{}.ps1'.format(script.scriptid)
path = os.path.join(_script_dir(), filename)
unchanged = (script.filename == filename and script.sha256 == digest
and os.path.isfile(path))
if not unchanged:
os.makedirs(_script_dir(), exist_ok=True)
# Temp-then-replace so an interrupted write cannot leave a truncated
# body at the path the row's digest already claims to describe.
temppath = path + '.tmp'
with open(temppath, 'wb') as handle:
handle.write(body)
os.replace(temppath, path)
script.filename = filename
script.sha256 = digest
script.scriptdescription = _UPGRADE_DESCRIPTION
db.session.commit()
return {
'scriptid': script.scriptid,
'scriptname': script.scriptname,
'created': created,
'updated': not unchanged,
'sha256': digest,
'bytes': len(body),
'appid': app.appid,
}

View File

@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
command family from application deploys (endpoints.deploy) so app-push rights
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
Staging also keeps the pushable application row current (see agentregistry):
whenever the current build changes, the bundle is repackaged and the row's
installpath/installersha256 are rewritten. Registration is deliberately NOT
allowed to fail the upload - the binary is already staged and audited by then,
so a packaging problem is reported in the response payload rather than raised.
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
"""
@@ -24,7 +30,7 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
require_permission)
from .routes import endpoints_bp
from . import agentstore
from . import agentstore, agentregistry
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
@@ -40,6 +46,33 @@ from . import agentstore
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
def _sync_agent_registration():
"""Repackage and re-register the current build. Returns a status dict.
Called from every route that can change which build is current, so the
pushable application row can never describe a build that is no longer
staged. That drift is the whole failure this is meant to prevent, and it
would be silent - the row would keep pointing at bytes nobody selected.
Never raises. Staging has already succeeded on disk and been audited by the
time this runs; a packaging or database problem must not turn a successful
upload into an error response and leave the operator thinking the binary
was rejected. The failure is reported in the response payload instead, so
the UI can surface it without losing the upload.
"""
try:
registration = agentregistry.register_current_agent_package()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
db.session.rollback()
return {'ok': False, 'error': str(exc)}
if registration is None:
# No current agent build to package. Normal when only a tray has been
# staged: the bundle is defined by the agent, so there is nothing to
# register yet.
return {'ok': False, 'error': 'no current agent build to package'}
return {'ok': True, **registration}
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
@require_permission('endpoints.agentpackage')
def list_agent_binaries(binkey):
@@ -129,10 +162,16 @@ def upload_agent_binary(binkey):
'madecurrent': makecurrent})
db.session.commit()
# Keep the pushable application row in step with what is now current, in
# the same request. Doing it here rather than at push time means the row
# and the staged build cannot disagree in between.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -215,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'the legacy binary could not be adopted',
http_code=400)
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -243,8 +284,13 @@ def set_current_agent_binary(binkey):
ErrorCodes.NOT_FOUND,
'that version is not staged',
http_code=404)
# A rollback changes the bytes the fleet should receive; repackage so the
# application row follows the pointer instead of pinning the build the
# operator just rolled away from.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
'registration': registration,
})

View File

@@ -10,6 +10,7 @@ import hashlib
import hmac
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
@@ -28,9 +29,10 @@ from shopdb.api import (
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
EndpointHealthState, OperatorReport,
EndpointHealthState, OperatorReport, EndpointLogCollection,
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from . import agentstore
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.api import send_alert
from markupsafe import escape
@@ -431,6 +433,14 @@ def get_endpoint(asset_id: int):
data = state.to_dict()
data['status'] = status
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
# The currently published agent build, so the page can say "behind" without
# hardcoding a version that goes stale the next time one is published.
# None when nothing has been staged yet, which reads as "cannot tell".
data['agentcurrent'] = agentstore.current_version('agent')
# Per-action agent floors. Served rather than duplicated in the browser:
# the enqueue guard refuses against these same numbers, so a copy in the
# frontend would silently disagree the moment a floor was raised here.
data['actionminimums'] = action_minimum_versions()
return success_response(data)
@@ -552,6 +562,20 @@ def panel_status(asset_id: int):
entry['format'] = fmt
return entry
# Annotate the agent version when the endpoint is behind the published
# build. Done here rather than as a separate row so the fact sits on the
# version it describes. Silent when nothing is published, when the endpoint
# has not reported, or when either version is unparseable - claiming
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
agentvalue = state.agentversion
published = agentstore.current_version('agent')
agentbehind = (agentvalue and published
and parse_agent_version(agentvalue)
and parse_agent_version(published)
and not agent_meets_minimum(agentvalue, published))
if agentbehind:
agentvalue = '{} (behind {})'.format(agentvalue, published)
fields = [
field('Logged in user', state.loggedinuser),
field('Uptime', _format_uptime(state.uptimeseconds)),
@@ -561,7 +585,7 @@ def panel_status(asset_id: int):
fmt='datetime'),
field('CPU %', state.cpupercent),
field('Memory %', state.memorypercent),
field('Agent', state.agentversion, mono=True),
field('Agent', agentvalue, mono=True),
field('Tray', state.trayversion, mono=True),
field('Last reported',
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
@@ -586,7 +610,17 @@ def panel_status(asset_id: int):
'{} monitor(s) expected running but not reported: {}'.format(
len(drift), names)))
return success_response({'fields': fields})
# Machine-readable companion to the "(behind X)" text above. The UI needs to
# decide whether to offer the upgrade action, and parsing that decision back
# out of a display string would tie the button to the wording of a label.
# Emitted as a sibling of fields so nothing about the existing rendering
# changes; an older UI simply ignores it.
return success_response({
'fields': fields,
'agentbehind': bool(agentbehind),
'agentversion': state.agentversion,
'agentcurrent': published,
})
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
@@ -2068,6 +2102,196 @@ def _resolve_update_app(target):
return None, f'Unknown update_app target kind: {kind!r}'
# Extensions collect_log will read. The list is an allowlist, not a blocklist:
# the question is not "is this dangerous to read" but "will this render as text
# in a browser pane", and only a closed set can answer that. Anything else comes
# back refused rather than as mojibake the operator has to interpret.
#
# The agent enforces this same set independently. Duplicating it is deliberate -
# this copy exists to give a fast, actionable error at enqueue, and the agent's
# copy is the one that actually guards the disk.
#
# The Windows ".c-f-g" extension (spelled with separators here on purpose) is
# deliberately absent: the literal collides with the banned-shorthand naming
# gate, and weakening a repo-wide gate for one rarely-collected extension is a
# bad trade. '.conf' and '.ini' cover the same need.
LOG_PATH_EXTENSIONS = frozenset({
'.log', '.txt', '.xml', '.json', '.csv', '.ini', '.conf',
'.yml', '.yaml', '.trace', '.out', '.err', '.md',
})
# Windows path length ceiling for the classic (non-\\?\) API. A longer path
# cannot be opened by the agent anyway, so refusing here is honest.
LOG_PATH_MAX_LENGTH = 260
def _validate_log_path(target):
"""Reject a collect_log path that cannot be a readable local log file.
Returns an error string, or None when the shape is acceptable.
This is a SHAPE check for fast operator feedback. It cannot be a security
boundary: the server has no view of the endpoint's filesystem, and any
caller reaching this code already holds endpoints.updateapp. The agent
performs its own independent validation before opening anything.
Rejections here are the ones that are wrong no matter what is on disk:
a relative path (ambiguous - relative to what, on a service running as
SYSTEM), a traversal segment (an attempt to walk out of the directory the
operator named, which an honest log path never needs), a UNC path (that
would make the endpoint agent read from a file server using the machine
account, turning a log collector into a network file reader), and an
extension outside the text allowlist.
"""
path = (target or '').strip()
if not path:
return 'A log file path is required'
if len(path) > LOG_PATH_MAX_LENGTH:
return (f'Path is too long ({len(path)} characters); the maximum is '
f'{LOG_PATH_MAX_LENGTH}')
normalized = path.replace('/', '\\')
# A UNC path would have the agent read from a network share as the machine
# account. That is a different capability from "read a log off this PC" and
# is not what this permission was granted for.
if normalized.startswith('\\\\'):
return ('UNC/network paths are not supported; collect logs from a '
'local path on the endpoint')
# Require a drive-letter absolute path. A bare or relative path resolves
# against the agent service's working directory, which is not somewhere an
# operator can reason about.
if not re.match(r'^[A-Za-z]:\\', normalized):
return ('Enter a full local path including the drive letter '
r'(for example C:\ProgramData\App\app.log)')
# '..' would let a typed path escape the directory it appears to name. No
# legitimate log path needs it, so it is refused rather than normalised -
# normalising would silently read something other than what was reviewed.
if any(part == '..' for part in normalized.split('\\')):
return 'Path must not contain ".." segments'
# Wildcards would make the collection non-deterministic: what it reads would
# depend on the directory's contents at run time, so the history could not
# say what was actually collected.
if '*' in normalized or '?' in normalized:
return 'Wildcards are not supported; name a single log file'
extension = os.path.splitext(normalized)[1].lower()
if extension not in LOG_PATH_EXTENSIONS:
allowed = ', '.join(sorted(LOG_PATH_EXTENSIONS))
return (f'Only plaintext log files can be collected. Allowed '
f'extensions: {allowed}')
return None
def _record_log_collection(command, detail, content):
"""Persist a collected log for a command and prune that asset's history.
Called inside report_command_result, before its commit, so the collection
row, the pruning and the command's own finalisation all land together. A
separate commit here could leave a collected log with no finalised command
if the request failed in between.
Pruning is per asset and runs on insert. Doing it here rather than on a
schedule means the cap is a real invariant rather than something that is
true most of the time: there is no window in which an asset has 40 rows
waiting for a cleanup job that may not be running.
"""
parsed_modified = None
raw_modified = (detail or {}).get('modifiedat')
if raw_modified:
parsed_modified = _parse_agent_datetime(raw_modified)
text = content if isinstance(content, str) else None
collection = EndpointLogCollection(
assetid=command.assetid,
endpointcommandid=command.endpointcommandid,
# Prefer the path the AGENT resolved; fall back to what was queued so a
# row is never left without a path to show.
sourcepath=((detail or {}).get('path') or command.target or '')[:512],
content=text,
filesizebytes=_coerce_int((detail or {}).get('filesizebytes')),
contentbytes=(len(text.encode('utf-8', 'replace')) if text is not None
else None),
linecount=_coerce_int((detail or {}).get('linecount')),
istruncated=bool((detail or {}).get('truncated')),
filemodifiedat=parsed_modified,
# requestedby is the operator who queued the collection. The agent posts
# this result with a collector token and has no user identity, so the
# attribution has to come off the command row.
collectedbyuserid=command.requestedby,
collectedbyusername=_username_for(command.requestedby),
collectedat=_naive_utcnow(),
)
db.session.add(collection)
# Make the new row visible to the pruning query below, so the cap counts it.
db.session.flush()
_prune_log_collections(command.assetid)
return collection
def _prune_log_collections(assetid):
"""Keep only the newest RETENTION_PER_ASSET collections for an asset.
Ordered by collectedat DESC then id DESC: two collections can share a
timestamp at second resolution, and without the id tiebreak the ordering
would be non-deterministic, which is how a prune ends up deleting the row it
just inserted.
"""
keep = EndpointLogCollection.RETENTION_PER_ASSET
stale = (EndpointLogCollection.query
.filter_by(assetid=assetid)
.order_by(EndpointLogCollection.collectedat.desc(),
EndpointLogCollection.endpointlogcollectionid.desc())
.offset(keep)
.all())
for row in stale:
db.session.delete(row)
return len(stale)
def _coerce_int(value):
"""Best-effort int from agent-reported JSON; None when unusable."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _username_for(userid):
"""Username snapshot for an id, or None. Never raises."""
if not userid:
return None
user = db.session.get(User, userid)
return getattr(user, 'username', None) if user is not None else None
def _parse_agent_datetime(value):
"""Parse an agent ISO-8601 timestamp into a naive UTC datetime.
The agent sends an offset-aware string. The column is naive-UTC like every
other datetime in this schema, so the offset is applied and then dropped -
keeping it would make this the one column that compares wrongly against the
rest.
"""
if not value:
return None
try:
text = str(value).strip().replace('Z', '+00:00')
parsed = datetime.fromisoformat(text)
except (TypeError, ValueError):
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
@@ -2081,6 +2305,8 @@ def _resolve_run_script(target):
the claim: an agent that receives it and ignores it is then distinguishable
from one that never saw it, which matters while agents of both vintages are
in the field. errormode rides the same rule, and for the same reason.
captureoutput too: an older agent that never saw the field simply keeps
withholding successful output, which is the safe direction to fail.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
@@ -2110,6 +2336,7 @@ def _resolve_run_script(target):
'runcontext': script.runcontext,
'workingdirectory': script.workingdirectory or '',
'errormode': script.errormode or 'default',
'captureoutput': bool(script.captureoutput),
}, None
@@ -2125,6 +2352,149 @@ def list_endpoint_commands(asset_id: int):
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/<int:asset_id>/scriptruns', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_script_runs(asset_id: int):
"""The last few run_script commands for one endpoint, newest first.
Separate from list_endpoint_commands because the Run Scripts tab is asking a
narrower question - "what happened the last few times I pushed a script
HERE" - and filtering a 20-row mixed feed client-side would silently show
fewer than five script runs whenever other commands are interleaved.
Carries resultdetail, which the plain command list does not. A script that
exits zero produces a green badge and nothing else; the exit code, the
context it ran as and the directory it ran in are the only evidence the
operator gets that the run did anything at all.
"""
limit = request.args.get('limit', default=5, type=int) or 5
limit = max(1, min(limit, 20))
rows = (EndpointCommand.query
.filter_by(assetid=asset_id, action='run_script')
.order_by(EndpointCommand.createdat.desc())
.limit(limit)
.all())
scriptnames = _script_names_for(rows)
payload = []
for row in rows:
data = row.to_dict(includedetail=True)
data['scriptname'] = scriptnames.get(row.endpointcommandid)
payload.append(data)
return success_response(payload)
@endpoints_bp.route('/<int:asset_id>/logcollections', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_log_collections(asset_id: int):
"""Collected-log history for one endpoint, newest first.
Metadata only - no content. The panel renders up to 20 rows and none of
them need the log body, which can be hundreds of kilobytes each; sending
them all would make opening the tab far more expensive than reading the one
log the operator actually wants.
Gated on endpoints.view because this is a listing. Reading a body, which is
the part that discloses file contents, requires the collect permission - see
get_endpoint_log_collection.
"""
rows = (EndpointLogCollection.query
.filter_by(assetid=asset_id)
.order_by(EndpointLogCollection.collectedat.desc(),
EndpointLogCollection.endpointlogcollectionid.desc())
.limit(EndpointLogCollection.RETENTION_PER_ASSET)
.all())
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/logcollections/<int:collection_id>', methods=['GET'])
@require_permission('endpoints.updateapp')
def get_endpoint_log_collection(collection_id: int):
"""One collected log including its content, for the scrollable pane.
Deliberately a heavier permission than the list. The list says a log was
collected; this returns what was IN it, which is the disclosure. It rides
the same permission that authorised the collection, so nobody gains through
this route what they could not have collected themselves.
"""
row = db.session.get(EndpointLogCollection, collection_id)
if row is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No log collection {collection_id}',
http_code=404)
return success_response(row.to_dict(includecontent=True))
@endpoints_bp.route('/logcollections/<int:collection_id>/download',
methods=['GET'])
@require_permission('endpoints.updateapp')
def download_endpoint_log_collection(collection_id: int):
"""Serve a collected log as a file attachment.
Served as text/plain with an explicit attachment disposition so the browser
saves it instead of rendering it. That matters beyond convenience: a
collected log is untrusted endpoint content, and inline rendering of a file
that happens to contain markup would execute it in the app's origin.
X-Content-Type-Options is set for the same reason - it stops the browser
sniffing past the declared text/plain and deciding the bytes are HTML.
"""
from flask import Response
row = db.session.get(EndpointLogCollection, collection_id)
if row is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No log collection {collection_id}',
http_code=404)
audit_log(
action='logcollectiondownloaded',
entitytype='Asset',
entityid=row.assetid,
changes={
'sourcepath': row.sourcepath,
'endpointlogcollectionid': row.endpointlogcollectionid,
'downloadedby': getattr(current_user, 'username', None),
},
details=f'Downloaded collected log {row.sourcepath}',
)
db.session.commit()
response = Response(row.content or '', mimetype='text/plain; charset=utf-8')
# The filename is already sanitised to [A-Za-z0-9._-] by the model, so it
# cannot break out of the quoted header value.
response.headers['Content-Disposition'] = (
f'attachment; filename="{row.filename}"')
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
def _script_names_for(rows):
"""Map command id -> script name for a batch of run_script rows.
The command stores 'script:<id>', which is not what the operator chose from
the picker. Resolved in one query rather than per row. A script deleted
since the run leaves the name absent, and the caller falls back to the raw
target rather than inventing one.
"""
wanted = {}
for row in rows:
kind, _sep, raw = str(row.target or '').partition(':')
if kind == 'script' and raw.isdigit():
wanted[row.endpointcommandid] = int(raw)
if not wanted:
return {}
found = {
script.scriptid: script.scriptname
for script in Script.query.filter(
Script.scriptid.in_(set(wanted.values()))).all()
}
return {cmdid: found[sid]
for cmdid, sid in wanted.items() if sid in found}
@endpoints_bp.route('/<int:asset_id>/policyholds', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_policy_holds(asset_id: int):
@@ -2515,6 +2885,29 @@ def enqueue_endpoint_command(asset_id: int):
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
# Capability, not authorization: does the agent on THIS endpoint actually
# implement the action? An agent older than the action falls through to its
# unknown-action default and answers "Unknown or disallowed action", which
# reads as a permission refusal and sends the operator to check a grant they
# already hold. Checked after the permission and policy gates so an
# unauthorized caller still gets the authorization answer rather than a
# version hint about a machine they may not act on.
#
# Deliberately a hard refusal, not a warning: the command is guaranteed to
# fail, and queueing it anyway spends the one in-flight command slot and
# writes a failed row that looks like the endpoint misbehaved.
minagentversion = minimum_agent_version_for(action)
if minagentversion:
state = EndpointState.query.filter_by(assetid=asset_id).first()
reportedversion = getattr(state, 'agentversion', None)
if not agent_meets_minimum(reportedversion, minagentversion):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} requires agent {minagentversion} or newer; '
f'this endpoint reports '
f'{reportedversion or "no version"}. Update the agent on this '
f'endpoint to use it.')
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
@@ -2538,6 +2931,18 @@ def enqueue_endpoint_command(asset_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
pinnedsha256 = (script or {}).get('sha256')
# collect_log reads a caller-supplied path. Validate its SHAPE here so an
# obviously wrong path fails immediately with a message the operator can act
# on, instead of consuming a poll cycle to come back as a generic endpoint
# failure. This is a usability gate, not the security boundary: the agent
# re-validates independently, because a server-side check cannot know what
# is really on that disk and must never be the only thing standing between a
# typed string and a file read.
if action == 'collect_log':
path_error = _validate_log_path(target)
if path_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, path_error)
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
@@ -2581,6 +2986,90 @@ def enqueue_endpoint_command(asset_id: int):
message='Command queued', http_code=201)
@endpoints_bp.route('/<int:asset_id>/agentupgrade', methods=['POST'])
@require_permission('endpoints.agentupdate')
def enqueue_agent_upgrade(asset_id: int):
"""Push the published agent build to one endpoint.
Replacing the agent is self-modifying the control plane: the process that
receives this command is the one being swapped. It therefore sits behind
endpoints.agentupdate rather than endpoints.command or endpoints.deploy -
see the permission's rationale in plugin.py.
This does NOT enqueue directly. It prepares the delivery (packages the
current build, seeds/refreshes the upgrade script) and then delegates to
enqueue_endpoint_command as a run_script push. Writing a second enqueue
path here would duplicate - and eventually drift from - the endpoint lock
check, the Configuration Policy gate, stale-command reaping and the
at-most-one-in-flight rule. Those are exactly the protections a
fleet-wide agent swap must not be the one action to skip.
Because it lands as run_script, the caller needs endpoints.runscript too
(enforced by the delegate as the elevated permission for that action).
That is deliberate: this delivers arbitrary SYSTEM PowerShell by the same
mechanism as any other script push.
"""
from . import agentregistry, agentupgradescript
# Refresh the package and the script body from the CURRENT staged build
# before queueing, so an upgrade cannot ship a stale payload if the row was
# written before the latest upload. Both operations are idempotent.
try:
registration = agentregistry.register_current_agent_package()
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Could not package the agent build: {exc}')
if registration is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No agent build is published. Upload an agent binary before '
'pushing an upgrade.')
try:
seeded = agentupgradescript.seed_upgrade_script()
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Could not prepare the upgrade script: {exc}')
if seeded is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'The agent upgrade script could not be prepared.')
# Rewrite the request body to the equivalent run_script push and hand it to
# the real enqueue route. The policyoverride/overridereason fields are
# forwarded untouched so break-glass keeps behaving exactly as it does for
# a normal script push, including its admin-role and reason requirements.
payload = request.get_json(silent=True) or {}
forwarded = {
'action': 'run_script',
'target': 'script:{}'.format(seeded['scriptid']),
}
if payload.get('policyoverride'):
forwarded['policyoverride'] = True
forwarded['overridereason'] = payload.get('overridereason') or ''
# Swap the parsed JSON for the delegate. Flask caches the parsed body on
# the request, so setting the cache is what makes get_json() return this.
request._cached_json = (forwarded, forwarded)
response = enqueue_endpoint_command(asset_id)
# Audit only a push that actually queued. The delegate returns a non-201
# for a lock, a policy denial or an in-flight command, and recording those
# as upgrades would put events in the log for pushes that never happened.
status = getattr(response, 'status_code', None)
if status == 201:
AuditLog.log(
'created', 'AgentUpgrade', entityid=asset_id,
entityname='agent {}'.format(registration['agentversion']),
details='agent upgrade queued for asset {} (version {}, script {})'
.format(asset_id, registration['agentversion'],
seeded['scriptid']))
db.session.commit()
return response
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
def resolved_monitors_for_agent():
"""Agent watch list: the monitors this endpoint should report on.
@@ -2763,6 +3252,20 @@ def report_command_result(command_id: int):
else EndpointCommand.STATUS_FAILED)
command.resultmessage = (payload.get('message') or '')[:2000] or None
detail = payload.get('detail')
# A successful collect_log carries the file body in detail.content. Move it
# into its own row and strip it from the detail before that detail is
# serialised onto the command: storing it twice would double the cost of the
# largest payload this API accepts, and resultjson is read by every command
# history list that only wants a status.
#
# Only on success. A failed collection's detail is small (the reason it
# failed) and belongs on the command, where failures are already read.
if command.action == 'collect_log' and succeeded and isinstance(detail, dict):
detail = dict(detail)
content = detail.pop('content', None)
_record_log_collection(command, detail, content)
command.resultjson = json.dumps(detail, separators=(',', ':')) \
if detail is not None else None
command.completedat = _naive_utcnow()

View File

@@ -0,0 +1,332 @@
# upgrade-shopdbagent.ps1
# ShopDB Endpoint Agent SELF-UPGRADE template. Delivered to an endpoint by the
# run_script command and executed by the agent as SYSTEM.
#
# WHY THIS TAKES NO PARAMETERS
# run_script executes "powershell.exe -File <script>" with no argument list, so
# there is no channel to pass BaseUrl/CollectorKey in. That is not a problem
# here: an upgrade by definition targets an endpoint that is ALREADY installed
# and therefore already holds its own config. This script READS that config and
# never rewrites it - the values stay exactly as provisioned, so an upgrade can
# not silently repoint an endpoint at a different server or key. A fresh
# install is still install-shopdbagent.ps1's job.
#
# WHY IT RE-LAUNCHES ITSELF DETACHED
# The agent process is what runs this script, and the upgrade must stop that
# same agent to release the lock on shopdbagent.exe (single-file self-contained
# exes are held open while running). If the script stayed a child of the agent,
# stopping the service would kill the script mid-copy and leave the install
# directory half-written. The first pass therefore copies itself to a temp
# location, relaunches detached, and exits immediately so the agent sees a
# clean, fast result. All real work happens in the detached second pass.
#
# REPORTING
# Success is NOT reported through the command result - by the time the upgrade
# succeeds, the agent that would have reported it has been restarted. The new
# version is confirmed on the next check-in. This script's own transcript is
# the local record; see $LogFile below.
[CmdletBinding()]
param(
# Set only when the script relaunches itself. Operators never pass this.
[switch] $Detached
)
$ErrorActionPreference = 'Stop'
$ServiceName = 'ShopDbEndpointAgent'
$InstallDir = 'C:\Program Files\ShopDB\EndpointAgent'
$TrayExeName = 'shopdb-endpoint-tray.exe'
$AgentExeName = 'shopdbagent.exe'
$StateDir = Join-Path $env:ProgramData 'ShopDB\EndpointAgent'
$WorkDir = Join-Path $StateDir 'upgrade'
$LogDir = Join-Path $StateDir 'logs'
$LogFile = Join-Path $LogDir ('upgrade-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
# Server-relative path of the managed agent bundle. This reuses the EXISTING
# agent-facing app-package route rather than adding a parallel download: it
# already authenticates with the collector key and already resolves an
# application's installpath, which is precisely what upload registration
# stamped for the agent bundle. The appid is substituted by the server when
# this template is rendered, because it is assigned when the row is created.
$PackagePath = '/api/endpoints/apppackage/app/{{AGENT_APPID}}'
function Write-Log {
param([string] $Message, [string] $Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
}
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
# 64-bit agent's config is not, and the upgrade would look unconfigured.
function Get-Hklm64Value {
param([string] $SubKey, [string] $Name)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.OpenSubKey($SubKey)
if (-not $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Config resolution mirrors the agent's own order of preference, so the upgrade
# reaches the same server the agent is actually talking to. Registry first;
# appsettings.json is the fallback for locked-down hosts (CyberArk/EPM) that
# gate the HKLM hive against reads.
function Get-AgentConfig {
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
$settings = Join-Path $InstallDir 'appsettings.json'
if (Test-Path $settings) {
try {
$json = Get-Content $settings -Raw | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
}
catch {
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
}
}
}
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
throw 'No BaseUrl/CollectorKey found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
}
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = $apiKey }
}
# ---------------------------------------------------------------------------
# First pass: relaunch detached and get out of the way. See header.
# ---------------------------------------------------------------------------
if (-not $Detached) {
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
$selfCopy = Join-Path $WorkDir 'upgrade-shopdbagent.ps1'
Copy-Item -Path $PSCommandPath -Destination $selfCopy -Force
# Copied out of the agent's own temp dir on purpose: the delivered script
# may be cleaned up by the agent once the command returns, which would
# delete this file out from under the detached run.
Start-Process -FilePath 'powershell.exe' `
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', `
'-File', "`"$selfCopy`"", '-Detached' `
-WindowStyle Hidden | Out-Null
Write-Host 'Upgrade handed off to a detached process; this agent will restart shortly.'
exit 0
}
# ---------------------------------------------------------------------------
# Second pass (detached): the actual upgrade.
# ---------------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
Write-Log '== ShopDB Endpoint Agent self-upgrade =='
try {
$config = Get-AgentConfig
Write-Log "server: $($config.BaseUrl)"
# 1. Download BEFORE stopping anything. A failed download must leave the
# running agent untouched rather than stranding the endpoint with a
# stopped service and no replacement binaries.
$zipPath = Join-Path $WorkDir 'agent-package.zip'
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
$url = $config.BaseUrl + $PackagePath
Write-Log "downloading package from $url ..."
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
# protocols the server will refuse.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $url -Headers @{ 'X-API-Key' = $config.ApiKey } `
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
$size = (Get-Item $zipPath).Length
Write-Log "downloaded $size bytes"
if ($size -lt 1MB) {
throw "Package is only $size bytes; refusing to install a payload this small."
}
# 2. Unpack to a staging dir and validate the payload BEFORE touching the
# install. Expanding straight over Program Files would leave a partially
# replaced install if the archive turned out to be short a binary.
$stage = Join-Path $WorkDir 'stage'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $stage -Force
$newAgent = Join-Path $stage $AgentExeName
if (-not (Test-Path $newAgent)) {
throw "$AgentExeName not found in the downloaded package; aborting before any change."
}
$newTray = Join-Path $stage $TrayExeName
$haveTray = Test-Path $newTray
Write-Log ("payload validated (agent{0})" -f $(if ($haveTray) { ' + tray' } else { ', no tray' }))
# 3. Keep the current binaries so a failed swap can be undone. The config
# files are deliberately NOT part of this - they are never modified.
$backup = Join-Path $WorkDir 'backup'
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
New-Item -ItemType Directory -Force -Path $backup | Out-Null
foreach ($name in @($AgentExeName, $TrayExeName)) {
$existing = Join-Path $InstallDir $name
if (Test-Path $existing) { Copy-Item $existing -Destination $backup -Force }
}
Write-Log 'backed up current binaries'
# 4. Stop the service and kill lingering processes. Until this completes
# the exes are locked and the copy would fail with a file-in-use error.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Log "stopping service $ServiceName ..."
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
catch { Write-Log "could not stop service: $($_.Exception.Message)" 'WARN' }
}
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @('shopdbagent', $trayBase)) {
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
Write-Log "killing $($p.ProcessName) (pid $($p.Id))"
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { }
}
}
Start-Sleep -Milliseconds 750
# 5. Swap the binaries. On failure, restore the backup and restart, so a
# broken upgrade leaves a RUNNING old agent rather than a dead endpoint
# that can no longer be reached to fix remotely.
try {
Copy-Item -Path $newAgent -Destination $InstallDir -Force
if ($haveTray) { Copy-Item -Path $newTray -Destination $InstallDir -Force }
Write-Log 'binaries replaced'
}
catch {
Write-Log "copy failed: $($_.Exception.Message); rolling back" 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw
}
# 6. Clear Mark-of-the-Web. Files extracted from a downloaded archive carry
# a Zone.Identifier stream; SmartScreen blocks the unsigned exe on first
# run, which would leave the service unable to start.
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch { }
}
# 7. Restart and verify it stays up. A service that starts and immediately
# faults is a failed upgrade, so confirm rather than assume.
Write-Log "starting service $ServiceName ..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 5
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc -or $svc.Status -ne 'Running') {
Write-Log 'service did not stay running; rolling back' 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw 'Upgraded service failed to start; rolled back to the previous build.'
}
$installed = (Get-Item (Join-Path $InstallDir $AgentExeName)).VersionInfo.FileVersion
Write-Log "upgrade complete; agent version now $installed"
# 8. Relaunch the tray for the interactive user.
#
# This CANNOT use the installer's `explorer.exe <path>` trick. That works in
# install-shopdbagent.ps1 only because the installer runs AS THE USER - note
# its own guard, `if ($interactiveUser -ne "SYSTEM")`. This script always
# runs as SYSTEM in session 0, where `explorer.exe <path>` hands the request
# to a shell that is not reachable from here and then exits 0. Start-Process
# would report success and we would log a relaunch that never happened,
# which is exactly the bug this replaces.
#
# It also must NOT descend into the session with a token-duplicating launch.
# UserContextSpool documents that WTSQueryUserToken is a privileged operation
# that endpoint protection blocks on these hardened hosts - the service tried
# that direction before and it produced nothing on a live endpoint.
#
# So the tray is started the same way its own data path is pinned: against
# the known ShopFloor account. Task Scheduler performs the session/token
# transition, and an Interactive principal with no RunLevel starts the tray
# at the user's normal integrity - preserving the de-elevation the installer
# wanted, because an elevated unsigned tray trips the CyberArk/EPM broker.
#
# Best-effort by design: the agent is already upgraded and running, and the
# HKLM Run key the installer wrote still starts the tray at next logon. A
# late tray is not worth failing - or rolling back - a good agent upgrade.
if ($haveTray) {
$trayPath = Join-Path $InstallDir $TrayExeName
# Same pinned account the spool uses (FileLoggerProvider.ShopFloorUserName):
# every shop-floor endpoint signs in as this local account, and the tray
# must run as it or the mapped-drive probe reports the wrong account's
# (empty) drive table. Only relaunch if that profile is actually present.
$trayUser = 'ShopFloor'
$trayUserProfile = Join-Path $env:SystemDrive (Join-Path 'Users' $trayUser)
if (Test-Path $trayUserProfile) {
$taskName = 'ShopDB tray upgrade relaunch'
try {
# Clear a leftover from an interrupted earlier upgrade so we do
# not stack duplicates.
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
$action = New-ScheduledTaskAction -Execute $trayPath
$principal = New-ScheduledTaskPrincipal -UserId $trayUser -LogonType Interactive
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
# Verify rather than assume. The old code logged success off a
# Start-Process that could not fail, so a missing tray looked
# like a clean upgrade. Poll for the real process instead.
$trayBaseName = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
$running = $null
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Milliseconds 500
$running = Get-Process -Name $trayBaseName -ErrorAction SilentlyContinue
if ($running) { break }
}
if ($running) { Write-Log "tray relaunched as $trayUser" }
else {
Write-Log ('tray did not appear after relaunch; it will start at next ' +
'logon via the HKLM Run key') 'WARN'
}
# One-shot hand-off, not a persistent artifact: leaving it would
# surface in the uninstaller's leftover sweep and in task audits.
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
catch {
Write-Log ("could not relaunch tray: $($_.Exception.Message); " +
'it will start at next logon') 'WARN'
}
}
else {
Write-Log ("no $trayUser profile at $trayUserProfile; " +
'leaving the tray to start at next logon') 'WARN'
}
}
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
exit 0
}
catch {
Write-Log "UPGRADE FAILED: $($_.Exception.Message)" 'ERROR'
exit 1
}

View File

@@ -110,6 +110,32 @@ COMMAND_ALLOWLIST = {
'PCDetail.vue).',
'targetkind': 'script',
},
'collect_log': {
'label': 'Collect log file',
'description': 'Read a plaintext log file from a known path on the '
'endpoint and return its contents to ShopDB for '
'viewing and download. Read-only: the agent opens the '
'file for reading with sharing permitted (so a log a '
'running application holds open can still be read), '
'never writes, and never deletes. Only the TAIL of a '
'file is returned, and only for plaintext extensions - '
'a binary or oversized file is refused rather than '
'mangled. Gated on endpoints.updateapp because the '
'reason to collect a log is almost always to diagnose '
'an application push, so it belongs with app lifecycle '
'rights rather than as a separate grant. Note this '
'still reads a caller-supplied path as SYSTEM: it is '
'bounded to text files, but it is not a sandbox.',
'targetkind': 'logpath',
# Introduced in agent 1.16.0. An older agent has no case for this action
# and falls through to its unknown-action default, which answers
# "Unknown or disallowed action" - a message that reads as an
# authorization refusal and sends the operator looking at permissions
# they already hold. Declaring the floor here lets the enqueue route
# refuse first, naming both versions, so the answer is the actionable
# one: this endpoint needs a newer agent.
'minagentversion': '1.16.0',
},
'update_app': {
'label': 'Install/update application',
'description': 'Push a ShopDB-hosted installer for an application (by app '
@@ -192,11 +218,18 @@ COMMAND_ALLOWLIST = {
# update_app - runs a pinned installer
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
# user context
#
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
# The reason to read a log is nearly always to find out why an app push behaved
# the way it did, so the right to install an application and the right to read
# that application's log are the same job. It is read-only and strictly weaker
# than the installer push the same permission already allows.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'scheduledtask_run': 'endpoints.corptools',
'run_script': 'endpoints.runscript',
'update_app': 'endpoints.updateapp',
'collect_log': 'endpoints.updateapp',
}
@@ -205,6 +238,70 @@ def elevated_permission_for(action):
return ELEVATED_ACTION_PERMISSIONS.get(action)
def minimum_agent_version_for(action):
"""The lowest agent version that implements an action, or None if any will do."""
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
def action_minimum_versions():
"""Every action that has an agent version floor, as {action: version}.
Actions without a floor are omitted rather than mapped to None: a caller
asking "is there a minimum for this?" gets a plain absent key, and the
payload stays small as the allowlist grows.
"""
return {
action: spec['minagentversion']
for action, spec in COMMAND_ALLOWLIST.items()
if spec.get('minagentversion')
}
def parse_agent_version(value):
"""A reported agent version as a comparable tuple, or None if unusable.
Agents report four parts ('1.15.0.0') while the csproj and the binary store
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
the floor it satisfies. Both are normalised to a tuple and compared
numerically, with missing trailing parts treated as zero, so '1.16.0' and
'1.16.0.0' are the same version.
Anything non-numeric returns None rather than a guess. The fleet carries
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
placeholder and a real build - the caller decides what to do about an
unknown version, which is a different question from being out of date.
"""
if not value:
return None
parts = []
for chunk in str(value).strip().split('.'):
if not chunk.isdigit():
return None
parts.append(int(chunk))
if not parts:
return None
while len(parts) < 4:
parts.append(0)
return tuple(parts[:4])
def agent_meets_minimum(reported, minimum):
"""Whether a reported agent version satisfies a minimum.
Returns False when either side is unparseable. That is the fail-closed
direction: an endpoint whose version we cannot read is one we cannot claim
supports the action, and letting it through only moves the failure to the
agent where the error message is worse.
"""
if not minimum:
return True
reportedparts = parse_agent_version(reported)
minimumparts = parse_agent_version(minimum)
if reportedparts is None or minimumparts is None:
return False
return reportedparts >= minimumparts
# Maps a monitored services/processes row to the start/stop/status actions that
# control it, so the services panel can render an inline toggle on exactly the
# rows that have a registered command action. The key is the agent-reported
@@ -339,6 +436,19 @@ POLICY_EXEMPT_ACTIONS = frozenset({
# policy is non-empty - a denial no operator can act on, because the fix
# lives in a policy that was never about this action.
'collect_now',
# Read-only diagnostic retrieval. Policy describes how a machine should be
# shaped - which monitors run, which service primitives are reachable - and
# reading a log file shapes nothing. An existing policy was authored before
# this action existed and so can never mention it, which under the
# intersection means every endpoint carrying any policy at all refuses log
# collection, including for an admin. That denial is also unactionable in
# the way that matters: logs are what an operator reaches for when an
# endpoint is already misbehaving, so gating them behind editing that same
# endpoint's policy withholds the evidence exactly when it is needed.
# Exemption from POLICY is not exemption from PERMISSION - collect_log
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
# the agent still refuses UNC, traversal, directories and non-text files.
'collect_log',
})

View File

@@ -61,6 +61,16 @@
</select>
</div>
<!-- Placeholder for the staged agent rollout. Shown only to operators who
hold the grant it will require, so it reads as "not built yet" to the
people waiting on it rather than as a missing permission to everyone
else. This page is due a refactor; the utility lands with that work. -->
<div v-if="canagentupdate" class="rollout-note">
Bulk Agent Update utility coming soon. Until then, agents update through
the existing installer push, and an endpoint behind the published build is
flagged on its own detail page.
</div>
<!-- Actions live here rather than per row. A tenth column of buttons
pushed itself off the edge of a nowrap table, and selecting one row
costs a single click while giving one command path instead of two. -->
@@ -194,6 +204,9 @@ const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Reserved for the staged rollout. Agent replacement is the control plane
// modifying itself, so it stays separate from ordinary app deployment.
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
const allselected = computed(() =>
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
@@ -515,6 +528,18 @@ function changePerPage(newPerPage) {
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
/* Deliberately quieter than .bulk-bar: this announces something that does not
exist yet, so it must not compete with controls that actually do work. */
.rollout-note {
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
font-size: 0.9rem;
color: var(--text-light);
background: var(--bg-card);
border: 1px dashed var(--border);
border-radius: 0.25rem;
}
.select-cell { width: 1%; }

View File

@@ -113,6 +113,13 @@
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
Continue
</span>
<span
v-if="s.captureoutput"
class="badge badge-secondary"
title="A successful run returns its output to ShopDB"
>
Returns output
</span>
</td>
<td>
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
@@ -213,6 +220,27 @@
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.captureoutput" type="checkbox" />
Return output to ShopDB on success
</label>
<details class="field-help">
<summary>How this works</summary>
<p>
A failed run always sends its output back, so you can diagnose
it from the Run Scripts history without going to the machine.
A successful run does not, unless you tick this.
</p>
<p>
Leave it off for scripts whose output could contain anything
sensitive - account names, share paths, anything a Write-Host
left behind. Turn it on for scripts where seeing the output is
the only way to tell the run did real work. Either way the
full output is written to the agent log on the endpoint.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
@@ -259,6 +287,7 @@ function emptyForm() {
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
captureoutput: false,
isactive: true
}
}
@@ -293,6 +322,7 @@ function openModal(script = null) {
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
captureoutput: !!script.captureoutput,
isactive: script.isactive
}
: emptyForm()
@@ -314,6 +344,7 @@ async function save() {
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
captureoutput: form.value.captureoutput,
isactive: form.value.isactive
}
let scriptid

View File

@@ -2,7 +2,7 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
EndpointLock,
EndpointLock, EndpointLogCollection,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
@@ -12,7 +12,7 @@ from .health import (
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointLock',
'EndpointLock', 'EndpointLogCollection',
'EndpointMonitor', 'OperatorReport',
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
]

View File

@@ -16,13 +16,23 @@ Two shapes, one per concern:
crash) so this stays a readable security/stability history
rather than a raw event firehose.
EndpointLogCollection
A plaintext log file collected from an endpoint on operator
demand. Kept out of EndpointCommand.resultjson because it is
bulky, needs its own per-asset retention cap, and has to be
addressable by id for the download route.
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
is the same physical thing as its Computer/Asset - no duplicate identity. The
agent reports a hostname; the plugin resolves it to the existing Computer.
"""
import json
import re
from datetime import datetime
import sqlalchemy as sa
from shopdb.api import db
@@ -275,8 +285,16 @@ class EndpointCommand(db.Model):
def __repr__(self):
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
def to_dict(self):
return {
def to_dict(self, includedetail=False):
"""Serialise a command for the operator views.
resultjson is opt-in. It is agent-reported structured detail, and for a
script it carries the evidence that a green badge alone does not: the
exit code, the context it ran as and the directory it really ran in. On
failure the agent also puts the captured output in there, so it is not
served to every list that only needs a status.
"""
data = {
'endpointcommandid': self.endpointcommandid,
'assetid': self.assetid,
'action': self.action,
@@ -289,6 +307,24 @@ class EndpointCommand(db.Model):
'claimedat': _utciso(self.claimedat),
'completedat': _utciso(self.completedat),
}
if includedetail:
data['resultdetail'] = self._resultdetail()
return data
def _resultdetail(self):
"""resultjson as a dict, or None.
Stored as text, so a malformed or truncated payload is possible. A
detail panel is diagnostics: failing to parse it must not break the
command list that surrounds it.
"""
if not self.resultjson:
return None
try:
parsed = json.loads(self.resultjson)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
class EndpointEvent(db.Model):
@@ -531,3 +567,130 @@ class EndpointLock(db.Model):
'releasedat': _utciso(self.releasedat),
'releasedbyusername': self.releasedbyusername,
}
class EndpointLogCollection(db.Model):
"""One plaintext log file collected from an endpoint by an operator.
Collected content lives here rather than in EndpointCommand.resultjson for
three reasons. It is bulky and would be dragged along by every command
history list that only wants a status; it needs its own retention rule
(RETENTION_PER_ASSET), which a command row cannot express without deleting
the audit record of the command itself; and the download route needs to
serve bytes, which means it needs a row it can address by id rather than a
JSON field it has to re-parse.
Retention is deliberately PER ASSET. A global cap would let one busy machine
evict another machine's history, and the history is read per machine on the
PC detail page, so a global cap would make the panel unreliable exactly
where it is used. Pruning happens on insert in the result route.
The content stored here is a TAIL, not necessarily the whole file - see
istruncated. A log that has run for months is not something to load into a
browser pane, and the agent bounds what it sends.
"""
__tablename__ = 'endpointlogcollections'
# How many collections to keep per asset. The operator asked for 20: enough
# to compare a handful of runs against each other, few enough that stored
# log text does not grow without bound across a fleet.
RETENTION_PER_ASSET = 20
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
# The command that produced this, kept for audit: it ties the collected
# bytes back to who queued the collection and when it ran. SET NULL rather
# than CASCADE so pruning or expiring a command never silently destroys a
# log an operator is still looking at.
endpointcommandid = db.Column(
db.Integer,
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
nullable=True,
)
# The path as the AGENT resolved it, which is not always the path the
# operator typed (environment variables get expanded endpoint-side). Storing
# the resolved one is what makes the history honest about what was read.
sourcepath = db.Column(db.String(512), nullable=False)
content = db.Column(db.Text(length=16777215), nullable=True)
# Size of the file ON DISK, not the size of content. When the tail is
# truncated these differ, and that difference is the whole point: it tells
# the operator they are looking at the end of something larger.
filesizebytes = db.Column(db.BigInteger, nullable=True)
contentbytes = db.Column(db.Integer, nullable=True)
linecount = db.Column(db.Integer, nullable=True)
istruncated = db.Column(db.Boolean, nullable=False,
default=False, server_default=sa.false())
# Last-write time reported by the endpoint. A log that has not changed since
# the previous collection is the fastest way to see an app is not logging.
filemodifiedat = db.Column(db.DateTime, nullable=True)
# Username snapshot alongside the id: the id alone becomes unreadable if the
# user is later removed, and this history is read long after collection.
collectedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
collectedbyusername = db.Column(db.String(100), nullable=True)
collectedat = db.Column(db.DateTime, nullable=False,
default=datetime.utcnow, index=True)
__table_args__ = (
# Serves both hot paths: listing an asset's history newest-first, and
# finding the oldest rows to prune on insert.
db.Index('idx_endpointlogcollections_assetcollected',
'assetid', 'collectedat'),
)
def __repr__(self):
return (f"<EndpointLogCollection asset={self.assetid} "
f"{self.sourcepath}>")
@property
def filename(self):
"""A download filename derived from the collected path."""
raw = (self.sourcepath or '').replace('/', '\\')
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
# Keep it to characters that survive a Content-Disposition header and a
# Windows save dialog without quoting games.
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
return f'{stamp}_{safe}'
def to_dict(self, includecontent=False):
"""Serialise a collection.
Content is opt-in for the same reason resultjson is on EndpointCommand:
the history list renders 20 rows and none of them need the log body,
which may be hundreds of kilobytes each.
"""
data = {
'endpointlogcollectionid': self.endpointlogcollectionid,
'assetid': self.assetid,
'endpointcommandid': self.endpointcommandid,
'sourcepath': self.sourcepath,
'filesizebytes': self.filesizebytes,
'contentbytes': self.contentbytes,
'linecount': self.linecount,
'istruncated': bool(self.istruncated),
'filemodifiedat': _utciso(self.filemodifiedat),
'collectedbyuserid': self.collectedbyuserid,
'collectedbyusername': self.collectedbyusername,
'collectedat': _utciso(self.collectedat),
'filename': self.filename,
}
if includecontent:
data['content'] = self.content or ''
return data

View File

@@ -206,7 +206,8 @@ class EndpointsPlugin(BasePlugin):
'already registered and allowlisted on the agent)', 'endpoints'),
('endpoints.updateapp',
'Push application installers to endpoints (runs a pinned '
'installer as SYSTEM)', 'endpoints'),
'installer as SYSTEM) and collect plaintext log files from '
'endpoints by path (read-only, text files only)', 'endpoints'),
# Blunt on purpose. This is not "system-level changes" - it is
# arbitrary code execution as SYSTEM unless the script descriptor
# pins a user context. Whoever grants it must see that here.
@@ -503,7 +504,12 @@ class EndpointsPlugin(BasePlugin):
},
'meta': [
{'key': 'detail'},
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
# datetime, not date: 'date' renders via
# toLocaleDateString and drops the clock time entirely,
# which made every event on a given day look
# simultaneous. Ordering a boot against the logon that
# followed it is the whole point of this feed.
{'key': 'eventtime', 'label': 'At', 'format': 'datetime'},
],
# Expands in place on click. The message body is several
# hundred words of Windows boilerplate around the one or

View File

@@ -99,6 +99,39 @@ def collector_authorized(pluginname: str):
from shopdb.core.api.collector import _check_collector_auth, _plugin_api_key
return _check_collector_auth(_plugin_api_key(pluginname), pluginname)
def application_package_location():
"""Where application installers live, and the URL prefix that serves them.
Returns (directory, urlprefix). A plugin that registers an installable
payload (the endpoint agent bundle, for example) must put the file exactly
where the applications API expects it and build installpath the same way,
or update_app cannot resolve the download. Reconstructing either by hand
lets the two drift silently, so both come from one place.
Imported lazily for the same reason as collector_authorized above:
shopdb.core.api imports back into shopdb.api, so a module-level import
would be circular at load time.
"""
from shopdb.core.api.applications import (
_apppackage_dir, APP_PACKAGE_URL_PREFIX)
return _apppackage_dir(), APP_PACKAGE_URL_PREFIX
def script_body_location():
"""Where script bodies live, and the URL prefix that serves them.
Returns (directory, urlprefix). Same reasoning as
application_package_location: a plugin that seeds a script into the
library must write the body where the download route reads it from, and
reconstructing that path by hand lets the two drift apart silently.
Imported lazily to avoid the shopdb.core.api -> shopdb.api cycle.
"""
from shopdb.core.api.scripts import _script_dir, SCRIPT_URL_PREFIX
return _script_dir(), SCRIPT_URL_PREFIX
# Serving an uploaded file safely. A plugin that serves user-supplied bytes must
# not have to remember the headers that keep an SVG or an HTML file from running
# as script in this origin - see shopdb/utils/uploads.py.
@@ -388,6 +421,8 @@ __all__ = [
'list_service_tokens',
'revoke_service_tokens',
'collector_authorized',
'application_package_location',
'script_body_location',
'SupportTeam',
# Serving uploads
'send_upload',

View File

@@ -112,6 +112,27 @@ def _validate_errormode(value):
return value, None
def _validate_captureoutput(value):
"""Normalize the "send output back on success" flag. Returns (value, error).
Absent means False: a script authored before this field existed never
returned its successful output to the server, and reading an omitted field
as True would start persisting output from bodies nobody reviewed with that
question in mind. Strings are accepted because the field arrives from a
form control that may send 'true'/'false' rather than a JSON boolean.
"""
if value is None:
return False, None
if isinstance(value, bool):
return value, None
normalized = str(value).strip().lower()
if normalized in ('true', '1', 'yes'):
return True, None
if normalized in ('false', '0', 'no', ''):
return False, None
return None, 'captureoutput must be true or false'
def _validate_workingdirectory(value):
"""Normalize the optional folder a script runs in. Returns (value, error).
@@ -176,6 +197,9 @@ def create_script():
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
errormode, err = _validate_errormode(data.get('errormode'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
@@ -185,6 +209,7 @@ def create_script():
runcontext=runcontext,
workingdirectory=workingdirectory,
errormode=errormode,
captureoutput=captureoutput,
)
db.session.add(script)
db.session.commit()
@@ -192,7 +217,8 @@ def create_script():
entityname=name,
changes={'runcontext': {'new': runcontext},
'workingdirectory': {'new': workingdirectory},
'errormode': {'new': errormode}})
'errormode': {'new': errormode},
'captureoutput': {'new': captureoutput}})
db.session.commit()
return success_response(script.to_dict(), message='Script created', http_code=201)
@@ -233,6 +259,18 @@ def update_script(script_id: int):
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.errormode = errormode
# Audited on change: this decides whether a successful run's output is
# persisted server-side, so who turned it on is worth being able to answer.
if 'captureoutput' in data:
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
if captureoutput != script.captureoutput:
AuditLog.log('updated', 'Script', entityid=script.scriptid,
entityname=script.scriptname,
changes={'captureoutput': {'old': script.captureoutput,
'new': captureoutput}})
script.captureoutput = captureoutput
if 'isactive' in data:
script.isactive = bool(data.get('isactive'))

View File

@@ -6,6 +6,8 @@ body never lives on the endpoint ahead of time and never crosses the command
wire; the agent downloads it with its collector credential and verifies sha256.
"""
import sqlalchemy as sa
from shopdb.extensions import db
from .base import BaseModel
@@ -69,6 +71,17 @@ class Script(BaseModel):
# existed reads as the permissive behavior it was authored under.
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
server_default=ERRORMODE_DEFAULT)
# Whether a SUCCESSFUL run sends its output back to the server. Failures
# always do: a failed run has to be diagnosable. Success is opt-in because
# script output carries whatever the body printed - hostnames, paths, a
# token an author echoed while debugging - and a run nobody needs to
# diagnose is not worth that exposure by default. The endpoint log keeps the
# full copy either way, so this only controls what crosses to the server.
#
# Off by default so existing scripts keep the behavior they were authored
# under, same rule as errormode above.
captureoutput = db.Column(db.Boolean, default=False, nullable=False,
server_default=sa.false())
def to_dict(self):
result = super().to_dict()

View File

@@ -58,7 +58,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'endpointapplications', 'endpointmonitors',
'operatorreports', 'endpointhealthstates',
'endpointhealthtransitions', 'endpointmonitorimpacts',
'endpointlocks'),
'endpointlocks', 'endpointlogcollections'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -0,0 +1,129 @@
"""The per-script "return output on success" flag.
A failed run has always sent its output back, because a failure has to be
diagnosable from the UI. A successful run has not, and that is the behaviour
being made opt-in here rather than simply reversed: script output contains
whatever the body printed, and persisting that server-side for every routine
green run is exposure nobody asked for.
The load-bearing property is the default. Every script authored before this
field existed ran under "success stays silent", so an absent field must never
be read as consent.
"""
import pytest
from werkzeug.security import generate_password_hash
from shopdb.core.models import Permission
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
@pytest.fixture
def headers(client, db):
from shopdb.core.models import User
Permission.seed_entries([SCRIPT_GRANT])
db.session.commit()
user = User(
username='captureauthor',
email='captureauthor@test.local',
passwordhash=generate_password_hash('testpass'),
)
user.directpermissions.append(
Permission.query.filter_by(name='endpoints.runscript').one())
db.session.add(user)
db.session.commit()
login = client.post('/api/auth/login',
json={'username': 'captureauthor', 'password': 'testpass'})
assert login.status_code == 200, login.get_json()
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
def _create(client, headers, name, **extra):
payload = {'scriptname': name}
payload.update(extra)
return client.post('/api/scripts', headers=headers, json=payload)
def test_a_script_without_the_flag_withholds_output(client, headers):
"""The load-bearing test. An omitted field is not consent to persist a
successful run's output server-side."""
created = _create(client, headers, 'no-capture')
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is False
def test_the_flag_can_be_turned_on(client, headers):
created = _create(client, headers, 'chatty-one', captureoutput=True)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is True
@pytest.mark.parametrize('sent,expected', [
('true', True),
('false', False),
(True, True),
(False, False),
])
def test_form_style_values_are_accepted(client, headers, sent, expected):
"""The field arrives from a checkbox, which may send a string."""
created = _create(client, headers, f'form-{sent}-{expected}',
captureoutput=sent)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['captureoutput'] is expected
def test_a_nonsense_value_is_rejected(client, headers):
"""Rejected rather than coerced: silently reading garbage as False would
hide a broken client, and as True would leak output nobody agreed to."""
created = _create(client, headers, 'bad-capture', captureoutput='maybe')
assert created.status_code == 400
assert 'captureoutput' in created.get_json()['data']['error']['message']
def test_the_flag_can_be_changed_on_an_existing_script(client, headers):
scriptid = _create(client, headers, 'toggle-me',
captureoutput=True).get_json()['data']['scriptid']
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
json={'captureoutput': False})
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['captureoutput'] is False
def test_an_unrelated_update_leaves_the_flag_alone(client, headers):
"""Editing a description must not quietly revert an author's decision."""
scriptid = _create(client, headers, 'keep-my-flag',
captureoutput=True).get_json()['data']['scriptid']
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
json={'scriptdescription': 'now documented'})
assert updated.get_json()['data']['captureoutput'] is True
def test_the_delivery_descriptor_carries_the_flag(app, client, db, headers):
"""The agent cannot honour a decision it is never told about."""
from plugins.endpoints.api.routes import _resolve_run_script
with app.app_context():
from shopdb.core.models import Script
script = Script(scriptname='delivered', filename='script-1.ps1',
sha256='a' * 64, runcontext='system',
captureoutput=True)
db.session.add(script)
db.session.commit()
descriptor, err = _resolve_run_script(f'script:{script.scriptid}')
assert err is None
assert descriptor['captureoutput'] is True

View File

@@ -0,0 +1,250 @@
"""Auto-registration of staged agent builds as a pushable Application.
The endpoint update_app push resolves an install source from the applications
table alone; it cannot see the agent version store. Before this, a build could
be staged and current while no application row existed at all, which is exactly
what was found in the fleet - the agent was unpushable.
These tests pin the properties that make the bridge trustworthy:
- staging creates the row, and re-staging updates it in place (never a row
per build, because appname is unique and drift is the failure mode);
- the packaged archive is byte-stable for unchanged input, because update_app
pins a download to a digest and a drifting hash breaks verification;
- every path that changes 'current' re-registers, including rollback;
- registration failure never fails an upload that already landed on disk.
Uploads use PE-magic bytes with a monkeypatched version parser, matching the
approach in test_endpoints_agentupload.py - building a real PE with an
RT_VERSION resource inline is impractical.
Every test runs against an ISOLATED store. create_app('testing') resolves
instance_path to the real repo instance/ directory, so without redirection
these tests stage fake 130-byte binaries into the developer's actual agent
store and leave invented versions behind in its manifest. The isolate fixture
below is autouse for that reason: it is a correctness requirement, not a
convenience.
"""
import hashlib
import io
import os
import zipfile
import pytest
from plugins.endpoints.api import agentstore, agentupload, agentregistry
from shopdb.core.models.application import Application
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current'
_PE_BYTES = b'MZ' + b'\x00' * 128
@pytest.fixture(autouse=True)
def isolate_store(tmp_path, monkeypatch):
"""Point the agent store and package output at a per-test directory.
Both must be redirected. Redirecting only the store would still write the
~zip into the real applicationpackages tree under a live application id.
"""
siteagent = tmp_path / 'siteagent'
packages = tmp_path / 'applicationpackages'
siteagent.mkdir()
packages.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
monkeypatch.setattr(agentregistry, 'application_package_location',
lambda: (str(packages), '/api/applications/package/'))
return tmp_path
def _upload(client, headers, monkeypatch, version, binkey='agent',
payload=None, makecurrent=None):
"""Stage a build through the real HTTP route."""
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version)
form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'),
'version': version}
if makecurrent is not None:
form['makecurrent'] = makecurrent
return client.post(UPLOAD_URL.format(binkey), data=form,
content_type='multipart/form-data', headers=headers)
def _agent_row():
return Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
def test_staging_a_build_creates_a_pushable_application_row(
client, db, app, auth_headers, monkeypatch):
"""Without this row, update_app has nothing to resolve and cannot push."""
assert _agent_row() is None
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
assert response.status_code == 200
registration = response.get_json()['data']['registration']
assert registration['ok'] is True
row = _agent_row()
assert row is not None
# The row must be resolvable and pinned: a path update_app can fetch, and a
# digest it can verify the bytes against.
assert row.installpath == '/api/applications/package/{}/{}'.format(
row.appid, agentregistry.AGENT_PACKAGE_FILENAME)
assert row.installersha256 == registration['sha256']
assert row.isinstallable is True
# Hidden: this is machine-managed, and must not appear in the software
# catalogue beside real licensed applications.
assert row.ishidden is True
def test_restaging_updates_the_same_row_rather_than_adding_one(
client, db, app, auth_headers, monkeypatch):
"""appname is unique; a row per build would break the push target."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
first = _agent_row()
firstid = first.appid
response = _upload(client, auth_headers, monkeypatch, '1.17.0.0')
assert response.status_code == 200
assert Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).count() == 1
row = _agent_row()
assert row.appid == firstid
# The row must follow the newly current build, not keep pointing at 1.16.
assert row.installersha256 == \
response.get_json()['data']['registration']['sha256']
def test_packaging_the_same_build_twice_yields_the_same_digest(
client, db, app, auth_headers, monkeypatch):
"""update_app pins bytes by digest.
zipfile stamps members with the current mtime by default, which made the
archive hash change on every call for identical input. That would rewrite
installersha256 out from under an endpoint mid-download and fail its
SHA-256 verification.
"""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
firstpayload, firstmanifest = agentstore.build_current_package()
secondpayload, secondmanifest = agentstore.build_current_package()
assert firstmanifest['sha256'] == secondmanifest['sha256']
assert firstpayload == secondpayload
def test_the_package_contains_the_staged_binary_under_a_fixed_name(
client, db, app, auth_headers, monkeypatch):
"""The install script addresses members by name, not by discovery."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
payload, manifest = agentstore.build_current_package()
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
assert archive.testzip() is None
names = archive.namelist()
assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names
assert manifest['agentversion'] == '1.16.0.0'
assert manifest['contents']['agent']['version'] == '1.16.0.0'
def test_the_written_package_file_matches_the_recorded_digest(
client, db, app, auth_headers, monkeypatch):
"""A row whose digest disagrees with the file on disk is unpushable."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
row = _agent_row()
path = agentregistry._package_path(row.appid)
assert os.path.isfile(path)
with open(path, 'rb') as handle:
ondisk = hashlib.sha256(handle.read()).hexdigest()
assert ondisk == row.installersha256
# The atomic-write temp file must not survive a successful write.
assert not os.path.isfile(path + '.tmp')
def test_rolling_back_repoints_the_row_at_the_restored_build(
client, db, app, auth_headers, monkeypatch):
"""A rollback changes the bytes the fleet should get.
If only upload re-registered, the row would still pin the build the
operator just rolled away from, and the push would ship it anyway.
"""
_upload(client, auth_headers, monkeypatch, '1.16.0.0',
payload=b'MZ' + b'\x01' * 256)
oldsha = _agent_row().installersha256
_upload(client, auth_headers, monkeypatch, '1.17.0.0',
payload=b'MZ' + b'\x02' * 256)
newsha = _agent_row().installersha256
assert newsha != oldsha
response = client.put(CURRENT_URL.format('agent'),
json={'version': '1.16.0.0'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['registration']['ok'] is True
assert agentstore.current_version('agent') == '1.16.0.0'
# Back to the rolled-to build's package, not the newer one.
assert _agent_row().installersha256 == oldsha
def test_a_registration_failure_does_not_fail_the_upload(
client, db, app, auth_headers, monkeypatch):
"""The binary is already staged and audited by the time this runs.
Turning a packaging problem into an error response would tell the operator
the upload was rejected when it actually succeeded, inviting a re-upload of
a build that is already there.
"""
def _explode():
raise OSError('simulated packaging failure')
monkeypatch.setattr(agentregistry.agentstore, 'build_current_package',
_explode)
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
assert response.status_code == 200
body = response.get_json()['data']
# The staging itself still stands...
assert body['current'] == '1.16.0.0'
# ...and the failure is surfaced rather than swallowed.
assert body['registration']['ok'] is False
assert 'simulated packaging failure' in body['registration']['error']
def test_staging_only_a_tray_reports_that_there_is_nothing_to_package(
client, db, app, auth_headers, monkeypatch):
"""The bundle is defined by the agent; a tray alone is not pushable."""
response = _upload(client, auth_headers, monkeypatch, '1.6.5.0',
binkey='tray')
assert response.status_code == 200
registration = response.get_json()['data']['registration']
assert registration['ok'] is False
assert 'no current agent build' in registration['error']
assert _agent_row() is None
def test_these_tests_do_not_touch_the_real_agent_store(
client, db, app, auth_headers, monkeypatch, isolate_store):
"""Guard the isolation itself.
An earlier draft of this file wrote fake 130-byte binaries into the repo's
real instance/siteagent/ tree and invented a version in its manifest,
because create_app('testing') shares the developer's instance_path. If that
redirection ever regresses, fail here rather than silently corrupting a
staged build again.
"""
_upload(client, auth_headers, monkeypatch, '9.9.9.9')
# The staged build landed inside the per-test directory...
staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9'
assert staged.is_dir()
# ...and the real store never saw it.
realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9')
assert not os.path.exists(realstore)

View File

@@ -0,0 +1,281 @@
"""The agent self-upgrade enqueue route.
Replacing the agent is self-modifying the control plane: the process that
receives the command is the one being swapped. The route therefore sits behind
endpoints.agentupdate, and - more importantly - it does NOT implement its own
enqueue. It prepares the delivery and delegates to enqueue_endpoint_command so
the endpoint lock, the Configuration Policy gate, stale-command reaping and the
at-most-one-in-flight rule all still apply.
These tests pin that delegation. A parallel enqueue path that quietly skipped a
lock or the in-flight rule would be invisible in normal use and would show up
as a fleet-wide agent swap landing on a quarantined machine.
The store is isolated per test: create_app('testing') resolves instance_path to
the real repo instance/, so without redirection these would stage binaries into
the developer's actual agent store.
"""
import os
import tempfile
import pytest
from werkzeug.security import generate_password_hash
from plugins.endpoints.api import agentstore, agentregistry, agentupgradescript
UPGRADE_URL = '/api/endpoints/{}/agentupgrade'
_PE_BYTES = b'MZ' + b'\x00' * 4096
@pytest.fixture(autouse=True)
def isolate_store(tmp_path, monkeypatch):
"""Redirect the agent store, package output and script bodies."""
siteagent = tmp_path / 'siteagent'
packages = tmp_path / 'applicationpackages'
scripts = tmp_path / 'scripts'
for directory in (siteagent, packages, scripts):
directory.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
monkeypatch.setattr(agentregistry, 'application_package_location',
lambda: (str(packages), '/api/applications/package/'))
monkeypatch.setattr(agentupgradescript, 'script_body_location',
lambda: (str(scripts), '/api/scripts/body/'))
return tmp_path
def _publish_agent(version='1.16.0.0'):
"""Stage a build so there is something to upgrade to."""
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
def _endpoint(db, hostname, agentversion='1.15.0.0'):
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
agentversion=agentversion))
db.session.commit()
return asset.assetid
def _login(db, client, username, permissions=None, admin=False):
"""A user holding exactly the named permissions (or the admin role)."""
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
rolename = 'admin' if admin else username + 'role'
role = Role.query.filter_by(rolename=rolename).first()
if role is None:
role = Role(rolename=rolename)
db.session.add(role)
db.session.flush()
if not admin:
for name in (permissions or []):
perm = Permission.query.filter_by(name=name).first()
if perm is not None and perm not in role.permissions:
role.permissions.append(perm)
db.session.commit()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def test_an_upgrade_queues_as_a_pinned_run_script(client, db, app):
"""The push must ride the normal script channel, digest-pinned.
Landing as run_script is what makes the body verifiable on the endpoint;
a bespoke action would need its own pinning and would not get it.
"""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE01')
headers = _login(db, client, 'upgradeadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert response.status_code == 201
command = EndpointCommand.query.filter_by(assetid=assetid).first()
assert command is not None
assert command.action == 'run_script'
seeded = agentupgradescript.seed_upgrade_script()
assert command.target == 'script:{}'.format(seeded['scriptid'])
# Pinned to the exact body approved at enqueue, so a later edit cannot
# swap what the endpoint runs.
assert command.pinnedsha256 == seeded['sha256']
def test_the_upgrade_requires_the_agentupdate_permission(client, db, app):
"""endpoints.command alone must not be able to swap the SYSTEM agent."""
_publish_agent()
assetid = _endpoint(db, 'UPGRADE02')
headers = _login(db, client, 'plainoperator',
permissions=['endpoints.view', 'endpoints.command'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 403
def test_an_upgrade_is_refused_when_no_build_is_published(client, db, app):
"""Nothing staged means there is no payload; refuse before queueing.
Queueing anyway would send an endpoint to fetch a package that does not
exist and fail opaquely minutes later.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'UPGRADE03')
headers = _login(db, client, 'nobuildadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 400
assert 'No agent build is published' in \
response.get_json()['data']['error']['message']
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_an_endpoint_lock_still_blocks_the_upgrade(client, db, app):
"""The delegate's lock check must not be bypassed by this route.
A quarantined machine is reserved by a person; a fleet agent swap is
exactly the kind of action that must respect that.
"""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE04')
# Lock as one user, then act as a different non-admin holder of the grant.
owner = _login(db, client, 'lockowner', admin=True)
locked = client.post(f'/api/endpoints/{assetid}/lock',
json={'reason': 'bench repair'}, headers=owner)
assert locked.status_code in (200, 201)
headers = _login(db, client, 'upgradeoperator',
permissions=['endpoints.view', 'endpoints.command',
'endpoints.agentupdate',
'endpoints.runscript'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 403
assert 'locked' in \
response.get_json()['data']['error']['message'].lower()
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_a_second_upgrade_cannot_stack_on_an_in_flight_command(client, db, app):
"""at-most-one-in-flight must still hold through this route."""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE05')
headers = _login(db, client, 'stackadmin', admin=True)
first = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert first.status_code == 201
second = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert second.status_code == 409
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 1
def test_the_upgrade_refreshes_the_package_before_queueing(client, db, app):
"""A newer staged build must be what gets pushed.
If the route queued against a previously registered row, an operator who
uploaded a new agent and immediately pushed would ship the old one.
"""
from shopdb.api import Application
_publish_agent('1.16.0.0')
assetid = _endpoint(db, 'UPGRADE06')
headers = _login(db, client, 'refreshadmin', admin=True)
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
row = Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
firstsha = row.installersha256
# Stage a different build and push to a second endpoint.
agentstore.store_version('agent', '1.17.0.0', b'MZ' + b'\x01' * 4096,
make_current=True)
second = _endpoint(db, 'UPGRADE07')
response = client.post(UPGRADE_URL.format(second), json={}, headers=headers)
assert response.status_code == 201
db.session.expire_all()
row = Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
assert row.installersha256 != firstsha
def test_a_failed_push_is_not_audited_as_an_upgrade(client, db, app):
"""A refused push must not leave an AgentUpgrade event behind."""
from shopdb.api import AuditLog
_publish_agent()
assetid = _endpoint(db, 'UPGRADE08')
headers = _login(db, client, 'auditadmin', admin=True)
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
before = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
# Second push is refused by the in-flight rule.
blocked = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert blocked.status_code == 409
after = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
assert after == before
def test_the_seeded_script_body_is_written_and_pinned(client, db, app,
isolate_store):
"""The agent verifies the body by digest, so both must exist and agree."""
import hashlib
_publish_agent()
assetid = _endpoint(db, 'UPGRADE09')
headers = _login(db, client, 'bodyadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 201
seeded = agentupgradescript.seed_upgrade_script()
path = os.path.join(str(isolate_store / 'scripts'),
'script-{}.ps1'.format(seeded['scriptid']))
assert os.path.isfile(path)
with open(path, 'rb') as handle:
body = handle.read()
assert hashlib.sha256(body).hexdigest() == seeded['sha256']
# The delivery path must be fully resolved, not a literal placeholder.
assert b'{{AGENT_APPID}}' not in body

View File

@@ -0,0 +1,158 @@
"""The agent version guard on command enqueue.
An agent older than an action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action". That reads as
an authorization refusal, so the operator goes looking at permissions they
already hold - which is exactly what happened on a live endpoint running 1.15.0
after collect_log shipped in 1.16.0.
These tests pin the refusal at the SERVER, before the command is ever queued,
and pin that the message names both versions so the answer is actionable.
"""
from werkzeug.security import generate_password_hash
def _endpoint(db, hostname, agentversion):
"""An Asset + Computer pair whose reported state carries an agent version."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
agentversion=agentversion))
db.session.commit()
return asset.assetid
def _admin(db, client, username='guardadmin'):
"""An admin, so permission and policy gates cannot be what refuses."""
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='admin').first()
if role is None:
role = Role(rolename='admin')
db.session.add(role)
db.session.commit()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _collect(client, headers, assetid):
return client.post(f'/api/endpoints/{assetid}/commands',
json={'action': 'collect_log',
'target': r'C:\logs\app.log'},
headers=headers)
def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db):
"""The command must not be queued at all.
Queueing it would spend the endpoint's one in-flight command slot and leave
a failed row that reads as endpoint misbehaviour, when the truth is the
agent simply predates the feature.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'oldagentbox', '1.15.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
message = response.get_json()['data']['error']['message']
# Both versions, or the operator cannot tell what to do about it.
assert '1.16.0' in message
assert '1.15.0.0' in message
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_an_agent_at_the_minimum_is_allowed(app, client, db):
"""The guard must not refuse a capable endpoint.
1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the
same build; a lexical compare would get this wrong.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'newagentbox', '1.16.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code in (200, 201)
assert EndpointCommand.query.filter_by(assetid=assetid,
action='collect_log').count() == 1
def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db):
"""'1.9.0.0' must not pass a '1.16.0' floor.
String comparison says '9' > '1' and lets it through. This is the specific
bug the numeric parse exists to prevent, so it gets its own test rather
than living only in the helper's unit coverage.
"""
assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
assert '1.16.0' in response.get_json()['data']['error']['message']
def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db):
"""A placeholder version fails closed.
Seeded rows carry '0.0.0-sample'. There is no honest ordering between a
placeholder and a real build, and letting it through only moves the failure
to the agent where the error message is the misleading one.
"""
assetid = _endpoint(db, 'samplebox', '0.0.0-sample')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
def test_an_action_without_a_floor_is_unaffected(app, client, db):
"""The guard is per-action, not global.
collect_now declares no minimum, so an old agent must still be able to run
it - otherwise adding a floor to one action quietly narrows the others.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'refreshbox', '1.11.0.0')
headers = _admin(db, client)
response = client.post(f'/api/endpoints/{assetid}/commands',
json={'action': 'collect_now'}, headers=headers)
assert response.status_code in (200, 201)
assert EndpointCommand.query.filter_by(assetid=assetid,
action='collect_now').count() == 1

View File

@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target,
requires_target, minimum_agent_version_for, agent_meets_minimum,
parse_agent_version,
)
@@ -196,6 +197,81 @@ def test_collect_now_survives_a_policy_that_never_mentions_it():
assert elevated_permission_for('collect_now') is None
def test_collect_log_survives_a_policy_that_never_mentions_it():
"""Log collection must not be narrowed away by an unrelated policy.
Same intersection trap as collect_now, but with a sharper failure: every
existing policy predates this action and so cannot name it, meaning any
endpoint carrying a policy refused log collection outright - including for
an admin, who reads "requires permission" and goes looking at a grant they
already hold. Logs are also what an operator reaches for when an endpoint
is already misbehaving, so a policy gate here withholds the evidence
exactly when it is wanted.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION. Unlike
# collect_now this one DOES carry an elevated grant, so the assertion is
# that the grant survived the exemption rather than being loosened by it.
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
def test_collect_log_declares_the_agent_version_that_implements_it():
"""collect_log names its minimum agent version.
An agent older than the action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action" - which
reads as an authorization refusal and sends the operator to check a grant
they already hold. The floor is what lets the server refuse first with the
actionable answer instead.
"""
assert minimum_agent_version_for('collect_log') == '1.16.0'
# Actions that every shipped agent understands must NOT declare a floor,
# or the guard starts refusing endpoints that are perfectly capable.
assert minimum_agent_version_for('collect_now') is None
assert minimum_agent_version_for('system_restart') is None
def test_agent_version_comparison_survives_real_fleet_version_strings():
"""Version comparison is numeric per part, not lexical.
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
while the csproj and binary store carry three ('1.16.0'), so the same build
must compare equal across both spellings. And a lexical compare ranks
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
old agent past the guard, the exact failure the guard exists to prevent.
"""
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
assert agent_meets_minimum('1.16.0', '1.16.0')
assert agent_meets_minimum('1.16.0.0', '1.16.0')
assert agent_meets_minimum('1.17.0.0', '1.16.0')
assert agent_meets_minimum('2.0.0.0', '1.16.0')
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
# The lexical trap.
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
def test_an_unreadable_agent_version_fails_the_guard_closed():
"""An unparseable or absent version does not satisfy a minimum.
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
endpoint reports nothing at all. There is no honest ordering between a
placeholder and a real build, so the guard refuses rather than guessing.
Letting it through would only move the failure to the agent, where the
error message is the misleading one.
"""
assert parse_agent_version('0.0.0-sample') is None
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
assert not agent_meets_minimum(None, '1.16.0')
assert not agent_meets_minimum('', '1.16.0')
# No declared floor means no opinion: an action every agent implements must
# stay available even to an endpoint whose version we cannot read.
assert agent_meets_minimum('0.0.0-sample', None)
assert agent_meets_minimum(None, None)
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:

View File

@@ -0,0 +1,200 @@
"""The Run Scripts tab's history feed.
A successful script produces a green badge and a one-line message; nothing in
that tells an operator whether the script did any work. These tests pin the
contract the tab relies on: only script runs, newest first, capped, and
carrying the agent's structured detail so the exit code and run context are
visible without opening the database.
"""
import json
from werkzeug.security import generate_password_hash
def _endpoint(db, hostname='scriptbox'):
"""An Asset + Computer pair with a reported state, as the routes expect."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _viewer(db, client, username='scriptviewer'):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='scriptviewerrole').first()
if role is None:
role = Role(rolename='scriptviewerrole')
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(['endpoints.view'])).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _script(db, name='test-open-notepad'):
from shopdb.core.models import Script
script = Script(scriptname=name, runcontext='system')
db.session.add(script)
db.session.commit()
return script.scriptid
def _command(db, assetid, target, createdat, action='run_script',
status='succeeded', resultjson=None, resultmessage=None):
from plugins.endpoints.models import EndpointCommand
command = EndpointCommand(assetid=assetid, action=action, target=target,
status=status, resultmessage=resultmessage,
resultjson=resultjson, createdat=createdat)
db.session.add(command)
db.session.commit()
return command.endpointcommandid
def test_only_script_runs_are_returned(app, client, db):
"""A restart sitting in the same feed is not a script run.
The tab asks a narrower question than the activity panel, so filtering has
to happen server-side; a mixed feed sliced client-side would quietly show
fewer than five script runs whenever other commands interleave.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
_command(db, assetid, 'restart', datetime(2026, 1, 1, 11, 0),
action='restart')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert response.status_code == 200
assert [row['action'] for row in rows] == ['run_script']
def test_newest_first_and_capped_at_five(app, client, db):
"""Six runs, five rows, most recent at the top."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
for hour in range(6):
_command(db, assetid, f'script:{scriptid}',
datetime(2026, 1, 1, 10 + hour, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert len(rows) == 5
stamps = [row['createdat'] for row in rows]
assert stamps == sorted(stamps, reverse=True)
def test_agent_detail_is_exposed(app, client, db):
"""The exit code and run context are the evidence behind a green badge."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultmessage='Script completed (system context)',
resultjson=json.dumps({'kind': 'script', 'exitcode': 0,
'runcontext': 'system'}))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['resultdetail']['exitcode'] == 0
assert row['resultdetail']['runcontext'] == 'system'
def test_the_script_name_is_resolved(app, client, db):
"""'script:5' is the wire target, not what the operator picked."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db, name='rebuild-shopfloor-user')
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['scriptname'] == 'rebuild-shopfloor-user'
def test_malformed_detail_does_not_break_the_list(app, client, db):
"""resultjson is agent-supplied text and can arrive truncated.
A diagnostics panel failing to parse must degrade to "no detail", not take
down the history that surrounds it.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultjson='{"kind": "script", "exitcode":')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
assert response.status_code == 200
assert response.get_json()['data'][0]['resultdetail'] is None
def test_view_permission_is_required(app, client, db):
"""History reveals what has been run on a machine, so it is gated."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns')
assert response.status_code in (401, 403)