Let Corporate Tools push the Report IP tool without granting script run
Report IP is an icon on every Public Desktop in the company, so a help desk caller asking for it to be re-run is asking for something they could already do themselves at the keyboard. Getting there through ShopDB meant holding endpoints.runscript, which is the permission that lets someone push ANY script in the library - the agent self-upgrade included. Handing that over to run a desktop shortcut is the bundling the permission split was created to take apart, so the work was simply not being done through the tool. The allowance is on one named script, never on the run_script action. The script id rides in the caller-controlled request body, so relaxing the action would grant the whole library and quietly rebuild the endpoints.deploy bundle. The enqueue route resolves the target to a real Script row first and matches the STORED name, because matching what the requester typed would let anyone name their way past the grant. Granting on a name is only defensible while the name cannot be made to mean something else. The body is source-controlled at plugins/endpoints/api/templates/report-ip-shortcut.ps1, seeded from there, pinned by SHA-256 on delivery, and the managed fields are re-asserted on every seed - so an operator cannot flip the row to runcontext=system, deactivate it, or edit the body and leave the name still pushable. The template lives under the plugin rather than agent/ because the image copies plugins/ but not agent/: reading it from agent/ works on a developer checkout and fails in the deployed container. The script runs in the signed-in user's session. This is not a preference. Launched as SYSTEM the tool re-enters the interactive session and the launcher exits 0 immediately, reporting success having observed nothing - the same session-0 boundary the collector already documents for mapped drives. User context fails closed when nobody is logged on, which is the honest answer. The seed runs in the migrate service alongside the other seeders, so an existing site picks the script up on the upgrade it already performs.
This commit is contained in:
159
plugins/endpoints/api/reportipscript.py
Normal file
159
plugins/endpoints/api/reportipscript.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Seed the Report IP desktop-shortcut script into the ShopDB script library.
|
||||
|
||||
run_script only delivers bodies that live in the script library, pinned by
|
||||
SHA-256. The shortcut-equivalent tool therefore has to exist as a real Script
|
||||
row with a stored body before Corporate Tools can push it.
|
||||
|
||||
The template is source-controlled at
|
||||
plugins/endpoints/api/templates/report-ip-shortcut.ps1 and seeded from there
|
||||
rather than typed into the library by hand, so the reviewed script and the one
|
||||
endpoints actually run are the same artifact. Editing the row's body in the UI
|
||||
is pointless - the next seed overwrites it.
|
||||
|
||||
That last point is load-bearing here in a way it is not for an ordinary script.
|
||||
REPORT_IP_SCRIPT_NAME is named in CORPTOOLS_PUSHABLE_SCRIPTS, so holders of
|
||||
endpoints.corptools may push this one script WITHOUT holding
|
||||
endpoints.runscript. Granting on a name is only safe because the name maps to a
|
||||
body that comes from source control and is digest-pinned on delivery: the name
|
||||
cannot be made to mean something else. If this row were hand-authorable, the
|
||||
allowance would become "corptools may run arbitrary code", which is exactly the
|
||||
bundling that endpoints.runscript was split out to prevent.
|
||||
|
||||
It lives under the plugin, not under agent/, because the Docker image copies
|
||||
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
|
||||
from agent/ resolves fine on a developer checkout and then fails in the
|
||||
deployed container, which is the worst place to discover it.
|
||||
|
||||
Unlike the agent self-upgrade seeder there is no placeholder to render: the
|
||||
script resolves its own target on the endpoint (shortcut first, installed path
|
||||
as fallback), so the body is shipped verbatim.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from shopdb.api import Script, db, script_body_location
|
||||
|
||||
# Reserved script name. Unique in the library, which makes seeding idempotent
|
||||
# and keeps a hand-made script from colliding with the managed one. This exact
|
||||
# string is what the enqueue route matches when deciding whether
|
||||
# endpoints.corptools is sufficient, so it must stay in lockstep with
|
||||
# CORPTOOLS_PUSHABLE_SCRIPTS in plugins/endpoints/commands.py - the test
|
||||
# test_reserved_report_ip_name_matches_the_commands_constant pins them together.
|
||||
REPORT_IP_SCRIPT_NAME = 'Report IP - Desktop Shortcut'
|
||||
|
||||
_REPORT_IP_DESCRIPTION = (
|
||||
'Managed: reproduces double-clicking the Report IP icon on the Public '
|
||||
'Desktop, re-reporting this computer\'s IP address. Runs in the signed-in '
|
||||
'user\'s session and fails closed if nobody is logged on. '
|
||||
'Seeded from source control - edits here are overwritten.'
|
||||
)
|
||||
|
||||
_TEMPLATE_FILENAME = 'report-ip-shortcut.ps1'
|
||||
|
||||
|
||||
def template_path():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'templates', _TEMPLATE_FILENAME)
|
||||
|
||||
|
||||
def _script_dir():
|
||||
"""Where script bodies live. Resolved through the contract surface helper
|
||||
so this cannot drift from the directory the download route serves from."""
|
||||
directory, _prefix = script_body_location()
|
||||
return directory
|
||||
|
||||
|
||||
def render_template():
|
||||
"""Return the script body verbatim.
|
||||
|
||||
No substitution: the endpoint resolves its own target at run time. Kept as
|
||||
a function so the seeder reads the file through one place and a future
|
||||
placeholder has an obvious home.
|
||||
"""
|
||||
with open(template_path(), 'r', encoding='utf-8') as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def seed_report_ip_script():
|
||||
"""Create or refresh the managed Report IP script row and its body.
|
||||
|
||||
Returns a dict describing the seeded script. Idempotent: unchanged input
|
||||
rewrites nothing and leaves the digest alone.
|
||||
"""
|
||||
body = render_template().encode('utf-8')
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
|
||||
script = Script.query.filter_by(scriptname=REPORT_IP_SCRIPT_NAME).first()
|
||||
created = script is None
|
||||
if created:
|
||||
script = Script(
|
||||
scriptname=REPORT_IP_SCRIPT_NAME,
|
||||
scriptdescription=_REPORT_IP_DESCRIPTION,
|
||||
# USER context, and this is not a preference - SYSTEM cannot do
|
||||
# this job at all. Measured on a live endpoint: launched as SYSTEM
|
||||
# the tool immediately re-entered the interactive session, leaving
|
||||
# the work in a different process, session and account
|
||||
# (pid 7240, session 1, owner ...\ShopFloor) while the SYSTEM-side
|
||||
# launcher exited 0 straight away. The command then reported
|
||||
# "completed (system context)" having observed nothing - a success
|
||||
# that means "handed off", not "worked".
|
||||
#
|
||||
# This is the same session-0 boundary EndpointCollector already
|
||||
# documents for mapped drives: session 0 has no access to a user's
|
||||
# drive table, HKCU or profile, so a SYSTEM process cannot see -
|
||||
# let alone report on - what happens in the user's session. The
|
||||
# agent's user path crosses that boundary properly via
|
||||
# CreateProcessAsUser and returns the child's REAL exit code.
|
||||
#
|
||||
# The tradeoff accepted here: user context fails closed when nobody
|
||||
# is signed in. That is the correct failure - an honest "no one is
|
||||
# logged on" beats a green result that observed nothing.
|
||||
runcontext='user',
|
||||
# Capture successful output too. The whole point of this tool is
|
||||
# the response line it prints, and an operator who pushed it needs
|
||||
# to read that line to know the IP was actually reported - a bare
|
||||
# "succeeded" answers a different question.
|
||||
captureoutput=True,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.flush()
|
||||
|
||||
# Body filename follows the library convention (script-<id>.ps1) so the
|
||||
# collector download route serves it like any other script.
|
||||
filename = 'script-{}.ps1'.format(script.scriptid)
|
||||
path = os.path.join(_script_dir(), filename)
|
||||
|
||||
unchanged = (script.filename == filename and script.sha256 == digest
|
||||
and os.path.isfile(path))
|
||||
if not unchanged:
|
||||
os.makedirs(_script_dir(), exist_ok=True)
|
||||
# Temp-then-replace so an interrupted write cannot leave a truncated
|
||||
# body at the path the row's digest already claims to describe.
|
||||
temppath = path + '.tmp'
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(body)
|
||||
os.replace(temppath, path)
|
||||
script.filename = filename
|
||||
script.sha256 = digest
|
||||
|
||||
# Re-assert the managed fields on every seed. A row edited in the UI drifts
|
||||
# back to the shipped definition here, which is what "edits are
|
||||
# overwritten" has to mean for the corptools allowance to stay honest: an
|
||||
# operator must not be able to flip this to runcontext=system, or
|
||||
# deactivate it, and leave the name still pushable. The context is part of
|
||||
# what makes the result trustworthy, so it is re-asserted like the rest.
|
||||
script.scriptdescription = _REPORT_IP_DESCRIPTION
|
||||
script.runcontext = 'user'
|
||||
script.captureoutput = True
|
||||
script.isactive = True
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scriptid': script.scriptid,
|
||||
'scriptname': script.scriptname,
|
||||
'created': created,
|
||||
'updated': not unchanged,
|
||||
'sha256': digest,
|
||||
'bytes': len(body),
|
||||
}
|
||||
@@ -31,7 +31,7 @@ from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointHealthState, OperatorReport, EndpointLogCollection,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, corptools_may_push_script, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from . import agentstore
|
||||
from . import agentauth
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
@@ -2992,10 +2992,28 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
# require_permission exists to prevent, reintroduced by checking by hand.
|
||||
elevated = elevated_permission_for(action)
|
||||
if elevated and not caller_holds(elevated):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
http_code=403)
|
||||
# One narrow exception: a SHIPPED script whose reviewed body is
|
||||
# operationally equivalent to something the person could already do
|
||||
# unaided at the machine (Report IP is an icon on every Public Desktop).
|
||||
# Those are pushable with endpoints.corptools alone.
|
||||
#
|
||||
# The allowance is keyed on the script's STORED name, read off a row we
|
||||
# resolve here - never on the caller-supplied target. Matching what the
|
||||
# requester typed would let anyone name their way past the grant, and
|
||||
# relaxing the run_script ACTION instead would hand every corptools
|
||||
# holder the whole library (the script id rides in the request body),
|
||||
# rebuilding the endpoints.deploy bundle the permission split removed.
|
||||
allowed_by_shipped_script = False
|
||||
if action == 'run_script' and caller_holds('endpoints.corptools'):
|
||||
resolved, _resolve_error = _resolve_run_script(target)
|
||||
if resolved is not None:
|
||||
allowed_by_shipped_script = corptools_may_push_script(
|
||||
resolved.get('scriptname'))
|
||||
if not allowed_by_shipped_script:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
http_code=403)
|
||||
|
||||
# Break-glass: an admin may push past a Configuration Policy denial, but
|
||||
# only by asking for it explicitly and saying why. The override is scoped to
|
||||
|
||||
74
plugins/endpoints/api/templates/report-ip-shortcut.ps1
Normal file
74
plugins/endpoints/api/templates/report-ip-shortcut.ps1
Normal file
@@ -0,0 +1,74 @@
|
||||
# Report IP - reproduces double-clicking the "Report IP" desktop icon.
|
||||
#
|
||||
# Seeded from source control by plugins/endpoints/api/reportipscript.py. Edits
|
||||
# made to the library row are overwritten on the next seed: the corptools
|
||||
# allowance grants this script BY NAME, so the name has to keep meaning this
|
||||
# reviewed body.
|
||||
#
|
||||
# RUNS IN THE USER SESSION, and that is a hard requirement rather than a
|
||||
# preference. Measured on a live endpoint, launching this tool as SYSTEM has it
|
||||
# immediately re-enter the interactive session - the work ended up in a separate
|
||||
# process under the signed-in account (session 1) while the SYSTEM-side launcher
|
||||
# exited 0 within a second, having observed nothing. Every attempt to capture
|
||||
# output or a verdict from the SYSTEM side therefore reported success for work
|
||||
# it never saw.
|
||||
#
|
||||
# This is the same session-0 boundary EndpointCollector documents for mapped
|
||||
# drives: a SYSTEM process has no view of a user's drive table, HKCU or profile.
|
||||
# The agent's user-context path crosses it properly (CreateProcessAsUser into
|
||||
# the active console session) and returns the child's real exit code, so the
|
||||
# verdict below is trustworthy in a way the SYSTEM path could not be.
|
||||
#
|
||||
# Consequently this script stays deliberately thin: the agent owns launching,
|
||||
# waiting and output capture. Re-implementing any of that here is what produced
|
||||
# the false results.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolved from the shortcut when present, so the arguments stay whatever the
|
||||
# icon on the desktop actually uses. The fallback matches the installed tool.
|
||||
$ShortcutPath = 'C:\Users\Public\Desktop\Report IP.lnk'
|
||||
$Exe = 'C:\ProgramData\ReportIP\GE_ReportIP_3_v1.EXE'
|
||||
$ExeArgs = @('/ForceUpdate=True', '/S')
|
||||
|
||||
# Working directory comes from the vendor's own scheduled task definition
|
||||
# (\ReportIP), which runs the tool from its install folder.
|
||||
$WorkDir = 'C:\ProgramData\ReportIP'
|
||||
|
||||
if (Test-Path -LiteralPath $ShortcutPath) {
|
||||
try {
|
||||
$link = (New-Object -ComObject WScript.Shell).CreateShortcut($ShortcutPath)
|
||||
if ($link.TargetPath) {
|
||||
$Exe = $link.TargetPath
|
||||
if ($link.Arguments) { $ExeArgs = @($link.Arguments) }
|
||||
if ($link.WorkingDirectory) { $WorkDir = $link.WorkingDirectory }
|
||||
}
|
||||
} catch {
|
||||
Write-Output "could not read the shortcut, using the installed path: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Exe)) {
|
||||
Write-Output "executable not found: $Exe"
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $WorkDir)) { $WorkDir = Split-Path -Parent $Exe }
|
||||
|
||||
# Identity is logged because it is the thing most likely to be wrong: if this
|
||||
# ever reports SYSTEM, the run is meaningless for the reasons in the header.
|
||||
Write-Output "running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
|
||||
Write-Output "session: $((Get-Process -Id $PID).SessionId)"
|
||||
Write-Output "launching: $Exe $($ExeArgs -join ' ')"
|
||||
|
||||
# -Wait, and the tool's own exit code is the verdict. No success-string scan:
|
||||
# the tool reports its outcome through its own channel, and inferring success
|
||||
# from console text was what produced both false failures and false successes
|
||||
# in earlier revisions.
|
||||
$process = Start-Process -FilePath $Exe `
|
||||
-ArgumentList $ExeArgs `
|
||||
-WorkingDirectory $WorkDir `
|
||||
-PassThru -Wait
|
||||
|
||||
Write-Output "exit code: $($process.ExitCode)"
|
||||
exit $process.ExitCode
|
||||
@@ -238,6 +238,44 @@ def elevated_permission_for(action):
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
# Shipped scripts that endpoints.corptools may push without holding
|
||||
# endpoints.runscript.
|
||||
#
|
||||
# This is an allowance on ONE NAMED SCRIPT, never on the run_script action. The
|
||||
# distinction is the whole point: relaxing the action would let any corptools
|
||||
# holder push ANY script in the library - including the agent self-upgrade, and
|
||||
# anything an operator uploads later - because the script id rides in the
|
||||
# caller-controlled request body. That would silently rebuild the endpoints.deploy
|
||||
# bundle the permission split took apart, which is the one outcome this must not
|
||||
# produce.
|
||||
#
|
||||
# Granting on a name is only defensible because these names are MANAGED: each is
|
||||
# seeded from a source-controlled template, pinned by SHA-256 on delivery, and
|
||||
# re-asserted on every seed (see reportipscript.py). The name therefore cannot be
|
||||
# made to mean something else by editing a row. An entry here is a statement that
|
||||
# the reviewed body is operationally equivalent to something the user could
|
||||
# already do unaided - Report IP is an icon on every Public Desktop in the
|
||||
# company, so pushing it grants no capability the person did not already have.
|
||||
#
|
||||
# Do NOT add a name here because a denial is inconvenient. The test for
|
||||
# membership is that a non-privileged employee can already trigger the same work
|
||||
# by hand on the machine in front of them. Anything else needs endpoints.runscript.
|
||||
CORPTOOLS_PUSHABLE_SCRIPTS = frozenset({
|
||||
'Report IP - Desktop Shortcut',
|
||||
})
|
||||
|
||||
|
||||
def corptools_may_push_script(scriptname):
|
||||
"""True when endpoints.corptools alone is enough to push this script.
|
||||
|
||||
Matched on the exact managed name. Callers must resolve the target to a real
|
||||
Script row FIRST and pass the stored scriptname, never a caller-supplied
|
||||
string: matching what the requester typed would let anyone name their way
|
||||
past the grant.
|
||||
"""
|
||||
return scriptname in CORPTOOLS_PUSHABLE_SCRIPTS
|
||||
|
||||
|
||||
def minimum_agent_version_for(action):
|
||||
"""The lowest agent version that implements an action, or None if any will do."""
|
||||
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
|
||||
@@ -449,6 +487,19 @@ POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
|
||||
# the agent still refuses UNC, traversal, directories and non-text files.
|
||||
'collect_log',
|
||||
# Script push is gated by WHO the caller is, not by what a PC type exposes.
|
||||
# Configuration Policy describes machine shape; it has no view on whether a
|
||||
# given person may execute code, so a policy that never mentions run_script
|
||||
# must not be read as withdrawing it. Leaving it policy-gated forced sites
|
||||
# to keep an all-PCs "allow run_script" policy row alive purely to avoid
|
||||
# losing the action everywhere - a rule that encoded no real intent and
|
||||
# whose deletion silently disarmed Script Push on every policied endpoint.
|
||||
# Authority lives on the caller axis instead: run_script maps to
|
||||
# endpoints.runscript in ELEVATED_ACTION_PERMISSIONS, checked with
|
||||
# caller_holds at the enqueue route BEFORE policy is consulted, so a scoped
|
||||
# personal token cannot inherit it from an admin owner. This exemption
|
||||
# widens WHERE the action may run, never WHO may run it.
|
||||
'run_script',
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user