Files
shopdb-flask/plugins/endpoints/api/reportipscript.py
benjamin.baker 2ff7b7e906 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.
2026-09-18 17:25:20 -04:00

160 lines
7.1 KiB
Python

"""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),
}