Hard delete applications, nest installer packages, detect PE versions

Soft delete left orphaned executables on disk and let a deleted name collide
with a fresh import, so delete is now a hard delete that removes the
associated files. The destructive paths (delete, revert-to-record) require a
typed OK because neither can be undone.

Installers keep their filename at import and nest per application/version,
which stops repeat imports doubling up. Version now comes from the same PE
detection the endpoints agent uses; when no version can be read the caller is
asked for one rather than silently storing a blank.

Applications also surface Version and a monitorable toggle, with launch path
and process name required only while monitorable is ticked. Fleet shows the
PC alias, matching PC Details.
This commit is contained in:
benjamin.baker
2026-09-07 15:04:21 -04:00
parent 79545db96e
commit 0134eaa219
17 changed files with 1600 additions and 148 deletions

View File

@@ -4,6 +4,7 @@ import glob
import hashlib
import re
import os
import shutil
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
@@ -63,6 +64,28 @@ POLICY_ACK_MESSAGE = (
'Confirm to create the policy.'
)
# Deleting an application is irreversible and takes its versions and uploaded
# installer with it, so the caller has to type this rather than click once.
# Checked server-side as well as in the dialog: the endpoint is reachable
# directly, and a confirmation that only exists in the UI is not a safeguard.
DELETE_CONFIRM_TOKEN = 'OK'
DELETE_CONFIRM_MESSAGE = (
'Deleting an application permanently removes it, its versions, and its '
'uploaded installer. This cannot be undone. Type OK to confirm.'
)
def _delete_confirmed(data):
"""True when the caller typed the confirmation token exactly.
Compared case-sensitively after stripping surrounding whitespace. Accepting
"ok" would defeat the point of asking for deliberate input.
"""
if not isinstance(data, dict):
return False
return str(data.get('confirm', '')).strip() == DELETE_CONFIRM_TOKEN
def _validate_monitorable(data, existing=None):
"""Return an error message if monitorable is set without its two fields.
@@ -122,6 +145,11 @@ def _sync_required_app_policy(app):
# msi/exe this is for, small enough that the disk cannot vanish behind it.
MAX_PACKAGE_BYTES = 500 * 1024 * 1024
# How much of an installer to read when looking for its version resource. The
# PE resource directory is usually within the first few MB; reading the whole
# file would mean holding a 500MB upload in memory to learn a version string.
PE_VERSION_SCAN_BYTES = 32 * 1024 * 1024
def _appimage_dir():
return os.path.join(current_app.instance_path, 'applicationimages')
@@ -145,6 +173,46 @@ def _sha256_of(stream):
return digest.hexdigest()
def _detect_installer_version(stream):
"""Read the FileVersion out of an uploaded PE installer, or None.
Deliberately reuses the endpoints plugin's PE reader rather than parsing the
format again here. A version detected at upload has to match what the agent
reports from the installed machine, and two separate implementations of the
same structure walk would drift apart and make an endpoint look
out-of-compliance for no real reason.
FileVersion only, never ProductVersion: ProductVersion may carry semver
metadata (1.5.0+abc123) which is not a version number we can compare or use
in a path. Returns None for MSI and archives, which are not PE files at all
and simply have no version to read.
"""
try:
from plugins.endpoints.api.agentstore import read_pe_file_version
except ImportError:
return None
position = stream.tell()
try:
# The version resource lives in the PE header near the start, but the
# resource directory it points into can sit much deeper. Read a bounded
# prefix so a 500MB installer is not pulled into memory whole.
data = stream.read(PE_VERSION_SCAN_BYTES)
finally:
stream.seek(position)
# The parser documents itself as never raising, but it walks offsets read
# out of an attacker-supplied file. A version is a convenience; it must not
# cost the operator the upload of a 500MB installer if that guarantee ever
# slips.
try:
return read_pe_file_version(data)
except Exception:
current_app.logger.warning('Version detection failed for an installer',
exc_info=True)
return None
def _replace_upload(directory, stem, ext, upload):
"""Save one file per application, replacing any prior extension.
@@ -400,31 +468,178 @@ def update_application(app_id: int):
return success_response(payload, message='Application updated')
def _remove_package_files(app_id: int):
"""Delete the stored installer(s) for an application, leaving the image.
Split out from _remove_upload_files because revert-to-record removes the
executables but keeps the icon: the record stays in the catalogue and a
record with no image is a worse record. Delete calls both.
Best-effort by design: a missing or unreadable file must not abort the
operation and strand the row. Returns the filenames actually removed so the
audit entry records what went.
"""
removed = []
packagedir = os.path.join(_apppackage_dir(), str(app_id))
for path in glob.glob(os.path.join(packagedir, '*')):
removed.append(os.path.basename(path))
if os.path.isdir(packagedir):
shutil.rmtree(packagedir, ignore_errors=True)
if os.path.isdir(packagedir):
current_app.logger.warning(
'Could not remove %s for appid=%s', packagedir, app_id)
return removed
def _remove_upload_files(app_id: int):
"""Delete the stored installer and image for an application.
Used by delete, which takes everything. Revert calls
_remove_package_files() instead so the image survives.
"""
removed = []
# The image is still one flat file per application; the installer now lives
# in a per-application directory so it can keep its vendor filename.
pattern = os.path.join(_appimage_dir(),
secure_filename(f'application-{app_id}') + '.*')
for path in glob.glob(pattern):
try:
os.remove(path)
removed.append(os.path.basename(path))
except OSError:
current_app.logger.warning(
'Could not remove %s while deleting appid=%s', path, app_id)
return removed + _remove_package_files(app_id)
def _clear_application_references(app_id: int):
"""Detach rows that point at this application before it is deleted.
The FKs differ in intent, so they are not treated the same:
- appversions.appid is NOT NULL and the versions are meaningless without
the application, so they go.
- computerinstalledapps.appid is NOT NULL; these are inventory links, so
the link goes while the computer stays.
- knowledgebase.appid is nullable and the article is somebody's writing,
so it is unlinked rather than deleted.
"""
AppVersion.query.filter_by(appid=app_id).delete(synchronize_session=False)
models = _computer_models()
if models is not None:
_, ComputerInstalledApp = models
ComputerInstalledApp.query.filter_by(appid=app_id).delete(
synchronize_session=False)
try:
from plugins.knowledgebase.models import KnowledgeBase
except ImportError:
return
KnowledgeBase.query.filter_by(appid=app_id).update(
{'appid': None}, synchronize_session=False)
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
@require_permission('applications.delete')
def delete_application(app_id: int):
"""Delete (deactivate) an application."""
"""Permanently delete an application, its versions, and its uploads.
This is a hard delete. The previous soft delete only set isactive=False,
which left the name reserved forever: creating an application with the same
name failed with "already exists" against a row no screen would show.
"""
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
app.isactive = False
if not _delete_confirmed(request.get_json(silent=True)):
return error_response(ErrorCodes.VALIDATION_ERROR, DELETE_CONFIRM_MESSAGE)
# Withdraw the generated policy too. Deactivating an application while its
# policy still runs would keep installing something the catalogue says is
# gone, and nothing in the UI would explain why. A customised policy is
# left alone, same as unticking the box.
# Withdraw the generated policy first. A policy that outlived its
# application would keep installing something that no longer exists.
appname = app.appname
app.isrequired = False
_sync_required_app_policy(app)
AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname)
_clear_application_references(app_id)
removed = _remove_upload_files(app_id)
db.session.delete(app)
# The audit row is the only record left that this application existed, so
# it names the files that went with it.
AuditLog.log('deleted', 'Application', entityid=app_id, entityname=appname,
changes={'removedfiles': removed} if removed else None)
db.session.commit()
return success_response(message='Application deleted')
REVERT_CONFIRM_MESSAGE = (
'Remove all policy associations and uploaded executables? This action '
"cannot be reversed. Type OK to proceed"
)
@applications_bp.route('/<int:app_id>/revert', methods=['POST'])
@require_permission('applications.edit')
def revert_application_to_record(app_id: int):
"""Strip an application back to a catalogue record.
The middle ground between editing and deleting. Deleting loses the name,
the description and the knowledgebase links, which is too much when the
intent is only to stop deploying something and re-upload a clean installer.
This withdraws the generated policy, drops the version rows and removes the
uploaded files, while the application itself stays in the catalogue.
Inventory links are deliberately kept: computerinstalledapps records that a
machine really does have this software on it, and that stays true whether
or not we still ship an installer for it. Deleting those would make the
fleet look clean when it is not.
Gated on applications.edit rather than applications.delete: the record
survives, and a user trusted to change an application is trusted to stop
deploying it.
"""
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found',
http_code=404)
if not _delete_confirmed(request.get_json(silent=True)):
return error_response(ErrorCodes.VALIDATION_ERROR, REVERT_CONFIRM_MESSAGE)
# Withdraw the generated policy first, for the same reason delete does: a
# policy left behind would keep pushing an installer we just removed.
app.isrequired = False
_sync_required_app_policy(app)
AppVersion.query.filter_by(appid=app_id).delete(synchronize_session=False)
# Executables only. The image is not an executable and the warning does not
# offer to remove it - the record survives this operation, and stripping its
# icon would degrade the thing we are explicitly keeping.
removed = _remove_package_files(app_id)
# Only clear an installpath we own. A UNC path typed by an operator points
# at their share, not our upload directory, and is not ours to wipe.
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
app.installpath = None
app.installersha256 = None
AuditLog.log('updated', 'Application', entityid=app_id,
entityname=app.appname,
changes={'revertedtorecord': {'new': True},
'removedfiles': removed})
db.session.commit()
return success_response(app.to_dict(), message='Application reverted to record')
# ---- Versions ----
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
@@ -736,14 +951,43 @@ def delete_application_image(app_id: int):
return success_response(app.to_dict(), message='Application image removed')
def _store_package(app_id: int, upload):
"""Save an installer under its own vendor filename, one per application.
Keeping the shipped name matters at import: "DeviceLoggerSetup.msi" is
recognisable as the thing already in the catalogue, whereas the old
application-<id>.msi told the operator nothing and made a re-import look
like new software, which is how duplicate applications and versions got
created in the first place.
Files are namespaced by application id so two applications shipping a
generic "setup.exe" cannot overwrite each other. Any previous installer for
this application is removed first, so replacing one does not accumulate.
Returns (storedname, originalname).
"""
directory = os.path.join(_apppackage_dir(), str(app_id))
if os.path.isdir(directory):
shutil.rmtree(directory, ignore_errors=True)
os.makedirs(directory, exist_ok=True)
original = os.path.basename(upload.filename or '')
# secure_filename can reduce a name to nothing (all-unicode, all-dots), and
# an empty name would save as a bare directory entry.
stored = secure_filename(original) or 'installer{}'.format(
os.path.splitext(original)[1].lower())
upload.save(os.path.join(directory, stored))
return stored, original
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
@require_permission('applications.edit')
def upload_application_package(app_id: int):
"""Upload (or replace) the installer for an application.
multipart/form-data: file=<installer>. The original filename is kept in
installpath so the download arrives named the way the vendor shipped it,
and the stored copy is application-<id><ext>.
multipart/form-data: file=<installer>. The installer is stored under the
filename the vendor shipped, inside a per-application directory.
"""
app = db.session.get(Application, app_id)
if not app:
@@ -773,8 +1017,11 @@ def upload_application_package(app_id: int):
'on the share and link to them with Install Path.'.format(
size / 1048576, MAX_PACKAGE_BYTES / 1048576))
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
# Read the version before saving: the stream is consumed by save().
detectedversion = _detect_installer_version(upload.stream)
filename, _original = _store_package(app_id, upload)
app.installpath = '{}{}/{}'.format(APP_PACKAGE_URL_PREFIX, app_id, filename)
# The endpoint installer push pins bytes by digest; compute it once here so
# the server, not the agent, decides what the correct payload is. Stamp it
@@ -805,6 +1052,10 @@ def upload_application_package(app_id: int):
return success_response(
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size,
'sha256': sha256,
# None means "no version resource" - the form must ask the operator
# rather than silently saving an installer with no version, which is
# how duplicate version rows appeared.
'detectedversion': detectedversion,
'appversionid': stamped_version.appversionid if stamped_version else None},
message='Installer uploaded')
@@ -821,6 +1072,10 @@ def serve_application_package(filename):
Gated on applications.download, not applications.view: a guest may be
granted the metadata permission, and "an open URL would publish it" is just
as true of a URL any anonymous visitor can reach.
filename is "<appid>/<vendorname>" now that installers keep the name they
shipped under. send_from_directory refuses to escape the base directory, so
the appid segment cannot be walked out of with "..".
"""
return send_upload(_apppackage_dir(), filename, as_attachment=True)
@@ -835,12 +1090,12 @@ def delete_application_package(app_id: int):
f'Application with ID {app_id} not found',
http_code=404)
for old in glob.glob(os.path.join(_apppackage_dir(),
secure_filename(f'application-{app_id}') + '.*')):
os.remove(old)
shutil.rmtree(os.path.join(_apppackage_dir(), str(app_id)),
ignore_errors=True)
# Only clear the path if it pointed at the upload; a site's own share path
# was typed by a person and is not ours to wipe.
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
app.installpath = None
app.installersha256 = None
db.session.commit()
return success_response(app.to_dict(), message='Installer removed')