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

@@ -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"<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),
}