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:
benjamin.baker
2026-09-16 18:25:48 -04:00
parent 158ee0507e
commit 2ff7b7e906
7 changed files with 448 additions and 7 deletions

View File

@@ -12,7 +12,8 @@ from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target, minimum_agent_version_for, agent_meets_minimum,
parse_agent_version,
parse_agent_version, corptools_may_push_script,
CORPTOOLS_PUSHABLE_SCRIPTS,
)
@@ -168,6 +169,55 @@ def test_endpoints_declares_the_restart_permission():
assert 'endpoints.command' in permissions
def test_run_script_still_requires_the_runscript_permission():
"""The corptools allowance is an exception for one named script, not a
relaxation of run_script. The action itself must keep demanding
endpoints.runscript, otherwise a corptools holder could push anything in
the library."""
assert elevated_permission_for('run_script') == 'endpoints.runscript'
def test_corptools_may_push_only_the_shipped_report_ip_script():
"""Corporate Tools can push the managed Report IP script by name, and
nothing else. A site-authored script that happens to be selected from the
same tab gets no allowance."""
assert corptools_may_push_script('Report IP - Desktop Shortcut') is True
assert corptools_may_push_script('Uninstall Everything') is False
assert corptools_may_push_script(None) is False
assert corptools_may_push_script('') is False
def test_corptools_pushable_scripts_stays_a_single_managed_entry():
"""Guards the blast radius of the exception. Growing this set widens what
endpoints.corptools can deliver without endpoints.runscript, so adding an
entry should be a deliberate change that fails this test first."""
assert CORPTOOLS_PUSHABLE_SCRIPTS == {'Report IP - Desktop Shortcut'}
def test_corptools_allowance_name_matches_the_seeder():
"""The allowance matches on the stored script name, so the seeder and the
allowlist must agree exactly. If either string is edited alone the
Corporate Tools button silently 403s."""
from plugins.endpoints.api.reportipscript import REPORT_IP_SCRIPT_NAME
assert REPORT_IP_SCRIPT_NAME in CORPTOOLS_PUSHABLE_SCRIPTS
def test_report_ip_script_body_does_not_relaunch_itself():
"""Pins the session-0 lesson. Launched as SYSTEM this tool re-enters the
interactive session, so the launcher observes nothing and exits 0 - a
success that means 'handed off', not 'worked'. The body must therefore
stay thin and let the agent's user-context path own launching and the exit
code; re-introducing a cmd.exe wrapper or a success-string scan is what
produced the false results."""
from plugins.endpoints.api.reportipscript import template_path
with open(template_path(), 'r', encoding='utf-8') as handle:
body = handle.read()
assert 'ComSpec' not in body
assert 'completed successfully' not in body
assert '-Wait' in body
assert 'exit $process.ExitCode' in body
def test_collect_now_is_allowlisted_targetless_and_routine():
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
the enqueue route accepts it, carries no wire target (fixed action), and
@@ -217,6 +267,62 @@ def test_collect_log_survives_a_policy_that_never_mentions_it():
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
def test_run_script_survives_a_policy_that_never_mentions_it(monkeypatch):
"""Script Push is gated by caller permission, not by machine shape.
Before this exemption the action stayed reachable only because a site kept
an all-PCs "allow run_script" policy row alive. That row expressed no real
configuration intent, and deleting it silently removed Script Push from
every policied endpoint at once - a failure that reads as authorization
("not permitted by policy") while the caller's grant was never in doubt.
Authority belongs on the caller axis, so a policy that is silent about
run_script must not be read as withdrawing it.
"""
import plugins.endpoints.commands as commands
class _Effective:
def allowed_actions(self):
# A real, unrelated policy: it shapes services and says nothing
# whatsoever about running scripts.
return {'service_start', 'service_stop'}
monkeypatch.setattr(
'plugins.computers.services.policyresolver.effective_policy_for',
lambda assetid: _Effective(),
)
allowed = commands.ALLOWED_ACTIONS_FOR(assetid=1)
assert 'run_script' in allowed
# Exemption from POLICY is not exemption from PERMISSION: the grant must
# survive the exemption rather than be loosened by it. The enqueue route
# checks this with caller_holds BEFORE consulting policy, so a scoped
# personal token cannot inherit it from an admin owner.
assert elevated_permission_for('run_script') == 'endpoints.runscript'
def test_policy_exemption_never_widens_past_the_code_allowlist(monkeypatch):
"""Exempt actions are re-added, but the base allowlist still bounds them.
The exemption set is unioned in after the policy intersection, so a bug
there could hand back an action the command layer does not implement.
"""
import plugins.endpoints.commands as commands
class _Effective:
def allowed_actions(self):
return {'service_start'}
monkeypatch.setattr(
'plugins.computers.services.policyresolver.effective_policy_for',
lambda assetid: _Effective(),
)
allowed = commands.ALLOWED_ACTIONS_FOR(assetid=1)
assert allowed <= set(commands.COMMAND_ALLOWLIST.keys())
def test_collect_log_declares_the_agent_version_that_implements_it():
"""collect_log names its minimum agent version.