Add log collection, script output capture, and timestamp fixes
Log Collector: a new collect_log action reads a plaintext log file from a known path and returns its tail for viewing and download. Rides the existing endpoints.updateapp grant rather than inventing one, since collecting a log is the same trust level as pushing an application. Collected logs land in their own table with per-asset retention of 20 instead of overloading the command audit row, and the agent refuses UNC, traversal, wildcards, directories and non-text content independently of the server check. collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops anything unnamed - so every policied endpoint refused log collection outright, including for admins, who saw a permission-shaped error for a grant they held. Exemption from policy is not exemption from permission: the elevated grant still applies, and a regression test asserts both halves so a future edit cannot quietly loosen the grant while "fixing" the policy gate. Script output capture: successful runs can return output, gated per script by captureoutput. Failures always returned it; success never did, which left a green badge as the only evidence a run produced. Timestamps: Activity and Operator Reports rendered date-only because the shared formatter's 'date' branch drops the clock time. Run Scripts additionally labels its timestamp, since a bare time meant "completed" on a terminal row but "queued" on an in-flight one - indistinguishable, and the wrong reading. Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked for it fails closed on the unknown-action default.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 } }.
|
||||
|
||||
@@ -211,7 +211,29 @@
|
||||
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. -->
|
||||
<Version>1.14.0</Version>
|
||||
<!-- 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>
|
||||
|
||||
@@ -231,6 +231,29 @@ export const endpointsApi = {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
48
migrations/versions/7d50_script_captureoutput.py
Normal file
48
migrations/versions/7d50_script_captureoutput.py
Normal 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')
|
||||
92
migrations/versions/7d51_endpoint_logcollections.py
Normal file
92
migrations/versions/7d51_endpoint_logcollections.py
Normal 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')
|
||||
@@ -594,6 +594,229 @@
|
||||
</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>
|
||||
|
||||
<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"
|
||||
@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"
|
||||
@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 -->
|
||||
@@ -930,6 +1153,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 +1202,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
|
||||
})
|
||||
|
||||
@@ -1518,9 +1759,232 @@ 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)
|
||||
|
||||
// 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() {
|
||||
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 +2042,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 +2566,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); }
|
||||
|
||||
@@ -10,6 +10,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -28,7 +29,7 @@ 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 ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
@@ -2068,6 +2069,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 +2272,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 +2303,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 +2319,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):
|
||||
@@ -2538,6 +2875,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,
|
||||
@@ -2763,6 +3112,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()
|
||||
|
||||
@@ -110,6 +110,24 @@ 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',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||
@@ -192,11 +210,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',
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +364,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',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
129
tests/test_core/test_script_captureoutput.py
Normal file
129
tests/test_core/test_script_captureoutput.py
Normal 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
|
||||
@@ -196,6 +196,26 @@ 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_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:
|
||||
|
||||
200
tests/test_plugins/test_endpoints_script_runs.py
Normal file
200
tests/test_plugins/test_endpoints_script_runs.py
Normal 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)
|
||||
Reference in New Issue
Block a user