TrueVuln — Database Schema
Stand: Mai 2026, alembic head = 022_backfill_ssvc_only_exploitation_source.
PostgreSQL 16. Times in UTC. TimestampMixin adds created_at + updated_at
to every audited table unless noted.
Entity Relationship Diagram (ERD)
Table Details
users
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| username |
VARCHAR(50) |
UNIQUE, NOT NULL, INDEX |
|
| email |
VARCHAR(255) |
UNIQUE, NOT NULL, INDEX |
|
| password_hash |
VARCHAR(255) |
NULLABLE |
bcrypt cost-12; NULL for SSO-only users |
| role |
ENUM userrole |
NOT NULL |
admin / editor / viewer |
| auth_provider |
ENUM authprovider |
NOT NULL |
local / ldap / oidc / saml |
| external_id |
VARCHAR(255) |
INDEX |
IdP subject / AD objectGUID |
| external_groups |
TEXT |
NULLABLE |
JSON array of group names/IDs from IdP |
| last_provider_sync |
TIMESTAMP |
NULLABLE |
Last successful SSO/LDAP login |
| totp_secret |
VARCHAR(255) |
NULLABLE |
Fernet-encrypted base32 |
| totp_enabled |
BOOLEAN |
DEFAULT FALSE |
MFA enabled |
| is_active |
BOOLEAN |
DEFAULT TRUE |
|
| is_verified |
BOOLEAN |
DEFAULT FALSE |
|
| failed_login_attempts |
INTEGER |
DEFAULT 0 |
Account lockout counter |
user_groups is an M:N association table (user_id, group_id).
groups
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| name |
VARCHAR(100) |
UNIQUE, NOT NULL |
|
| description |
TEXT |
NULLABLE |
|
| policy_id |
INTEGER |
FK policies.id, NULLABLE |
Group-default SLA policy |
assets
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| hostname |
VARCHAR(255) |
NOT NULL, INDEX |
FQDN or short |
| ip_address |
VARCHAR(45) |
INDEX |
IPv4/IPv6 |
| wazuh_agent_id |
VARCHAR(10) |
UNIQUE, INDEX |
Zero-padded ("006") |
| nessus_host_uuid |
VARCHAR(64) |
INDEX |
Pinned after first Nessus match |
| intune_device_id |
VARCHAR(64) |
INDEX |
Pinned after first Graph match |
| defender_machine_id |
VARCHAR(64) |
INDEX |
Defender for Endpoint machine |
| aad_device_id |
VARCHAR(64) |
INDEX |
Entra device id — shared Intune/Defender anchor |
| vmware_uuid |
VARCHAR(64) |
INDEX |
ESXi hardware UUID / vCenter instanceUuid (migration 045) |
| vmware_build |
VARCHAR(32) |
NULLABLE |
VMkernel / vpxd build — VMware states its fixes as builds, so this is what decides a vSphere CVE |
| operating_system |
VARCHAR(255) |
NULLABLE |
OS family |
| os_version |
VARCHAR(100) |
NULLABLE |
|
| kernel_version |
VARCHAR(100) |
NULLABLE |
|
| criticality |
VARCHAR(16) |
NOT NULL DEFAULT 'normal' |
low / normal / high / critical — URS multiplier |
| compliance_frameworks |
TEXT |
NULLABLE |
JSON array of mandatory framework slugs |
| source |
ENUM assetsource |
NOT NULL |
WAZUH / NESSUS / INTUNE / VCENTER / MANUAL |
| status |
ENUM assetstatus |
NOT NULL |
active / inactive |
| last_scan |
TIMESTAMP |
NULLABLE |
|
| last_seen |
TIMESTAMP |
NULLABLE |
|
| description |
TEXT |
NULLABLE |
|
| location |
VARCHAR(255) |
NULLABLE |
|
| owner |
VARCHAR(255) |
NULLABLE |
Free-text owner string |
| assigned_user_id |
INTEGER |
FK users.id, INDEX |
Default assignee for vulns |
| policy_id |
INTEGER |
FK policies.id, NULLABLE |
SLA policy override |
asset_groups is an M:N association table (asset_id, group_id).
vulnerabilities
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| cve_id |
VARCHAR(50) |
NOT NULL, INDEX |
CVE-YYYY-N or NESSUS-PLUGIN-{id} |
| asset_id |
INTEGER |
FK assets.id, NOT NULL, INDEX |
|
| cvss_score |
FLOAT |
INDEX |
Latest authoritative score |
| cvss_vector |
VARCHAR(100) |
NULLABLE |
|
| severity |
ENUM vulnseverity |
NOT NULL |
critical / high / medium / low / none |
| status |
ENUM vulnstatus |
NOT NULL |
open / patched / pending_verification / accepted_risk / false_positive / deferred / patch_failed |
| title |
TEXT |
NULLABLE |
|
| description |
TEXT |
NULLABLE |
|
| package_name |
VARCHAR(255) |
INDEX |
Comma-joined merged-packages |
| package_version |
VARCHAR(100) |
NULLABLE |
|
| fixed_version |
VARCHAR(100) |
NULLABLE |
From CVE-5 affected[].versions[].lessThan or NVD cpeMatch.versionEndExcluding — drives PATCH AVAILABLE pill |
| exploitable |
BOOLEAN |
DEFAULT FALSE, INDEX |
Wazuh signal |
| exploit_available |
BOOLEAN |
DEFAULT FALSE |
Wazuh signal |
| exploit_maturity |
VARCHAR(50) |
NULLABLE |
poc / functional / high (Nessus) |
| exploitation_status |
VARCHAR(20) |
INDEX |
SSVC: none / poc / active / widespread (Vulnrichment) |
| exploitation_source |
VARCHAR(20) |
NULLABLE |
vulnrichment / nvd / cvelistv5 / nessus / manual — source pin |
| ssvc_technical_impact |
VARCHAR(16) |
INDEX |
SSVC: partial / total |
| ssvc_automatable |
VARCHAR(8) |
INDEX |
SSVC: yes / no |
| published_date |
TIMESTAMP |
NULLABLE |
CVE publication date (used in "Newly Published" widget sort) |
| detected_at |
TIMESTAMP |
NOT NULL, INDEX |
First detection in our system |
| patched_at |
TIMESTAMP |
NULLABLE |
|
| references |
TEXT |
NULLABLE |
JSON array of URLs |
| cwe_id |
VARCHAR(20) |
NULLABLE |
CWE identifier |
| assigned_user_id |
INTEGER |
FK users.id, INDEX |
|
| assigned_group_id |
INTEGER |
FK groups.id, INDEX |
|
| deferred_until |
TIMESTAMP |
NULLABLE |
|
| defer_reason |
TEXT |
NULLABLE |
|
| notification_suppressed |
BOOLEAN |
DEFAULT FALSE |
Bell-icon toggle |
| epss_score |
FLOAT |
INDEX |
0.0–1.0 |
| epss_percentile |
FLOAT |
NULLABLE |
0.0–1.0 |
| epss_updated_at |
TIMESTAMP |
NULLABLE |
|
| kev_listed |
BOOLEAN |
DEFAULT FALSE, INDEX |
CISA KEV |
| kev_date_added |
TIMESTAMP |
NULLABLE |
|
| kev_ransomware_use |
BOOLEAN |
DEFAULT FALSE |
|
| kev_short_description |
TEXT |
NULLABLE |
|
| euvd_listed |
BOOLEAN |
DEFAULT FALSE, INDEX |
ENISA EUVD exploited |
| euvd_critical |
BOOLEAN |
DEFAULT FALSE |
ENISA-flagged critical |
| euvd_date_added |
TIMESTAMP |
NULLABLE |
|
| euvd_id |
VARCHAR(50) |
NULLABLE |
ENISA EUVD-ID |
| enrichment_sources |
TEXT |
NULLABLE |
JSON array, e.g. ["epss","kev","euvd"] |
| enrichment_updated_at |
TIMESTAMP |
NULLABLE |
Last enrichment run |
| sources |
TEXT |
NOT NULL DEFAULT '["wazuh"]' |
JSON array of scanners that detected it (wazuh, nessus, manual) |
| nessus_plugin_id |
VARCHAR(20) |
INDEX |
Nessus plugin reference |
| nessus_finding_uuid |
VARCHAR(64) |
NULLABLE |
Per-host-per-plugin stable id |
| first_detected_by |
VARCHAR(20) |
NULLABLE |
wazuh / nessus / manual |
| nessus_vpr_score |
FLOAT |
INDEX |
Tenable VPR 0–10 |
Computed-but-not-persisted on the response model: priority_score, cpr_score,
cross_confirmed (true if sources length ≥ 2).
Unique pair (effective): (cve_id, asset_id) — enforced application-side; the
sync paths use savepoint-protected inserts to handle race-induced duplicates.
scans
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| asset_id |
INTEGER |
FK assets.id, NOT NULL, INDEX |
|
| scan_type |
ENUM scantype |
NOT NULL |
WAZUH / NESSUS (uppercase post-019) |
| status |
ENUM scanstatus |
NOT NULL |
PENDING / RUNNING / COMPLETED / FAILED |
| started_at |
TIMESTAMP |
NULLABLE |
|
| completed_at |
TIMESTAMP |
NULLABLE |
|
| vulnerabilities_found |
INTEGER |
DEFAULT 0 |
|
| vulnerabilities_new |
INTEGER |
DEFAULT 0 |
|
| vulnerabilities_resolved |
INTEGER |
DEFAULT 0 |
|
| error_message |
TEXT |
NULLABLE |
|
API-layer display map (_display_scan_type() in app/routers/scans.py)
collapses legacy values FULL / SYSCOLLECTOR / MANUAL → wazuh so the UI
shows the human-friendly source string.
scan_schedules
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| name |
VARCHAR(200) |
NOT NULL |
|
| interval |
ENUM scheduleinterval |
NOT NULL |
HOURLY / DAILY / WEEKLY / CRON |
| cron_expression |
VARCHAR(100) |
NULLABLE |
e.g. 0 2 * * * |
| scanner_type |
VARCHAR(20) |
NOT NULL DEFAULT 'wazuh' |
wazuh / nessus |
| enabled |
BOOLEAN |
NOT NULL DEFAULT TRUE |
|
| last_run |
TIMESTAMP |
NULLABLE |
|
| next_run |
TIMESTAMP |
NULLABLE |
Re-computed every 60 s by scheduler_sync |
| created_by |
INTEGER |
NULLABLE |
users.id |
policies
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| name |
VARCHAR(255) |
NOT NULL |
|
| description |
TEXT |
NULLABLE |
|
| category |
VARCHAR(100) |
NOT NULL |
|
| status |
ENUM policystatus |
DEFAULT active |
active / warning / disabled — DISABLED suppresses SLA mails for assets pinned to this policy |
| critical_sla_days |
INTEGER |
DEFAULT 2 |
|
| high_sla_days |
INTEGER |
DEFAULT 7 |
|
| medium_sla_days |
INTEGER |
DEFAULT 30 |
|
| low_sla_days |
INTEGER |
DEFAULT 90 |
|
notification_logs
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| vulnerability_id |
INTEGER |
FK, INDEX |
|
| asset_id |
INTEGER |
FK, INDEX |
|
| user_id |
INTEGER |
FK, INDEX |
Recipient user |
| notification_type |
ENUM notificationtype |
NOT NULL, INDEX |
SLA_BREACH / ASSIGNMENT / NEW_VULNERABILITY / KEV_ALERT / MANUAL |
| sent_at |
TIMESTAMP |
NOT NULL, INDEX |
|
| subject |
VARCHAR(500) |
NULLABLE |
|
| recipient_email |
VARCHAR(255) |
NULLABLE |
|
| status |
ENUM notificationstatus |
NOT NULL |
SENT / FAILED |
| message_body |
TEXT |
NULLABLE |
|
| error_message |
TEXT |
NULLABLE |
|
The hourly SLA job applies a per-vuln 24h throttle by querying this table.
audit_logs
| Column |
Type |
Constraints |
Purpose |
| id |
INTEGER |
PK |
|
| user_id |
INTEGER |
FK users.id, INDEX |
Nullable for system events |
| event_type |
ENUM auditeventtype |
NOT NULL, INDEX |
Login success/fail, role change, MFA reset, password change, delete, etc. |
| event_description |
VARCHAR(500) |
NOT NULL |
|
| ip_address |
VARCHAR(45) |
NULLABLE |
|
| user_agent |
VARCHAR(500) |
NULLABLE |
|
| resource_type |
VARCHAR(50) |
NULLABLE |
vulnerability / asset / user … |
| resource_id |
VARCHAR(50) |
NULLABLE |
|
| old_value |
TEXT |
NULLABLE |
JSON diff |
| new_value |
TEXT |
NULLABLE |
JSON diff |
| timestamp |
TIMESTAMP |
NOT NULL, INDEX |
|
settings
Key-value store. Stable keys:
| Key |
Value |
Purpose |
smtp_config |
JSON |
host, port, user, pass (Fernet-encrypted), tls flag |
notification_mode |
digest | single |
New-vuln + SLA mail mode |
notification_min_severity |
critical | high | ... |
Filter for new-vuln digest |
sla_breach_enabled |
true | false |
Master toggle for the hourly SLA job |
enrichment_epss_enabled |
true | false |
|
enrichment_kev_enabled |
true | false |
|
enrichment_euvd_enabled |
true | false |
|
nessus_config |
JSON |
base_url, access_key, secret_key, verify_ssl, default_scan_ids, auto_create_assets |
ldap_config |
JSON |
server, base DN, search filter |
ldap_bind_password_encrypted |
string |
Fernet-encrypted |
oidc_config |
JSON |
issuer, client_id, client_secret, scopes |
saml_config |
JSON |
sp + idp settings |
role_mappings |
JSON |
[{group_pattern, role}] |
email_template_* |
HTML/Jinja-light |
Override default mail templates |
kev_cache_json |
JSON |
CISA KEV catalog cache (24h TTL) |
euvd_cache_json |
JSON |
ENISA EUVD catalog cache (24h TTL) |
kev_alert_enabled |
true | false |
Master toggle for the hourly KEV alert mail (default on) |
kev_alert_sources |
cisa,euvd |
Which KEV sources feed the alert; empty = all |
kev_alert_recipients |
string |
Comma/semicolon list; empty = notification_default_recipients, else active admins |
kev_alert_state |
JSON |
{cve: {at, n}} — what was already mailed, so a run is idempotent |
compliance_results
| Column |
Type |
Notes |
| id, asset_id (FK) |
|
|
| policy_id |
VARCHAR(64), INDEX |
Wazuh SCA policy id |
| policy_name, policy_description |
|
|
| total_checks, pass_count, fail_count, not_applicable_count |
INTEGER |
|
| score |
FLOAT |
Plain pass-% |
| weighted_score |
FLOAT, INDEX |
impact-weighted score (sum_impact_passed / sum_impact) |
| end_scan, last_synced |
TIMESTAMP |
|
compliance_checks
| Column |
Type |
Notes |
| id, result_id (FK CASCADE) |
|
|
| check_id |
VARCHAR(64) |
Wazuh internal id |
| title, description, rationale, remediation |
TEXT |
|
| result |
VARCHAR(20), INDEX |
passed / failed / not applicable |
| severity |
VARCHAR(20) |
Wazuh-side severity |
compliance_impacts
| Column |
Type |
Notes |
| id, cis_id (INDEX), benchmark (INDEX) |
UNIQUE (cis_id, benchmark) |
|
| level |
VARCHAR(16) |
L1 / L2 / NG |
| title |
VARCHAR(500) |
|
| impact |
INTEGER DEFAULT 50 |
0-100 |
Loaded via POST /compliance/impacts/import (multipart CSV). Unique constraint
makes repeat uploads upsert cleanly.
asset_risk_snapshots
| Column |
Type |
Notes |
| id, asset_id (FK CASCADE) |
|
|
| snapshot_date |
TIMESTAMP, INDEX |
one row per asset per day |
| avs |
FLOAT |
Asset Vulnerability Score 0-100 |
| ass |
FLOAT |
Asset Security Score 0-100 |
| urs |
FLOAT |
Unified Risk Score 0-100 |
| severity |
VARCHAR(16) |
none / low / medium / high / critical |
| criticality_factor |
FLOAT |
snapshot of the multiplier at compute time |
Pruned > 90 days by the 04:00 UTC nightly job.
ai_analyses
| Column |
Type |
Notes |
| id, vulnerability_id (FK CASCADE) |
|
|
| analysis_text |
TEXT |
LLM output |
| threat_level |
VARCHAR(20) |
low/medium/high/critical |
| exploits_found, workarounds, remediation_steps, threat_intel_sources |
TEXT (JSON arrays) |
|
| model_version |
VARCHAR(50) |
|
| confidence_score |
VARCHAR(20) |
|
| analysis_timestamp |
TIMESTAMP |
|
| cache_expires_at |
TIMESTAMP |
TTL for forced refresh |
ai_reports
| Column |
Type |
Notes |
| id, created_at, created_by_id (FK users.id) |
|
|
| global_strategy |
TEXT |
LLM-produced strategy summary |
| recommendations |
TEXT |
JSON array |
Constraints and Foreign Keys
vulnerabilities.asset_id → assets.id (NO CASCADE — vulns survive asset reassignment)
compliance_results.asset_id → assets.id (ON DELETE CASCADE)
compliance_checks.result_id → compliance_results.id (ON DELETE CASCADE)
asset_risk_snapshots.asset_id → assets.id (ON DELETE CASCADE)
notification_logs.{vulnerability,asset,user}_id → NULLABLE — log preserved if entity deleted
audit_logs.user_id → NULLABLE for the same reason
compliance_impacts UNIQUE(cis_id, benchmark)
PostgreSQL enum types created per ENUM column:
userrole, authprovider, assetsource, assetstatus, vulnseverity,
vulnstatus, scantype, scanstatus, scheduleinterval, policystatus,
notificationtype, notificationstatus, auditeventtype.
⚠️ Enums on Postgres: SQLAlchemy defaults to mapping by NAME, but several
enums are stored by VALUE (AuthProvider, ScanType since 019 unification).
These columns declare SQLEnum(..., values_callable=lambda x: [e.value for e in x])
to keep the postgres type and python enum in sync.
Migration History
| Rev |
Title |
Notes |
| 001 |
initial_schema |
Users, assets, vulns, scans, policies |
| 002 |
combined_updates |
Status / severity refinements |
| 003 |
add_user_deleted_audit_event |
AuditEventType extension |
| 004 |
add_ai_reports |
ai_reports table |
| 005 |
add_asset_groups_and_policies |
M:N + policy_id |
| 006 |
lowercase_enum_values |
Normalises legacy mixed-case enums |
| 007 |
add_enrichment_fields |
EPSS / KEV |
| 008 |
add_euvd_fields |
EUVD + critical flag |
| 009 |
multi_provider_auth |
LDAP / OIDC / SAML columns, role mappings |
| 010 |
add_nessus_integration |
sources, nessus_plugin_id, first_detected_by, widens cve_id to 50 |
| 011 |
add_nessus_vpr_score |
Tenable VPR column |
| 012 |
add_exploitation_status |
SSVC exploitation_status |
| 013 |
add_ssvc_technical_impact_automatable |
SSVC technical_impact + automatable |
| 014 |
add_nessus_scan_type |
Adds nessus enum value to scantype |
| 015 |
fix_nessus_scan_type_uppercase |
Postgres enum uppercase NESSUS |
| 016 |
add_compliance_tables |
compliance_results + compliance_checks |
| 017 |
add_urs_schema |
compliance_impacts, asset_risk_snapshots, assets.criticality, assets.compliance_frameworks, compliance_results.weighted_score |
| 018 |
heal_compliance_sequences |
self-heals compliance_results.id / compliance_checks.id sequences |
| 019 |
unify_scan_type_wazuh |
Adds enum value wazuh to scantype (DDL-only) |
| 020 |
backfill_wazuh_scan_type |
no-op — replaced by _display_scan_type() API-layer mapping (see commit 06f1615) |
| 021 |
drop_manual_scan_type_rows |
Removes orphaned scan rows with legacy MANUAL type |
| 022 |
backfill_ssvc_only_exploitation_source |
Pins exploitation_source='vulnrichment' on rows that have SSVC but no source pin |
Migration commands
Performance Optimisations
- B-tree indexes on filter-heavy columns (
cve_id, severity, status, kev_listed, euvd_listed, epss_score, exploitation_status, ssvc_*, nessus_vpr_score, package_name, detected_at).
- Composite-effective queries on
(asset_id, status) use the asset_id + status indexes plus PostgreSQL's bitmap-AND.
- JSON columns (
sources, enrichment_sources, references) keep the row narrow; LIKE queries on them are used sparingly (e.g. sources LIKE '%"wazuh"%').
asset_risk_snapshots pruning at 90 days keeps the trend-arrow query bounded.
kev_cache_json / euvd_cache_json in settings table cache external feeds 24 h, avoiding per-vuln HTTP fetches during enrichment.
Backup Strategy
Restoring a single table is straightforward via pg_restore -t from the dump.
Full DR test recommended quarterly.