Stop scripts reporting success after doing nothing
A run_script command was judged solely on PowerShell's exit code, and PowerShell treats most errors as non-terminating: it writes the error, runs the next line, and still exits zero. The account-provisioning script reported succeeded having provisioned nothing, and because no output was captured there was no evidence to contradict the verdict. Scripts now carry an errormode. Strict prepends $ErrorActionPreference = 'Stop' so the first error ends the run and the exit code means what it says. It is opt-in per script rather than global because bodies authored against the permissive semantics rely on them - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item - and would start aborting midway, leaving endpoints half-configured. The default is unchanged, so every existing script keeps running exactly as before. The prologue is applied after digest verification, so the SHA-256 still validates the bytes the server delivered. stdout/stderr is captured on both paths: redirected streams for SYSTEM, a transcript file under the ShopFloor profile for user-session runs, since CreateProcessAsUser cannot inherit the service's pipes across the session boundary. Output is always logged locally and returned to the server only on failure, so routine output stays on the machine that produced it instead of filling command history. Also adds the optional per-script working directory, which the same surfaces needed, and a collapsible explainer on the scripts page - the dropdown is not self-explanatory, and an operator who does not know about non-terminating errors has no reason to ever leave the default. Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour.
This commit is contained in:
@@ -901,6 +901,7 @@ public sealed class CommandExecutor
|
||||
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
ApplyErrorMode(script, tempPath, name);
|
||||
return runInUser
|
||||
? RunScriptInUserSession(name, script, tempPath)
|
||||
: await RunScriptAsSystem(name, script, tempPath, token);
|
||||
@@ -933,18 +934,203 @@ public sealed class CommandExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort. A missing transcript directory must not stop the script from
|
||||
// running: the capture is diagnostics, and losing diagnostics is a smaller
|
||||
// failure than refusing to do the work that was asked for.
|
||||
private void TryEnsureTranscriptDirectory(string transcriptPath, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(transcriptPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory!);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error,
|
||||
"run_script {Name}: could not prepare the output transcript folder; "
|
||||
+ "the script will still run but its output will not be captured", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the transcript back and delete it. Deleted whether or not the read
|
||||
// worked: it sits in the operator's own profile where they could read it,
|
||||
// and a script's output is not theirs to keep lying around after the agent
|
||||
// has reported it.
|
||||
private string ReadAndClearTranscript(string transcriptPath, string name)
|
||||
{
|
||||
var output = "";
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
output = CombineScriptOutput(File.ReadAllText(transcriptPath), null);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error, "run_script {Name}: could not read the output transcript", name);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
File.Delete(transcriptPath);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(
|
||||
error, "run_script {Name}: transcript cleanup failed", name);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Cap on captured script output. Two different limits are at work: the
|
||||
// endpoint log should stay readable, and the server payload rides an HTTP
|
||||
// POST into a MEDIUMTEXT column. A runaway loop printing megabytes must not
|
||||
// turn a failed script into a failed REPORT - losing the verdict entirely is
|
||||
// worse than losing the tail of the evidence. Head is kept rather than tail
|
||||
// because the FIRST error is the one that explains the failure; under strict
|
||||
// mode nothing after it ran anyway.
|
||||
private const int MaxCapturedOutputChars = 8000;
|
||||
|
||||
// Merge the two streams into one operator-readable blob. stderr is labelled
|
||||
// rather than concatenated blind, because "which stream said this" is the
|
||||
// difference between a warning a script prints and the error that killed it.
|
||||
private static string CombineScriptOutput(string? stdout, string? stderr)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
parts.Add(stdout!.TrimEnd());
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
parts.Add("[stderr] " + stderr!.TrimEnd());
|
||||
var combined = string.Join(Environment.NewLine, parts);
|
||||
if (combined.Length <= MaxCapturedOutputChars)
|
||||
return combined;
|
||||
// Say so explicitly. Silently truncated evidence reads as a script that
|
||||
// stopped talking, which is a different diagnosis than one that was cut.
|
||||
return combined.Substring(0, MaxCapturedOutputChars)
|
||||
+ Environment.NewLine
|
||||
+ $"[truncated after {MaxCapturedOutputChars} characters]";
|
||||
}
|
||||
|
||||
// The endpoint log gets the output ALWAYS, success or failure. This is the
|
||||
// copy that stays on the box: it never leaves the endpoint, so it costs no
|
||||
// exposure, and it is the only record when a script reports success while
|
||||
// doing nothing useful. The server copy is failure-only (see ScriptDetail).
|
||||
private void LogScriptOutput(string name, string output, int? exitCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"run_script {Name} produced no output (exit {Exit})",
|
||||
name, exitCode?.ToString() ?? "unknown");
|
||||
return;
|
||||
}
|
||||
logger.LogInformation(
|
||||
"run_script {Name} output (exit {Exit}):{NewLine}{Output}",
|
||||
name, exitCode?.ToString() ?? "unknown", Environment.NewLine, output);
|
||||
}
|
||||
|
||||
// Prepended to a strict-mode body. Written on ONE line so every line number
|
||||
// PowerShell reports still matches the body the author sees in the WebUI
|
||||
// editor: a multi-line prologue would shift them and make the error text
|
||||
// point at the wrong statement during exactly the debugging session that
|
||||
// needed it.
|
||||
private const string StrictModePrologue =
|
||||
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'";
|
||||
|
||||
// Turn a script's non-terminating errors fatal when the library says strict.
|
||||
//
|
||||
// MUST run AFTER the SHA-256 check. The digest pins the bytes the SERVER
|
||||
// holds; prepending first would rewrite the file and fail its own integrity
|
||||
// check. Verify what was delivered, then modify how it is interpreted.
|
||||
//
|
||||
// Best-effort by design: if the rewrite fails the script still runs under
|
||||
// PowerShell's default rules. That is the behaviour it had before this
|
||||
// existed, so a failed rewrite degrades to "as before" rather than blocking
|
||||
// work, and the warning says the verdict is the weaker one.
|
||||
private void ApplyErrorMode(ScriptDeliveryClaim script, string scriptPath, string name)
|
||||
{
|
||||
if (!string.Equals(script.ErrorMode, "strict", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var body = File.ReadAllText(scriptPath);
|
||||
File.WriteAllText(scriptPath, StrictModePrologue + Environment.NewLine + body);
|
||||
logger.LogInformation(
|
||||
"run_script {Name}: strict error mode applied; a non-terminating "
|
||||
+ "error will now stop the script and fail the command", name);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error,
|
||||
"run_script {Name}: could not apply strict error mode; running under "
|
||||
+ "PowerShell defaults, so a non-terminating error will NOT fail the command",
|
||||
name);
|
||||
}
|
||||
}
|
||||
|
||||
// Where the script actually runs. Empty means the agent staging directory,
|
||||
// which is right for a self-contained script and wrong for one that shells
|
||||
// out to a corporate tool already installed on the endpoint (Sysinternals
|
||||
// and similar, delivered by Intune) and invokes it by relative path. The
|
||||
// server carries that folder per script.
|
||||
//
|
||||
// A NAMED DIRECTORY THAT IS ABSENT FAILS THE COMMAND. Falling back to
|
||||
// staging would run SYSTEM PowerShell somewhere other than where the author
|
||||
// said; the script would then fail further in with an error naming the
|
||||
// missing tool rather than the missing folder, or half-succeed, which is
|
||||
// worse. Fail closed and name the folder.
|
||||
private static bool TryResolveWorkingDirectory(
|
||||
ScriptDeliveryClaim? script, string scriptPath,
|
||||
out string workingDirectory, out string error)
|
||||
{
|
||||
error = "";
|
||||
workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
|
||||
var requested = script?.WorkingDirectory;
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
return true;
|
||||
|
||||
if (!Directory.Exists(requested))
|
||||
{
|
||||
error = $"working directory not found on this endpoint: {requested}";
|
||||
return false;
|
||||
}
|
||||
|
||||
workingDirectory = requested!;
|
||||
return true;
|
||||
}
|
||||
|
||||
// System context: run through the agent's own service account and wait for
|
||||
// the exit code, the same as before. Fine for machine-scoped scripts that do
|
||||
// not need the interactive user's session resources.
|
||||
private async Task<CommandResult> RunScriptAsSystem(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
|
||||
{
|
||||
if (!TryResolveWorkingDirectory(
|
||||
script, scriptPath, out var workingDirectory, out var directoryError))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} not started: {Error}", name, directoryError);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} not run: {directoryError}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "",
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
@@ -960,19 +1146,27 @@ public sealed class CommandExecutor
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start script {name}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory),
|
||||
};
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(token);
|
||||
// Read only AFTER exit, for the reason spelled out on the schtasks path:
|
||||
// ReadToEnd blocks until the streams close, which IS process exit, so
|
||||
// reading first would make the caller's cancellation unable to fire.
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(token);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(token);
|
||||
var exit = process.ExitCode;
|
||||
var output = CombineScriptOutput(stdout, stderr);
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = exit == 0,
|
||||
Message = exit == 0
|
||||
? $"Script {name} completed (system context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
exit == 0 ? null : output),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,13 +1182,57 @@ public sealed class CommandExecutor
|
||||
// hung script pinned the caller forever.
|
||||
private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// Where a user-session script's output is captured to.
|
||||
//
|
||||
// The SYSTEM path can simply redirect the child's stdout/stderr handles.
|
||||
// This path cannot: it goes through CreateProcessAsUser to cross a session
|
||||
// boundary, and inheriting pipe handles across that boundary is exactly the
|
||||
// part of the launcher that is delicate (session enumeration, token
|
||||
// duplication). So PowerShell redirects to a file itself and the agent reads
|
||||
// it back after exit.
|
||||
//
|
||||
// The file lands in the OPERATOR's profile, not the agent's ProgramData
|
||||
// staging folder: staging is SYSTEM-owned and the signed-in operator cannot
|
||||
// write there, which is the same constraint that put the tray's own log in
|
||||
// the profile. Safe to rely on here because a user-context script only runs
|
||||
// when someone is interactively signed in, and a signed-in account has a
|
||||
// materialised profile by definition.
|
||||
private static string UserTranscriptPath(int scriptId)
|
||||
=> Path.Combine(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
$"shopdb-script-{scriptId}-output.txt");
|
||||
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
{
|
||||
var quotedPath = "\"" + scriptPath + "\"";
|
||||
// Redirect inside the user session rather than from the service. The
|
||||
// child is launched through CreateProcessAsUser, so the agent holds no
|
||||
// handles to its streams; -Command wraps the same -File invocation and
|
||||
// sends both streams to a file the operator can write, which the agent
|
||||
// reads back after exit. 2>&1 merges stderr into the same transcript
|
||||
// because ordering between the two matters more than telling them apart
|
||||
// when reconstructing what a script did.
|
||||
var transcriptPath = UserTranscriptPath(script.ScriptId);
|
||||
var quotedTranscript = "\"" + transcriptPath + "\"";
|
||||
TryEnsureTranscriptDirectory(transcriptPath, name);
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath;
|
||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
|
||||
+ "\"& { & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy "
|
||||
+ "Bypass -File " + quotedPath.Replace("\"", "\\\"")
|
||||
+ " *> " + quotedTranscript.Replace("\"", "\\\"") + "; exit $LASTEXITCODE }\"";
|
||||
if (!TryResolveWorkingDirectory(
|
||||
script, scriptPath, out var workingDirectory, out var directoryError))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} not started: {Error}", name, directoryError);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} not run: {directoryError}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true,
|
||||
@@ -1008,7 +1246,8 @@ public sealed class CommandExecutor
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} could not run in the user session: {outcome.Error}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory,
|
||||
ReadAndClearTranscript(transcriptPath, name)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1018,19 +1257,22 @@ public sealed class CommandExecutor
|
||||
// as unresolved rather than success, because we never saw an exit
|
||||
// code and cannot claim it worked.
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} still running after {Timeout}; stopped waiting",
|
||||
name, UserScriptWaitTimeout);
|
||||
"run_script for {Name} still running after {Timeout}; stopped waiting. "
|
||||
+ "Partial output remains at {Transcript} while it runs",
|
||||
name, UserScriptWaitTimeout, transcriptPath);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} did not finish within "
|
||||
+ $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory),
|
||||
};
|
||||
}
|
||||
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
var output = ReadAndClearTranscript(transcriptPath, name);
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
@@ -1039,12 +1281,14 @@ public sealed class CommandExecutor
|
||||
: ok
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
ok ? null : output),
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var detail = new Dictionary<string, object>
|
||||
{
|
||||
@@ -1056,11 +1300,27 @@ public sealed class CommandExecutor
|
||||
detail["scriptid"] = script.ScriptId;
|
||||
if (!string.IsNullOrWhiteSpace(script.RunContext))
|
||||
detail["runcontext"] = script.RunContext!;
|
||||
if (!string.IsNullOrWhiteSpace(script.ErrorMode))
|
||||
detail["errormode"] = script.ErrorMode!;
|
||||
}
|
||||
if (scriptPath is not null)
|
||||
detail["path"] = scriptPath;
|
||||
// The directory the script REALLY ran in, not the one that was asked
|
||||
// for. An agent too old to know the field reports nothing here, which is
|
||||
// how an operator tells "ignored" apart from "honoured" while agents of
|
||||
// both vintages are in the field.
|
||||
if (!string.IsNullOrWhiteSpace(workingDirectory))
|
||||
detail["workingdirectory"] = workingDirectory!;
|
||||
if (exitCode is not null)
|
||||
detail["exitcode"] = exitCode;
|
||||
// FAILURE ONLY. Callers pass output only when the command failed, so a
|
||||
// successful run's stdout never leaves the endpoint. Script output can
|
||||
// carry whatever the body happened to print - hostnames, paths, tokens
|
||||
// an author echoed while debugging - and there is no reason to accept
|
||||
// that exposure for a run nobody needs to diagnose. The endpoint log
|
||||
// keeps the full copy either way.
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
detail["output"] = output!;
|
||||
return detail;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,21 @@ public sealed class AppDeliveryClaim
|
||||
// downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// RunContext is 'system' (the agent's own service account) or 'user' (the
|
||||
// interactive logged-on session), decided per script in the ShopDB library.
|
||||
// WorkingDirectory is the optional folder to run the script IN. Empty means the
|
||||
// agent's staging directory, which is what every script did before the field
|
||||
// existed. It is there so a script can sit beside a corporate tool already
|
||||
// installed on the endpoint and invoke it by relative path; it confers no
|
||||
// rights, since the body is already arbitrary PowerShell that could change its
|
||||
// own directory.
|
||||
// ErrorMode is 'default' or 'strict'. Default is what PowerShell does unaided:
|
||||
// a non-terminating error is written and the script continues, so powershell.exe
|
||||
// still exits 0 and the command reports SUCCEEDED even when the body did none of
|
||||
// its work. Strict makes the agent prepend $ErrorActionPreference = 'Stop' so the
|
||||
// first error terminates and the exit code means what an operator reads it to
|
||||
// mean. Per script, never global: strict changes execution semantics, and a body
|
||||
// written to tolerate a failing cleanup or to probe with a cmdlet that errors on
|
||||
// absence would start aborting halfway. An absent or unrecognised value is
|
||||
// treated as default, so an old server that never sends the field is safe.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
@@ -189,6 +204,8 @@ public sealed class ScriptDeliveryClaim
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
||||
[JsonPropertyName("errormode")] public string? ErrorMode { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -131,6 +131,21 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<!-- 1.13.0: run_script honors a per-script working directory. The script
|
||||
library gains an optional workingdirectory, the server carries it in the
|
||||
run_script descriptor, and ScriptDeliveryClaim gains WorkingDirectory
|
||||
(AGENT-CONTRACT.md). The point is corporate tooling shipped by Intune: a
|
||||
script that shells out to a vendor CLI needs to run beside that CLI, not
|
||||
in the staging folder. Empty means the existing staging path, so older
|
||||
packages and every existing script keep their current behavior - this is
|
||||
additive, hence minor. Fails closed: a named directory that does not
|
||||
exist on the endpoint aborts the run with a clear error rather than
|
||||
silently falling back to staging, because a script that ran in the wrong
|
||||
folder is worse than one that did not run. The directory confers no
|
||||
rights of its own - it is a cwd, not a grant, and the run still happens
|
||||
in the script's configured context. Server-side validation rejects UNC
|
||||
paths and traversal before the value is ever persisted. No tray change -
|
||||
tray stays 1.4.0. -->
|
||||
<!-- 1.12.0: startup jitter before first contact. Both loops previously made
|
||||
their first request the instant the service started, and the service is
|
||||
installed start=auto, so a patch reboot put the whole fleet on the wire
|
||||
@@ -171,7 +186,23 @@
|
||||
member of that group, so Start-ScheduledTask returned access denied and
|
||||
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
|
||||
is the side that listens, so an older tray simply never answers. -->
|
||||
<Version>1.12.0</Version>
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. No tray change - tray
|
||||
stays 1.4.0. -->
|
||||
<Version>1.14.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
41
migrations/versions/7d48_script_workingdirectory.py
Normal file
41
migrations/versions/7d48_script_workingdirectory.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Add scripts workingdirectory
|
||||
|
||||
Backs the optional "run in this folder" field on a library script. The agent
|
||||
otherwise runs a delivered script from its own staging directory, which is
|
||||
correct for a self-contained script and wrong for one that shells out to a
|
||||
corporate tool already installed on the endpoint (Sysinternals and similar,
|
||||
delivered by Intune) and referenced by relative path.
|
||||
|
||||
NULLABLE with no default, so every script row that existed before this
|
||||
migration stays valid and keeps today's behaviour exactly: empty means the
|
||||
staging directory. The path is validated in shopdb/core/api/scripts.py where a
|
||||
rejection can explain itself, not in the schema where it would only produce an
|
||||
IntegrityError.
|
||||
|
||||
This column grants no capability. A script body is already arbitrary PowerShell
|
||||
run as SYSTEM and can change its own directory; the boundary is the
|
||||
endpoints.runscript grant and the digest-pinned library.
|
||||
|
||||
Revision ID: 7d48_script_workingdir
|
||||
Revises: 7d47_endpoints_operatorreports
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d48_script_workingdir'
|
||||
down_revision = '7d47_endpoints_operatorreports'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('scripts', sa.Column(
|
||||
'workingdirectory', sa.String(length=255), nullable=True,
|
||||
comment='Folder to run the script in; empty means the agent staging dir'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('scripts', 'workingdirectory')
|
||||
46
migrations/versions/7d49_script_errormode.py
Normal file
46
migrations/versions/7d49_script_errormode.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Add scripts errormode
|
||||
|
||||
Backs the per-script "how should a non-terminating error be treated" field.
|
||||
|
||||
PowerShell invoked with -File exits 0 whenever it reaches the end of the body,
|
||||
even if every meaningful line inside wrote an error. The agent derives the
|
||||
command verdict from that exit code, so a script could report SUCCEEDED while
|
||||
doing none of its work - which is exactly how an account-provisioning script
|
||||
came back green with the account only partly built.
|
||||
|
||||
NOT NULL with server_default 'default', so every script row that existed before
|
||||
this migration keeps today's permissive behaviour exactly. Strict is opt-in per
|
||||
script and never retroactive: turning it on globally would change the meaning of
|
||||
bodies that were authored under the permissive rule and deliberately tolerate a
|
||||
failing Remove-Item, probe with Get-LocalUser, or loop past a bad item. Those
|
||||
would begin aborting partway through, which is worse than a false green because
|
||||
it leaves the endpoint half-configured.
|
||||
|
||||
The value is validated in shopdb/core/api/scripts.py where a rejection can
|
||||
explain itself, not in the schema where it would only produce an IntegrityError.
|
||||
|
||||
Revision ID: 7d49_script_errormode
|
||||
Revises: 7d48_script_workingdir
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d49_script_errormode'
|
||||
down_revision = '7d48_script_workingdir'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('scripts', sa.Column(
|
||||
'errormode', sa.String(length=16), nullable=False,
|
||||
server_default='default',
|
||||
comment="'default' keeps PowerShell's permissive behaviour; "
|
||||
"'strict' stops on the first error so the exit code is honest"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('scripts', 'errormode')
|
||||
@@ -512,6 +512,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
|
||||
from Corporate Tools above - that starts a task the endpoint already
|
||||
approved, this delivers script content ShopDB holds. Separate grant
|
||||
(endpoints.runscript), separate tab, so neither implies the other. -->
|
||||
<div v-show="activegroup === 'runscripts'" class="detail-full">
|
||||
<div class="card">
|
||||
<h3>Run Scripts</h3>
|
||||
<p class="corptools-intro">
|
||||
Sends a script from the ShopDB script library to this computer. The
|
||||
agent fetches the body by digest and runs it in the context the
|
||||
script itself specifies.
|
||||
</p>
|
||||
<!-- Persistent, same reasoning as the Corporate Tools warning: this
|
||||
is arbitrary code on a live machine, and the operator must still
|
||||
be able to see that when they decide to press Run. -->
|
||||
<p class="corptools-warning">
|
||||
A script here runs as SYSTEM unless it is configured to run as the
|
||||
logged-on user. It takes effect immediately on the live computer and
|
||||
cannot be undone from here.
|
||||
</p>
|
||||
|
||||
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
|
||||
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
|
||||
No runnable scripts. A script needs an uploaded body before it can be
|
||||
sent; add one under Settings then return here.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label for="runscript-picker">Script</label>
|
||||
<select
|
||||
id="runscript-picker"
|
||||
v-model="selectedscriptid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select a script...</option>
|
||||
<option
|
||||
v-for="script in runnablescripts"
|
||||
:key="script.scriptid"
|
||||
:value="script.scriptid"
|
||||
>{{ script.scriptname }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- What the operator is about to run, stated before they run it.
|
||||
Run context and working directory are authoring decisions made
|
||||
in the library and are shown read-only here: overriding either
|
||||
at push time would mean the script that ran is not the script
|
||||
that was reviewed. -->
|
||||
<div v-if="selectedscript" class="runscripts-detail">
|
||||
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
|
||||
{{ selectedscript.scriptdescription }}
|
||||
</p>
|
||||
<dl class="runscripts-facts">
|
||||
<dt>Runs as</dt>
|
||||
<dd>
|
||||
{{ selectedscript.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
<dt>Runs in</dt>
|
||||
<dd class="mono">
|
||||
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
|
||||
</dd>
|
||||
</dl>
|
||||
<p
|
||||
v-if="selectedscript.workingdirectory"
|
||||
class="runscripts-note"
|
||||
>
|
||||
This script requires that folder to exist on the computer. If it
|
||||
does not, the run fails and reports the missing folder rather
|
||||
than running somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="runscripts-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!selectedscript || firingScript"
|
||||
@click="confirmRunScript"
|
||||
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
@@ -686,7 +770,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi, scriptsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -829,6 +913,16 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
// during setup, which reads canDeploy before this line would otherwise run.
|
||||
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
|
||||
|
||||
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
|
||||
// starts a task the endpoint already holds and has allowlisted, while
|
||||
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
|
||||
// the script pins a user context. Those were split out of the old
|
||||
// endpoints.deploy bundle for exactly this reason (see
|
||||
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
|
||||
// scripts off the Corporate Tools gate would put the bundle back together in
|
||||
// the UI after the backend took it apart.
|
||||
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
|
||||
|
||||
// Operator report screenshots are an uncontrolled capture of whatever was on
|
||||
// the operator's screen, so they carry their own grant rather than riding
|
||||
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
|
||||
@@ -868,6 +962,15 @@ const detailtabs = computed(() => {
|
||||
if (canDeploy.value && liveness.value) {
|
||||
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
|
||||
}
|
||||
// Run Scripts is its own tab rather than a card in Corporate Tools because it
|
||||
// is a different grant and a different trust model - a library of arbitrary
|
||||
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
|
||||
// already approved. Liveness-gated for the same reason as Corporate Tools:
|
||||
// the command queues against a live agent, so offering it for a machine that
|
||||
// is not checking in only produces a command nothing will collect.
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1377,6 +1480,47 @@ const corporateTools = [
|
||||
|
||||
const firingTask = ref('')
|
||||
|
||||
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
|
||||
// of corporateTools above) because these are site-authored scripts that change
|
||||
// without an agent release.
|
||||
const scriptlibrary = ref([])
|
||||
const scriptsloading = ref(false)
|
||||
const scriptsloaded = ref(false)
|
||||
const selectedscriptid = ref('')
|
||||
const firingScript = ref(false)
|
||||
|
||||
// Only scripts that can actually run. A row with no uploaded body is rejected
|
||||
// at enqueue by _resolve_run_script, so listing it would offer a button whose
|
||||
// only outcome is an error; the library page is where that gets fixed.
|
||||
const runnablescripts = computed(
|
||||
() => scriptlibrary.value.filter((script) => script.hasbody))
|
||||
|
||||
const selectedscript = computed(
|
||||
() => runnablescripts.value.find(
|
||||
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
|
||||
|
||||
// Lazy and permission-gated: an operator without endpoints.runscript never has
|
||||
// the tab, and so never issues a request the server would refuse anyway.
|
||||
async function loadScriptLibrary() {
|
||||
if (scriptsloaded.value || scriptsloading.value) return
|
||||
scriptsloading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'true' })
|
||||
scriptlibrary.value = response.data.data || []
|
||||
scriptsloaded.value = true
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to load the script library'))
|
||||
} finally {
|
||||
scriptsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
})
|
||||
|
||||
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||
// confirmRestart - these start real work on a live machine and some of them
|
||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||
@@ -1412,6 +1556,41 @@ async function confirmRunTask(tool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
|
||||
// system_restart is gated", so the confirmation is TYPED rather than an OK
|
||||
// button - and unlike Corporate Tools there is no non-destructive branch,
|
||||
// because every script here is arbitrary code by definition. The confirm names
|
||||
// the context and the folder so the operator sees what the library decided, not
|
||||
// just the script's name.
|
||||
async function confirmRunScript() {
|
||||
const script = selectedscript.value
|
||||
if (!script) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
const context = script.runcontext === 'user'
|
||||
? 'the logged-on user'
|
||||
: 'SYSTEM'
|
||||
const folder = script.workingdirectory || 'the agent staging folder'
|
||||
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
|
||||
+ `It runs as ${context}, in ${folder}.\n`
|
||||
+ 'It starts immediately and cannot be undone from here.'
|
||||
+ '\n\nType RUN to confirm.')
|
||||
if (typed !== 'RUN') return
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
firingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
@@ -1864,6 +2043,55 @@ async function confirmRetire() {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
|
||||
picker-specific pieces are new. */
|
||||
.runscripts-empty {
|
||||
padding: 0.75rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.runscripts-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.runscripts-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
/* Two columns so the label and its value stay on one line: the folder is the
|
||||
fact most likely to be skimmed, and wrapping it under its own label made it
|
||||
read as body copy in review. */
|
||||
.runscripts-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-facts dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.runscripts-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
/* Defined locally: .mono is not a global utility in this build, and a path
|
||||
rendered in the body font is harder to read back against the endpoint. */
|
||||
.runscripts-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
.runscripts-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
85
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
85
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Run Scripts tab on PC Details.
|
||||
*
|
||||
* The thing worth pinning here is the SEPARATION. run_script and
|
||||
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
|
||||
* (endpoints.runscript and endpoints.corptools), split out of a single
|
||||
* endpoints.deploy bundle precisely so that being able to trigger a corporate
|
||||
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
|
||||
*
|
||||
* That split lives in the backend. The UI can quietly undo it by hanging the
|
||||
* script picker off the Corporate Tools tab, whose only gate is
|
||||
* endpoints.corptools - and the failure would be invisible in review, because
|
||||
* the page would look correct and the server would simply 403 the push. These
|
||||
* tests fail if someone merges the two.
|
||||
*
|
||||
* Asserted against the file text, matching systemScriptsSettings.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
|
||||
|
||||
describe('PCDetail Run Scripts tab', () => {
|
||||
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
|
||||
expect(source).toMatch(
|
||||
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
|
||||
})
|
||||
|
||||
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
|
||||
// The corptools block must not gain a run_script call: that would put the
|
||||
// action behind endpoints.corptools in the UI while the server still
|
||||
// demands endpoints.runscript.
|
||||
const corptools = source.slice(
|
||||
source.indexOf("activegroup === 'corptools'"),
|
||||
source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(corptools).not.toMatch(/run_script/)
|
||||
expect(source).toMatch(/activegroup === 'runscripts'/)
|
||||
})
|
||||
|
||||
it('enqueues run_script with a script-id target', () => {
|
||||
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
|
||||
})
|
||||
|
||||
it('requires a typed confirmation before running', () => {
|
||||
// Every script on this tab is arbitrary code, so unlike Corporate Tools
|
||||
// there is no safe branch that gets a plain OK button.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/Type RUN to confirm/)
|
||||
expect(body).toMatch(/typed !== 'RUN'/)
|
||||
})
|
||||
|
||||
it('warns that a script runs as SYSTEM before it is sent', () => {
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
|
||||
})
|
||||
|
||||
it('shows the run context and working directory read-only', () => {
|
||||
// Both are authoring decisions made in the library. Offering an override
|
||||
// here would mean the script that ran is not the script that was reviewed,
|
||||
// so they must be rendered as facts and never bound with v-model.
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
|
||||
expect(block).toMatch(/selectedscript\.workingdirectory/)
|
||||
expect(block).toMatch(/selectedscript\.runcontext/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
|
||||
})
|
||||
|
||||
it('offers only scripts that have a body', () => {
|
||||
// A bodyless script is rejected at enqueue by _resolve_run_script, so
|
||||
// listing it would offer a button whose only outcome is an error.
|
||||
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
|
||||
})
|
||||
|
||||
it('reports the push as queued rather than as finished', () => {
|
||||
// The agent collects the command on its next check-in; claiming it ran
|
||||
// would assert an outcome nothing has observed.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
|
||||
})
|
||||
})
|
||||
@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
connections with a `truncated` flag in `detail`, because an oversized payload
|
||||
rejected by the server returns nothing at all and a partial answer is more
|
||||
useful than a failed one. Standard reports carry listening ports only.
|
||||
- `run_script` - run a reviewed PowerShell script from the script library. The
|
||||
body is NOT carried on the command channel; the claim carries a descriptor and
|
||||
the agent fetches the bytes separately (section 7.4).
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
gated by user JWT + `endpoints.command`. Only one command may be in flight per
|
||||
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
|
||||
|
||||
### 7.4 `run_script` claim descriptor
|
||||
|
||||
A `run_script` claim carries a `script` object alongside the usual
|
||||
`endpointcommandid` and `action`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scriptid": 12,
|
||||
"scriptname": "sfld_autologon",
|
||||
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
|
||||
"sha256": "3f9a...", // 64 hex chars, lowercase
|
||||
"runcontext": "system", // "system" | "user"
|
||||
"workingdirectory": "C:\\Tools", // "" = agent staging folder
|
||||
"errormode": "default" // "default" | "strict"
|
||||
}
|
||||
```
|
||||
|
||||
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
|
||||
against `sha256`, and refuse to run on mismatch. The body never travels on the
|
||||
command channel so a queued command cannot be used to smuggle code past review.
|
||||
|
||||
`workingdirectory` is the folder the script runs from. Empty means the agent's
|
||||
staging directory, which is correct for a self-contained script and wrong for
|
||||
one invoking a separately-installed tool by relative path. A pinned folder that
|
||||
does not exist MUST fail the command rather than falling back, so the script
|
||||
never runs somewhere unintended.
|
||||
|
||||
`errormode` selects how the run is judged:
|
||||
|
||||
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
|
||||
the script writes the error, continues, and still exits zero.
|
||||
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
|
||||
error terminates the run and the exit code reflects reality.
|
||||
|
||||
`default` is the default precisely BECAUSE it is permissive. Every script
|
||||
authored before this field existed relies on that behaviour, and some rely on it
|
||||
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
|
||||
when a thing is absent, looping past a bad item. Promoting `strict` globally
|
||||
would make those bodies abort partway and leave endpoints half-configured, so it
|
||||
is opt-in per script.
|
||||
|
||||
Two consequences the agent MUST honour:
|
||||
|
||||
- The strict prologue is applied AFTER digest verification, so the hash still
|
||||
validates the bytes the server actually delivered.
|
||||
- Because the prologue adds a line, an error reported on line N of a strict run
|
||||
refers to line N-1 of the stored body. Surfaces showing the body should say so.
|
||||
|
||||
An agent that does not recognise an `errormode` value MUST treat it as
|
||||
`default`. The server always sends the field explicitly, even at the default, so
|
||||
an agent that received it and ignored it stays distinguishable from one that
|
||||
never saw it.
|
||||
|
||||
#### Script output
|
||||
|
||||
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
|
||||
It is returned to the server in `detail.output` only when the command FAILED.
|
||||
Routine output stays on the machine that produced it: shipping every successful
|
||||
run back would put arbitrary script output, including whatever an author printed
|
||||
while debugging, into the server's command history for no diagnostic gain.
|
||||
Output is truncated to 8000 characters and flagged when truncated, because an
|
||||
oversized payload rejected by the server returns nothing at all.
|
||||
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
|
||||
@@ -2070,9 +2070,15 @@ def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
Accepts either "script:<scriptid>" or a bare script name. The descriptor
|
||||
carries a collector-authenticated download URL, the body's SHA-256, and the
|
||||
per-script run context (system or user). The agent fetches the body with
|
||||
its collector credential and runs it; the body never crosses the wire.
|
||||
carries a collector-authenticated download URL, the body's SHA-256, the
|
||||
per-script run context (system or user), and the optional folder to run in.
|
||||
The agent fetches the body with its credential and runs it; the body never
|
||||
crosses the wire.
|
||||
|
||||
workingdirectory is sent even when empty so the field is always present in
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2100,6 +2106,8 @@ def _resolve_run_script(target):
|
||||
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
|
||||
'sha256': script.sha256,
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
}, None
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,66 @@
|
||||
written to act "as the logged-on user" will not behave the way it reads.
|
||||
</div>
|
||||
|
||||
<details class="explainer">
|
||||
<summary>How does this work?</summary>
|
||||
<div class="explainer-body">
|
||||
<h4>Delivery</h4>
|
||||
<p>
|
||||
The body is stored here and hashed. At push time the agent downloads it
|
||||
with its own credential and checks the digest before running anything,
|
||||
so the bytes that run are the bytes that were reviewed. The body never
|
||||
travels on the command channel and is deleted from the endpoint after
|
||||
the run.
|
||||
</p>
|
||||
|
||||
<h4>Runs in</h4>
|
||||
<p>
|
||||
<strong>System</strong> is the agent's own service account: no desktop,
|
||||
no mapped drives, no signed-in person's credentials.
|
||||
<strong>User</strong> runs in the interactive session instead, and only
|
||||
works when somebody is actually signed in.
|
||||
</p>
|
||||
|
||||
<h4>Run in folder</h4>
|
||||
<p>
|
||||
The working directory for the run. Blank means the agent's staging
|
||||
folder, which is right for a self-contained script. Set it when the
|
||||
script calls a tool installed separately on the endpoint by relative
|
||||
path. If the folder is missing the run fails outright rather than
|
||||
running somewhere unintended.
|
||||
</p>
|
||||
|
||||
<h4>When a line fails</h4>
|
||||
<p>
|
||||
This is the one worth understanding. PowerShell treats most errors as
|
||||
<em>non-terminating</em>: it writes the error, carries on to the next
|
||||
line, and still exits zero. Because the command's verdict comes from
|
||||
that exit code, <strong>Continue</strong> means a script can report
|
||||
success having accomplished nothing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stop</strong> makes the first error end the run, so the verdict
|
||||
means what you would assume it means. It is not the default because it
|
||||
changes how a script behaves: a body that tolerates a failing cleanup,
|
||||
probes with a cmdlet that errors when something is absent, or loops
|
||||
past bad items will start aborting partway through, leaving the
|
||||
endpoint half-configured. Read the script first, then switch it on.
|
||||
</p>
|
||||
|
||||
<h4>Where the output goes</h4>
|
||||
<p>
|
||||
Script output is always written to the agent log on the endpoint. It is
|
||||
sent back to ShopDB only when the run fails, so routine output stays on
|
||||
the machine that produced it. Long output is truncated and marked as
|
||||
such.
|
||||
</p>
|
||||
<p class="hint">
|
||||
In Stop mode the agent adds one line to the top of the script, so an
|
||||
error reported on line N refers to line N-1 of the body shown here.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
@@ -29,6 +89,8 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Runs in</th>
|
||||
<th>On error</th>
|
||||
<th>Body</th>
|
||||
<th>Digest</th>
|
||||
<th>Active</th>
|
||||
@@ -41,6 +103,17 @@
|
||||
<td class="cell-truncate" :title="s.scriptdescription">
|
||||
{{ s.scriptdescription || '-' }}
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.workingdirectory">
|
||||
{{ s.workingdirectory || 'Agent staging folder' }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
||||
Stop on first error
|
||||
</span>
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
<span v-else class="badge badge-warning">No body - cannot run</span>
|
||||
@@ -53,7 +126,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="scripts.length === 0">
|
||||
<td colspan="6" style="text-align:center; color: var(--text-light);">
|
||||
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
||||
No scripts
|
||||
</td>
|
||||
</tr>
|
||||
@@ -99,6 +172,41 @@
|
||||
No body uploaded yet. A script without a body cannot be pushed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Run in folder
|
||||
<span class="hint">(optional, endpoint-local absolute path)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.workingdirectory"
|
||||
type="text"
|
||||
class="form-control mono"
|
||||
placeholder="C:\Program Files\CorporateTools"
|
||||
/>
|
||||
<p class="hint">
|
||||
Leave blank to run from the agent's staging folder. Set this only
|
||||
when the script calls a tool already installed on the endpoint by
|
||||
relative path. The folder must exist on the endpoint or the push
|
||||
fails rather than running somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
When a line fails
|
||||
<span class="hint">(how the run is judged)</span>
|
||||
</label>
|
||||
<select v-model="form.errormode" class="form-control">
|
||||
<option value="default">Continue - report success if the script reaches the end</option>
|
||||
<option value="strict">Stop on the first error and fail the command</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
PowerShell normally writes an error and carries on, and still
|
||||
exits zero, so a script can report success having done none of
|
||||
its work. Stop makes the first failure end the run and fail the
|
||||
command. Leave this on Continue unless you have read the script
|
||||
and know it does not rely on a step being allowed to fail.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
@@ -143,6 +251,8 @@ function emptyForm() {
|
||||
return {
|
||||
scriptname: '',
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
@@ -175,6 +285,8 @@ function openModal(script = null) {
|
||||
? {
|
||||
scriptname: script.scriptname,
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
@@ -194,6 +306,8 @@ async function save() {
|
||||
const payload = {
|
||||
scriptname: form.value.scriptname,
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
@@ -249,4 +363,27 @@ onMounted(loadData)
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
}
|
||||
.explainer {
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.explainer summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.explainer-body {
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
.explainer-body h4 {
|
||||
margin: 0.9rem 0 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.explainer-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,4 +47,59 @@ describe('SystemScriptsSettings', () => {
|
||||
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
|
||||
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
|
||||
})
|
||||
|
||||
it('offers an optional working directory and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.workingdirectory/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
|
||||
})
|
||||
|
||||
it('shows the staging default rather than an empty cell', () => {
|
||||
// A blank cell reads as "unknown". The folder is the difference between a
|
||||
// corporate tool being found and the script failing further in, so the
|
||||
// list has to state the default out loud.
|
||||
expect(source).toMatch(/Agent staging folder/)
|
||||
})
|
||||
|
||||
it('offers an error mode and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.errormode/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/errormode/)
|
||||
})
|
||||
|
||||
it('defaults a new script to the permissive mode', () => {
|
||||
// Strict changes how a body behaves partway through. Defaulting to it
|
||||
// would silently alter every script authored from here on, so a new
|
||||
// script has to start where the old ones already are.
|
||||
const empty = source.slice(source.indexOf('function emptyForm'))
|
||||
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
|
||||
})
|
||||
|
||||
it('names both error modes by their consequence, not by their flag', () => {
|
||||
// "default" and "strict" mean nothing to the person choosing. What they
|
||||
// need to know is which one can report success having done nothing.
|
||||
expect(source).toMatch(/Continue - report success if the script reaches the end/)
|
||||
expect(source).toMatch(/Stop on the first error and fail the command/)
|
||||
})
|
||||
|
||||
it('explains the non-terminating error trap that justifies the choice', () => {
|
||||
// The whole feature exists because PowerShell exits zero after an error.
|
||||
// If the page never says so, the dropdown looks like pedantry and stays
|
||||
// on the setting that produces false green.
|
||||
expect(source).toMatch(/non-terminating/i)
|
||||
expect(source).toMatch(/exits? zero/i)
|
||||
})
|
||||
|
||||
it('offers the explainer collapsed so it does not bury the page', () => {
|
||||
expect(source).toMatch(/<details class="explainer">/)
|
||||
expect(source).toMatch(/How does this work\?/)
|
||||
expect(source).not.toMatch(/<details class="explainer" open>/)
|
||||
})
|
||||
|
||||
it('states that output is kept locally and only returned on failure', () => {
|
||||
// Operators otherwise assume either that nothing was captured or that
|
||||
// every run is shipped back to the server.
|
||||
const explainer = source.slice(source.indexOf('Where the output goes'))
|
||||
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,11 +26,13 @@ gets the checkbox in the user editor.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
|
||||
from shopdb.core.models import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
|
||||
ERRORMODES, ERRORMODE_DEFAULT, AuditLog)
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
@@ -93,6 +95,69 @@ def _validate_runcontext(value):
|
||||
return value, None
|
||||
|
||||
|
||||
def _validate_errormode(value):
|
||||
"""Normalize/validate an errormode, defaulting to permissive when absent.
|
||||
|
||||
Defaults to ERRORMODE_DEFAULT rather than strict on purpose. Strict promotes
|
||||
PowerShell's non-terminating errors to terminating ones, so a body that was
|
||||
written to tolerate a failing cleanup or to probe with a cmdlet that errors
|
||||
when the target is absent would begin aborting partway through. An absent
|
||||
field therefore means "what this script already did", never a silent upgrade.
|
||||
"""
|
||||
if value is None:
|
||||
return ERRORMODE_DEFAULT, None
|
||||
value = str(value).strip().lower()
|
||||
if value not in ERRORMODES:
|
||||
return None, 'errormode must be one of: {}'.format(', '.join(sorted(ERRORMODES)))
|
||||
return value, None
|
||||
|
||||
|
||||
def _validate_workingdirectory(value):
|
||||
"""Normalize the optional folder a script runs in. Returns (value, error).
|
||||
|
||||
Empty means "the agent's staging directory", which is the behaviour every
|
||||
script had before this field existed. The purpose is to let a script sit
|
||||
beside a corporate tool already installed on the endpoint and call it by
|
||||
relative path.
|
||||
|
||||
The checks are shape checks, NOT a privilege boundary - a script body is
|
||||
already arbitrary SYSTEM PowerShell and can change its own directory. They
|
||||
exist so a typo fails here with an explanation instead of on the endpoint:
|
||||
|
||||
- must be an absolute local path (a drive letter), because a relative one
|
||||
is meaningless without knowing what it would be relative TO
|
||||
- no UNC. The stated use case is a tool already on this machine; a UNC
|
||||
working directory resolves as the MACHINE ACCOUNT against a file server,
|
||||
which moves where the binaries come from and is a different trust story
|
||||
than "Intune put this here"
|
||||
- no traversal segment, which only ever obscures what the path really is
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
value = str(value).strip().strip('"')
|
||||
if not value:
|
||||
return None, None
|
||||
if value.startswith('\\\\') or value.startswith('//'):
|
||||
return None, ('workingdirectory must be a local path on the endpoint, '
|
||||
'not a UNC share')
|
||||
normalized = value.replace('/', '\\')
|
||||
if not re.match(r'^[A-Za-z]:\\', normalized):
|
||||
return None, ('workingdirectory must be an absolute path including a '
|
||||
r'drive letter, for example C:\Program Files\Sysinternals')
|
||||
if '..' in normalized.split('\\'):
|
||||
return None, 'workingdirectory must not contain a ".." segment'
|
||||
if len(normalized) > 255:
|
||||
return None, 'workingdirectory must be 255 characters or fewer'
|
||||
# Trailing separator is noise, EXCEPT at a drive root: "C:\" trimmed to
|
||||
# "C:" is drive-RELATIVE on Windows and would resolve to wherever the
|
||||
# process happened to be on that drive, which is the opposite of pinning a
|
||||
# folder. Keep the root form intact.
|
||||
trimmed = normalized.rstrip('\\')
|
||||
if len(trimmed) < 3:
|
||||
trimmed = normalized[:3]
|
||||
return trimmed, None
|
||||
|
||||
|
||||
@scripts_bp.route('', methods=['POST'])
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def create_script():
|
||||
@@ -105,6 +170,12 @@ def create_script():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'A script named {name!r} already exists')
|
||||
runcontext, err = _validate_runcontext(data.get('runcontext'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
workingdirectory, err = _validate_workingdirectory(data.get('workingdirectory'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
errormode, err = _validate_errormode(data.get('errormode'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
@@ -112,12 +183,16 @@ def create_script():
|
||||
scriptname=name,
|
||||
scriptdescription=data.get('scriptdescription'),
|
||||
runcontext=runcontext,
|
||||
workingdirectory=workingdirectory,
|
||||
errormode=errormode,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'Script', entityid=script.scriptid,
|
||||
entityname=name,
|
||||
changes={'runcontext': {'new': runcontext}})
|
||||
changes={'runcontext': {'new': runcontext},
|
||||
'workingdirectory': {'new': workingdirectory},
|
||||
'errormode': {'new': errormode}})
|
||||
db.session.commit()
|
||||
return success_response(script.to_dict(), message='Script created', http_code=201)
|
||||
|
||||
@@ -147,6 +222,17 @@ def update_script(script_id: int):
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.runcontext = runcontext
|
||||
if 'workingdirectory' in data:
|
||||
workingdirectory, err = _validate_workingdirectory(
|
||||
data.get('workingdirectory'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.workingdirectory = workingdirectory
|
||||
if 'errormode' in data:
|
||||
errormode, err = _validate_errormode(data.get('errormode'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.errormode = errormode
|
||||
if 'isactive' in data:
|
||||
script.isactive = bool(data.get('isactive'))
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ from .user import (User, Role, Permission, full_permission_catalog,
|
||||
SCOPE_NEVER_GRANT, SCOPES_DENIED_ENDPOINT_ACTIONS)
|
||||
from .application import Application, AppVersion
|
||||
from .script_library import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
|
||||
RUNCONTEXT_USER)
|
||||
RUNCONTEXT_USER, ERRORMODES, ERRORMODE_DEFAULT,
|
||||
ERRORMODE_STRICT)
|
||||
from .supportteam import SupportTeam, SupportTeamContact
|
||||
from .setting import Setting
|
||||
from .auditlog import AuditLog
|
||||
@@ -78,6 +79,9 @@ __all__ = [
|
||||
'RUNCONTEXTS',
|
||||
'RUNCONTEXT_SYSTEM',
|
||||
'RUNCONTEXT_USER',
|
||||
'ERRORMODES',
|
||||
'ERRORMODE_DEFAULT',
|
||||
'ERRORMODE_STRICT',
|
||||
# Support teams
|
||||
'SupportTeam',
|
||||
'SupportTeamContact',
|
||||
|
||||
@@ -16,6 +16,26 @@ RUNCONTEXT_SYSTEM = 'system'
|
||||
RUNCONTEXT_USER = 'user'
|
||||
RUNCONTEXTS = (RUNCONTEXT_SYSTEM, RUNCONTEXT_USER)
|
||||
|
||||
# How the agent treats a PowerShell NON-TERMINATING error.
|
||||
#
|
||||
# DEFAULT is what PowerShell does on its own: the error is written, the script
|
||||
# keeps going, and powershell.exe still exits 0. That is why a script could
|
||||
# report succeeded while doing none of its work - the exit code answers "did
|
||||
# the interpreter reach the end", not "did the script do its job".
|
||||
#
|
||||
# STRICT prepends $ErrorActionPreference = 'Stop' so the first error terminates
|
||||
# and the exit code becomes non-zero, which makes the reported verdict mean
|
||||
# what an operator assumes it means.
|
||||
#
|
||||
# Default is DEFAULT, deliberately. Strict changes execution semantics: a body
|
||||
# that tolerates a failing Remove-Item, probes with Get-LocalUser, or loops past
|
||||
# a bad item would start aborting partway. Existing scripts were authored under
|
||||
# the permissive rule, so this is opt-in per script AFTER the body has been read
|
||||
# rather than a library-wide switch flipped on code nobody re-checked.
|
||||
ERRORMODE_DEFAULT = 'default'
|
||||
ERRORMODE_STRICT = 'strict'
|
||||
ERRORMODES = (ERRORMODE_DEFAULT, ERRORMODE_STRICT)
|
||||
|
||||
|
||||
class Script(BaseModel):
|
||||
"""ShopDB-hosted PowerShell script the endpoint agent runs on push."""
|
||||
@@ -32,6 +52,23 @@ class Script(BaseModel):
|
||||
# 'system' or 'user' (see RUNCONTEXTS). Per-script so a drive-mapping script
|
||||
# can target the user session while a machine-wide script runs as SYSTEM.
|
||||
runcontext = db.Column(db.String(16), default=RUNCONTEXT_SYSTEM, nullable=False)
|
||||
# Optional folder to run the script IN. Empty means the agent's staging
|
||||
# directory, which is the right default for a self-contained script and the
|
||||
# wrong one for a script that shells out to a corporate tool delivered by
|
||||
# Intune (Sysinternals, vendor CLIs) and referenced by relative path.
|
||||
#
|
||||
# This grants NOTHING. The body is already arbitrary PowerShell the agent
|
||||
# runs as SYSTEM, and it could Set-Location anywhere itself; the security
|
||||
# boundary is endpoints.runscript plus the digest-pinned library, not the
|
||||
# working directory. Recorded here so this is not later mistaken for a
|
||||
# privilege surface and gated harder than it needs to be.
|
||||
workingdirectory = db.Column(db.String(255))
|
||||
# 'default' or 'strict' (see ERRORMODES). Opt-in per script because strict
|
||||
# turns non-terminating errors fatal and would change how existing bodies
|
||||
# run. Not nullable with a default so a row written before this column
|
||||
# existed reads as the permissive behavior it was authored under.
|
||||
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
|
||||
server_default=ERRORMODE_DEFAULT)
|
||||
|
||||
def to_dict(self):
|
||||
result = super().to_dict()
|
||||
|
||||
138
tests/test_core/test_script_errormode.py
Normal file
138
tests/test_core/test_script_errormode.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""The per-script error mode.
|
||||
|
||||
PowerShell treats most errors as non-terminating: it writes the error, runs the
|
||||
next line, and still exits zero. The agent judges a run by that exit code, so a
|
||||
script could report success having accomplished none of its work - which is
|
||||
exactly what happened with the account-provisioning script that "succeeded"
|
||||
without provisioning anything.
|
||||
|
||||
Strict mode fixes the verdict by making the first error terminating. It is
|
||||
deliberately NOT the default, because it changes how an existing body behaves
|
||||
partway through: a script that tolerates a failing cleanup, probes with a cmdlet
|
||||
that errors when something is absent, or loops past bad items would start
|
||||
aborting midway and leave the endpoint half-configured. These tests pin the
|
||||
permissive default hard, since that is the property protecting every script
|
||||
authored before this field existed.
|
||||
"""
|
||||
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='modeauthor',
|
||||
email='modeauthor@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': 'modeauthor', '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_a_mode_stays_permissive(client, headers):
|
||||
"""The load-bearing test. Every script written before this field existed
|
||||
was authored against permissive semantics; defaulting to strict would
|
||||
change how all of them run without anyone touching them."""
|
||||
created = _create(client, headers, 'no-mode')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_strict_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'strict-one', errormode='strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', ['stop', 'true', 'yes', 'none', ''])
|
||||
def test_an_unrecognised_mode_is_rejected(client, headers, bad):
|
||||
"""Rejected rather than coerced. Silently falling back to permissive would
|
||||
let a caller believe a script fails loudly when it does not, which is the
|
||||
precise failure this field exists to remove."""
|
||||
created = _create(client, headers, f'bad-{bad or "blank"}', errormode=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'errormode' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_casing_is_normalised_rather_than_refused(client, headers):
|
||||
"""A caller typing Strict means strict. Case is a keyboard artefact, not a
|
||||
different intention, so it is normalised - unlike the values above, which
|
||||
are genuinely different words and could mean something the author did not
|
||||
intend."""
|
||||
created = _create(client, headers, 'cased', errormode='Strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_a_script_can_be_moved_back_to_permissive(client, headers):
|
||||
"""Strict has to be reversible without recreating the script: the way you
|
||||
discover a body cannot tolerate it is by watching it abort in production."""
|
||||
scriptid = _create(client, headers, 'reversible',
|
||||
errormode='strict').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'errormode': 'default'})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even at the default, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-strict', filename='script-1.ps1',
|
||||
sha256='a' * 64, errormode='strict')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_the_descriptor_defaults_rather_than_omitting(client, db, headers):
|
||||
"""An absent key would leave the agent guessing. It sends the permissive
|
||||
value explicitly so both ends agree on what unset means."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-default', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'default'
|
||||
149
tests/test_core/test_script_workingdirectory.py
Normal file
149
tests/test_core/test_script_workingdirectory.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""The optional per-script working directory.
|
||||
|
||||
A delivered script runs from the agent's staging directory. That is correct for
|
||||
a self-contained script and wrong for one that shells out to a corporate tool
|
||||
already installed on the endpoint by Intune and invokes it by relative path -
|
||||
sfld_autologon needs Autologon.exe beside it. This field pins the folder.
|
||||
|
||||
The validation here is a SHAPE check, not a privilege boundary: the body is
|
||||
already arbitrary SYSTEM PowerShell that could Set-Location anywhere. These
|
||||
tests pin that distinction so the field is not later mistaken for a security
|
||||
control, and pin the default so old scripts keep behaving exactly as before.
|
||||
"""
|
||||
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='scriptauthor',
|
||||
email='scriptauthor@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': 'scriptauthor', '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_a_folder_stays_unpinned(client, headers):
|
||||
"""The default must remain "the agent decides", or every pre-existing
|
||||
script silently acquires a folder it was never authored for."""
|
||||
created = _create(client, headers, 'no-folder')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert not created.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_a_folder_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'autologon',
|
||||
workingdirectory=r'C:\Program Files\Sysinternals')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert (created.get_json()['data']['workingdirectory']
|
||||
== r'C:\Program Files\Sysinternals')
|
||||
|
||||
|
||||
def test_a_trailing_separator_is_trimmed(client, headers):
|
||||
created = _create(client, headers, 'trailing',
|
||||
workingdirectory='C:\\Tools\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_a_drive_root_keeps_its_separator(client, headers):
|
||||
r"""C:\ must not degrade to C:. On Windows "C:" is drive-RELATIVE and
|
||||
resolves to wherever the process last was on that drive, which is the exact
|
||||
opposite of pinning a folder - a silent wrong answer, not an error."""
|
||||
created = _create(client, headers, 'driveroot', workingdirectory='C:\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == 'C:\\'
|
||||
|
||||
|
||||
def test_forward_slashes_are_normalized(client, headers):
|
||||
created = _create(client, headers, 'slashes',
|
||||
workingdirectory='C:/Tools/Corporate')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools\Corporate'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [
|
||||
r'Tools\Corporate', # relative: relative to WHAT?
|
||||
r'\\fileserver\tools', # UNC: machine-account access to a file server
|
||||
r'C:\Tools\..\..\Windows', # traversal: obscures the real target
|
||||
])
|
||||
def test_a_malformed_folder_is_rejected_with_an_explanation(client, headers, bad):
|
||||
"""Rejected at the API, where the message can say why, rather than on the
|
||||
endpoint where the failure would name a missing tool instead."""
|
||||
created = _create(client, headers, f'bad-{abs(hash(bad))}',
|
||||
workingdirectory=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'workingdirectory' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_folder_can_be_cleared_back_to_the_default(client, headers):
|
||||
"""Reverting to staging must be reachable; otherwise a mistyped pin is
|
||||
permanent short of deleting and re-uploading the script."""
|
||||
scriptid = _create(client, headers, 'clearable',
|
||||
workingdirectory=r'C:\Tools').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'workingdirectory': ''})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert not updated.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even when empty, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered', filename='script-1.ps1',
|
||||
sha256='a' * 64, workingdirectory=r'C:\Tools')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_the_descriptor_sends_an_empty_string_when_unpinned(client, db, headers):
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='unpinned', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == ''
|
||||
Reference in New Issue
Block a user