None blocked the merge; all four were mechanisms that did not mechanise. _RUNNING_STATES existed twice, in api/routes.py and services/reconciler.py, with a comment asking a human to keep them in lockstep. The panel and the reconciler read the same agent snapshot, so a drift between the copies would paint a service green and flag it as drift on the same page. One definition now, owned by the reconciler and imported by routes - api -> services is the direction the plugin layout already runs in, and the reconciler's import of routes is deferred, so there is no cycle. The operator-report upload checked its 10 MB cap AFTER upload.read() had already pulled the whole body into memory, so the check could not prevent the thing it was there to prevent. content_length is now an early out and the read itself is bounded to cap+1, which is enough to know the cap was exceeded without taking a body that lied about its length. The multipart envelope carries the other form fields too, so the early out allows 64 KB of slack rather than rejecting an image that is just under. The same handler forced the extension to .png and the mimetype to image/png whatever the bytes were, then served the file back under that type. The upload headers (CSP sandbox + nosniff) make the document case inert, so this was never the hole it looks like, but nothing is gained by storing a file we already know is not what it claims. The PNG signature is now checked. The lightbox bound Escape and the arrow keys to a div and never focused it, so every one of those bindings was dead until the user happened to click the backdrop. It takes focus on open and hands it back to the thumbnail on close. On the two upload tests, honestly: the PNG one was confirmed to fail with the check removed. The oversize one passes either way, because the old code did reject an oversized image - it just did so after reading all of it. That test pins the response, not the fix. Whether the read is bounded is not observable through the response, so nothing here proves it; the change stands on reading the handler. Suite 2311 passing, frontend 316 passing, build clean. Scope: product
159 lines
6.4 KiB
Python
159 lines
6.4 KiB
Python
"""Warn-only monitor reconciler.
|
|
|
|
The reconciler answers one question on every agent check-in: for the monitors a
|
|
site policy (or the shipped bundle) attaches to this endpoint, is each one the
|
|
endpoint is EXPECTED to be running actually reported as running in the latest
|
|
snapshot? Every monitor that is expected-running but not observed-running is
|
|
DRIFT, and drift is surfaced to an operator for MANUAL intervention.
|
|
|
|
This is warn-only. The reconciler NEVER starts, stops, or enqueues anything - it
|
|
compares observed state against expected state and records the difference. Whether
|
|
to later automate remediation (a keep-alive that starts a process when it drifts)
|
|
is a separate, deliberate opt-in the product does not ship switched on.
|
|
|
|
Two seams are kept deliberately narrow so this stays easy to change:
|
|
|
|
monitor_expects_running(monitor)
|
|
The single place that decides "should this monitor be up?". Today every
|
|
attached monitor is treated as expected-running (attachment implies intent
|
|
to run). When an observe-only monitor kind arrives - one that only collects
|
|
general state and must never be flagged as drift - this is the one function
|
|
that branches on it. Nothing else in the reconciler encodes that policy.
|
|
|
|
compute_drift(asset_id, reported_services)
|
|
Pure given its inputs: it resolves the attached monitors and diffs them
|
|
against the reported running set. It reads no request state and writes no
|
|
rows, so the ingest path can call it inside its own transaction and tests
|
|
can call it directly.
|
|
"""
|
|
|
|
# The running-state vocabulary a watched process/service can report. A watched
|
|
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
|
|
# source, so what counts as running is a single rule rather than two.
|
|
#
|
|
# It lives here, and api/routes.py imports it, because the panel and this
|
|
# reconciler MUST agree: a service the panel paints green that this flags as
|
|
# drift is a contradiction shown to the same person on the same page. It was
|
|
# two copies with a comment asking a human to keep them in lockstep, which is
|
|
# not a mechanism.
|
|
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
|
|
|
|
|
def monitor_expects_running(monitor):
|
|
"""Whether an attached monitor is expected to be running on an endpoint.
|
|
|
|
The seam for the "should be up" decision. Today attachment implies intent to
|
|
run, so every active monitor returns True. A future observe-only monitor kind
|
|
(general state collection, never keep-alive) branches HERE and returns False
|
|
so it is never reported as drift - no other part of the reconciler needs to
|
|
change.
|
|
"""
|
|
return bool(getattr(monitor, 'isactive', True))
|
|
|
|
|
|
def _monitor_identity(monitor):
|
|
"""The (kind, osname) a monitor watches, or None when it has no identity.
|
|
|
|
Mirrors how the services panel reads a monitor's OS identity: a process
|
|
monitor is keyed by its processname, a service monitor by its servicename.
|
|
"""
|
|
kind = (getattr(monitor, 'kind', '') or '').lower() or None
|
|
if kind == 'process':
|
|
osname = getattr(monitor, 'processname', None)
|
|
elif kind == 'service':
|
|
osname = getattr(monitor, 'servicename', None)
|
|
else:
|
|
osname = None
|
|
osname = (osname or '').strip()
|
|
if not kind or not osname:
|
|
return None
|
|
return kind, osname
|
|
|
|
|
|
def _reported_running_keys(reported_services):
|
|
"""Set of (kind, lowercased-name) the snapshot reports as RUNNING.
|
|
|
|
Tolerant of the two shapes the agent snapshot uses: dict entries with
|
|
name/service and state/status (and an optional kind), or bare strings.
|
|
Only running entries land in the set; a stopped or unknown entry is absent,
|
|
which is what makes an expected-running monitor read as drift.
|
|
"""
|
|
running = set()
|
|
for entry in reported_services or ():
|
|
if isinstance(entry, dict):
|
|
name = entry.get('name') or entry.get('service') or ''
|
|
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
|
|
kind = (entry.get('kind') or '').lower() or None
|
|
else:
|
|
name, raw, kind = str(entry), '', None
|
|
name = name.strip().lower()
|
|
if not name or raw not in RUNNING_STATES:
|
|
continue
|
|
running.add((kind, name))
|
|
return running
|
|
|
|
|
|
def _is_running(kind, osname, running_keys):
|
|
"""Whether a monitor's identity appears running in the snapshot.
|
|
|
|
A snapshot entry may omit kind (older agents, or a plain string list), so a
|
|
match on name alone under an unknown kind still counts as running - the
|
|
reconciler must not invent drift just because the agent did not label a row.
|
|
"""
|
|
needle = osname.strip().lower()
|
|
if (kind, needle) in running_keys:
|
|
return True
|
|
if (None, needle) in running_keys:
|
|
return True
|
|
return False
|
|
|
|
|
|
def compute_drift(asset_id, reported_services):
|
|
"""Compute the current drift list for an endpoint. Pure, no writes.
|
|
|
|
Resolves the monitors attached to the asset's PC type (shipped defaults plus
|
|
site policy), keeps only those that are expected-running, and returns one
|
|
entry per monitor whose identity is not reported as running. Returns an empty
|
|
list when nothing is attached or nothing has drifted, so the caller can store
|
|
it as "computed, no drift" distinct from a null "never computed".
|
|
|
|
Isolated from Flask request state: it reads the monitor catalog and the
|
|
passed-in snapshot only, so the ingest path calls it inside its own
|
|
transaction and tests call it directly.
|
|
"""
|
|
try:
|
|
from plugins.endpoints.api.routes import _resolved_monitors_for_asset
|
|
except Exception:
|
|
return []
|
|
|
|
monitors = _resolved_monitors_for_asset(asset_id) or []
|
|
if not monitors:
|
|
return []
|
|
|
|
running_keys = _reported_running_keys(reported_services)
|
|
|
|
drift = []
|
|
seen = set()
|
|
for monitor in monitors:
|
|
if not monitor_expects_running(monitor):
|
|
continue
|
|
identity = _monitor_identity(monitor)
|
|
if identity is None:
|
|
continue
|
|
kind, osname = identity
|
|
key = (kind, osname.lower())
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if _is_running(kind, osname, running_keys):
|
|
continue
|
|
drift.append({
|
|
'monitorid': getattr(monitor, 'endpointmonitorid', None),
|
|
'kind': kind,
|
|
'name': osname,
|
|
'displayname': getattr(monitor, 'displayname', None) or osname,
|
|
'expected': 'running',
|
|
'observed': 'notrunning',
|
|
})
|
|
return drift
|