# VulnCheck — 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)

```
   ┌─────────┐  M:N (user_groups)  ┌────────┐
   │  User   │◄───────────────────►│ Group  │
   └────┬────┘                     └────┬───┘
        │                               │
        │ 1:N assigned_user_id          │ M:N (asset_groups)
        ▼                               ▼
   ┌──────────────────────────────────────────────┐
   │                    Asset                     │
   │  (hostname, ip, os, wazuh_agent_id,          │
   │   nessus_host_uuid, criticality, policy_id)  │
   └────┬──────────────────┬──────────────────────┘
        │ 1:N              │ 1:N
        ▼                  ▼
   ┌──────────┐    ┌──────────────────┐   ┌───────────┐
   │   Scan   │    │ Vulnerability    │──►│ AIAnalysis│
   │          │    │ (cve, cvss, vpr, │   └───────────┘
   │          │    │  ssvc, sources,  │
   │          │    │  enrichment...)  │
   └──────────┘    └──────────────────┘
                          │ 1:N
                          ▼
                   ┌─────────────────┐
                   │ NotificationLog │
                   └─────────────────┘

   ┌────────────────────┐  1:N  ┌──────────────────┐
   │ ComplianceResult   │──────►│ ComplianceCheck  │
   └────────────────────┘       └──────────────────┘
   ComplianceImpact   (cis_id, benchmark unique) — lookup table
   AssetRiskSnapshot  (asset_id, snapshot_date)  — daily URS history

   ┌────────┐    1:N    ┌──────────┐
   │ Policy │──────────►│  Asset   │   (asset.policy_id)
   └────────┘           └──────────┘

   Standalone tables:
     AuditLog, Setting, ScanSchedule, AIReport
```

---

## 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 |
| 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` / `manual` / `nessus` / etc. |
| 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 | `NEW_VULN` / `SLA_BREACH` / `STATUS_CHANGE` / etc. |
| 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) |

### `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

```bash
# Apply all pending
docker compose exec backend alembic upgrade head

# Show current head
docker compose exec backend alembic current

# Step back one
docker compose exec backend alembic downgrade -1

# Generate new (autogenerate from models)
docker compose exec backend alembic revision --autogenerate -m "add foo column"
```

---

## 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

```bash
# Nightly logical dump
docker compose exec postgres pg_dump -U vulnmanager vulnmanager \
    | gzip > /backup/vulnmanager-$(date +%F).sql.gz

# Point-in-Time Recovery (PITR): enable WAL archiving in postgresql.conf
#   wal_level = replica
#   archive_mode = on
#   archive_command = 'cp %p /backup/wal/%f'
```

Restoring a single table is straightforward via `pg_restore -t` from the dump.
Full DR test recommended quarterly.
