Gate the script library on the grant that pushes scripts

The script library was gated on applications.*, so anyone holding the
software-delivery grant could rewrite the bytes of a script that a separately
trusted operator later pushed to a bay. The push was gated on
endpoints.runscript; the CONTENT of what got pushed was not. An installer is a
vendor artefact, but a script here is arbitrary PowerShell this site wrote that
the agent runs as SYSTEM, so the write and the push must require the same
trust.

All six script routes now require endpoints.runscript, including the body
download - reading the bytes is not a lesser act than listing them. Core names
the permission as a string rather than importing it from the endpoints plugin
so the module stays importable with the plugin absent.

This tightens access and there is deliberately no backfill: the operator scope
reaches applications.create/edit, so operator-scope holders lose the library.
Granting them endpoints.runscript on upgrade would undo the change and hand
every operator remote SYSTEM code execution, since the same grant gates the
push. A test pins that no prebuilt scope confers it.

Adds the System Scripts settings page and card. The route requires the
permission rather than admin, because the grant is meant for non-admin
operators and the sibling admin gate would lock them out of the library the
grant exists for. The settings icon map gains terminal and key; the agent-key
card had been asking for key since it shipped and silently drawing a puzzle
piece.

The library had no test coverage before this.
This commit is contained in:
benjamin.baker
2026-09-09 06:37:02 -04:00
parent 67e701b538
commit fd18099199
8 changed files with 533 additions and 15 deletions

View File

@@ -6,8 +6,23 @@ its body by digest on push (the same model as application installer delivery).
The body never crosses the command wire and is never pre-staged on the endpoint;
the agent downloads it with its collector credential and verifies sha256.
Gated on the applications.* permissions - scripts are software delivery like
apps, so they ride the same operator grant rather than a separate permission.
Gated on endpoints.runscript, the same standalone grant that authorizes pushing
a script. These are NOT applications: an application installer is a vendor
artefact, while a script here is arbitrary PowerShell this site wrote that the
agent runs as SYSTEM. Gating library management on applications.* meant anyone
with the software-delivery grant could rewrite the bytes of a script a trusted
operator later pushes, which is a privilege escalation wearing a content-
management hat. The write and the push now require the same trust.
This TIGHTENS access, and there is deliberately NO backfill migration. The
operator scope grants applications.create/edit (they are edit verbs), so
operator-scope holders could reach this library before and cannot now. Do not
"fix" that by granting endpoints.runscript to applications.edit holders on
upgrade: it is in SCOPE_NEVER_GRANT precisely because it is a per-user checkbox
decision, and the same grant also authorizes the remote PUSH, so a backfill
would hand every operator SYSTEM code execution on the fleet. Admins are
unaffected via the role bypass; anyone else who genuinely needs the library
gets the checkbox in the user editor.
"""
import os
@@ -32,6 +47,11 @@ SCRIPT_EXTENSIONS = {'.ps1'}
SCRIPT_URL_PREFIX = '/api/scripts/body/'
# A delivery script is small; cap well under the installer limit.
MAX_SCRIPT_BYTES = 1 * 1024 * 1024
# Owned by the endpoints plugin, which is where the risk is described in full
# ("ARBITRARY CODE, executes as SYSTEM"). Core names it as a string rather than
# importing it so this module stays importable with the plugin absent - the
# grant simply cannot be held by anyone if nothing seeds it.
SCRIPT_PERMISSION = 'endpoints.runscript'
def _script_dir():
@@ -42,7 +62,7 @@ scripts_bp = Blueprint('scripts', __name__)
@scripts_bp.route('', methods=['GET'])
@require_permission('applications.view')
@require_permission(SCRIPT_PERMISSION)
def list_scripts():
"""List scripts (active by default)."""
query = Script.query
@@ -55,7 +75,7 @@ def list_scripts():
@scripts_bp.route('/<int:script_id>', methods=['GET'])
@require_permission('applications.view')
@require_permission(SCRIPT_PERMISSION)
def get_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -74,7 +94,7 @@ def _validate_runcontext(value):
@scripts_bp.route('', methods=['POST'])
@require_permission('applications.create')
@require_permission(SCRIPT_PERMISSION)
def create_script():
"""Create a script catalog entry (body uploaded separately)."""
data = request.get_json(silent=True) or {}
@@ -103,7 +123,7 @@ def create_script():
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
@require_permission('applications.edit')
@require_permission(SCRIPT_PERMISSION)
def update_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -135,7 +155,7 @@ def update_script(script_id: int):
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
@require_permission('applications.delete')
@require_permission(SCRIPT_PERMISSION)
def delete_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -149,7 +169,7 @@ def delete_script(script_id: int):
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
@require_permission('applications.edit')
@require_permission(SCRIPT_PERMISSION)
def upload_script_body(script_id: int):
"""Upload (or replace) the .ps1 body for a script.
@@ -196,15 +216,15 @@ def upload_script_body(script_id: int):
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
@require_permission('applications.download')
@require_permission(SCRIPT_PERMISSION)
def download_script_body(filename: str):
"""Operator-facing script body download (JWT/RBAC).
The endpoint agent uses the collector-authenticated plugin route instead;
this one is for humans reviewing what a script does.
Gated on applications.download, not applications.view: this serves the
SYSTEM-context PowerShell a site pushes to its bays, and a guest may be
granted the metadata permission.
Same grant as the rest of the library: this serves the SYSTEM-context
PowerShell a site pushes to its bays, so reading it is not a lesser act
than listing it.
"""
return send_upload(_script_dir(), filename, as_attachment=True)