main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""Script library API endpoints.
|
|
|
|
The ShopDB-hosted script library backs the endpoint run_script delivery path: a
|
|
PowerShell script is authored/uploaded once here, and the endpoint agent fetches
|
|
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 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
|
|
import re
|
|
|
|
from flask import Blueprint, request, current_app
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
|
|
ERRORMODES, ERRORMODE_DEFAULT, AuditLog)
|
|
from shopdb.utils.responses import (
|
|
success_response,
|
|
error_response,
|
|
ErrorCodes,
|
|
)
|
|
from shopdb.utils.authz import require_permission
|
|
from shopdb.utils.uploads import send_upload
|
|
from shopdb.core.api.applications import _sha256_of, _replace_upload
|
|
|
|
# Scripts are PowerShell for the endpoint agent. The guard is a sanity check on
|
|
# the upload, not a security boundary - the download route always sends as an
|
|
# attachment and the agent runs it explicitly.
|
|
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():
|
|
return os.path.join(current_app.instance_path, 'scripts')
|
|
|
|
|
|
scripts_bp = Blueprint('scripts', __name__)
|
|
|
|
|
|
@scripts_bp.route('', methods=['GET'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def list_scripts():
|
|
"""List scripts (active by default)."""
|
|
query = Script.query
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(Script.isactive == True) # noqa: E712
|
|
if search := request.args.get('search'):
|
|
query = query.filter(Script.scriptname.ilike(f'%{search}%'))
|
|
rows = query.order_by(Script.scriptname).all()
|
|
return success_response([row.to_dict() for row in rows])
|
|
|
|
|
|
@scripts_bp.route('/<int:script_id>', methods=['GET'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def get_script(script_id: int):
|
|
script = db.session.get(Script, script_id)
|
|
if not script:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
|
|
return success_response(script.to_dict())
|
|
|
|
|
|
def _validate_runcontext(value):
|
|
"""Normalize/validate a runcontext, defaulting to system when absent."""
|
|
if value is None:
|
|
return RUNCONTEXT_SYSTEM, None
|
|
value = str(value).strip().lower()
|
|
if value not in RUNCONTEXTS:
|
|
return None, 'runcontext must be one of: {}'.format(', '.join(sorted(RUNCONTEXTS)))
|
|
return value, None
|
|
|
|
|
|
def _validate_errormode(value):
|
|
"""Normalize/validate an errormode, defaulting to permissive when absent.
|
|
|
|
Defaults to ERRORMODE_DEFAULT rather than strict on purpose. Strict promotes
|
|
PowerShell's non-terminating errors to terminating ones, so a body that was
|
|
written to tolerate a failing cleanup or to probe with a cmdlet that errors
|
|
when the target is absent would begin aborting partway through. An absent
|
|
field therefore means "what this script already did", never a silent upgrade.
|
|
"""
|
|
if value is None:
|
|
return ERRORMODE_DEFAULT, None
|
|
value = str(value).strip().lower()
|
|
if value not in ERRORMODES:
|
|
return None, 'errormode must be one of: {}'.format(', '.join(sorted(ERRORMODES)))
|
|
return value, None
|
|
|
|
|
|
def _validate_captureoutput(value):
|
|
"""Normalize the "send output back on success" flag. Returns (value, error).
|
|
|
|
Absent means False: a script authored before this field existed never
|
|
returned its successful output to the server, and reading an omitted field
|
|
as True would start persisting output from bodies nobody reviewed with that
|
|
question in mind. Strings are accepted because the field arrives from a
|
|
form control that may send 'true'/'false' rather than a JSON boolean.
|
|
"""
|
|
if value is None:
|
|
return False, None
|
|
if isinstance(value, bool):
|
|
return value, None
|
|
normalized = str(value).strip().lower()
|
|
if normalized in ('true', '1', 'yes'):
|
|
return True, None
|
|
if normalized in ('false', '0', 'no', ''):
|
|
return False, None
|
|
return None, 'captureoutput must be true or false'
|
|
|
|
|
|
def _validate_workingdirectory(value):
|
|
"""Normalize the optional folder a script runs in. Returns (value, error).
|
|
|
|
Empty means "the agent's staging directory", which is the behaviour every
|
|
script had before this field existed. The purpose is to let a script sit
|
|
beside a corporate tool already installed on the endpoint and call it by
|
|
relative path.
|
|
|
|
The checks are shape checks, NOT a privilege boundary - a script body is
|
|
already arbitrary SYSTEM PowerShell and can change its own directory. They
|
|
exist so a typo fails here with an explanation instead of on the endpoint:
|
|
|
|
- must be an absolute local path (a drive letter), because a relative one
|
|
is meaningless without knowing what it would be relative TO
|
|
- no UNC. The stated use case is a tool already on this machine; a UNC
|
|
working directory resolves as the MACHINE ACCOUNT against a file server,
|
|
which moves where the binaries come from and is a different trust story
|
|
than "Intune put this here"
|
|
- no traversal segment, which only ever obscures what the path really is
|
|
"""
|
|
if value is None:
|
|
return None, None
|
|
value = str(value).strip().strip('"')
|
|
if not value:
|
|
return None, None
|
|
if value.startswith('\\\\') or value.startswith('//'):
|
|
return None, ('workingdirectory must be a local path on the endpoint, '
|
|
'not a UNC share')
|
|
normalized = value.replace('/', '\\')
|
|
if not re.match(r'^[A-Za-z]:\\', normalized):
|
|
return None, ('workingdirectory must be an absolute path including a '
|
|
r'drive letter, for example C:\Program Files\Sysinternals')
|
|
if '..' in normalized.split('\\'):
|
|
return None, 'workingdirectory must not contain a ".." segment'
|
|
if len(normalized) > 255:
|
|
return None, 'workingdirectory must be 255 characters or fewer'
|
|
# Trailing separator is noise, EXCEPT at a drive root: "C:\" trimmed to
|
|
# "C:" is drive-RELATIVE on Windows and would resolve to wherever the
|
|
# process happened to be on that drive, which is the opposite of pinning a
|
|
# folder. Keep the root form intact.
|
|
trimmed = normalized.rstrip('\\')
|
|
if len(trimmed) < 3:
|
|
trimmed = normalized[:3]
|
|
return trimmed, None
|
|
|
|
|
|
@scripts_bp.route('', methods=['POST'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def create_script():
|
|
"""Create a script catalog entry (body uploaded separately)."""
|
|
data = request.get_json(silent=True) or {}
|
|
name = (data.get('scriptname') or '').strip()
|
|
if not name:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname is required')
|
|
if Script.query.filter_by(scriptname=name).first():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'A script named {name!r} already exists')
|
|
runcontext, err = _validate_runcontext(data.get('runcontext'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
workingdirectory, err = _validate_workingdirectory(data.get('workingdirectory'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
errormode, err = _validate_errormode(data.get('errormode'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
|
|
script = Script(
|
|
scriptname=name,
|
|
scriptdescription=data.get('scriptdescription'),
|
|
runcontext=runcontext,
|
|
workingdirectory=workingdirectory,
|
|
errormode=errormode,
|
|
captureoutput=captureoutput,
|
|
)
|
|
db.session.add(script)
|
|
db.session.commit()
|
|
AuditLog.log('created', 'Script', entityid=script.scriptid,
|
|
entityname=name,
|
|
changes={'runcontext': {'new': runcontext},
|
|
'workingdirectory': {'new': workingdirectory},
|
|
'errormode': {'new': errormode},
|
|
'captureoutput': {'new': captureoutput}})
|
|
db.session.commit()
|
|
return success_response(script.to_dict(), message='Script created', http_code=201)
|
|
|
|
|
|
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def update_script(script_id: int):
|
|
script = db.session.get(Script, script_id)
|
|
if not script:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
if 'scriptname' in data:
|
|
name = (data.get('scriptname') or '').strip()
|
|
if not name:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname cannot be empty')
|
|
clash = Script.query.filter(Script.scriptname == name,
|
|
Script.scriptid != script_id).first()
|
|
if clash:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'A script named {name!r} already exists')
|
|
script.scriptname = name
|
|
if 'scriptdescription' in data:
|
|
script.scriptdescription = data.get('scriptdescription')
|
|
if 'runcontext' in data:
|
|
runcontext, err = _validate_runcontext(data.get('runcontext'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
script.runcontext = runcontext
|
|
if 'workingdirectory' in data:
|
|
workingdirectory, err = _validate_workingdirectory(
|
|
data.get('workingdirectory'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
script.workingdirectory = workingdirectory
|
|
if 'errormode' in data:
|
|
errormode, err = _validate_errormode(data.get('errormode'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
script.errormode = errormode
|
|
# Audited on change: this decides whether a successful run's output is
|
|
# persisted server-side, so who turned it on is worth being able to answer.
|
|
if 'captureoutput' in data:
|
|
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
|
|
if err:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
|
if captureoutput != script.captureoutput:
|
|
AuditLog.log('updated', 'Script', entityid=script.scriptid,
|
|
entityname=script.scriptname,
|
|
changes={'captureoutput': {'old': script.captureoutput,
|
|
'new': captureoutput}})
|
|
script.captureoutput = captureoutput
|
|
if 'isactive' in data:
|
|
script.isactive = bool(data.get('isactive'))
|
|
|
|
db.session.commit()
|
|
return success_response(script.to_dict(), message='Script updated')
|
|
|
|
|
|
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def delete_script(script_id: int):
|
|
script = db.session.get(Script, script_id)
|
|
if not script:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
|
|
name = script.scriptname
|
|
db.session.delete(script)
|
|
db.session.commit()
|
|
AuditLog.log('deleted', 'Script', entityid=script_id, entityname=name)
|
|
db.session.commit()
|
|
return success_response(None, message='Script deleted')
|
|
|
|
|
|
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
|
|
@require_permission(SCRIPT_PERMISSION)
|
|
def upload_script_body(script_id: int):
|
|
"""Upload (or replace) the .ps1 body for a script.
|
|
|
|
multipart/form-data: file=<script.ps1>. Stored as script-<id>.ps1 and the
|
|
digest is stamped so the endpoint delivery path can pin exact bytes.
|
|
"""
|
|
script = db.session.get(Script, script_id)
|
|
if not script:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
|
|
|
|
upload = request.files.get('file')
|
|
if not upload or not upload.filename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
|
ext = os.path.splitext(upload.filename)[1].lower()
|
|
if ext not in SCRIPT_EXTENSIONS:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Unsupported script type {}. Allowed: {}'.format(
|
|
ext, ', '.join(sorted(SCRIPT_EXTENSIONS))))
|
|
|
|
upload.stream.seek(0, os.SEEK_END)
|
|
size = upload.stream.tell()
|
|
upload.stream.seek(0)
|
|
if size > MAX_SCRIPT_BYTES:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Script is {:.0f}KB; the limit is {:.0f}KB.'.format(
|
|
size / 1024, MAX_SCRIPT_BYTES / 1024))
|
|
|
|
filename = _replace_upload(_script_dir(), f'script-{script_id}', ext, upload)
|
|
script.filename = filename
|
|
script.sha256 = _sha256_of(upload.stream)
|
|
db.session.commit()
|
|
|
|
AuditLog.log('updated', 'Script', entityid=script_id,
|
|
entityname=script.scriptname,
|
|
changes={'filename': {'new': filename},
|
|
'sha256': {'new': script.sha256},
|
|
'bytes': {'new': size}})
|
|
db.session.commit()
|
|
return success_response(
|
|
{**script.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
|
|
message='Script body uploaded')
|
|
|
|
|
|
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
|
@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.
|
|
|
|
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)
|