"""Endpoints plugin main class. Endpoint RMM telemetry reported by the ShopDB agent. Accepts collector input over HTTPS per ADR-006: the agent posts a per-host payload to POST /api/collector/endpoints, the plugin resolves the reported hostname to an existing computer asset, overwrites that asset's cached EndpointState, and appends any curated Windows events the report carried - all in one commit, so the cached snapshot and the events it summarizes never disagree. Identity is the hostname, resolved against the computers plugin. An unresolved hostname is reported as a ValueError (surfaced to the caller as a validation error), never silently dropped: an unknown host is a real gap between the fleet and ShopDB, the same discipline the backups collector uses for machine numbers. """ import json import logging import re from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db from .api import endpoints_bp from .eventcatalog import EVENT_CATALOG from .models import ( EndpointState, EndpointEvent, EndpointCommand, EndpointApplication, ) logger = logging.getLogger(__name__) # The fixed catalog of Windows Event IDs the agent forwards. Derived from # EVENT_CATALOG (eventcatalog.py) so the ingest filter and the display titles # can never drift. IDs outside this set are ignored on ingest rather than # stored, keeping endpointevents a readable security/stability history instead # of a raw event firehose. CURATED_EVENT_IDS = frozenset(EVENT_CATALOG) # Pre-compiled regex for fingerprint validation (64 lowercase hex chars). # Used in hot path (40,000+ validations/hour), so compile once at module load. _FP_REGEX = re.compile(r'\A[0-9a-f]{64}\Z') # Top-level payload keys the ingest path knows about today - either persisted on # EndpointState, appended as events, or relayed to the computers collector. # Anything the agent sends outside this set is not stored yet; it is logged (see # apply_collector_payload) so new agent telemetry - disks, bios, memory, etc. - # is observable in the logs before the server grows columns for it. Keep this in # lockstep with the keys consumed in apply_collector_payload and the computers # handoff subset. KNOWN_PAYLOAD_KEYS = frozenset({ 'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype', 'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress', 'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds', 'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces', 'scheduledtasks', 'applications', 'events', 'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain', 'pendingreboot', 'usercontext', 'diskhealth', 'rebootreasons', 'bootinfo', 'defender', 'dellupdates', }) def _parse_dt(value): """Best-effort parse of an ISO-8601 string to naive UTC, or None. astimezone(tz=None) converts to the SERVER's local zone, not to UTC, so an agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server and then re-served labelled 'Z'. The same row's server-written lastreportedat stayed true UTC, so one row carried two time bases four hours apart, and the dashboard's 48-hour stability window - which compares a real UTC cutoff against these values - dropped events four hours early. Invisible on a UTC server, which is why it survived: nothing in the Dockerfile, the compose files or the Windows deploy pins TZ, so whether the data is right depends on the host's clock settings. AGENT-CONTRACT.md mandates UTC on the wire. A NAIVE value is therefore taken as UTC already, rather than guessed at. """ if not value: return None if isinstance(value, datetime): if value.tzinfo is not None: return value.astimezone(timezone.utc).replace(tzinfo=None) return value text = str(value).strip().replace('Z', '+00:00') try: parsed = datetime.fromisoformat(text) except ValueError: return None if parsed.tzinfo is not None: parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) return parsed def _as_json(value): """Serialize a list/dict to JSON text, or None when nothing was sent.""" if value is None: return None 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.""" def __init__(self): self._manifest = self._load_manifest() def _load_manifest(self) -> Dict: manifest_path = Path(__file__).parent / 'manifest.json' if manifest_path.exists(): with open(manifest_path, 'r') as f: return json.load(f) return {} @property def meta(self) -> PluginMeta: return PluginMeta( name=self._manifest.get('name', 'endpoints'), version=self._manifest.get('version', '0.1.0'), description=self._manifest.get( 'description', 'Endpoint RMM telemetry'), author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', ['computers']), core_version=self._manifest.get('core_version', '>=0.20.0,<1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/endpoints'), ) def get_blueprint(self) -> Optional[Blueprint]: return endpoints_bp def get_models(self) -> List[Type]: return [EndpointState, EndpointEvent, EndpointCommand, EndpointApplication] def get_permissions(self) -> List: """RBAC for the human-facing read routes. View only. Ingest is not covered here: the collector path authorizes on a collector.ingest-scoped service token, not a user JWT. There is no write/delete permission because operators do not edit telemetry - the agent is the sole author, and history is pruned by policy, not by hand. """ return [ ('endpoints.view', 'View endpoint telemetry', 'endpoints'), # The FLEET-WIDE list and dashboards, separate from endpoints.view, # which covers reading ONE endpoint that the caller already reached # through its PC page. # # They were one permission, and that made a reasonable policy # impossible to express: a site that wants signed-out visitors to # see live status on a PC page had to hand them the whole fleet, # including every logged-in username on the floor, because the # panels and the list were gated on the same string. # # 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 # MTConnect toggle). Separate from endpoints.view because acting on a # shop-floor PC is a heavier grant than reading its telemetry. ('endpoints.command', 'Send allowlisted commands to endpoint agents', 'endpoints'), # Operator-submitted issue reports and their screenshots. Separate # from endpoints.view because the screenshot is whatever was on the # operator's screen when they hit report: it can carry part numbers, # customer names or other shopfloor content that reading telemetry # does not imply a right to see. Telemetry is machine-collected and # uniform; this is an uncontrolled human capture, so it is its own # grant rather than a facet of endpoints.view. ('endpoints.operatorreports', 'View operator-submitted reports and their screenshots', 'endpoints'), # Elevated grant for destructive endpoint actions (system_restart). # Required IN ADDITION to endpoints.command by the enqueue route, so a # remote reboot is a strictly heavier grant than a routine toggle. ('endpoints.restart', 'Restart endpoint computers (destructive)', 'endpoints'), # Elevated grants for the actions gated by SCOPE, never by # Configuration Policy. Each is required IN ADDITION to # endpoints.command by the enqueue route. # # These were one permission (endpoints.deploy) covering run_script, # update_app and scheduledtask_run. That bundle could not express # "may push an application update" without also granting arbitrary # SYSTEM code execution, so it is split. The three are NOT equal # risk and their descriptions must not pretend otherwise. ('endpoints.corptools', 'Start approved scheduled tasks on endpoints (limited to tasks ' 'already registered and allowlisted on the agent)', 'endpoints'), ('endpoints.updateapp', 'Push application installers to endpoints (runs a pinned ' 'installer as SYSTEM) and collect plaintext log files from ' 'endpoints by path (read-only, text files only)', 'endpoints'), # Blunt on purpose. This is not "system-level changes" - it is # arbitrary code execution as SYSTEM unless the script descriptor # pins a user context. Whoever grants it must see that here. ('endpoints.runscript', 'Run staged scripts on endpoints (ARBITRARY CODE, executes as ' 'SYSTEM unless the script pins a user context; grant only to ' 'fully trusted operators)', '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. # It now also governs SUPPLYING the binary, not just wrapping it. # Before the agent store, the exe could only be staged by someone # with filesystem access to instance/siteagent/; the upload routes # put that behind this same grant, so a holder chooses the file # every future package installs and runs as SYSTEM. Whoever grants # this has to be able to see that from the description. ('endpoints.agentpackage', 'Upload the agent binary the fleet installs, and generate or ' 'download an agent or adoption-tool package (supplies an ' 'executable that runs as SYSTEM; the package contains a live ' 'collector key)', 'endpoints'), # Pushing an agent REPLACEMENT to running endpoints is a distinct, # heavier job than either packaging (endpoints.agentpackage) or # pushing an ordinary application installer (endpoints.deploy). # Kept separate on purpose: the agent runs as SYSTEM and is the # command channel itself, so replacing it is self-modifying the # control plane. Folding it into endpoints.deploy would let anyone # who can push an app silently swap the agent; folding it into # endpoints.agentpackage would let a packager who only mints ZIPs # also force a live fleet-wide swap. A future Operator role can # hold upload without this, or this without upload. ('endpoints.agentupdate', 'Push agent/tray binary updates to running endpoints ' '(replaces the SYSTEM agent)', '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 # UI (see get_settings_cards). Admins hold it via the catalog. ('collector.ingest.endpoints', '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. `envkeyfallback` gates the bootstrap .env collector key: while true the old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the only path before a UI key exists). The regenerate flow flips it false so the .env key stops working and the managed token is the sole credential, making revocation an instant kill switch. `agentkeyprovisioned` records that a UI-managed key has been minted so the settings page can render the rotate/revoke state instead of the first-run generate state. """ return [ { 'key': 'plugin.endpoints.envkeyfallback', 'value': 'true', 'valuetype': 'boolean', 'category': 'plugin.endpoints', 'description': 'Accept the bootstrap .env collector key for ' 'endpoints. Turned off once a UI-managed agent ' 'key is provisioned so revocation is a real kill ' 'switch.', }, { 'key': 'plugin.endpoints.agentkeyprovisioned', 'value': 'false', 'valuetype': 'boolean', 'category': 'plugin.endpoints', 'description': 'True once an operator has minted a UI-managed ' 'endpoint agent key from the settings page.', }, ] def get_settings_cards(self) -> List[Dict]: """The endpoints management card on the global settings page. A settings card rather than a docs page because it is the operator's control surface: manage the agent key (the kill switch), build the site agent, and configure what it reports. Admin only - key management mints a collector service credential. """ return [ { 'group': 'Endpoints', 'to': '/settings/endpoints/agentkey', 'icon': 'key', 'title': 'Endpoints', 'description': 'Manage the endpoint agent key, build the site ' 'agent, and configure what it reports.', 'position': 27, }, # Its own card rather than a section of the agent key page: the # audience differs. Key management is admin-only, while the script # library is reachable by any endpoints.runscript holder, and # burying it under an admin page would hide it from exactly the # operators the grant exists for. { 'group': 'Endpoints', 'to': '/settings/endpoints/scripts', 'icon': 'terminal', 'title': 'System Scripts', 'description': 'PowerShell the agent can run on an endpoint. ' 'Runs as SYSTEM; used to trigger scheduled ' 'tasks already registered on the endpoint.', 'position': 28, }, { 'group': 'Endpoints', 'to': '/settings/endpoints/server-cert', 'icon': 'shield-check', 'title': 'Server Certificate', 'description': 'Manage the TLS certificate nginx uses for HTTPS. ' 'Agents validate this certificate via pinning or ' 'Windows trusted roots.', 'position': 29, }, ] def get_navigation_items(self) -> List[Dict]: """Sidebar entry for the fleet list view. Filed under Assets, immediately after PCs (position 15), because the fleet view is a telemetry lens over the same computers rather than a different kind of thing. It shipped declaring a section of its own, which rendered a one-item 'Fleet' group at the bottom of the sidebar, far from the PCs it describes. """ return [ { 'name': 'Fleet', 'icon': 'activity', 'route': '/endpoints', 'position': 16, 'permission': 'endpoints.fleet', 'section': 'assets', }, ] def init_app(self, app: Flask, db_instance) -> None: logger.info('Endpoints plugin initialized (v%s)', self.meta.version) # ---- ADR-010 UI hooks ----------------------------------------------- def get_asset_panels(self) -> List[Dict]: """Per-PC detail panels, computer assets only. Panels feeding the generic renderer: the current status snapshot (keyvalue), monitored services and processes split into two list cards that share one services snapshot (filtered by kind), the most recent curated events (list), and the network interfaces (table). Each is a single-asset indexed read, so the cost is the same profile as the warranty/backups panels. Panels hide themselves when their endpoint returns nothing. """ return [ { 'id': 'endpoints-status', 'title': 'Endpoint status', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/status', 'render': 'keyvalue', 'empty': 'No agent report yet.', 'position': 10, }, { 'id': 'endpoints-tracked-services', 'title': 'Tracked Services', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/services', 'render': 'list', 'collapsible': True, 'filter': {'key': 'kind', 'equals': 'service'}, # Facet on the STATE, not the status colour. statecolor is only # ever green, red or grey here, so chips derived from it would # read "OK" and "Critical" - not how anybody talks about a # service. statelabel carries the real vocabulary: Running, # Stopped, Paused, and the Windows transitional states. Any # value not listed still gets a chip, appended after these. 'facets': { 'key': 'statelabel', 'order': ['Running', 'Stopped', 'Paused', 'Start Pending', 'Stop Pending', 'Continue Pending', 'Pause Pending', 'Unknown'], }, 'status': { 'key': 'statecolor', 'levels': { 'green': 'ok', 'yellow': 'warning', 'red': 'critical', }, }, 'map': { 'title': 'name', 'badge': {'label': 'statelabel', 'color': 'statecolor'}, 'action': 'action', }, 'commandendpoint': '/api/endpoints/{assetid}/commands', '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. # Lives in serviceprocess rather than monitors: that tab is the # box's own health, this is what runs on it. 'group': 'serviceprocess', }, { 'id': 'endpoints-tracked-processes', 'title': 'Tracked Processes', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/services', 'render': 'list', 'collapsible': True, 'filter': {'key': 'kind', 'equals': 'process'}, # Facet on the STATE, not the status colour. statecolor is only # ever green, red or grey here, so chips derived from it would # read "OK" and "Critical" - not how anybody talks about a # service. statelabel carries the real vocabulary: Running, # Stopped, Paused, and the Windows transitional states. Any # value not listed still gets a chip, appended after these. 'facets': { 'key': 'statelabel', 'order': ['Running', 'Stopped', 'Paused', 'Start Pending', 'Stop Pending', 'Continue Pending', 'Pause Pending', 'Unknown'], }, 'status': { 'key': 'statecolor', 'levels': { 'green': 'ok', 'yellow': 'warning', 'red': 'critical', }, }, 'map': { 'title': 'name', 'badge': {'label': 'statelabel', 'color': 'statecolor'}, 'action': 'action', }, 'commandendpoint': '/api/endpoints/{assetid}/commands', 'permission': 'endpoints.command', 'empty': 'No monitored processes reported.', 'position': 13, # observed process state, not something you act on here 'group': 'serviceprocess', }, { # One feed instead of two cards. Events and commands answer the # same question - what happened to this box - and reading them # separately meant interleaving two lists by eye. Sits high on # the page because it is the first thing looked at when # something has gone wrong, and NOT collapsed by default for # the same reason. 'id': 'endpoints-activity', 'title': 'Activity', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/activity', 'render': 'list', 'collapsible': True, 'status': { 'key': 'severity', 'levels': { 'info': 'ok', 'warning': 'warning', 'critical': 'critical', }, }, 'map': { 'title': 'title', 'badge': { 'label': 'severity', # The renderer reads the colour from a row field; with # no colour declared every badge renders grey, which is # what the feed spent its first version doing. 'color': 'severitycolor', 'labelmap': { 'critical': 'Critical', 'warning': 'Warning', 'info': 'Info', }, }, 'meta': [ {'key': 'detail'}, # datetime, not date: 'date' renders via # toLocaleDateString and drops the clock time entirely, # which made every event on a given day look # simultaneous. Ordering a boot against the logon that # followed it is the whole point of this feed. {'key': 'eventtime', 'label': 'At', 'format': 'datetime'}, ], # Expands in place on click. The message body is several # hundred words of Windows boilerplate around the one or # two lines that matter, so it stays collapsed until asked # for rather than pushing every other row off the card. 'body': 'body', }, 'empty': 'Nothing recorded for this endpoint yet.', 'position': 20, # the event and command feed IS the activity tab 'group': 'activity', }, { 'id': 'endpoints-interfaces', 'title': 'Network interfaces', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/interfaces', 'render': 'table', 'collapsible': True, 'columns': [ {'key': 'alias', 'label': 'Adapter'}, {'key': 'ipv4', 'label': 'IPv4'}, {'key': 'mac', 'label': 'MAC'}, {'key': 'gateway', 'label': 'Gateway'}, {'key': 'subnet', 'label': 'Subnet'}, {'key': 'dhcp', 'label': 'Assignment'}, {'key': 'role', 'label': 'Role'}, {'key': 'link', 'label': 'Link'}, ], 'empty': 'No network interfaces reported.', 'position': 16, # adapter state is watched, not something you act on. # Declared in monitors but RENDERED at the top of the Details # tab (PCDetail.vue lifts it by id and excludes it here): which # adapter is up and what address it holds is asked at the same # moment as "what is this box". 'group': 'monitors', }, { # What this box has been on the network, carved out of the # hand-written Network card that used to sit on Details and # show only current addresses with a Primary badge. # # The history is ACCIDENTAL: _record_interfaces matches on MAC # then IP and never deletes, so old addresses accumulate. That # turned out to be worth reading rather than worth cleaning # away, so it is presented as what it already is. 'id': 'endpoints-iphistory', 'title': 'IP History', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/iphistory', 'render': 'table', 'collapsible': True, 'columns': [ {'key': 'ipaddress', 'label': 'IP address', 'mono': True}, {'key': 'macaddress', 'label': 'MAC', 'mono': True}, {'key': 'status', 'label': 'Status'}, {'key': 'firstseen', 'label': 'First seen', 'format': 'date'}, # NOT "last seen": modifieddate only moves when a field on # the row changes, so an address still held and reported # unchanged keeps an old stamp here. {'key': 'lastchanged', 'label': 'Last changed', 'format': 'date'}, ], 'empty': 'No addresses on record for this endpoint.', 'position': 16.5, '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', }, { 'id': 'endpoints-hardware', 'title': 'Hardware', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/hardware', 'render': 'keyvalue', 'collapsible': True, 'empty': 'No hardware inventory reported.', 'position': 14, 'group': 'monitors', }, { 'id': 'endpoints-osinfo', 'title': 'Operating system', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/osinfo', 'render': 'keyvalue', 'collapsible': True, 'empty': 'No OS inventory reported.', 'position': 15, 'group': 'monitors', }, { # Session-scoped, so it sits with the other things watched # rather than acted on. The user context card still carries a # one-line summary; this is the same data with room to show a # remote path and a per-drive badge, and both are built from # _mapped_drive_rows so they cannot disagree. # # Room reserved here for the future one-click reconnect: the # list render already supports a row action, so adding it is a # map/commandendpoint change rather than a new card. 'id': 'endpoints-mappeddrives', 'title': 'Mapped drives', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/mappeddrives', 'render': 'list', 'collapsible': True, 'status': { 'key': 'statecolor', 'levels': { 'green': 'ok', 'yellow': 'warning', 'red': 'critical', }, }, 'map': { 'title': 'drive', 'meta': [{'key': 'remote', 'mono': True}], 'badge': {'label': 'statuslabel', 'color': 'statecolor'}, }, 'empty': 'No mapped drives reported.', 'position': 21, 'group': 'monitors', }, { # Volumes and physical drive health in one card, as two # sections. VISUAL GROUPING ONLY: the two tables share no key # (storage is per volume, health is per physical device) and # nothing is reconciled across them. They are one card because # the operator reading a full disk and the one reading a dying # disk are usually the same person in the same minute. 'id': 'endpoints-storage', 'title': 'Storage', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/storage', 'render': 'tabs', 'collapsible': True, 'empty': 'No disks reported.', 'position': 17, 'group': 'monitors', }, { # The listening set is the box's exposed network surface. The # full connection table is deliberately not here: it is an # on-demand command, because a per-cycle capture of every # connection is a lot of rows nobody reads. 'id': 'endpoints-listeners', 'title': 'Listening ports', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/listeners', 'render': 'table', 'collapsible': True, 'columns': [ {'key': 'localport', 'label': 'Port'}, {'key': 'protocol', 'label': 'Protocol'}, {'key': 'localaddress', 'label': 'Address'}, {'key': 'processname', 'label': 'Process'}, {'key': 'processid', 'label': 'PID'}, ], 'empty': 'No listening ports reported.', 'position': 19, 'group': 'monitors', }, { # Session-scoped data collected by a probe in the interactive # user's session, so it is absent whenever nobody is signed in. # Last on the page: it is the least reliably present card, and # an empty one at the bottom reads as "not reported" rather # than pushing the populated cards down. 'id': 'endpoints-usercontext', 'title': 'User context', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/asset/{assetid}/usercontext', 'render': 'keyvalue', 'collapsible': True, 'empty': 'No user session data reported.', 'position': 40, 'group': 'monitors', }, { # Operator-submitted issue reports: a free-text note and an # optional screenshot the operator captured from the tray. Has # a tab of its own rather than sitting in System Monitors: that # tab is machine-collected health, this is a human telling you # something, and the thumbnail grid is far bulkier than the # fact cards it used to sit between. # A custom render carries the thumbnail grid and the expanding # lightbox with left/right cycling the generic modes cannot. 'id': 'endpoints-operator-reports', 'title': 'Operator Reports', 'assettypes': ['computer'], 'endpoint': '/api/endpoints/panel/{assetid}/operatorreports', 'render': 'operatorreports', 'imagebase': '/api/endpoints/operatorreports', 'collapsible': True, 'empty': 'No operator reports for this endpoint.', 'position': 18, 'group': 'reports', # Not endpoints.view: the screenshots can carry shopfloor # content that reading telemetry does not imply a right to see. # The panel route and the image route re-check this server-side; # withholding the declaration only keeps the tab from appearing. 'permission': 'endpoints.operatorreports', }, ] def get_dashboard_widgets(self) -> List[Dict]: """Fleet-wide exception cards. Run once on dashboard load, not per PC. Both cards hide when empty and cap their rows, so a healthy fleet adds nothing to the dashboard and an unhealthy one adds a bounded list. """ return [ { 'id': 'endpoints-quiet', 'viewall': '/pcs', 'title': 'Endpoints gone quiet', 'shortname': 'Agent quiet', 'endpoint': '/api/endpoints/dashboard/quiet', 'render': 'exceptions', 'severity': 'warning', 'permission': 'endpoints.fleet', 'empty': 'hide', 'position': 22, 'map': { 'title': 'hostname', 'detail': '', 'meta': [{'key': 'quietdays', 'label': 'silent', 'suffix': ' days'}], 'link': '/pcs/{computerid}', }, }, { 'id': 'endpoints-stability', 'viewall': '/pcs', 'title': 'Endpoints with crashes or shutdowns', 'shortname': 'Crashes', 'endpoint': '/api/endpoints/dashboard/stability', 'render': 'exceptions', 'severity': 'critical', 'permission': 'endpoints.fleet', 'empty': 'hide', 'position': 16, 'map': { 'title': 'hostname', 'detail': '', 'meta': [{'key': 'eventcount', 'suffix': ' events (48h)'}], 'link': '/pcs/{computerid}', }, }, ] # ---- ADR-006 collector contract ------------------------------------- def get_collector_schema(self) -> Optional[Dict]: return { 'identityfield': 'hostname', 'fields': { 'type': 'object', 'required': ['hostname'], 'properties': { 'hostname': { 'type': 'string', 'description': 'Endpoint hostname. Resolved against ' 'the computers plugin (computers.hostname).', }, 'adopt': { 'type': 'boolean', 'description': 'First-contact only. When true and the ' 'hostname does not resolve, adoption is ' 'delegated to the computers collector ' '(see DESIGN-DECISIONS-agent.md 1a). This ' 'plugin never creates assets itself.', }, 'loggedinuser': {'type': 'string'}, 'agentversion': {'type': 'string'}, 'trayversion': {'type': 'string'}, 'lastboottime': {'type': 'string', 'format': 'date-time'}, 'uptimeseconds': {'type': 'integer'}, 'idleseconds': {'type': 'integer'}, 'cpupercent': {'type': 'number'}, 'memorypercent': {'type': 'number'}, 'services': { 'type': 'array', 'description': 'Monitored services/processes and their ' 'run state (UDC, eDnc, Intune, ...).', }, 'ports': { 'type': 'array', 'description': 'Open listening ports.', }, 'interfaces': { 'type': 'array', 'description': 'Network interface information.', }, 'scheduledtasks': { 'type': 'array', 'description': 'Scheduled task inventory + last run.', }, 'disks': { 'type': 'array', 'description': 'Fixed local disks: {drive, label, ' 'filesystem, sizebytes, freebytes, ' 'usedbytes, freepercent}. Newest ' 'snapshot, replaced on every report.', }, 'bios': { 'type': 'object', 'description': 'Firmware identity: vendor, version, ' 'release date.', }, 'memory': { 'type': 'object', 'description': 'Physical memory totals in bytes.', }, 'processor': { 'type': 'object', 'description': 'CPU identity, core and logical ' 'processor counts, max clock.', }, 'osinfo': { 'type': 'object', 'description': 'OS build, version, and install date.', }, 'domain': { 'type': 'string', 'description': 'Windows domain or workgroup membership.', }, 'pendingreboot': { 'type': 'boolean', 'description': 'True when the endpoint is holding a ' 'reboot. Absent means never reported, ' 'which is not the same as false.', }, 'diskhealth': { 'type': 'array', 'description': 'Per-drive health from two sources: ' 'physicaldisk (the only one reporting ' 'NVMe) and diskdrive (ATA/SATA and ' 'fallback). Each entry carries a ' 'precomputed predictfailure; null there ' 'means the drive did not answer.', }, 'rebootreasons': { 'type': 'array', 'description': 'Which signal(s) drove pendingreboot: ' 'componentbasedservicing, windowsupdate, ' 'pendingfilerename. Empty array means ' 'checked and nothing pending; omit when ' 'the check failed.', }, 'bootinfo': { 'type': 'object', 'description': 'hiberbootenabled (fast startup is ' 'configured) and lastboottype (what the ' 'last boot actually did). Uptime is not ' 'trustworthy when fast startup is in ' 'play.', }, 'defender': { 'type': 'object', 'description': 'Defender service/realtime state and ' 'signature and scan ages in days. ' 'Persisted but not surfaced.', }, 'usercontext': { 'type': 'object', 'description': 'Session-scoped data collected by a probe ' 'running as the signed-in user: mapped ' 'drives (netuse), user-owned listeners, ' 'and idle seconds. Absent when nobody is ' 'signed in.', }, 'applications': { 'type': 'array', 'description': 'Installed-software SBOM from both ' 'Windows Uninstall registry hives. Each ' 'item is {displayname, displayversion, ' 'publisher}, de-duped on name+version. ' 'Stored as a per-asset snapshot, ' 'replaced on every report.', 'items': { 'type': 'object', 'required': ['displayname'], 'properties': { 'displayname': {'type': 'string'}, 'displayversion': {'type': 'string'}, 'publisher': {'type': 'string'}, }, }, }, 'events': { 'type': 'array', 'description': 'Curated Windows Event Log records. Only ' 'IDs in the server catalog are stored.', 'items': { 'type': 'object', 'required': ['eventid'], 'properties': { 'eventid': {'type': 'integer'}, 'eventrecordid': { 'type': 'integer', 'description': 'Windows event record id; ' 'unique per channel per ' 'machine, used to dedup.', }, 'eventchannel': {'type': 'string'}, 'eventsource': {'type': 'string'}, 'eventtime': {'type': 'string', 'format': 'date-time'}, 'eventmessage': {'type': 'string'}, 'fields': {'type': 'object'}, }, }, }, }, }, } # PC-identifying and hardware fields forwarded to the computers collector, # which owns the Asset/Computer row and renders the Hardware and Network # cards. The endpoints plugin only caches telemetry, so these must be # relayed or vendor/model/OS/IP never land on the PC (see # DESIGN-DECISIONS-agent.md 1a). _COMPUTER_HANDOFF_FIELDS = ( 'loggedinuser', 'serialnumber', 'pctype', 'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime', ) def _forward_to_computers(self, hostname: str, payload: Dict): """Return the computers plugin and the PC subset to hand off, or None. Both adoption and steady-state reports relay the same hardware subset, so the Hardware and Network cards fill in on every report, not just at first contact. """ from flask import current_app manager = current_app.extensions.get('plugin_manager') computers = manager.get_plugin('computers') if manager else None if computers is None: return None, None subset = {'hostname': hostname} for field in self._COMPUTER_HANDOFF_FIELDS: if payload.get(field): subset[field] = payload.get(field) return computers, subset def _adopt_computer(self, hostname: str, payload: Dict, warnings: List[str]) -> None: """Delegate first-contact adoption to the computers collector. This plugin never creates Asset or Computer rows itself (see DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole authority for that, so on an unknown hostname we forward the PC-identifying subset of the payload to its collector. It runs inside the same transaction, so a later failure rolls the adoption back. """ computers, adoptpayload = self._forward_to_computers(hostname, payload) if computers is None: raise ValueError( 'cannot adopt {!r}: computers plugin is not available' .format(hostname)) computers.apply_collector_payload(adoptpayload) db.session.flush() warnings.append('adopted new computer for hostname: {!r}'.format(hostname)) @staticmethod def _is_valid_fingerprint(fp: str) -> bool: """Validate SHA256 fingerprint format: exactly 64 lowercase hex chars.""" if not fp or not isinstance(fp, str): return False return _FP_REGEX.fullmatch(fp) is not None def _replace_applications(self, assetid: int, applications, warnings: List[str]) -> int: """Update an asset's SBOM snapshot using delta comparison. The agent reports the full installed-software list each cycle along with a fingerprint hash (SHA256 of displayname|displayversion|publisher) for each entry. Rather than delete-all-insert-all, compare fingerprints to detect actual changes: - Add rows for new fingerprints - Remove rows for fingerprints no longer reported - Leave unchanged rows untouched This reduces database writes from ~100 ops/endpoint/check-in to ~2 ops (99% of check-ins have no application changes). Malformed items (no displayname or fingerprint) are skipped, not fatal. Legacy fallback: If agent does not send fingerprints, fall back to delete-all-insert-all for backward compatibility during rollout. Safety features: - Row-level locking prevents concurrent update races - Fingerprint format validation (64 hex chars) - Delete count verification detects silent failures - Memory limits prevent pathological cases (>5000 apps) - Metrics logged for monitoring """ from flask import current_app # Memory safety: prevent loading massive inventories MAX_APPS_PER_ENDPOINT = 5000 if not applications: # Empty list means no apps installed - delete all existing deleted = EndpointApplication.query.filter_by(assetid=assetid).delete( synchronize_session=False) current_app.logger.info( f'[collector] asset_id={assetid} apps_removed={deleted} ' f'apps_added=0 (empty list)') return 0 # Check if agent sent fingerprints - legacy agents won't have them yet has_fingerprints = any(item.get('fingerprint') for item in applications if isinstance(item, dict)) if not has_fingerprints: # LEGACY FALLBACK: No fingerprints, use delete-all-insert-all # SAFETY: Clear any existing NULL-fingerprint apps first to prevent # UNIQUE constraint violations (MySQL allows multiple NULL values) deleted = EndpointApplication.query.filter_by(assetid=assetid).delete( synchronize_session=False) current_app.logger.info( f'[collector] asset_id={assetid} legacy_delete={deleted} apps') stored = 0 for item in applications: if not isinstance(item, dict): warnings.append('skipped non-object application entry') continue displayname = (item.get('displayname') or '').strip() if not displayname: warnings.append('skipped application with no displayname') continue db.session.add(EndpointApplication( assetid=assetid, displayname=displayname[:255], displayversion=_clamp(item.get('displayversion'), 128), publisher=_clamp(item.get('publisher'), 255), app_fingerprint=None, # Agent didn't send it )) stored += 1 # Flush to detect constraint violations early db.session.flush() current_app.logger.info( f'[collector] asset_id={assetid} apps_stored={stored} (legacy mode)' ) return stored # DELTA LOGIC: Build incoming fingerprint map and validate entries incoming = {} for item in applications: if not isinstance(item, dict): warnings.append('skipped non-object application entry') continue displayname = (item.get('displayname') or '').strip() fingerprint = (item.get('fingerprint') or '').strip() if not displayname: warnings.append('skipped application with no displayname') continue if not fingerprint: warnings.append('skipped application with no fingerprint') continue # SAFETY: Validate fingerprint format (64 lowercase hex chars) if not self._is_valid_fingerprint(fingerprint): warnings.append( f'skipped application with invalid fingerprint: {displayname} ' f'(fingerprint: {fingerprint[:32]}...)' ) continue # Truncate strings for MySQL strict mode compatibility incoming[fingerprint] = { 'displayname': displayname[:255], 'displayversion': _clamp(item.get('displayversion'), 128), 'publisher': _clamp(item.get('publisher'), 255), } incoming_fps = set(incoming.keys()) # SAFETY: Memory limit check - prevent loading massive inventories # Query exactly MAX rows, then check if more exist (off-by-one fix) # Concurrency protection: UNIQUE constraint on (assetid, app_fingerprint) # handles race conditions without explicit row locks current_apps = (EndpointApplication.query .filter_by(assetid=assetid) .limit(MAX_APPS_PER_ENDPOINT) .all()) if len(current_apps) >= MAX_APPS_PER_ENDPOINT: # This endpoint has too many apps - fall back to legacy mode warnings.append( f'asset has {len(current_apps)} apps (exceeds {MAX_APPS_PER_ENDPOINT}), ' f'falling back to legacy mode' ) # Recursive call with has_fingerprints=False to trigger legacy path # (This is safe because we're already in a transaction) EndpointApplication.query.filter_by(assetid=assetid).delete( synchronize_session=False) stored = 0 for fp, app_data in incoming.items(): db.session.add(EndpointApplication( assetid=assetid, displayname=app_data['displayname'], displayversion=app_data['displayversion'], publisher=app_data['publisher'], app_fingerprint=fp, )) stored += 1 db.session.flush() current_app.logger.warning( f'[collector] asset_id={assetid} apps_stored={stored} ' f'(legacy mode - memory limit exceeded, had {len(current_apps)} apps)' ) return stored current_fps = {app.app_fingerprint for app in current_apps if app.app_fingerprint} # Skip NULL fingerprints from migration # TRANSITION: If we have apps without fingerprints, this is the first report # after agent upgrade. Delete all NULL-fingerprint apps to prevent duplicates. # SAFETY: Validate incoming apps BEFORE deleting legacy apps to prevent # leaving asset with zero apps if transaction aborts. null_fp_apps = [app for app in current_apps if app.app_fingerprint is None] if null_fp_apps: if not incoming_fps: current_app.logger.error( f'[collector] asset_id={assetid} received empty fingerprint set ' f'during transition, keeping {len(null_fp_apps)} legacy apps') warnings.append( f'agent sent empty fingerprint set during NULL transition, ' f'kept {len(null_fp_apps)} legacy apps') return len(null_fp_apps) for app in null_fp_apps: db.session.delete(app) current_app.logger.info( f'[collector] asset_id={assetid} removed {len(null_fp_apps)} ' f'legacy apps (transition to delta mode)') # Calculate delta to_add = incoming_fps - current_fps # New apps to_remove = current_fps - incoming_fps # Uninstalled apps unchanged = current_fps & incoming_fps # Unchanged (don't touch) # Remove uninstalled apps deleted_count = 0 if to_remove: deleted_count = EndpointApplication.query.filter( EndpointApplication.assetid == assetid, EndpointApplication.app_fingerprint.in_(list(to_remove)) ).delete(synchronize_session=False) # SAFETY: Verify delete count matches expected. Log as WARNING # instead of raising error - concurrent updates are rare but valid, # and aborting the entire transaction loses state/events/hardware data. if deleted_count != len(to_remove): current_app.logger.warning( f'[collector] asset_id={assetid} delete_count_mismatch: ' f'expected={len(to_remove)} actual={deleted_count} ' f'(possible concurrent update, proceeding with caution)') warnings.append( f'delete count mismatch: expected {len(to_remove)}, ' f'got {deleted_count} (concurrent update?)') # Add new apps for fp in to_add: app_data = incoming[fp] db.session.add(EndpointApplication( assetid=assetid, displayname=app_data['displayname'], displayversion=app_data['displayversion'], publisher=app_data['publisher'], app_fingerprint=fp, )) # SAFETY: Flush to detect constraint violations before continuing # Only flush if we made changes (skip when unchanged apps = 99% of cases) if to_add or to_remove: db.session.flush() current_app.logger.info( f'[collector] asset_id={assetid} apps_added={len(to_add)} ' f'apps_removed={len(to_remove)} unchanged={len(unchanged)} (delta mode)' ) return len(to_add) def apply_collector_payload(self, payload: Dict) -> Dict: """Idempotently upsert one endpoint's state and append its events. The cached EndpointState and any EndpointEvent rows are written in the same transaction (the dispatcher owns the commit), so a reader never sees a snapshot without the events that produced it. """ from plugins.computers.models import Computer warnings: List[str] = [] hostname = (payload.get('hostname') or '').strip() if not hostname: raise ValueError('hostname is required') computer = (Computer.query .filter(db.func.lower(Computer.hostname) == hostname.lower()) .first()) if computer is None: # First-contact adoption is delegated to the computers collector, # which is the only authority allowed to create Asset + Computer # rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents # assets itself. Adoption runs in the dispatcher's transaction, so a # later ValueError rolls the whole report back. if payload.get('adopt'): self._adopt_computer(hostname, payload, warnings) computer = (Computer.query .filter(db.func.lower(Computer.hostname) == hostname.lower()) .first()) if computer is None: raise ValueError( 'could not resolve hostname to a computer: {!r}'.format(hostname)) else: # Already adopted: relay the hardware/identity subset on every # report so the computers plugin keeps vendor/model/OS/IP current. # Without this, Hardware and Network stay blank after adoption # because this plugin only caches telemetry. computers, subset = self._forward_to_computers(hostname, payload) if computers is not None and len(subset) > 1: computers.apply_collector_payload(subset) db.session.flush() assetid = computer.assetid # Authorization: if authenticated via client certificate, verify the # certificate's asset matches the payload's hostname asset. Without this # check an agent with a valid cert for asset A can submit telemetry for # asset B by changing the hostname field. from flask import g if hasattr(g, 'cert_authenticated_asset_id'): if g.cert_authenticated_asset_id != assetid: self.logger.warning( '[collector] AUTHORIZATION DENIED: Certificate authenticates ' 'asset %d but payload hostname %s resolves to asset %d', g.cert_authenticated_asset_id, hostname, assetid) raise PermissionError( f'Certificate authenticates asset {g.cert_authenticated_asset_id} ' f'but payload reports for asset {assetid}') state = EndpointState.query.filter_by(assetid=assetid).first() action = 'updated' if state is None: state = EndpointState(assetid=assetid) db.session.add(state) action = 'created' state.hostname = hostname if 'loggedinuser' in payload: state.loggedinuser = payload.get('loggedinuser') if 'agentversion' in payload: state.agentversion = payload.get('agentversion') if 'trayversion' in payload: state.trayversion = payload.get('trayversion') if 'lastboottime' in payload: state.lastboottime = _parse_dt(payload.get('lastboottime')) if 'uptimeseconds' in payload: state.uptimeseconds = payload.get('uptimeseconds') if 'idleseconds' in payload: state.idleseconds = payload.get('idleseconds') if 'cpupercent' in payload: state.cpupercent = payload.get('cpupercent') if 'memorypercent' in payload: state.memorypercent = payload.get('memorypercent') if 'services' in payload: state.servicesjson = _as_json(payload.get('services')) if 'ports' in payload: state.portsjson = _as_json(payload.get('ports')) if 'interfaces' in payload: state.interfacesjson = _as_json(payload.get('interfaces')) if 'scheduledtasks' in payload: state.scheduledtasksjson = _as_json(payload.get('scheduledtasks')) # Hardware/OS inventory. Same presence guard as above: a report that # omits a key must not wipe the last-known value. These change rarely # (a RAM upgrade, a BIOS flash), so a transient collection failure on # one cycle should leave the previous snapshot standing rather than # blank the asset record. if 'disks' in payload: state.disksjson = _as_json(payload.get('disks')) if 'bios' in payload: state.biosjson = _as_json(payload.get('bios')) if 'memory' in payload: state.memoryjson = _as_json(payload.get('memory')) if 'processor' in payload: state.processorjson = _as_json(payload.get('processor')) if 'osinfo' in payload: state.osinfojson = _as_json(payload.get('osinfo')) if 'domain' in payload: # Clamped like every other String column here: an over-long value # aborts the whole collector transaction under STRICT_TRANS_TABLES, # so one bad agent report would drop a machine's entire telemetry. state.domain = _clamp(payload.get('domain'), 255) if 'pendingreboot' in payload: state.pendingreboot = payload.get('pendingreboot') if 'diskhealth' in payload: state.diskhealthjson = _as_json(payload.get('diskhealth')) if 'rebootreasons' in payload: state.rebootreasonsjson = _as_json(payload.get('rebootreasons')) if 'bootinfo' in payload: state.bootinfojson = _as_json(payload.get('bootinfo')) if 'defender' in payload: state.defenderjson = _as_json(payload.get('defender')) # Presence-guarded like the probe output below: the agent omits the key # entirely when the collector utility is not installed, and overwriting # to null there would discard a still-valid scan result from before the # utility was removed. if 'dellupdates' in payload: state.dellupdatesjson = _as_json(payload.get('dellupdates')) # Session-scoped probe output. The agent repeats its last result between # probe runs and omits the key entirely when nobody is signed in, so the # presence guard preserves the last known mapped drives across an # unattended period instead of flapping the field to null. if 'usercontext' in payload: state.usercontextjson = _as_json(payload.get('usercontext')) # Installed-software SBOM: stored as a per-asset SNAPSHOT in its own # table (endpointapplications), not a JSON blob on the state, so it can # later join the applications catalog ("Managed Applications"). Only # replace when the key is present: a report that omits applications must # not wipe the last-known inventory. The delete + re-insert happens in # this same transaction (the dispatcher owns the commit), so a reader # never sees a half-written list. displayname is required; malformed # rows are skipped rather than failing the whole report. if 'applications' in payload: self._replace_applications(assetid, payload.get('applications'), warnings) state.lastreportedat = datetime.utcnow() # Warn-only reconciler: on this check-in, compare the monitors site # policy expects to be running against what the snapshot reports, and # cache the drift on the state in THIS transaction (same ledger commit as # servicesjson). No start/stop is issued - drift is surfaced for an # operator to act on. Reads the freshly reported services when present, # else the last-known snapshot, so a report that omits services still # reconciles against what we last saw. Fail-open: a reconciler error must # never fail an otherwise-good check-in, so drift is left unchanged. try: if 'services' in payload: reported_services = payload.get('services') or [] else: reported_services = json.loads(state.servicesjson or '[]') from .services.reconciler import compute_drift state.driftjson = _as_json(compute_drift(assetid, reported_services)) except Exception: logger.exception( 'reconciler failed for asset %s; leaving drift unchanged', assetid) # Log any keys the agent sent that the ingest path does not consume yet. # New agent telemetry (disks, bios, memory, ...) lands here first: it is # not persisted until the server grows columns for it, but logging the # keys and a compact preview makes incoming telemetry visible in the # logs so we can confirm the agent is sending it before wiring the UI. unknown_keys = [key for key in payload if key not in KNOWN_PAYLOAD_KEYS] if unknown_keys: preview = {key: payload.get(key) for key in sorted(unknown_keys)} try: preview_text = json.dumps(preview, default=str)[:2000] except (TypeError, ValueError): preview_text = repr(preview)[:2000] logger.info( 'endpoint %s sent %d unpersisted telemetry key(s): %s | preview=%s', hostname, len(unknown_keys), sorted(unknown_keys), preview_text) stored_events = 0 skipped_events = 0 # Records the agent already sent must not be appended a second time. # The agent carries a per-channel watermark so the steady state is # "only what is new", but a reinstalled agent, a restored image, or two # overlapping reports can all replay records. Resolve that here rather # than relying on the unique index to raise, because an IntegrityError # would abort the whole collector transaction and lose the state upsert # along with it. # # One query for the whole batch, not one per record: a seeding agent # sends up to the per-channel cap at once. candidates = [] for record in (payload.get('events') or []): try: eventid = int(record.get('eventid')) except (TypeError, ValueError): warnings.append('skipped event with non-integer eventid') continue if eventid not in CURATED_EVENT_IDS: # Outside the catalog: ignored on purpose, not an error. continue try: eventrecordid = record.get('eventrecordid') eventrecordid = (int(eventrecordid) if eventrecordid is not None else None) except (TypeError, ValueError): eventrecordid = None candidates.append((eventid, eventrecordid, record)) keyed = [(record.get('eventchannel'), eventrecordid) for _e, eventrecordid, record in candidates if eventrecordid is not None] already_stored = set() if keyed: # SAFETY: Row-level locking prevents race condition (vuln #7 from audit). # WITH FOR UPDATE locks matching rows in MySQL, preventing concurrent INSERT # of duplicates. The UNIQUE constraint on (assetid, eventchannel, eventrecordid) # provides additional protection against race conditions. rows = (db.session.query(EndpointEvent.eventchannel, EndpointEvent.eventrecordid) .filter(EndpointEvent.assetid == assetid, EndpointEvent.eventrecordid.in_( [recordid for _c, recordid in keyed])) .with_for_update() .all()) already_stored = {(channel, recordid) for channel, recordid in rows} for eventid, eventrecordid, record in candidates: eventchannel = record.get('eventchannel') if eventrecordid is not None: key = (eventchannel, eventrecordid) if key in already_stored: skipped_events += 1 continue # Guard against the same record appearing twice inside one # payload as well as against rows already in the table. already_stored.add(key) db.session.add(EndpointEvent( assetid=assetid, eventid=eventid, eventrecordid=eventrecordid, eventchannel=eventchannel, eventsource=record.get('eventsource'), eventtime=_parse_dt(record.get('eventtime')), eventmessage=record.get('eventmessage'), payloadjson=_as_json(record.get('fields')), )) stored_events += 1 if skipped_events: logger.info( '[collector] asset_id=%d hostname=%s events_stored=%d ' 'events_skipped=%d (duplicates detected)', assetid, hostname, stored_events, skipped_events) else: logger.debug( 'endpoint %s stored %d event(s), no duplicates', hostname, stored_events) # flush, not commit: the collector dispatcher owns the transaction. db.session.flush() # Health rollup, AFTER the flush so the events just written are visible # to the evaluator's query in this same transaction. Recomputed here, # per asset, rather than by the Fleet page across the whole fleet on # every load - the verdict changes when telemetry changes, so this is # where it belongs, and it lands in the SAME commit as the telemetry it # was derived from. # # Fail-open, matching the reconciler above: a health error must never # fail an otherwise-good check-in. The previous verdict simply stands, # which is correct - a failed evaluation is not evidence of health. try: from .services.health import evaluate_endpoint_health evaluate_endpoint_health( assetid, state, computertypeid=getattr(computer, 'computertypeid', None)) db.session.flush() except Exception: logger.exception( 'health evaluation failed for asset %s; leaving verdict ' 'unchanged', assetid) return { 'action': action, 'assetid': assetid, 'identityvalue': hostname, 'storedevents': stored_events, 'warnings': warnings, }