Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37cf6c17b2 | ||
|
|
3a68ef7fc2 | ||
|
|
1e0f3e06bd | ||
|
|
9e5b46979e | ||
|
|
2458b940b4 | ||
|
|
a080a7adae | ||
|
|
c1af9741e6 |
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 ></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',
|
||||
|
||||
Reference in New Issue
Block a user