diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 6032331b..0bd56537 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -21,7 +21,7 @@ from shopdb.api import ( AuditLog, collector_authorized, User, ) -from ..models import EndpointState, EndpointEvent, EndpointCommand +from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title @@ -674,6 +674,32 @@ def panel_interfaces(asset_id: int): return success_response(rows) +@endpoints_bp.route('/asset//applications', methods=['GET']) +@require_permission('endpoints.view') +def panel_applications(asset_id: int): + """Table panel: the installed-software (SBOM) snapshot for this endpoint. + + Reads the endpointapplications rows the collector last wrote for this asset + (a per-report snapshot, not history) and returns them sorted by name. This + is the raw discovered inventory: appid is carried through but not yet linked + to the applications catalog ("Managed Applications"), so no managed status + is surfaced here. An endpoint with no reported SBOM yields no rows and the + panel hides itself. + """ + rows = (EndpointApplication.query + .filter_by(assetid=asset_id) + .order_by(db.func.lower(EndpointApplication.displayname)) + .all()) + return success_response([ + { + 'displayname': row.displayname, + 'displayversion': row.displayversion or '-', + 'publisher': row.publisher or '-', + } + for row in rows + ]) + + # ============================================================================= # Dashboard # ============================================================================= diff --git a/plugins/endpoints/migrations/versions/0004_endpoint_applications.py b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py new file mode 100644 index 00000000..9aab6ce2 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py @@ -0,0 +1,84 @@ +"""endpoint installed-software (SBOM) snapshot table. + +Adds endpointapplications: one row per installed application discovered on an +endpoint, reported by the agent as {displayname, displayversion, publisher}. +Stored as a per-asset snapshot (replaced on each report), the same way the +services/ports inventories are kept as the newest list rather than history. + +appid is groundwork for a future link to the core applications catalog (the +"Managed Applications" concept): a nullable FK with ON DELETE SET NULL so +retiring a managed Application does not delete discovered rows. No +discovery-to-catalog matcher exists yet; the column is laid down now so the +association can be added without a second migration. + +Idempotent: creation checks the live schema first so a db.create_all() site +(which already built the table) re-runs clean on strict MySQL 8. +""" +from alembic import op + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0004apps' +down_revision = 'endpoints0003cmdtarget' +branch_labels = None +depends_on = None + + +def _has_table(table_name): + from sqlalchemy import inspect + + return inspect(op.get_bind()).has_table(table_name) + + +def upgrade(): + if _has_table('endpointapplications'): + return + + op.create_table( + 'endpointapplications', + sa.Column('endpointapplicationid', sa.Integer(), 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), + sa.Column('publisher', sa.String(length=255), nullable=True), + sa.Column('appid', sa.Integer(), nullable=True), + sa.Column('recordedat', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('endpointapplicationid'), + sa.ForeignKeyConstraint( + ['assetid'], ['assets.assetid'], + name='fk_endpointapplications_assetid', + ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['appid'], ['applications.appid'], + name='fk_endpointapplications_appid', + ondelete='SET NULL'), + ) + op.create_index( + 'idx_endpointapplications_assetid', + 'endpointapplications', ['assetid']) + op.create_index( + 'idx_endpointapplications_appid', + 'endpointapplications', ['appid']) + op.create_index( + 'idx_endpointapplications_displayname', + 'endpointapplications', ['displayname']) + op.create_index( + 'idx_endpointapplications_assetname', + 'endpointapplications', ['assetid', 'displayname']) + + +def downgrade(): + if not _has_table('endpointapplications'): + return + + op.drop_index('idx_endpointapplications_assetname', + table_name='endpointapplications') + op.drop_index('idx_endpointapplications_displayname', + table_name='endpointapplications') + op.drop_index('idx_endpointapplications_appid', + table_name='endpointapplications') + op.drop_index('idx_endpointapplications_assetid', + table_name='endpointapplications') + op.drop_table('endpointapplications') diff --git a/plugins/endpoints/models/__init__.py b/plugins/endpoints/models/__init__.py index 1e1fabaa..1e7936a9 100644 --- a/plugins/endpoints/models/__init__.py +++ b/plugins/endpoints/models/__init__.py @@ -1,5 +1,9 @@ """Endpoints plugin models.""" -from .endpoint import EndpointState, EndpointEvent, EndpointCommand +from .endpoint import ( + EndpointState, EndpointEvent, EndpointCommand, EndpointApplication, +) -__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand'] +__all__ = [ + 'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication', +] diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index 0b0e4593..c3e543c2 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -246,3 +246,80 @@ class EndpointEvent(db.Model): 'eventmessage': self.eventmessage, 'recordedat': _utciso(self.recordedat), } + + +class EndpointApplication(db.Model): + """One installed-software (SBOM) entry reported for an endpoint. + + The agent enumerates both Windows Uninstall registry hives and emits a + per-item {displayname, displayversion, publisher}, de-duped on + name+version. The server stores that as a per-asset SNAPSHOT: on each + report the existing rows for the asset are replaced with the reported set, + mirroring how the services/ports inventories are kept as the newest list + rather than an append-only history. History of installs is not what + operators ask for; the current inventory is. + + appid is groundwork for a future link to the core applications catalog + (the "Managed Applications" concept): a nullable, unenforced-by-match FK + that a later matcher can populate to associate a discovered install with a + managed Application row. No matching logic exists yet - the column and the + relationship are laid down here so the association can be added without a + second migration. + """ + + __tablename__ = 'endpointapplications' + + endpointapplicationid = db.Column(db.Integer, primary_key=True) + + # The asset this install belongs to. Not unique: many applications per + # endpoint. CASCADE so removing the asset clears its SBOM snapshot. + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + + asset = db.relationship('Asset', lazy='joined', viewonly=True) + + # Wire fields, stored under the same lowercase/no-underscore names the agent + # emits. displayname is always present (the agent skips entries without it); + # displayversion and publisher default to '' on the agent side. + displayname = db.Column(db.String(255), nullable=False, index=True) + displayversion = db.Column(db.String(128), nullable=True) + publisher = db.Column(db.String(255), nullable=True) + + # Groundwork link to the core applications catalog ("Managed Applications"). + # Nullable and unpopulated for now: no discovery-to-catalog matcher is wired + # yet. SET NULL so retiring a managed Application does not delete discovered + # SBOM rows - the install is still on the machine, it is just no longer + # managed. + appid = db.Column( + db.Integer, + db.ForeignKey('applications.appid', ondelete='SET NULL'), + nullable=True, + index=True, + ) + + application = db.relationship('Application', lazy='select', viewonly=True) + + # When this snapshot row was written (the report that produced it). + recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = ( + db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'endpointapplicationid': self.endpointapplicationid, + 'assetid': self.assetid, + 'displayname': self.displayname, + 'displayversion': self.displayversion, + 'publisher': self.publisher, + 'appid': self.appid, + 'recordedat': _utciso(self.recordedat), + } diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 252023eb..65b5b549 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -26,7 +26,9 @@ from shopdb.api import db from .api import endpoints_bp from .eventcatalog import EVENT_CATALOG -from .models import EndpointState, EndpointEvent, EndpointCommand +from .models import ( + EndpointState, EndpointEvent, EndpointCommand, EndpointApplication, +) logger = logging.getLogger(__name__) @@ -49,7 +51,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({ 'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress', 'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds', 'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces', - 'scheduledtasks', 'events', + 'scheduledtasks', 'applications', 'events', }) @@ -106,7 +108,8 @@ class EndpointsPlugin(BasePlugin): return endpoints_bp def get_models(self) -> List[Type]: - return [EndpointState, EndpointEvent, EndpointCommand] + return [EndpointState, EndpointEvent, EndpointCommand, + EndpointApplication] def get_permissions(self) -> List: """RBAC for the human-facing read routes. @@ -389,6 +392,26 @@ class EndpointsPlugin(BasePlugin): 'empty': 'No network interfaces reported.', 'position': 16, }, + { + # Installed-software (SBOM) inventory. Low-priority reference + # data, not something looked at on every visit, so it starts + # collapsed and sits near the bottom. Raw discovered list for + # now: no managed-application linkage is surfaced yet. + 'id': 'endpoints-applications', + 'title': 'Installed software', + 'assettypes': ['computer'], + 'endpoint': '/api/endpoints/asset/{assetid}/applications', + 'render': 'table', + 'collapsible': True, + 'collapsedDefault': True, + 'columns': [ + {'key': 'displayname', 'label': 'Name'}, + {'key': 'displayversion', 'label': 'Version'}, + {'key': 'publisher', 'label': 'Publisher'}, + ], + 'empty': 'No installed software reported.', + 'position': 30, + }, ] def get_dashboard_widgets(self) -> List[Dict]: @@ -483,6 +506,24 @@ class EndpointsPlugin(BasePlugin): 'type': 'array', 'description': 'Scheduled task inventory + last run.', }, + '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 ' @@ -555,6 +596,41 @@ class EndpointsPlugin(BasePlugin): db.session.flush() warnings.append('adopted new computer for hostname: {!r}'.format(hostname)) + def _replace_applications(self, assetid: int, applications, + warnings: List[str]) -> int: + """Replace an asset's SBOM snapshot with the reported set. + + The agent reports the full installed-software list each cycle, so the + current inventory is the newest report, not an accumulation. Delete the + asset's existing rows and insert the reported ones in the same + transaction the caller flushes. appid is left null: no + discovery-to-catalog ("Managed Applications") matcher is wired yet. + Malformed items (no displayname) are skipped, not fatal. + """ + EndpointApplication.query.filter_by(assetid=assetid).delete( + synchronize_session=False) + + if not applications: + return 0 + + 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=(item.get('displayversion') or None), + publisher=(item.get('publisher') or None), + )) + stored += 1 + return stored + def apply_collector_payload(self, payload: Dict) -> Dict: """Idempotently upsert one endpoint's state and append its events. @@ -631,6 +707,18 @@ class EndpointsPlugin(BasePlugin): if 'scheduledtasks' in payload: state.scheduledtasksjson = _as_json(payload.get('scheduledtasks')) + # 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() # Log any keys the agent sent that the ingest path does not consume yet. diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 657297e9..3c5590a4 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -53,7 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'configurationpolicies', 'configurationpolicyactions', 'businesspolicies', 'businesspolicyitems'), 'employees': ('directoryemployees',), - 'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands'), + 'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications'), 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', 'manifestentryhostnames', 'manifestentrymachinenumbers', 'manifestinusechecks', 'manifestinusecheckprocesses', diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 7cfb6c1a..d2dd6f9b 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -91,9 +91,10 @@ EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder' EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the # endpointstates + endpointevents tables the core chain never owned, 0002 -# adds the endpointcommands queue plus the MTConnect state columns, and 0003 -# adds the generic command target/targetkind columns. -EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0003cmdtarget' +# adds the endpointcommands queue plus the MTConnect state columns, 0003 +# adds the generic command target/targetkind columns, and 0004 adds the +# endpointapplications SBOM snapshot table. +EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0004apps' # Plugins built after the cutover: their 0001 baseline really creates tables the # core chain never owned.