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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user