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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user