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:
benjamin.baker
2026-08-30 11:20:45 -04:00
committed by cproudlock
parent 7040832565
commit 902339baf9
7 changed files with 290 additions and 10 deletions

View File

@@ -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.