Merge origin/main (West Jeff) into wilm
Bring in the natural-key import fix, Metrics->Monitors rename, PC detail tabbing and panel groups, guest search grants, and the 2026-08-30 security fixes. Reconcile endpoint packaging/installer, migrations, contract version, and tests; keep the delivery uninstaller and 6-step installer alongside main's permission-based RBAC and pin-drift protection.
This commit is contained in:
@@ -23,7 +23,7 @@ import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import error_response, ErrorCodes, require_role
|
||||
from shopdb.api import error_response, ErrorCodes, require_permission
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
@@ -66,7 +66,7 @@ def _build_readme():
|
||||
|
||||
|
||||
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def package_adoption_tool():
|
||||
"""Bundle the adoption tool as a downloadable ZIP.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
|
||||
_CLAIM_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
def _pin_holds(command, descriptor):
|
||||
"""Does the descriptor still carry the digest this command was queued for?
|
||||
|
||||
True when nothing was pinned (actions that deliver no content), or when the
|
||||
descriptor is missing entirely - a vanished script or version already sends
|
||||
the command through with no descriptor so the agent fails closed, and that
|
||||
path is not a content swap.
|
||||
"""
|
||||
if not command.pinnedsha256:
|
||||
return True
|
||||
if descriptor is None:
|
||||
return True
|
||||
return hmac.compare_digest(
|
||||
str(descriptor.get('sha256') or ''), command.pinnedsha256)
|
||||
|
||||
|
||||
def _fail_on_pin_drift(command, action):
|
||||
"""Refuse a claim whose content changed after the operator approved it."""
|
||||
command.status = EndpointCommand.STATUS_FAILED
|
||||
command.resultmessage = (
|
||||
'Refused: the {} content changed after this command was queued'
|
||||
.format(action))
|
||||
command.completedat = _naive_utcnow()
|
||||
db.session.commit()
|
||||
current_app.logger.warning(
|
||||
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
|
||||
'longer matches the resolved descriptor',
|
||||
command.endpointcommandid, action, command.assetid,
|
||||
command.pinnedsha256)
|
||||
AuditLog.log('refused', 'EndpointCommand',
|
||||
entityid=command.endpointcommandid,
|
||||
entityname=action,
|
||||
details='content digest changed after enqueue')
|
||||
db.session.commit()
|
||||
return ('', 204)
|
||||
|
||||
|
||||
def _reap_stale_commands(asset_id):
|
||||
"""Expire in-flight commands an agent will never finish for this endpoint.
|
||||
|
||||
@@ -1097,14 +1135,39 @@ def _resolve_update_app(target):
|
||||
if application is None or not application.installpath:
|
||||
return None, ('Application for appversionid {} has no installer package'
|
||||
.format(ident))
|
||||
# PIN THE FILE THAT IS ACTUALLY SERVED.
|
||||
#
|
||||
# /api/endpoints/apppackage/<appversionid> resolves the version to its
|
||||
# APPLICATION and serves application.installpath - there is no
|
||||
# version-specific artifact behind that route. So version.sha256 pinned
|
||||
# a digest that need not describe the bytes delivered, and where it was
|
||||
# null the descriptor carried no digest at all and the agent had nothing
|
||||
# to verify against. Deriving it from the application, exactly as the
|
||||
# app branch does, makes the pin describe the delivery.
|
||||
sha256 = (application.installersha256
|
||||
or _backfill_app_installer_sha256(application))
|
||||
if not sha256:
|
||||
return None, ('Application for appversionid {} has an installer '
|
||||
'package with no recorded SHA-256, so the delivery '
|
||||
'cannot be verified'.format(ident))
|
||||
|
||||
# Carry the real filename so the agent's Path.GetExtension routes an MSI
|
||||
# through msiexec. Without it every MSI push failed, because this branch
|
||||
# ended the URL at the id and the extension was simply absent.
|
||||
filename = _hosted_package_filename(application)
|
||||
installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid)
|
||||
if filename:
|
||||
installpath = '/api/endpoints/apppackage/app/{}/{}'.format(
|
||||
application.appid, filename)
|
||||
|
||||
return {
|
||||
'source': 'version',
|
||||
'appversionid': version.appversionid,
|
||||
'appid': version.appid,
|
||||
'appname': application.appname,
|
||||
'version': version.version,
|
||||
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
|
||||
'sha256': version.sha256,
|
||||
'installpath': installpath,
|
||||
'sha256': sha256,
|
||||
'silentargs': version.silentargs,
|
||||
}, None
|
||||
|
||||
@@ -1209,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
# update_app pins a specific application version; verify it resolves to a
|
||||
# real installer now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint minutes later.
|
||||
# Digest of the content this push was APPROVED against. Kept on the row so
|
||||
# the claim path can refuse a descriptor that has changed since; see
|
||||
# EndpointCommand.pinnedsha256.
|
||||
pinnedsha256 = None
|
||||
|
||||
if action == 'update_app':
|
||||
_descriptor, resolve_error = _resolve_update_app(target)
|
||||
descriptor, resolve_error = _resolve_update_app(target)
|
||||
if resolve_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
|
||||
pinnedsha256 = (descriptor or {}).get('sha256')
|
||||
|
||||
# run_script pins a ShopDB-hosted script; verify it resolves to a real
|
||||
# uploaded body now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint later.
|
||||
if action == 'run_script':
|
||||
_script, script_error = _resolve_run_script(target)
|
||||
script, script_error = _resolve_run_script(target)
|
||||
if script_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||
pinnedsha256 = (script or {}).get('sha256')
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
@@ -1252,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
targetkind=targetkind if target else None,
|
||||
status=EndpointCommand.STATUS_PENDING,
|
||||
requestedby=getattr(current_user, 'userid', None),
|
||||
pinnedsha256=pinnedsha256,
|
||||
)
|
||||
db.session.add(command)
|
||||
|
||||
@@ -1312,6 +1383,8 @@ def claim_next_command():
|
||||
# fail closed rather than 500 the poll.
|
||||
if command.action == 'update_app':
|
||||
descriptor, _resolve_error = _resolve_update_app(command.target)
|
||||
if not _pin_holds(command, descriptor):
|
||||
return _fail_on_pin_drift(command, 'update_app')
|
||||
if descriptor is not None:
|
||||
claim['app'] = descriptor
|
||||
# run_script rides a resolved script descriptor: the agent gets a
|
||||
@@ -1322,6 +1395,8 @@ def claim_next_command():
|
||||
# agent fail closed rather than 500 the poll.
|
||||
if command.action == 'run_script':
|
||||
script_descriptor, _script_error = _resolve_run_script(command.target)
|
||||
if not _pin_holds(command, script_descriptor):
|
||||
return _fail_on_pin_drift(command, 'run_script')
|
||||
if script_descriptor is not None:
|
||||
claim['script'] = script_descriptor
|
||||
return success_response(claim)
|
||||
@@ -1351,6 +1426,33 @@ def report_command_result(command_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
# An agent may only finalize ITS OWN command. The collector key is shared
|
||||
# fleet-wide, so without this any agent could post a result for any command
|
||||
# id - marking an install succeeded on a machine that never ran it, which
|
||||
# writes a false compliance record. Binding to the claimed hostname is a
|
||||
# floor, not a fix: the credential still does not prove which endpoint is
|
||||
# calling, and only a per-endpoint credential closes that.
|
||||
# Optional because the shipped agent does not send it yet; enforced when it
|
||||
# does, so an updated fleet gets the check without a flag day.
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if hostname:
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if caller_asset_id != command.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
|
||||
# Only a claimed command can be finalized. Needs no agent change, and it
|
||||
# closes two things the hostname check cannot: reporting on a command that
|
||||
# was never claimed, and overwriting a terminal result - which would let a
|
||||
# recorded failure be replaced by a success.
|
||||
if command.status != EndpointCommand.STATUS_RUNNING:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Command {} is {}, not running'.format(
|
||||
command_id, command.status),
|
||||
http_code=409)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
succeeded = bool(payload.get('succeeded'))
|
||||
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
||||
|
||||
@@ -31,7 +31,8 @@ import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import success_response, error_response, ErrorCodes, require_role
|
||||
from shopdb.api import (success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
@@ -166,16 +167,16 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
||||
server after it was generated; regenerate the key in the UI to revoke it.
|
||||
@@ -456,14 +457,14 @@ def _siteagent_status_payload():
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent', methods=['GET'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def get_siteagent():
|
||||
"""Report whether the site agent build has a prebuilt binary staged."""
|
||||
return success_response(_siteagent_status_payload())
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/package', methods=['POST'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def package_siteagent():
|
||||
"""Bundle the site agent as a downloadable ZIP.
|
||||
|
||||
|
||||
@@ -38,7 +38,17 @@ def upgrade():
|
||||
|
||||
op.create_table(
|
||||
'endpointapplications',
|
||||
sa.Column('endpointapplicationid', sa.Integer(), nullable=False),
|
||||
# BigInteger, not Integer. This is a SNAPSHOT table: every check-in
|
||||
# deletes the endpoint's rows and reinserts them, so the surrogate key
|
||||
# burns through values at (endpoints x installed apps) per report and
|
||||
# never reuses them. A 32-bit key exhausts on MySQL - a few hundred
|
||||
# endpoints reporting a few hundred apps every 15 minutes reaches 2.1
|
||||
# billion inside a couple of years, and the failure is an insert that
|
||||
# stops working forever. SQLite has no such ceiling, so no test would
|
||||
# have caught it. Changed while the table is still unreleased.
|
||||
sa.Column('endpointapplicationid',
|
||||
sa.BigInteger().with_variant(sa.Integer, 'sqlite'),
|
||||
nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('displayname', sa.String(length=255), nullable=False),
|
||||
sa.Column('displayversion', sa.String(length=128), nullable=True),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Pin the approved content digest on an endpoint command.
|
||||
|
||||
Delivery descriptors (run_script, update_app) are re-resolved when the agent
|
||||
claims a command, not when an operator queues it. Between those two moments the
|
||||
underlying script body or installer package can be replaced by anyone who can
|
||||
edit the applications catalog, and the descriptor's SHA-256 would then describe
|
||||
the NEW bytes: the agent verifies correctly and runs something the operator
|
||||
never approved.
|
||||
|
||||
pinnedsha256 records the digest as it stood at enqueue. The claim path compares
|
||||
and refuses the command if it has moved, so a swap fails the push instead of
|
||||
shipping it.
|
||||
|
||||
Null for actions that deliver no content, and null on rows queued before this
|
||||
migration - those are treated as unpinned and claim as before.
|
||||
|
||||
Idempotent: checks the live schema first so a db.create_all() site re-runs clean
|
||||
on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0005cmdpin'
|
||||
down_revision = 'endpoints0004apps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column('endpointcommands', 'pinnedsha256'):
|
||||
op.add_column('endpointcommands',
|
||||
sa.Column('pinnedsha256', sa.String(length=64),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column('endpointcommands', 'pinnedsha256'):
|
||||
op.drop_column('endpointcommands', 'pinnedsha256')
|
||||
@@ -172,6 +172,19 @@ class EndpointCommand(db.Model):
|
||||
# Who queued it (userid) for audit; the agent never sees this.
|
||||
requestedby = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# The content digest as it stood WHEN AN OPERATOR APPROVED THIS PUSH.
|
||||
#
|
||||
# Delivery descriptors are re-resolved at claim time, minutes or hours
|
||||
# later, so without this the bytes an operator approved and the bytes the
|
||||
# agent runs are two different lookups against mutable content. Whoever can
|
||||
# replace a script body or an installer package could swap it in the gap
|
||||
# and the digest would faithfully describe the new bytes.
|
||||
#
|
||||
# Set at enqueue for run_script and update_app; null for actions that
|
||||
# deliver no content. The claim path refuses a command whose descriptor no
|
||||
# longer matches, so a swap fails the command instead of shipping.
|
||||
pinnedsha256 = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# Agent-reported outcome. resultjson holds structured detail (for example
|
||||
# the observed MTConnect running state); resultmessage is a short summary.
|
||||
resultmessage = db.Column(db.Text, nullable=True)
|
||||
@@ -282,7 +295,16 @@ class EndpointApplication(db.Model):
|
||||
|
||||
__tablename__ = 'endpointapplications'
|
||||
|
||||
endpointapplicationid = db.Column(db.Integer, primary_key=True)
|
||||
# BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale
|
||||
# on every check-in, so the key never stops climbing and a 32-bit column
|
||||
# eventually stops accepting inserts - see the migration for the arithmetic.
|
||||
#
|
||||
# The variant is not decoration: SQLite only autoincrements a column typed
|
||||
# exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail
|
||||
# with a NOT NULL violation on the test database. Nothing caught that,
|
||||
# because no test inserts into this table.
|
||||
endpointapplicationid = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True)
|
||||
|
||||
# The asset this install belongs to. Not unique: many applications per
|
||||
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.
|
||||
|
||||
@@ -78,6 +78,20 @@ def _as_json(value):
|
||||
return json.dumps(value, separators=(',', ':'), sort_keys=True)
|
||||
|
||||
|
||||
def _clamp(value, length):
|
||||
"""A string bounded to its column width, or None.
|
||||
|
||||
Ingest writes whatever an endpoint reports. A registry value longer than its
|
||||
column is not a data-quality nuisance on MySQL: in strict mode it raises
|
||||
Data too long and fails the whole check-in transaction, so one machine with
|
||||
a malformed Uninstall entry stops reporting telemetry and events entirely.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:length] or None
|
||||
|
||||
|
||||
class EndpointsPlugin(BasePlugin):
|
||||
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
|
||||
|
||||
@@ -131,10 +145,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
# including every logged-in username on the floor, because the
|
||||
# panels and the list were gated on the same string.
|
||||
#
|
||||
# It is deliberately NOT named '.view'. guest_grantable_permissions
|
||||
# opens any '.view' or '.read' permission to the guest role, so this
|
||||
# name is what keeps the fleet roster off the public surface while
|
||||
# per-asset telemetry can still be granted.
|
||||
# The name avoids '.view' for readability, but the NAME no longer
|
||||
# decides anything: guest_grantable_permissions is a declared list
|
||||
# now, and this plugin blesses endpoints.view alone below. Adding
|
||||
# endpoints.fleet there would be what opens the roster, and the
|
||||
# docstring on that hook says why not to.
|
||||
('endpoints.fleet',
|
||||
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
|
||||
# Issue an allowlisted command to an endpoint agent (for example the
|
||||
@@ -152,8 +167,19 @@ class EndpointsPlugin(BasePlugin):
|
||||
# running a staged script or pushing an installer is a strictly
|
||||
# heavier grant than a routine service/process toggle.
|
||||
('endpoints.deploy',
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
# Packaging the agent is a heavier grant than deploying to one, and
|
||||
# it is not the same job either: the generated ZIP carries a live
|
||||
# collector key stamped into its install script, so whoever holds
|
||||
# this can mint a distributable artefact containing the fleet-wide
|
||||
# command-channel credential. It was require_role('admin'), which
|
||||
# this codebase otherwise reserves for the few genuinely
|
||||
# role-shaped checks - and a role cannot be granted to one person
|
||||
# for one job the way a permission can.
|
||||
('endpoints.agentpackage',
|
||||
'Generate and download an agent or adoption-tool package '
|
||||
'(contains a live collector key)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||
# touching computers/geenforce collectors. Minted from the settings
|
||||
@@ -162,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
|
||||
]
|
||||
|
||||
def get_guest_grantable_permissions(self) -> List[str]:
|
||||
"""Declared because a site chose it: live status on a PC page.
|
||||
|
||||
endpoints.view only. NOT endpoints.fleet - that is the roster, and the
|
||||
whole reason the two were split.
|
||||
|
||||
KNOWN GAP, tracked separately: the per-asset panels this opens are
|
||||
keyed on a small integer asset id and the status panel emits the
|
||||
logged-in username, so walking ids reconstructs most of the roster the
|
||||
split exists to protect. Bounding or rate-limiting the per-asset panels
|
||||
is the fix. Until then a site enabling guest access should know the
|
||||
split is a speed bump here, not a wall.
|
||||
"""
|
||||
return ['endpoints.view']
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Settings owned by the endpoints agent-key kill switch.
|
||||
|
||||
@@ -310,6 +351,9 @@ class EndpointsPlugin(BasePlugin):
|
||||
'permission': 'endpoints.command',
|
||||
'empty': 'No monitored services reported.',
|
||||
'position': 12,
|
||||
# Service state is what is being watched; the Start/Stop
|
||||
# controls belong beside it, not in a tab of their own.
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-tracked-processes',
|
||||
@@ -348,6 +392,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
'permission': 'endpoints.command',
|
||||
'empty': 'No monitored processes reported.',
|
||||
'position': 13,
|
||||
# observed process state, not something you act on here
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# One feed instead of two cards. Events and commands answer the
|
||||
@@ -391,6 +437,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
},
|
||||
'empty': 'Nothing recorded for this endpoint yet.',
|
||||
'position': 20,
|
||||
# the event and command feed IS the activity tab
|
||||
'group': 'activity',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-interfaces',
|
||||
@@ -410,6 +458,33 @@ class EndpointsPlugin(BasePlugin):
|
||||
],
|
||||
'empty': 'No network interfaces reported.',
|
||||
'position': 16,
|
||||
# adapter state is watched, not something you act on
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Installed-software (SBOM) inventory. Raw discovered list for
|
||||
# now: no managed-application linkage is surfaced yet.
|
||||
#
|
||||
# NOT collapsible. It used to be, and started collapsed, because
|
||||
# it was low-priority reference data sitting near the bottom of
|
||||
# one long page. It is now the Software tab's whole reason to
|
||||
# exist, and somebody who clicked that tab has already said what
|
||||
# they came for - making them click again to see it is a step
|
||||
# that asks a question already answered.
|
||||
'id': 'endpoints-applications',
|
||||
'title': 'Installed software',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/applications',
|
||||
'render': 'table',
|
||||
'columns': [
|
||||
{'key': 'displayname', 'label': 'Name'},
|
||||
{'key': 'displayversion', 'label': 'Version'},
|
||||
{'key': 'publisher', 'label': 'Publisher'},
|
||||
],
|
||||
'empty': 'No installed software reported.',
|
||||
'position': 30,
|
||||
# the installed-software inventory
|
||||
'group': 'software',
|
||||
},
|
||||
{
|
||||
# Installed-software (SBOM) inventory. Low-priority reference
|
||||
@@ -641,11 +716,18 @@ class EndpointsPlugin(BasePlugin):
|
||||
if not displayname:
|
||||
warnings.append('skipped application with no displayname')
|
||||
continue
|
||||
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
|
||||
# mode an over-long value raises Data too long and takes down the
|
||||
# WHOLE check-in transaction, so one machine with a malformed
|
||||
# Uninstall registry entry stops reporting entirely - telemetry,
|
||||
# events and all - until somebody uninstalls the offender. SQLite
|
||||
# does not enforce declared lengths, so no test here would ever see
|
||||
# it. displayname was already clamped; these two were not.
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid,
|
||||
displayname=displayname[:255],
|
||||
displayversion=(item.get('displayversion') or None),
|
||||
publisher=(item.get('publisher') or None),
|
||||
displayversion=_clamp(item.get('displayversion'), 128),
|
||||
publisher=_clamp(item.get('publisher'), 255),
|
||||
))
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
"""Endpoints plugin smoke + collector-contract tests.
|
||||
|
||||
Asserts the plugin loads cleanly, satisfies the framework contract, and that
|
||||
its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests
|
||||
as the plugin grows.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def test_endpoints_plugin_meta_is_valid():
|
||||
"""meta returns a PluginMeta with the expected name and prefix."""
|
||||
plugin = EndpointsPlugin()
|
||||
assert plugin.meta.name == 'endpoints'
|
||||
assert plugin.meta.api_prefix == '/api/endpoints'
|
||||
assert 'computers' in plugin.meta.dependencies
|
||||
|
||||
|
||||
def test_endpoints_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
plugin = EndpointsPlugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_endpoints_plugin_get_models_returns_models():
|
||||
"""get_models returns the two telemetry tables."""
|
||||
plugin = EndpointsPlugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 2
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
|
||||
|
||||
def test_endpoints_collector_schema_identity_is_hostname():
|
||||
"""ADR-006 schema declares hostname as the identity field."""
|
||||
plugin = EndpointsPlugin()
|
||||
schema = plugin.get_collector_schema()
|
||||
assert schema is not None
|
||||
assert schema['identityfield'] == 'hostname'
|
||||
assert 'hostname' in schema['fields']['required']
|
||||
|
||||
|
||||
def test_endpoints_curated_event_ids_include_core_security_ids():
|
||||
"""The curated catalog covers the agreed logon/stability IDs."""
|
||||
for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011,
|
||||
10016, 1001):
|
||||
assert eventid in CURATED_EVENT_IDS
|
||||
|
||||
|
||||
def test_curated_ids_match_the_event_catalog():
|
||||
"""Ingest filter and display catalog cannot drift apart."""
|
||||
assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG)
|
||||
|
||||
|
||||
def test_critical_event_ids_are_a_subset_of_the_catalog():
|
||||
"""Dashboard stability card only queries known critical IDs."""
|
||||
assert set(CRITICAL_EVENT_IDS)
|
||||
assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG))
|
||||
|
||||
|
||||
def test_endpoints_asset_panels_are_computer_scoped():
|
||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
assert len(panels) == 5
|
||||
for panel in panels:
|
||||
assert panel['assettypes'] == ['computer']
|
||||
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
|
||||
assert panel['render'] in ('keyvalue', 'list', 'table')
|
||||
|
||||
|
||||
def test_services_and_processes_are_split_into_two_filtered_cards():
|
||||
"""The single services card is now two cards filtered by row kind, both
|
||||
reading the same services snapshot so a future top-20 collector is just
|
||||
another filtered panel over the same feed."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = {p['id']: p for p in plugin.get_asset_panels()}
|
||||
services = panels['endpoints-tracked-services']
|
||||
processes = panels['endpoints-tracked-processes']
|
||||
assert services['title'] == 'Tracked Services'
|
||||
assert processes['title'] == 'Tracked Processes'
|
||||
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
|
||||
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
|
||||
# Both slice one endpoint feed.
|
||||
assert services['endpoint'] == processes['endpoint']
|
||||
assert services['endpoint'].endswith('/services')
|
||||
# Command wiring carries over to both cards.
|
||||
assert services['permission'] == 'endpoints.command'
|
||||
assert processes['permission'] == 'endpoints.command'
|
||||
|
||||
|
||||
def test_recent_events_card_is_last_and_starts_collapsed():
|
||||
"""Events is the lowest-priority card: forced to the bottom of the column
|
||||
and collapsed by default."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
events = next(p for p in panels if p['id'] == 'endpoints-events')
|
||||
assert events['collapsedDefault'] is True
|
||||
assert events['position'] == max(p['position'] for p in panels)
|
||||
|
||||
|
||||
def test_endpoints_dashboard_widgets_declared():
|
||||
"""Quiet + stability exception cards are declared and hide when empty."""
|
||||
plugin = EndpointsPlugin()
|
||||
widgets = plugin.get_dashboard_widgets()
|
||||
ids = {w['id'] for w in widgets}
|
||||
assert {'endpoints-quiet', 'endpoints-stability'} <= ids
|
||||
for widget in widgets:
|
||||
assert widget['render'] == 'exceptions'
|
||||
assert widget['empty'] == 'hide'
|
||||
assert widget['permission'] == 'endpoints.view'
|
||||
|
||||
|
||||
def test_endpoints_navigation_item_points_at_fleet_view():
|
||||
"""The fleet list view is exposed as a single /endpoints nav item."""
|
||||
plugin = EndpointsPlugin()
|
||||
items = plugin.get_navigation_items()
|
||||
routes = {item['route'] for item in items}
|
||||
assert '/endpoints' in routes
|
||||
|
||||
|
||||
def test_system_restart_is_allowlisted_and_policy_selectable():
|
||||
"""Restart is a real allowlisted action, so the policy builder (which
|
||||
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
|
||||
assert is_allowed_action('system_restart')
|
||||
assert 'system_restart' in COMMAND_ALLOWLIST
|
||||
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
|
||||
|
||||
|
||||
def test_system_restart_requires_the_elevated_restart_permission():
|
||||
"""Restart maps to endpoints.restart so the enqueue route can demand a
|
||||
heavier grant than the routine endpoints.command toggle. Routine actions
|
||||
carry no elevated requirement."""
|
||||
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
||||
assert elevated_permission_for('mtconnect_start') is None
|
||||
|
||||
|
||||
def test_endpoints_declares_the_restart_permission():
|
||||
"""The dedicated restart permission is declared for role-grid seeding."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.restart' in permissions
|
||||
assert 'endpoints.command' in permissions
|
||||
|
||||
|
||||
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
|
||||
needs no elevated grant beyond endpoints.command."""
|
||||
assert is_allowed_action('collect_now')
|
||||
assert 'collect_now' in COMMAND_ALLOWLIST
|
||||
assert not requires_target('collect_now')
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||
the enqueue route accepts them and carries their subject on the wire:
|
||||
run_script carries a script name, update_app carries an appversionid."""
|
||||
for action in ('run_script', 'update_app'):
|
||||
assert is_allowed_action(action)
|
||||
assert action in COMMAND_ALLOWLIST
|
||||
assert requires_target(action)
|
||||
assert COMMAND_ALLOWLIST['run_script']['targetkind'] == 'script'
|
||||
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
|
||||
|
||||
|
||||
def test_delivery_actions_require_the_elevated_deploy_permission():
|
||||
"""run_script and update_app both demand endpoints.deploy ON TOP OF
|
||||
endpoints.command, so pushing software is a strictly heavier grant than a
|
||||
routine service/process toggle."""
|
||||
assert elevated_permission_for('run_script') == 'endpoints.deploy'
|
||||
assert elevated_permission_for('update_app') == 'endpoints.deploy'
|
||||
|
||||
|
||||
def test_endpoints_declares_the_deploy_permission():
|
||||
"""The dedicated software-delivery permission is declared for role seeding."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.deploy' in permissions
|
||||
|
||||
|
||||
def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
||||
"""The ingest allow-set must list every key the handler consumes today, so
|
||||
those keys are never mislogged as 'unpersisted'. Anything the agent sends
|
||||
outside this set is logged for observability before columns exist."""
|
||||
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
||||
|
||||
persisted_or_relayed = {
|
||||
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
|
||||
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
|
||||
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||
'scheduledtasks', 'events',
|
||||
}
|
||||
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
|
||||
|
||||
|
||||
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
|
||||
"""Disk/bios/memory inventory is collected at the agent before the server
|
||||
persists it. Those keys must be OUTSIDE the known set so ingest logs them
|
||||
rather than dropping them silently."""
|
||||
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
||||
|
||||
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||
'applications'):
|
||||
assert key not in KNOWN_PAYLOAD_KEYS
|
||||
|
||||
|
||||
|
||||
def test_telemetrymeta_route_is_registered_for_freshness_gating():
|
||||
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
|
||||
lightweight freshness probe must be exposed on the plugin blueprint."""
|
||||
from flask import Flask
|
||||
|
||||
application = Flask(__name__)
|
||||
application.register_blueprint(EndpointsPlugin().get_blueprint())
|
||||
rules = {rule.rule for rule in application.url_map.iter_rules()}
|
||||
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
|
||||
|
||||
|
||||
def test_fleet_list_route_resolves_without_trailing_slash():
|
||||
"""The Fleet list route must match the slash-less URL the SPA calls.
|
||||
|
||||
The list is the only route on the bare blueprint prefix. If it demands a
|
||||
trailing slash, Flask 308-redirects /api/endpoints to /api/endpoints/, and
|
||||
behind the /shopdb reverse proxy that redirect drops the path prefix and
|
||||
the list call fails while the detail routes (which carry a path suffix)
|
||||
keep working. strict_slashes=False makes both forms resolve directly.
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
application = Flask(__name__)
|
||||
application.register_blueprint(
|
||||
EndpointsPlugin().get_blueprint(), url_prefix='/api/endpoints')
|
||||
adapter = application.url_map.bind('localhost')
|
||||
|
||||
endpoint, _ = adapter.match('/api/endpoints', method='GET')
|
||||
assert endpoint.endswith('list_endpoints')
|
||||
endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET')
|
||||
assert endpoint_slash.endswith('list_endpoints')
|
||||
|
||||
Reference in New Issue
Block a user