Tester reported CVE-2023-48795 hitting PuTTY 0.73 AND WinSCP 6.1.2
on the same host — both joined into a single
`vulnerabilities.package_name` string ("PuTTY..., WinSCP...") with
ONE `fixed_version`. Per-package fix tracking impossible.
Schema (migration 023)
- New table `vulnerability_packages` (vuln_id, package_name,
package_version, fixed_version, source, first_detected_at,
last_seen_at). Unique on (vuln_id, package_name).
- Backfill creates one child per existing vuln carrying the joined
string verbatim (no comma-split — joined names may contain commas).
- ON DELETE CASCADE + ORM passive_deletes so asset deletes propagate.
Sync (Wazuh)
- sync_agent_vulnerabilities collects per-package rows in a dict
keyed by package_name and upserts VulnerabilityPackage children
after the parent insert/update.
- last_seen_at updated each sync; future enhancement can prune
packages Wazuh stopped reporting (mirror parent-row stale logic).
Override service
- _apply_single_override propagates Vulnrichment/NVD/cvelistV5
fixed_version into every child package row that has none AND
whose installed_version differs from the proposed fix
(Ghostscript inclusive-bound case).
API
- VulnerabilityResponse gains `packages: List[PackageInfo]` and
`has_fix_any: bool`. _build_vuln_response emits per-package data.
- Forward refs resolved with model_rebuild().
Frontend
- VulnerabilityPackage type + Vulnerability.packages + has_fix_any
- PATCH AVAILABLE badge on list page now uses has_fix_any +
fixed_version != installed_version check (no more false positives
when supposed fix equals affected version).
- Detail page renders a per-package table (Installed / Fixed in /
FIX indicator / source tag) when child rows exist, falls back to
legacy single-package summary for pseudo-CVEs.
No breaking changes — parent columns (`package_name`,
`package_version`, `fixed_version`) stay for legacy queries. Nessus
sync still writes only the parent row; per-package Nessus support
can land as a follow-up.