Exposure Changes
Exposure changes answer the core EASM question: what changed in the external attack surface since previous scans?
The MVP stores immutable change events in PostgreSQL and exposes them through organization-scoped API endpoints. Events are generated best-effort from existing asset, vulnerability, and file persistence paths. A failure to write an exposure change must not fail a scan, vulnerability update, or file upload.
Exposure Changes are separate from Asset History and Asset Scan Changes. Asset History is a dedicated per-Asset lifecycle/system timeline stored in asset_history; Asset Scan Changes are field-level scanner-observed technical changes stored in asset_scan_changes; Exposure Changes remain the global attack-surface/security feed.
Asset Detail uses asset_history as System Changes and asset_scan_changes as Scan Changes. The top chart intentionally shows only those two categories. The History tab switches between System and Scan modes; Recent Changes merges the latest rows from both domains. Exposure Changes are not consumed by this Asset Detail history UI.
Table
exposure_changes is created by backend/migrations/008_exposure_changes.sql.
Important columns:
| Column | Purpose |
|---|---|
organization_id |
Organization boundary for RBAC and filtering |
scan_id, scan_job_id |
Optional scan attribution when the event was produced by scan output |
asset_id, threat_id |
Optional affected entity references. Legacy API responses may also include vulnerability_id for compatibility. |
change_type |
Event category, such as asset_discovered |
entity_type |
Entity family, such as asset, service, vulnerability, or file |
title, description |
Human-readable timeline text |
severity, risk_score |
Vulnerability or high-signal event severity context |
old_value, new_value |
Structured before/after payloads |
metadata |
Source plugin and future integration context |
created_at |
Timeline order |
Indexes cover organization timeline queries, change type filters, asset/vulnerability history, scan filtering, and severity filters.
Event Types
Implemented in the MVP:
asset_discoveredservice_discoveredwebapp_discoveredurl_discoveredpath_discoveredcertificate_discoveredthreat_status_changedvulnerability_discoveredvulnerability_status_changedvulnerability_exploit_status_changedfile_collectedasset_criticality_changedasset_approval_changedasset_disabled_changedasset_stale_changed
Reserved for future implementation:
port_openedport_closedtechnology_changedasset_reappeared
Event Sources
Assets:
assets.Service.Upsertrecords a discovery event only when PostgreSQL reports the asset row was newly inserted.- Service, WebApp, and certificate assets use specialized change types and titles.
url_discoveredandpath_discoveredmay remain in immutable historical records from earlier Asset models; new production WebApp discovery useswebapp_discovered.- Dedup updates that only refresh
last_seen_at, confidence, or metadata do not create another discovery event. - Manual lifecycle updates create
asset_approval_changed,asset_disabled_changed, orasset_stale_changedevents.
Threats:
- Plugin-discovered vulnerability Threats record
vulnerability_discoveredonly when the vulnerability upsert inserts a new row. - Threat status transitions record
threat_status_changedwithentity_type=threatandmetadata.threat_type. - Vulnerability-only exploit flag changes record
vulnerability_exploit_status_changedafter a manual exploit flag change succeeds. - Historical
vulnerability_status_changedevents remain readable for compatibility.
Files:
files.Service.SaveFilerecordsfile_collectedafter object storage and metadata persistence succeed.
Scan attribution:
- The worker attaches scan context while processing normalized plugin output, so scan-created assets and vulnerability Threats can reference
scan_id. - File artifacts already carry scan and scan job ids through
SaveFileInput.
API
GET /api/v1/organizations/{org_id}/changesGET /api/v1/organizations/{org_id}/changes/summary?days=7GET /api/v1/assets/{asset_id}/changes
All endpoints are read-only and use the existing organization/resource RBAC model. Clients may read assigned organization changes; admins may read all organizations.
Duplicate Prevention
The primary duplicate prevention is source-based:
- Assets emit only when an upsert inserts a new asset.
- Vulnerability Threats emit only when an upsert inserts a new vulnerability Threat.
The changes service also checks for an existing similar event by organization, change type, entity id, and scan id before inserting. This protects against repeated calls in the same scan without adding a state machine.
Current Limitations
port_closedis not implemented yet.- Automatic stale detection is not implemented yet. Manual stale flag changes are recorded as
asset_stale_changed. asset_reappearedis not implemented yet.- Technology diffing is not implemented yet.
- Change detection is based on newly inserted assets and vulnerability Threats in this MVP.
- Scan job attribution is available for file artifacts; asset and vulnerability Threat discovery events currently carry scan attribution but not per-plugin job attribution.
Relationship to Asset Scan Changes
Asset Scan Changes are the canonical fine-grained technical diff store for scanner-owned metadata.asset_info changes. They record fields such as service.version, http.server, paths, certificate SANs, and DNS record sets at Asset scope.
Exposure Changes should not duplicate every field-level scan diff. It remains a higher-level organization feed for discovery, Threat, lifecycle, file, and security-relevant activity.
Future Roadmap
The exposure_changes table is intentionally self-contained so future systems can consume it without changing scanner execution semantics:
- notifications and webhook fan-out
- SIEM export pipelines
- AI agent context
- stale asset detection
- closed-port detection based on historical service observations
- technology change diffs
Asset Criticality Changes
Manual criticality updates create a best-effort exposure change event with change_type=asset_criticality_changed, entity_type=asset, old_value.criticality, and new_value.criticality. Recording failure does not block the criticality update.
Asset Lifecycle Changes
Manual asset lifecycle updates create best-effort exposure change events:
asset_approval_changedasset_disabled_changedasset_stale_changed
Events use entity_type=asset and store the changed boolean field in old_value and new_value. Recording failure does not block the lifecycle update.