diff --git a/plugins/endpoints/migrations/versions/0015_endpoint_system_telemetry.py b/plugins/endpoints/migrations/versions/0015_endpoint_system_telemetry.py new file mode 100644 index 00000000..17e2198a --- /dev/null +++ b/plugins/endpoints/migrations/versions/0015_endpoint_system_telemetry.py @@ -0,0 +1,78 @@ +"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates. + +Phase 2 system-side telemetry. All three are collect-and-persist; none is +surfaced in the UI by this change. + +rebootreasonsjson is NOT redundant with the existing pendingreboot column. That +column is a single boolean OR-ed from three registry signals, so it cannot say +which one fired, and the difference is operationally significant: a +PendingFileRenameOperations entry means files are staged for replacement on the +next boot, so the machine is mid-servicing, whereas a lone Component Based +Servicing or Windows Update key can sit set for weeks on a box nobody has +rebooted. Same boolean, very different urgency. + +bootinfojson exists because uptime lies under fast startup - a machine reports +days of uptime after what the operator experienced as a shutdown, and a "reboot" +that was really a hybrid resume does not clear the driver or kernel state a +support call is usually trying to clear. + +defenderjson is stored deliberately unsurfaced. Defender is known to produce +false positives in this environment, so wiring it to a card now would train +operators to ignore that card; persisting it starts the history accumulating for +whenever someone is ready to interpret it. + +JSON rather than scalars: reboot reasons is a variable-length list, and the boot +and Defender payloads are small related groups that are read together and would +otherwise be six sparse columns. + +MEDIUMTEXT for consistency with the other snapshot columns on this table. + +Nullable with no backfill. Null means never reported, which is deliberately +distinct from an empty list (checked, nothing pending) - defaulting the reboot +reasons to [] would assert every pre-existing endpoint was confirmed clean. + +Idempotent: each column-add checks the live schema first, so a re-run and a +db.create_all() site both stay clean on strict MySQL 8. +""" +from alembic import op + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0015systemtelemetry' +down_revision = 'endpoints0014diskhealth' +branch_labels = None +depends_on = None + +TABLE_NAME = 'endpointstates' + +_COLUMNS = ( + 'rebootreasonsjson', + 'bootinfojson', + 'defenderjson', +) + + +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(): + for column_name in _COLUMNS: + if not _has_column(TABLE_NAME, column_name): + op.add_column( + TABLE_NAME, + sa.Column(column_name, sa.Text(length=16777215), nullable=True)) + + +def downgrade(): + for column_name in reversed(_COLUMNS): + if _has_column(TABLE_NAME, column_name): + op.drop_column(TABLE_NAME, column_name) diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index 025b16b8..5e173028 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -108,6 +108,27 @@ class EndpointState(db.Model): # dying. Null = never reported. diskhealthjson = db.Column(db.Text(length=16777215), nullable=True) + # Which signal(s) drove pendingreboot. That column is a single bool OR-ed + # from three sources and so cannot say WHY, and the difference matters: a + # pendingfilerename means files are staged for replacement on next boot, so + # the box is mid-servicing, while a lone servicing key can sit set for weeks + # on a machine nobody rebooted. Empty list = checked, nothing pending. + # Null = never reported or the check failed. + rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True) + + # Fast startup configuration and what the last boot actually did. Kept + # because uptime is not trustworthy under fast startup: a box can report + # days of uptime after what the operator experienced as a shutdown, and a + # "reboot" that was a hybrid resume does not clear the state a support call + # is usually trying to clear. + bootinfojson = db.Column(db.Text(length=16777215), nullable=True) + + # Defender health. Persisted but deliberately NOT surfaced anywhere yet: + # this environment produces known false positives, and wiring it to a card + # now would train operators to ignore that card. Stored so the history is + # already accumulating when someone is ready to interpret it. + defenderjson = db.Column(db.Text(length=16777215), nullable=True) + # Warn-only reconciler output, refreshed on every agent check-in in the same # commit as servicesjson. Each entry names a policy/shipped-attached monitor # the endpoint is expected to be running but the latest snapshot does not diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index dac7bdf3..44062c46 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -54,6 +54,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({ 'scheduledtasks', 'applications', 'events', 'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain', 'pendingreboot', 'usercontext', 'diskhealth', + 'rebootreasons', 'bootinfo', 'defender', }) @@ -790,6 +791,28 @@ class EndpointsPlugin(BasePlugin): '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 ' @@ -1035,6 +1058,12 @@ class EndpointsPlugin(BasePlugin): 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')) # 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