Persist and surface endpoint installed-software (SBOM)
The endpoint agent already reports an installed-software list (SBOM), but ingress dropped it: the collector schema had no entry for it and there was nowhere to store it, so operators could not see what is installed on a PC. Persist it as a per-asset snapshot in a new endpointapplications table, replaced on every report (current inventory is what operators ask for, not install history). Add applications to the collector schema and KNOWN_PAYLOAD_KEYS so ingress stops discarding it, a read route for the raw collection, and a collapsed-by-default Installed software panel to surface it. appid is nullable groundwork for a future Managed Applications link; no matcher is wired yet.
This commit is contained in:
committed by
cproudlock
parent
7040832565
commit
902339baf9
@@ -21,7 +21,7 @@ from shopdb.api import (
|
|||||||
AuditLog, collector_authorized, User,
|
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 ..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
|
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||||
|
|
||||||
@@ -674,6 +674,32 @@ def panel_interfaces(asset_id: int):
|
|||||||
return success_response(rows)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/asset/<int:asset_id>/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
|
# Dashboard
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
"""Endpoints plugin models."""
|
"""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',
|
||||||
|
]
|
||||||
|
|||||||
@@ -246,3 +246,80 @@ class EndpointEvent(db.Model):
|
|||||||
'eventmessage': self.eventmessage,
|
'eventmessage': self.eventmessage,
|
||||||
'recordedat': _utciso(self.recordedat),
|
'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"<EndpointApplication {self.displayname!r} asset={self.assetid}>"
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ from shopdb.api import db
|
|||||||
|
|
||||||
from .api import endpoints_bp
|
from .api import endpoints_bp
|
||||||
from .eventcatalog import EVENT_CATALOG
|
from .eventcatalog import EVENT_CATALOG
|
||||||
from .models import EndpointState, EndpointEvent, EndpointCommand
|
from .models import (
|
||||||
|
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
|||||||
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
|
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
|
||||||
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||||
'scheduledtasks', 'events',
|
'scheduledtasks', 'applications', 'events',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +108,8 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
return endpoints_bp
|
return endpoints_bp
|
||||||
|
|
||||||
def get_models(self) -> List[Type]:
|
def get_models(self) -> List[Type]:
|
||||||
return [EndpointState, EndpointEvent, EndpointCommand]
|
return [EndpointState, EndpointEvent, EndpointCommand,
|
||||||
|
EndpointApplication]
|
||||||
|
|
||||||
def get_permissions(self) -> List:
|
def get_permissions(self) -> List:
|
||||||
"""RBAC for the human-facing read routes.
|
"""RBAC for the human-facing read routes.
|
||||||
@@ -389,6 +392,26 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'empty': 'No network interfaces reported.',
|
'empty': 'No network interfaces reported.',
|
||||||
'position': 16,
|
'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]:
|
def get_dashboard_widgets(self) -> List[Dict]:
|
||||||
@@ -483,6 +506,24 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'type': 'array',
|
'type': 'array',
|
||||||
'description': 'Scheduled task inventory + last run.',
|
'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': {
|
'events': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'description': 'Curated Windows Event Log records. Only '
|
'description': 'Curated Windows Event Log records. Only '
|
||||||
@@ -555,6 +596,41 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
|
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:
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||||
"""Idempotently upsert one endpoint's state and append its events.
|
"""Idempotently upsert one endpoint's state and append its events.
|
||||||
|
|
||||||
@@ -631,6 +707,18 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
if 'scheduledtasks' in payload:
|
if 'scheduledtasks' in payload:
|
||||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
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()
|
state.lastreportedat = datetime.utcnow()
|
||||||
|
|
||||||
# Log any keys the agent sent that the ingest path does not consume yet.
|
# Log any keys the agent sent that the ingest path does not consume yet.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|||||||
'configurationpolicies', 'configurationpolicyactions',
|
'configurationpolicies', 'configurationpolicyactions',
|
||||||
'businesspolicies', 'businesspolicyitems'),
|
'businesspolicies', 'businesspolicyitems'),
|
||||||
'employees': ('directoryemployees',),
|
'employees': ('directoryemployees',),
|
||||||
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands'),
|
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications'),
|
||||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
|||||||
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||||
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
|
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
|
||||||
# endpointstates + endpointevents tables the core chain never owned, 0002
|
# endpointstates + endpointevents tables the core chain never owned, 0002
|
||||||
# adds the endpointcommands queue plus the MTConnect state columns, and 0003
|
# adds the endpointcommands queue plus the MTConnect state columns, 0003
|
||||||
# adds the generic command target/targetkind columns.
|
# adds the generic command target/targetkind columns, and 0004 adds the
|
||||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0003cmdtarget'
|
# endpointapplications SBOM snapshot table.
|
||||||
|
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0004apps'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
Reference in New Issue
Block a user