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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user