fix(wazuh): give offline hosts a six-week grace window before their CVEs vanish

The asset sync fetched active agents only, and the sync-driven reconcile
inactivates every agent id the sync does not report. Since INACTIVE assets are
hidden from every CVE view, a host that was merely switched off lost all its
findings at the next sync — while Wazuh itself kept listing the agent and its
vulnerabilities. Holiday, sick leave or a spare laptop in a drawer looked
exactly like a decommissioned machine.

Root cause confirmed: not the 30-day soft-inactive window (which never got a
chance to apply here), but the connection-state filter feeding seen_ids.

- sync fetches ALL agents, paginated; disconnected ones stay in seen_ids
- last_seen now carries Wazuh's lastKeepAlive instead of "when we synced",
  so there is a real stamp to measure against
- per-agent status comes from status_for_last_seen(): ACTIVE inside the
  window, INACTIVE outside — replaces the binary connected/disconnected test
- reconcile_asset_lifecycle uses max(last_scan, last_seen): a host that keeps
  checking in but cannot be scanned while offline no longer ages out early
- window default 30 -> 42 days, one setting for both paths; migration 044
  rewrites only rows still holding the old default
- agents registered but never connected are skipped instead of imported

tests/test_asset_grace_window.py covers the window edges, UTC keepalive
parsing, the newest-stamp reconcile, and guards the sync against a
status filter being reintroduced.
This commit is contained in:
2026-08-17 11:07:30 +02:00
parent 630f34f450
commit 0845fbe5db
4 changed files with 287 additions and 13 deletions
@@ -0,0 +1,36 @@
"""Widen the asset inactivity window from 30 to 42 days (six weeks)
Revision ID: 044
Revises: 043
Create Date: 2026-08-17 10:00:00.000000
The window now governs the Wazuh sync too: a disconnected agent keeps its
findings visible until the window closes, instead of losing them at the next
sync. Four weeks is too tight for a three-week absence plus a weekend, so the
default moves to six.
Only rows still holding the old default '30' are rewritten — an operator who
deliberately set another value keeps it. Assets whose stored settings row does
not exist at all pick up the new code default (asset_lifecycle._DEFAULT_DAYS).
"""
from alembic import op
revision = "044"
down_revision = "043"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"UPDATE settings SET value = '42' "
"WHERE key = 'asset_inactive_after_days' AND trim(value) = '30'"
)
def downgrade() -> None:
op.execute(
"UPDATE settings SET value = '30' "
"WHERE key = 'asset_inactive_after_days' AND trim(value) = '42'"
)
+47 -8
View File
@@ -865,15 +865,46 @@ def sync_wazuh_assets(
indexer_password=indexer_password,
verify_ssl=config.get("verify_ssl", False)
) as client:
agents = client.get_agents(status="active") # Only sync active agents? or all?
# Let's get all to be safe, filter by active later if needed
# agents = client.get_agents()
# ALL agents, disconnected ones included. Fetching active-only was
# a data-loss bug, not a filter: the reconcile below inactivates
# every agent id this sync does not report, and an INACTIVE asset
# is hidden from every CVE view — so a laptop switched off over
# the weekend lost its findings within one sync, while Wazuh
# itself kept showing them. How long an offline host stays visible
# is now the grace window's decision (asset_inactive_after_days),
# not the TCP connection's.
from app.services.asset_lifecycle import (
parse_source_timestamp,
status_for_last_seen,
threshold_days,
)
grace_days = threshold_days(db)
agents = []
while True:
page = client.get_agents(limit=500, offset=len(agents))
agents.extend(page)
# ponytail: 20k-agent ceiling guards against a server that
# ignores `offset` and would otherwise loop forever. Raise it
# if a deployment ever gets that big.
if len(page) < 500 or len(agents) >= 20000:
break
stats["total_from_wazuh"] = len(agents)
stats["never_connected_skipped"] = 0
for agent in agents:
agent_id = agent.get("id")
if agent_id == "000": continue # Skip manager itself
# Registered but never checked in: no inventory, no keepalive,
# nothing to scan. Left out of seen_ids on purpose so a stale
# asset behind such an id still ages out normally.
last_keepalive = parse_source_timestamp(agent.get("lastKeepAlive"))
if last_keepalive is None and agent.get("status") == "never_connected":
stats["never_connected_skipped"] += 1
continue
seen_wazuh_agent_ids.add(agent_id)
hostname = agent.get("name")
@@ -895,9 +926,13 @@ def sync_wazuh_assets(
asset.ip_address = ip or asset.ip_address
asset.operating_system = os_name or asset.operating_system
asset.os_version = os_version or asset.os_version
asset.last_seen = datetime.now()
# Wazuh's own clock, not ours: `datetime.now()` recorded
# when TrueVuln ran a sync, which made every registered
# agent look permanently fresh and left no usable stamp to
# measure a grace window against.
asset.last_seen = last_keepalive or datetime.now()
asset.source = AssetSource.WAZUH
asset.status = AssetStatus.ACTIVE if agent.get("status") == "active" else AssetStatus.INACTIVE
asset.status = status_for_last_seen(asset.last_seen, grace_days)
stats["updated"] += 1
else:
# Create
@@ -905,6 +940,7 @@ def sync_wazuh_assets(
default_group_setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
group_id = int(default_group_setting.value) if default_group_setting and default_group_setting.value else None
first_seen = last_keepalive or datetime.now()
new_asset = Asset(
hostname=hostname,
ip_address=ip,
@@ -912,8 +948,11 @@ def sync_wazuh_assets(
operating_system=os_name,
os_version=os_version,
source=AssetSource.WAZUH,
status=AssetStatus.ACTIVE,
last_seen=datetime.now()
# A host discovered for the first time but offline for
# months arrives INACTIVE — it should not import as a
# live system just because we happened to meet it now.
status=status_for_last_seen(first_seen, grace_days),
last_seen=first_seen
)
# Handle default group
+62 -5
View File
@@ -14,7 +14,13 @@ views but its rows + audit log survive. If a later sync sees it again
it auto-reactivates to ACTIVE.
Settings:
asset_inactive_after_days integer, default 30. 0 = disabled.
asset_inactive_after_days integer, default 42 (six weeks). 0 = disabled.
Six weeks, not one connection loss: a host that is switched off is not a
host that is gone. Holiday, sick leave and a spare laptop in a drawer all
look identical to an agent-connection check, so the same window governs
both the time-based reconcile here and the per-agent status the Wazuh sync
writes (see `status_for_last_seen`).
Both transitions are audit-logged (user_id=None = automated).
"""
@@ -33,7 +39,7 @@ from app.models.setting import Setting
logger = logging.getLogger(__name__)
_SETTING_KEY = "asset_inactive_after_days"
_DEFAULT_DAYS = 30
_DEFAULT_DAYS = 42
def _threshold_days(db: Session) -> int:
@@ -46,6 +52,50 @@ def _threshold_days(db: Session) -> int:
return _DEFAULT_DAYS
# Public name — the Wazuh sync reads the same knob so one setting governs
# both the per-agent status and the nightly reconcile.
threshold_days = _threshold_days
def parse_source_timestamp(raw) -> Optional[datetime]:
"""Wazuh/Nessus report UTC ISO stamps; our DateTime columns are naive local.
Comparing a tz-aware stamp against `datetime.now()` raises, so everything
entering `last_seen` is converted to naive local time here.
"""
if not raw:
return None
try:
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except (ValueError, TypeError):
return None
return dt.astimezone().replace(tzinfo=None) if dt.tzinfo else dt
def status_for_last_seen(
last_seen: Optional[datetime],
days: int,
now: Optional[datetime] = None,
) -> AssetStatus:
"""ACTIVE while the host was last seen inside the grace window.
Replaces the binary "is the agent connected right now" test the Wazuh
sync used to run. A disconnected agent keeps its findings visible until
the window closes — Wazuh itself keeps showing them, and a laptop that
is off for a week is not a patched laptop.
days <= 0 disables the window (same knob as reconcile_asset_lifecycle):
nothing ages out, only a vanished agent id or an operator decommission
hides an asset. No stamp at all → INACTIVE (never checked in).
"""
if days <= 0:
return AssetStatus.ACTIVE
if last_seen is None:
return AssetStatus.INACTIVE
cutoff = (now or datetime.now()) - timedelta(days=days)
return AssetStatus.ACTIVE if last_seen >= cutoff else AssetStatus.INACTIVE
# Explicit audit-event mapping. Anything not in this dict falls through to
# ASSET_UPDATED. Operators reading the audit log can tell a sync-driven
# deactivation from a manual decommission because the transition shape is
@@ -110,7 +160,12 @@ def reconcile_asset_lifecycle(db: Session) -> dict:
# instantly vanish, but an old never-synced one is a corpse too).
active = db.query(Asset).filter(Asset.status == AssetStatus.ACTIVE).all()
for asset in active:
ref = asset.last_scan or asset.created_at
# Newest evidence of existence wins. last_scan alone was wrong for a
# host Wazuh still knows but cannot scan while it is offline: the
# agent keeps checking in (last_seen), no inventory is fetched
# (last_scan stands still) — and the asset aged out mid-grace-window.
stamps = [t for t in (asset.last_scan, asset.last_seen) if t]
ref = max(stamps) if stamps else asset.created_at
if ref is None:
continue
if ref < cutoff:
@@ -118,7 +173,8 @@ def reconcile_asset_lifecycle(db: Session) -> dict:
asset.status = AssetStatus.INACTIVE
_audit_asset_status(
db, asset, old, "inactive",
f"not seen in any source for >{days}d (last_scan={asset.last_scan})",
f"not seen in any source for >{days}d "
f"(last_scan={asset.last_scan}, last_seen={asset.last_seen})",
)
stats["inactivated"] += 1
@@ -126,7 +182,8 @@ def reconcile_asset_lifecycle(db: Session) -> dict:
# (DECOMMISSIONED is operator-final — never auto-revived.)
inactive = db.query(Asset).filter(Asset.status == AssetStatus.INACTIVE).all()
for asset in inactive:
if asset.last_scan and asset.last_scan >= cutoff:
stamps = [t for t in (asset.last_scan, asset.last_seen) if t]
if stamps and max(stamps) >= cutoff:
asset.status = AssetStatus.ACTIVE
_audit_asset_status(
db, asset, "inactive", "active",
+142
View File
@@ -0,0 +1,142 @@
"""Offline hosts keep their CVEs for six weeks — run: python tests/test_asset_grace_window.py
A Wazuh agent that is merely switched off used to lose every finding at the
next sync: the sync fetched active agents only, and the reconcile inactivates
any agent id it does not see — INACTIVE assets are hidden from all CVE views.
Wazuh itself keeps showing those agents, so the dashboard disagreed with its
own source for the most ordinary reason there is (holiday, sick leave, a
laptop in a drawer).
Now `last_seen` carries Wazuh's own lastKeepAlive and one grace window
(asset_inactive_after_days, default 42) decides: inside the window the host
stays ACTIVE with all findings, outside it ages out as before.
"""
import os
import sys
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.models.asset import Asset, AssetStatus # noqa: E402
from app.models.setting import Setting # noqa: E402
from app.services.asset_lifecycle import ( # noqa: E402
parse_source_timestamp,
reconcile_asset_lifecycle,
status_for_last_seen,
)
class _Q:
def __init__(self, result):
self._result = result
def filter(self, *a, **k):
return self
def first(self):
return self._result[0] if self._result else None
def all(self):
return self._result
class _DB:
"""Just enough Session for reconcile_asset_lifecycle."""
def __init__(self, setting, assets):
self.setting = setting
self.assets = assets
def query(self, model):
if model is Setting:
return _Q([self.setting] if self.setting else [])
if model is Asset:
return _Q(self.assets)
return _Q([])
def add(self, obj):
pass
def commit(self):
pass
def _test_grace_window():
now = datetime(2026, 8, 17, 12, 0, 0)
# Disconnected two weeks — Wazuh still lists it, so must keep its CVEs.
assert status_for_last_seen(now - timedelta(days=14), 42, now) is AssetStatus.ACTIVE
# One day inside the window is still inside.
assert status_for_last_seen(now - timedelta(days=41, hours=23), 42, now) is AssetStatus.ACTIVE
# Past six weeks: gone for real.
assert status_for_last_seen(now - timedelta(days=43), 42, now) is AssetStatus.INACTIVE
# Never checked in at all.
assert status_for_last_seen(None, 42, now) is AssetStatus.INACTIVE
# 0 disables the window entirely — same knob semantics as the reconcile.
assert status_for_last_seen(now - timedelta(days=999), 0, now) is AssetStatus.ACTIVE
assert status_for_last_seen(None, 0, now) is AssetStatus.ACTIVE
def _test_keepalive_parsing():
# Wazuh reports UTC with a Z suffix; comparing that against datetime.now()
# raises unless it is converted to naive local time first.
parsed = parse_source_timestamp("2026-08-17T10:00:00Z")
assert parsed is not None and parsed.tzinfo is None, parsed
_ = parsed < datetime.now() # must not raise
assert parse_source_timestamp(None) is None
assert parse_source_timestamp("") is None
assert parse_source_timestamp("not a date") is None
def _test_reconcile_prefers_newest_stamp():
now = datetime.now()
# Offline for two months → no inventory fetch (last_scan stands still),
# but the agent checked in two days ago. Must survive.
offline_but_seen = Asset(hostname="laptop-holiday", status=AssetStatus.ACTIVE)
offline_but_seen.last_scan = now - timedelta(days=60)
offline_but_seen.last_seen = now - timedelta(days=2)
# Nothing has heard from this one in two months → really gone.
gone = Asset(hostname="decommissioned-vm", status=AssetStatus.ACTIVE)
gone.last_scan = now - timedelta(days=60)
gone.last_seen = now - timedelta(days=60)
db = _DB(Setting(key="asset_inactive_after_days", value="42"),
[offline_but_seen, gone])
stats = reconcile_asset_lifecycle(db)
assert offline_but_seen.status is AssetStatus.ACTIVE, (
"a host seen 2 days ago must keep its findings even if the last "
"inventory scan is 60 days old")
assert gone.status is AssetStatus.INACTIVE, "60d without any contact must age out"
assert stats["inactivated"] == 1, stats
assert stats["threshold_days"] == 42, stats
def _test_sync_fetches_disconnected_agents():
# Guard for the actual data-loss line: the moment the Wazuh asset sync
# filters for connected agents again, every offline host's findings
# disappear at the next sync, no matter how wide the grace window is.
src = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "routers", "assets.py")
with open(src, encoding="utf-8") as fh:
body = fh.read()
sync = body.split("def sync_wazuh_assets")[1].split("\n@router")[0]
assert 'get_agents(status=' not in sync, (
"the asset sync must fetch ALL agents — a status filter drops "
"disconnected agents out of seen_ids and the reconcile then hides "
"their CVEs")
def demo():
_test_grace_window()
_test_keepalive_parsing()
_test_reconcile_prefers_newest_stamp()
_test_sync_fetches_disconnected_agents()
print("OK — offline hosts keep their CVEs inside the six-week grace window")
if __name__ == "__main__":
demo()