Author SHA1 Message Date
vulncheckandClaude Opus 4.8 37cf6c17b2 chore(migration): alembic 028 reconcile legacy Nessus assets
One-shot data migration that flips ACTIVE NESSUS-sourced assets with
nessus_host_uuid IS NULL to INACTIVE, with audit log entries. Closes
the backlog that the runtime path cannot reach (it only matches by
pinned UUID). Idempotent — clean DBs are a no-op. Downgrade is a
no-op (operator decision to revive).

Apply with:
  docker compose exec backend alembic upgrade head

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:10:06 +02:00
vulncheckandClaude Opus 4.8 3a68ef7fc2 fix(lifecycle): legacy Nessus asset reconcile + diagnostic logging
Asset rows created before nessus_host_uuid was pinned (matched by IP
or hostname in older syncs) are silently skipped by
reconcile_missing_from_sync because id_field.isnot(None) excludes
them. After a reduced scan they stay ACTIVE forever, contradicting
the "sync-driven INACTIVE" promise in feedback 808246f.

- asset_lifecycle.reconcile_missing_from_sync: log a count of legacy
  unpinned ACTIVE assets so the next tester reproduction surfaces
  the root cause immediately.
- nessus_sync.reconcile_legacy_nessus_assets: one-shot helper that
  flips these rows to INACTIVE with an audit-log entry, safe to run
  multiple times.

Fixes feedback 2026-06-02 #3 (INACTIVE not flipping on reduced scan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:47:54 +02:00
vulncheckandClaude Opus 4.8 1e0f3e06bd fix(eol): add VC++ Redistributable slugs + reject sub-component matches
- _PRODUCT_SLUGS extended with visual-cpp aliases (vcredist,
  microsoftvisualc..., msvcr, msvcp) so 2005/2008/2010 etc. flow
  through to endoflife.date's visual-cpp product.
- _WRAPPER_TOKENS adds nativeclient, setupsupportfiles, setup,
  premium, clicktorun, subscription — these name a tracked product
  but have a different lifecycle, so they were producing false EOL
  matches (e.g. SQL Server 2008 R2 Native Client inheriting SQL
  Server's EOL date).

Fixes feedback 2026-06-02 #6 (Coverage Gap missing EOL entries).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:47:13 +02:00
vulncheckandClaude Opus 4.8 9e5b46979e fix(vulns): exclude EOL / Nessus-plugin pseudo-CVEs from published_date view
COALESCE(published_date, detected_at) made every EOL row ride the
detected_at fallback to the top of the "Newly Published" list — a
tester-reported false chronological order. Skip rows whose cve_id
starts with EOL- or NESSUS-PLUGIN- when sort_by=published_date and the
caller did not explicitly search=EOL-.

Fixes feedback 2026-06-02 #5a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:46:46 +02:00
vulncheckandClaude Opus 4.8 2458b940b4 fix(eol): slug-based pseudo-CVE id for Nessus EOL findings
New helper _slug_pseudo_cve resolves the plugin_name to an
endoflife.date slug via eol_service.resolve_product_slug, then emits
EOL-{SLUG}-{VERSION|P####} instead of EOL-NESSUS-{plugin_id}. Office
variants keep the year-based EOL-MS-OFFICE-YYYY id for the language-
pack dedup. Legacy _office_pseudo_cve retained as a fallback when slug
resolution fails.

Detection Sources panel (nessus + plugin_id) is untouched — the
plugin id remains queryable from the vuln.nessus_plugin_id column.

Fixes feedback 2026-06-02 #2b, #4. Existing rows still need a one-off
backfill (out of scope here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:46:02 +02:00
vulncheckandClaude Opus 4.8 a080a7adae fix(dashboard): card layout equal-height + truncate + flags column visible
- card root gets flex flex-col h-full so cards in the 3-col grid are
  equal height (no whitespace below short lists)
- table gains explicit colgroup with table-fixed so the Flags column
  cannot be clipped by overflow-x-auto at lg:1/3 width
- CVE cell now truncates with a tooltip instead of overflowing
- grid wrapper uses items-stretch for symmetric row heights

Fixes feedback 2026-06-02 #2a, #2c, #5b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:45:15 +02:00
vulncheckandClaude Opus 4.8 c1af9741e6 fix(assets): case-insensitive hostname sort + null-safe last_scan
Hostnames now sort by LOWER(hostname) so 'alpine' < 'Webserver'.
last_scan ORDER BY applies nulls_last on both asc and desc (was
implicit on desc only). Fixes feedback 2026-06-02 #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:44:42 +02:00
7 changed files with 249 additions and 22 deletions
@@ -0,0 +1,81 @@
"""Reconcile legacy Nessus-sourced assets without a pinned nessus_host_uuid
Revision ID: 028
Revises: 027
Create Date: 2026-06-02 14:00:00.000000
Tester feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
Nessus scan): the event-driven reconciliation in
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
created by older Nessus syncs that matched by IP or hostname (before the
UUID-backfill path was added) have `nessus_host_uuid IS NULL` and are
silently skipped. After a reduced scan they stay ACTIVE forever, which
contradicts the "sync-driven INACTIVE" promise.
This migration is the one-shot cleanup for the existing backlog (33 rows
in the test instance). New rows created after the 0006 commit (which
adds the diagnostic log + the `reconcile_legacy_nessus_assets` runtime
helper) are handled in code.
Idempotent: a row already INACTIVE matches the filter only when the
status check is omitted, so the body re-checks status before flipping.
Audit-logged via the same `_audit_asset_status` helper as the runtime
path so the audit trail is consistent.
Downgrade is a no-op — restoring a row to ACTIVE would require operator
intent, not a migration reversal.
"""
from alembic import op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Use raw SQL through the migration's session_bind so the connection
# is the same one alembic manages — no extra pool, no second engine.
bind = op.get_bind()
# Re-import the model in the migration context. Alembic env has
# already imported Base.metadata via app.models.base; this import
# pulls in the Asset / AuditLog / AssetSource / AssetStatus enums
# we need for the audit insert.
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_lifecycle import _audit_asset_status
from sqlalchemy.orm import Session
with Session(bind=bind) as db:
legacy = (
db.query(Asset)
.filter(
Asset.source == AssetSource.NESSUS,
Asset.status == AssetStatus.ACTIVE,
Asset.nessus_host_uuid.is_(None),
)
.all()
)
if not legacy:
# Nothing to do — migration is a no-op on already-clean DBs.
return
for a in legacy:
a.status = AssetStatus.INACTIVE
_audit_asset_status(
db,
a,
"active",
"inactive",
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
"flipped by alembic migration 028 (reconcile_legacy_nessus_assets)",
)
db.commit()
def downgrade() -> None:
# No-op. Restoring INACTIVE -> ACTIVE is an operator decision, not a
# migration reversal. The legacy rows can be re-activated by a fresh
# Nessus sync that reports them (event-driven revive in
# reconcile_missing_from_sync).
pass
+9 -3
View File
@@ -7,7 +7,7 @@ from typing import Optional, List, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc, nulls_last
from sqlalchemy import asc, desc, func, nulls_last
from pydantic import BaseModel, field_validator
from app.database import get_db
@@ -387,9 +387,15 @@ async def list_assets(
sort_dir = desc if sort_order == "desc" else asc
if sort_by in _SORT_MAP_DIRECT:
col = _SORT_MAP_DIRECT[sort_by]
# last_scan can be NULL (never-synced) — keep them at the bottom.
if sort_by == "last_scan":
query = query.order_by(sort_dir(nulls_last(col)))
# NULL-safe on BOTH directions — never-scanned assets land at
# the bottom regardless of asc/desc (PG: NULLS LAST is independent
# of the primary direction).
query = query.order_by(nulls_last(sort_dir(col)))
elif sort_by == "hostname":
# Case-insensitive alpha sort — a host called "alpine" must
# not outrank "Webserver" because the literal `A` > `W`.
query = query.order_by(nulls_last(sort_dir(func.lower(col))))
else:
query = query.order_by(sort_dir(col))
elif sort_by == "policy_name":
+10
View File
@@ -696,6 +696,16 @@ async def list_vulnerabilities(
ordering_expr = sa_func.coalesce(
Vulnerability.published_date, Vulnerability.detected_at
)
# Exclude EOL / Nessus-plugin pseudo-CVEs from the "newly published"
# view — they have no real `published_date` and would otherwise ride
# the COALESCE fallback to the top of the list. Skip when the
# caller explicitly asked for EOL rows via `search=EOL-`.
search_eol = (search or "").upper().startswith("EOL")
if not search_eol:
query = query.filter(
~Vulnerability.cve_id.like("EOL-%"),
~Vulnerability.cve_id.like("NESSUS-PLUGIN-%"),
)
if sort_order == "desc":
query = query.order_by(
ordering_expr.is_(None), desc(ordering_expr), desc(Vulnerability.id)
+22
View File
@@ -203,6 +203,28 @@ def reconcile_missing_from_sync(
for a in stale:
a.source = AssetSource.NESSUS
# Diagnostic: log how many candidates we're about to evaluate so
# a tester reporting "INACTIVE never flips" can paste this line
# in the bug report — it tells us if the issue is upstream (no
# nessus_host_uuid pinned) or downstream (reconcile logic).
legacy_unpinned = (
db.query(Asset)
.filter(
Asset.source == source,
Asset.status == AssetStatus.ACTIVE,
id_field.is_(None),
)
.count()
)
if legacy_unpinned:
logger.info(
"asset sync-reconcile (%s): %d ACTIVE assets have no %s pinned "
"— they will be skipped by the per-id reconcile. Consider a "
"host-name backfill job to set nessus_host_uuid for legacy rows.",
source.value if hasattr(source, "value") else source,
legacy_unpinned, id_field.key,
)
active_q = (
db.query(Asset)
.filter(
+18
View File
@@ -108,6 +108,20 @@ _PRODUCT_SLUGS: dict[str, str] = {
"postgres": "postgresql",
"mongodb": "mongodb",
"redis": "redis",
# Microsoft Visual C++ Redistributable (all flavours — 2005/2008/2010/2012/2013/2015-2022).
# endoflife.date exposes the product as `visual-cpp`; map any sane
# spelling here. Versions are matched by endoflife.date.
"visualc": "visual-cpp",
"visualcppredistributable": "visual-cpp",
"microsoftvisualc": "visual-cpp",
"microsoftvisualcp": "visual-cpp",
"microsoftvisualcppr": "visual-cpp",
"microsoftvisualcpprdistributable": "visual-cpp",
"microsoftvisualcplusplus": "visual-cpp",
"vcredist": "visual-cpp",
"vcruntime": "visual-cpp",
"msvcr": "visual-cpp",
"msvcp": "visual-cpp",
# Adobe
"adobeacrobat": "adobe-acrobat",
"adobeacrobatreader": "adobe-acrobat",
@@ -139,6 +153,10 @@ _WRAPPER_TOKENS = (
"veeam", "explorerfor", "backup", "connector", "odbc", "jdbc",
"driver", "clientfor", "agentfor", "pluginfor", "extensionfor",
"providerfor", "managementpack", "monitoringfor",
# Sub-components of a tracked product that have their own (different)
# lifecycle — matching the parent would give a false EOL signal.
"nativeclient", "setupsupportfiles", "setupsql", "setup",
"premium", "clicktorun", "subscription",
)
+83 -3
View File
@@ -204,12 +204,46 @@ def _office_year(plugin_name: str) -> Optional[str]:
def _office_pseudo_cve(plugin_name: str, plugin_id) -> str:
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`."""
"""Return `EOL-MS-OFFICE-YYYY` for Office variants, else `EOL-NESSUS-{pid}`.
NOTE: prefer `_slug_pseudo_cve(plugin_name, plugin_id, installed_version)`
for the slug-based naming (`EOL-{SLUG}-{VERSION}`). Kept as a fallback
when product-name resolution fails.
"""
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
return f"EOL-NESSUS-{plugin_id}"
def _slug_pseudo_cve(plugin_name: str, plugin_id, installed_version: Optional[str] = None) -> str:
"""Return product-name-based pseudo-CVE id (`EOL-MSSQLSERVER-...`) when
we can resolve a slug, falling back to the legacy `EOL-NESSUS-{pid}`.
Slug comes from `eol_service.resolve_product_slug(plugin_name)`. The
trailing token is the installed version (sanitised) or the last 4
digits of the plugin id when no version is present, so the row is
still stable across re-syncs.
"""
# Office is its own special case — keep the year-based id so the
# language-pack dedup in `run_nessus_sync` keeps working.
if _OFFICE_RE.search(plugin_name or "") and _office_year(plugin_name):
return f"EOL-MS-OFFICE-{_office_year(plugin_name)}"
# Lazy import: eol_service imports from a few places; avoid a hard
# import cycle at module load.
try:
from app.services.eol_service import resolve_product_slug
slug = resolve_product_slug(plugin_name)
except Exception:
slug = None
if slug:
if installed_version:
safe_v = re.sub(r"[^A-Za-z0-9._-]", "_", str(installed_version))[:24] or "x"
return f"EOL-{slug.upper()}-{safe_v}"[:50]
# No version → last 4 digits of plugin id keeps it stable
return f"EOL-{slug.upper()}-P{str(plugin_id)[-4:]}"[:50]
return f"EOL-NESSUS-{plugin_id}"
def _normalise_office_pkg(pkg: str) -> str:
"""Collapse all Office sub-flavour strings to the unified `MS Office`."""
if _OFFICE_RE.search(pkg or ""):
@@ -240,7 +274,7 @@ def _upsert_nessus_eol(
widget alongside endoflife.date findings. Dedup key is (cve_id,
asset_id), stable across re-syncs.
"""
cve_id = _office_pseudo_cve(plugin_name, plugin_id)
cve_id = _slug_pseudo_cve(plugin_name, plugin_id, installed_version)
# Strip the "... Unsupported Version Detection" suffix for a clean
# PACKAGE column ("Microsoft SQL Server"). Office sub-flavours collapse
# to "MS Office" so the language-pack noise stops multiplying rows.
@@ -500,7 +534,9 @@ def run_nessus_sync(
# Mark as seen THIS run so the source-backfill below
# doesn't immediately drop nessus + patch the row we
# just upserted (it keys on cve_id membership).
seen_cves_for_asset.add(f"EOL-NESSUS-{plugin_id}")
seen_cves_for_asset.add(
_slug_pseudo_cve(eol_name, plugin_id, installed_version)
)
if _upsert_nessus_eol(
db,
asset=asset,
@@ -874,3 +910,47 @@ def run_nessus_sync(
stats["vulns_marked_patched"], len(stats["unmatched_hosts"]),
)
return stats
def reconcile_legacy_nessus_assets(db: Session) -> dict:
"""One-shot helper for testers: flip ACTIVE NESSUS-sourced assets that
have no `nessus_host_uuid` pinned (legacy rows from before the
reconcile path was hardened) to INACTIVE.
These rows were created by older Nessus syncs that matched by IP
only, so they never get a UUID and are silently skipped by
`reconcile_missing_from_sync`. Without this, a reduced scan leaves
them all ACTIVE.
Returns {"inactivated": int, "scanned": int}.
Safe to run multiple times. Logs an audit entry for each row flipped.
"""
from app.services.asset_lifecycle import _audit_asset_status
from app.models.asset import AssetSource, AssetStatus
legacy = (
db.query(Asset)
.filter(
Asset.source == AssetSource.NESSUS,
Asset.status == AssetStatus.ACTIVE,
Asset.nessus_host_uuid.is_(None),
)
.all()
)
stats = {"scanned": len(legacy), "inactivated": 0}
for a in legacy:
a.status = AssetStatus.INACTIVE
_audit_asset_status(
db, a, "active", "inactive",
"legacy Nessus-sourced asset without pinned nessus_host_uuid — "
"cannot be reconciled event-driven; flipped via reconcile_legacy_nessus_assets",
)
stats["inactivated"] += 1
if stats["inactivated"]:
db.commit()
logger.info(
"nessus legacy reconcile: %d inactivated (of %d legacy ACTIVE rows)",
stats["inactivated"], stats["scanned"],
)
return stats
+26 -16
View File
@@ -34,7 +34,7 @@ function renderVulnWidget(opts: {
: c >= 50 ? 'text-red-700 font-bold'
: c >= 20 ? 'text-orange-700' : 'text-gray-600';
return (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden">
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden flex flex-col h-full">
<div className="p-4 border-b border-gray-200 flex justify-between items-baseline bg-gray-50/50">
<div>
<h3 className="text-base font-bold text-gray-900 font-mono">{title}</h3>
@@ -42,23 +42,33 @@ function renderVulnWidget(opts: {
</div>
<Link href={viewAllHref || "/vulnerabilities"} prefetch className="text-vulncheck-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</Link>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<div className="overflow-x-auto flex-1">
<table className="min-w-full divide-y divide-gray-200 table-fixed">
<colgroup>
<col className="w-[34%]" />
<col className="w-[12%]" />
<col className="w-[10%]" />
<col className="w-[12%]" />
<col className="w-[12%]" />
<col className="w-[20%]" />
</colgroup>
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">CVE</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>
<th className="px-3 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">CVE</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Sev</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CVSS Base Score">CVSS</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="Priority Score 0-100">PRIO</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase" title="CPR 0-100 (JacquesKruger)">CPR</th>
<th className="px-2 py-2 text-left text-[10px] font-mono font-medium text-gray-500 uppercase">Flags</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-xs">
{vulns.map((vuln) => (
<tr key={vuln.id} onClick={() => onRowClick(vuln.cve_id)} className="cursor-pointer hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 whitespace-nowrap font-bold text-indigo-600 hover:text-indigo-800">{vuln.cve_id}</td>
<td className="px-3 py-2 whitespace-nowrap">
<td className="px-2 py-2 font-bold text-indigo-600 hover:text-indigo-800">
<div className="truncate" title={vuln.cve_id}>{vuln.cve_id}</div>
</td>
<td className="px-2 py-2">
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ${vuln.severity === 'critical' ? 'bg-red-100 text-red-700'
: vuln.severity === 'high' ? 'bg-orange-100 text-orange-700'
: vuln.severity === 'medium' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
@@ -66,14 +76,14 @@ function renderVulnWidget(opts: {
{vuln.severity.toUpperCase().slice(0, 4)}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap font-bold">{vuln.cvss_score ?? '—'}</td>
<td className={`px-3 py-2 whitespace-nowrap ${prioCls(vuln.priority_score ?? null)}`}>
<td className="px-2 py-2 font-bold">{vuln.cvss_score ?? '—'}</td>
<td className={`px-2 py-2 ${prioCls(vuln.priority_score ?? null)}`}>
{vuln.priority_score == null ? '—' : vuln.priority_score.toFixed(0)}
</td>
<td className={`px-3 py-2 whitespace-nowrap ${cprCls(vuln.cpr_score ?? null)}`}>
<td className={`px-2 py-2 ${cprCls(vuln.cpr_score ?? null)}`}>
{vuln.cpr_score == null ? '—' : vuln.cpr_score.toFixed(1)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<td className="px-2 py-2">
<div className="flex gap-1 flex-wrap">
{vuln.kev_listed && <span className="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-700">KEV</span>}
{vuln.euvd_listed && <span className="inline-flex items-center rounded bg-blue-100 px-1 py-0.5 text-[9px] font-bold text-blue-700">EUVD</span>}
@@ -664,7 +674,7 @@ export default function Dashboard() {
Vulnrichment corrections + KEV/EUVD just-landed)
middle = Newly Published CVEs (sort published_date desc)
right = Newly EOL / EOS (endoflife.date pseudo-CVEs) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{renderVulnWidget({
title: 'Recent Critical CVEs',
subtitle: 'CVSS ≥ 8 or KEV or EUVD · sorted by priority',