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.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.ServiceProcess;
|
using System.ServiceProcess;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ShopDbEndpointAgent.Core;
|
namespace ShopDbEndpointAgent.Core;
|
||||||
@@ -167,6 +169,11 @@ public sealed class CommandExecutor
|
|||||||
case "network_connections":
|
case "network_connections":
|
||||||
return NetworkConnections();
|
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:
|
default:
|
||||||
logger.LogWarning("Rejected unknown command action {Action}", action);
|
logger.LogWarning("Rejected unknown command action {Action}", action);
|
||||||
return new CommandResult
|
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()
|
private Dictionary<int, string> GetProcessNameSnapshot()
|
||||||
{
|
{
|
||||||
var names = new Dictionary<int, string>();
|
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
|
// 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
|
// 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
|
// 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)
|
private void LogScriptOutput(string name, string output, int? exitCode)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(output))
|
if (string.IsNullOrWhiteSpace(output))
|
||||||
@@ -1166,7 +1415,7 @@ public sealed class CommandExecutor
|
|||||||
? $"Script {name} completed (system context)"
|
? $"Script {name} completed (system context)"
|
||||||
: $"Script {name} exited with code {exit}",
|
: $"Script {name} exited with code {exit}",
|
||||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
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} completed (user context)"
|
||||||
: $"Script {name} exited with code {exit}",
|
: $"Script {name} exited with code {exit}",
|
||||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
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(
|
private static Dictionary<string, object> ScriptDetail(
|
||||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode,
|
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode,
|
||||||
string? workingDirectory = null, string? output = null)
|
string? workingDirectory = null, string? output = null)
|
||||||
@@ -1313,12 +1574,12 @@ public sealed class CommandExecutor
|
|||||||
detail["workingdirectory"] = workingDirectory!;
|
detail["workingdirectory"] = workingDirectory!;
|
||||||
if (exitCode is not null)
|
if (exitCode is not null)
|
||||||
detail["exitcode"] = exitCode;
|
detail["exitcode"] = exitCode;
|
||||||
// FAILURE ONLY. Callers pass output only when the command failed, so a
|
// Always present on failure; on success only when the script opted in
|
||||||
// successful run's stdout never leaves the endpoint. Script output can
|
// via captureoutput (see ShouldReturnOutput). Script output can carry
|
||||||
// carry whatever the body happened to print - hostnames, paths, tokens
|
// whatever the body happened to print - hostnames, paths, tokens an
|
||||||
// an author echoed while debugging - and there is no reason to accept
|
// author echoed while debugging - so a successful run stays silent
|
||||||
// that exposure for a run nobody needs to diagnose. The endpoint log
|
// unless someone decided otherwise after reading the body. The endpoint
|
||||||
// keeps the full copy either way.
|
// log keeps the full copy either way.
|
||||||
if (!string.IsNullOrWhiteSpace(output))
|
if (!string.IsNullOrWhiteSpace(output))
|
||||||
detail["output"] = output!;
|
detail["output"] = output!;
|
||||||
return detail;
|
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
|
// 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
|
// 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.
|
// 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
|
public sealed class ScriptDeliveryClaim
|
||||||
{
|
{
|
||||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||||
@@ -206,6 +213,7 @@ public sealed class ScriptDeliveryClaim
|
|||||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||||
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
||||||
[JsonPropertyName("errormode")] public string? ErrorMode { 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 } }.
|
// 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
|
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
|
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
|
||||||
code changes. Tray is at 1.6.5 from wj. -->
|
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>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|||||||
@@ -231,6 +231,29 @@ export const endpointsApi = {
|
|||||||
listCommands(assetId) {
|
listCommands(assetId) {
|
||||||
return api.get(`/endpoints/${assetId}/commands`)
|
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
|
// Active break-glass holds. Readable by any endpoints.view holder, since a
|
||||||
// suppressed action is drift an operator must be able to explain.
|
// suppressed action is drift an operator must be able to explain.
|
||||||
listPolicyHolds(assetId) {
|
listPolicyHolds(assetId) {
|
||||||
|
|||||||
@@ -309,8 +309,13 @@ import {
|
|||||||
// unmount so the blobs are not leaked.
|
// unmount so the blobs are not leaked.
|
||||||
const reportImageUrls = ref({})
|
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) {
|
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
|
// 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Audit Footer -->
|
<!-- Audit Footer -->
|
||||||
@@ -930,6 +1153,14 @@ const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript
|
|||||||
const canViewOperatorReports = computed(
|
const canViewOperatorReports = computed(
|
||||||
() => authStore.hasPermission('endpoints.operatorreports'))
|
() => 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 detailtabs = computed(() => {
|
||||||
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
||||||
const has = (key) => filled.has ? filled.has(key) : false
|
const has = (key) => filled.has ? filled.has(key) : false
|
||||||
@@ -971,6 +1202,16 @@ const detailtabs = computed(() => {
|
|||||||
if (canRunScript.value && liveness.value) {
|
if (canRunScript.value && liveness.value) {
|
||||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
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
|
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
|
// 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.
|
// never open this, and the library is irrelevant to every other tab.
|
||||||
watch(activegroup, (group) => {
|
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
|
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||||
// confirmRestart - these start real work on a live machine and some of them
|
// confirmRestart - these start real work on a live machine and some of them
|
||||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||||
@@ -1578,12 +2042,22 @@ async function confirmRunScript() {
|
|||||||
|
|
||||||
firingScript.value = true
|
firingScript.value = true
|
||||||
try {
|
try {
|
||||||
await endpointsApi.enqueueCommand(
|
const response = await endpointsApi.enqueueCommand(
|
||||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||||
// and the script's own result comes back separately. Saying "ran" here
|
// and the script's own result comes back separately. Saying "ran" here
|
||||||
// would claim an outcome nothing has observed yet.
|
// would claim an outcome nothing has observed yet.
|
||||||
toast.success(`${script.scriptname} queued for ${label}`)
|
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) {
|
} catch (error) {
|
||||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2092,6 +2566,258 @@ async function confirmRetire() {
|
|||||||
.runscripts-actions {
|
.runscripts-actions {
|
||||||
margin-top: 1rem;
|
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.critical .triage-dot { background: var(--danger); }
|
||||||
.triage-chip.ok { color: var(--success-dark); }
|
.triage-chip.ok { color: var(--success-dark); }
|
||||||
.triage-chip.warning { color: var(--warning-dark); }
|
.triage-chip.warning { color: var(--warning-dark); }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ from shopdb.api import (
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||||
EndpointHealthState, OperatorReport,
|
EndpointHealthState, OperatorReport, EndpointLogCollection,
|
||||||
)
|
)
|
||||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, 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
|
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}'
|
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):
|
def _resolve_run_script(target):
|
||||||
"""Resolve a run_script target to a script delivery descriptor.
|
"""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
|
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
|
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.
|
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(':')
|
kind, _sep, raw = str(target or '').partition(':')
|
||||||
script = None
|
script = None
|
||||||
@@ -2110,6 +2303,7 @@ def _resolve_run_script(target):
|
|||||||
'runcontext': script.runcontext,
|
'runcontext': script.runcontext,
|
||||||
'workingdirectory': script.workingdirectory or '',
|
'workingdirectory': script.workingdirectory or '',
|
||||||
'errormode': script.errormode or 'default',
|
'errormode': script.errormode or 'default',
|
||||||
|
'captureoutput': bool(script.captureoutput),
|
||||||
}, None
|
}, None
|
||||||
|
|
||||||
|
|
||||||
@@ -2125,6 +2319,149 @@ def list_endpoint_commands(asset_id: int):
|
|||||||
return success_response([row.to_dict() for row in rows])
|
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'])
|
@endpoints_bp.route('/<int:asset_id>/policyholds', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def list_endpoint_policy_holds(asset_id: int):
|
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)
|
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||||
pinnedsha256 = (script or {}).get('sha256')
|
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()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
if state is None:
|
if state is None:
|
||||||
return error_response(ErrorCodes.NOT_FOUND,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
@@ -2763,6 +3112,20 @@ def report_command_result(command_id: int):
|
|||||||
else EndpointCommand.STATUS_FAILED)
|
else EndpointCommand.STATUS_FAILED)
|
||||||
command.resultmessage = (payload.get('message') or '')[:2000] or None
|
command.resultmessage = (payload.get('message') or '')[:2000] or None
|
||||||
detail = payload.get('detail')
|
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=(',', ':')) \
|
command.resultjson = json.dumps(detail, separators=(',', ':')) \
|
||||||
if detail is not None else None
|
if detail is not None else None
|
||||||
command.completedat = _naive_utcnow()
|
command.completedat = _naive_utcnow()
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ COMMAND_ALLOWLIST = {
|
|||||||
'PCDetail.vue).',
|
'PCDetail.vue).',
|
||||||
'targetkind': 'script',
|
'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': {
|
'update_app': {
|
||||||
'label': 'Install/update application',
|
'label': 'Install/update application',
|
||||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||||
@@ -192,11 +210,18 @@ COMMAND_ALLOWLIST = {
|
|||||||
# update_app - runs a pinned installer
|
# update_app - runs a pinned installer
|
||||||
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
||||||
# user context
|
# 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 = {
|
ELEVATED_ACTION_PERMISSIONS = {
|
||||||
'system_restart': 'endpoints.restart',
|
'system_restart': 'endpoints.restart',
|
||||||
'scheduledtask_run': 'endpoints.corptools',
|
'scheduledtask_run': 'endpoints.corptools',
|
||||||
'run_script': 'endpoints.runscript',
|
'run_script': 'endpoints.runscript',
|
||||||
'update_app': 'endpoints.updateapp',
|
'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
|
# policy is non-empty - a denial no operator can act on, because the fix
|
||||||
# lives in a policy that was never about this action.
|
# lives in a policy that was never about this action.
|
||||||
'collect_now',
|
'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">
|
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||||
Continue
|
Continue
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="s.captureoutput"
|
||||||
|
class="badge badge-secondary"
|
||||||
|
title="A successful run returns its output to ShopDB"
|
||||||
|
>
|
||||||
|
Returns output
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||||
@@ -213,6 +220,27 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input v-model="form.isactive" type="checkbox" />
|
<input v-model="form.isactive" type="checkbox" />
|
||||||
@@ -259,6 +287,7 @@ function emptyForm() {
|
|||||||
scriptdescription: '',
|
scriptdescription: '',
|
||||||
workingdirectory: '',
|
workingdirectory: '',
|
||||||
errormode: 'default',
|
errormode: 'default',
|
||||||
|
captureoutput: false,
|
||||||
isactive: true
|
isactive: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +322,7 @@ function openModal(script = null) {
|
|||||||
scriptdescription: script.scriptdescription || '',
|
scriptdescription: script.scriptdescription || '',
|
||||||
workingdirectory: script.workingdirectory || '',
|
workingdirectory: script.workingdirectory || '',
|
||||||
errormode: script.errormode || 'default',
|
errormode: script.errormode || 'default',
|
||||||
|
captureoutput: !!script.captureoutput,
|
||||||
isactive: script.isactive
|
isactive: script.isactive
|
||||||
}
|
}
|
||||||
: emptyForm()
|
: emptyForm()
|
||||||
@@ -314,6 +344,7 @@ async function save() {
|
|||||||
scriptdescription: form.value.scriptdescription,
|
scriptdescription: form.value.scriptdescription,
|
||||||
workingdirectory: form.value.workingdirectory,
|
workingdirectory: form.value.workingdirectory,
|
||||||
errormode: form.value.errormode,
|
errormode: form.value.errormode,
|
||||||
|
captureoutput: form.value.captureoutput,
|
||||||
isactive: form.value.isactive
|
isactive: form.value.isactive
|
||||||
}
|
}
|
||||||
let scriptid
|
let scriptid
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from .endpoint import (
|
from .endpoint import (
|
||||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||||
EndpointLock,
|
EndpointLock, EndpointLogCollection,
|
||||||
)
|
)
|
||||||
from .monitor import EndpointMonitor
|
from .monitor import EndpointMonitor
|
||||||
from .operatorreport import OperatorReport
|
from .operatorreport import OperatorReport
|
||||||
@@ -12,7 +12,7 @@ from .health import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||||
'EndpointLock',
|
'EndpointLock', 'EndpointLogCollection',
|
||||||
'EndpointMonitor', 'OperatorReport',
|
'EndpointMonitor', 'OperatorReport',
|
||||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
|||||||
crash) so this stays a readable security/stability history
|
crash) so this stays a readable security/stability history
|
||||||
rather than a raw event firehose.
|
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
|
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
|
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.
|
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from shopdb.api import db
|
from shopdb.api import db
|
||||||
|
|
||||||
|
|
||||||
@@ -275,8 +285,16 @@ class EndpointCommand(db.Model):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self, includedetail=False):
|
||||||
return {
|
"""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,
|
'endpointcommandid': self.endpointcommandid,
|
||||||
'assetid': self.assetid,
|
'assetid': self.assetid,
|
||||||
'action': self.action,
|
'action': self.action,
|
||||||
@@ -289,6 +307,24 @@ class EndpointCommand(db.Model):
|
|||||||
'claimedat': _utciso(self.claimedat),
|
'claimedat': _utciso(self.claimedat),
|
||||||
'completedat': _utciso(self.completedat),
|
'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):
|
class EndpointEvent(db.Model):
|
||||||
@@ -531,3 +567,130 @@ class EndpointLock(db.Model):
|
|||||||
'releasedat': _utciso(self.releasedat),
|
'releasedat': _utciso(self.releasedat),
|
||||||
'releasedbyusername': self.releasedbyusername,
|
'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'),
|
'already registered and allowlisted on the agent)', 'endpoints'),
|
||||||
('endpoints.updateapp',
|
('endpoints.updateapp',
|
||||||
'Push application installers to endpoints (runs a pinned '
|
'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
|
# Blunt on purpose. This is not "system-level changes" - it is
|
||||||
# arbitrary code execution as SYSTEM unless the script descriptor
|
# arbitrary code execution as SYSTEM unless the script descriptor
|
||||||
# pins a user context. Whoever grants it must see that here.
|
# pins a user context. Whoever grants it must see that here.
|
||||||
@@ -503,7 +504,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
'meta': [
|
'meta': [
|
||||||
{'key': 'detail'},
|
{'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
|
# Expands in place on click. The message body is several
|
||||||
# hundred words of Windows boilerplate around the one or
|
# hundred words of Windows boilerplate around the one or
|
||||||
|
|||||||
@@ -112,6 +112,27 @@ def _validate_errormode(value):
|
|||||||
return value, None
|
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):
|
def _validate_workingdirectory(value):
|
||||||
"""Normalize the optional folder a script runs in. Returns (value, error).
|
"""Normalize the optional folder a script runs in. Returns (value, error).
|
||||||
|
|
||||||
@@ -176,6 +197,9 @@ def create_script():
|
|||||||
if err:
|
if err:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||||
errormode, err = _validate_errormode(data.get('errormode'))
|
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:
|
if err:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||||
|
|
||||||
@@ -185,6 +209,7 @@ def create_script():
|
|||||||
runcontext=runcontext,
|
runcontext=runcontext,
|
||||||
workingdirectory=workingdirectory,
|
workingdirectory=workingdirectory,
|
||||||
errormode=errormode,
|
errormode=errormode,
|
||||||
|
captureoutput=captureoutput,
|
||||||
)
|
)
|
||||||
db.session.add(script)
|
db.session.add(script)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -192,7 +217,8 @@ def create_script():
|
|||||||
entityname=name,
|
entityname=name,
|
||||||
changes={'runcontext': {'new': runcontext},
|
changes={'runcontext': {'new': runcontext},
|
||||||
'workingdirectory': {'new': workingdirectory},
|
'workingdirectory': {'new': workingdirectory},
|
||||||
'errormode': {'new': errormode}})
|
'errormode': {'new': errormode},
|
||||||
|
'captureoutput': {'new': captureoutput}})
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(script.to_dict(), message='Script created', http_code=201)
|
return success_response(script.to_dict(), message='Script created', http_code=201)
|
||||||
|
|
||||||
@@ -233,6 +259,18 @@ def update_script(script_id: int):
|
|||||||
if err:
|
if err:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||||
script.errormode = errormode
|
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:
|
if 'isactive' in data:
|
||||||
script.isactive = bool(data.get('isactive'))
|
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.
|
wire; the agent downloads it with its collector credential and verifies sha256.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
@@ -69,6 +71,17 @@ class Script(BaseModel):
|
|||||||
# existed reads as the permissive behavior it was authored under.
|
# existed reads as the permissive behavior it was authored under.
|
||||||
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
|
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
|
||||||
server_default=ERRORMODE_DEFAULT)
|
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):
|
def to_dict(self):
|
||||||
result = super().to_dict()
|
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
|
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():
|
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||||
the enqueue route accepts them and carries their subject on the wire:
|
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