"""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, AssetSource, 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_wazuh_last_scan_is_not_liveness(): """The two instances must agree once the window closes. The CVE sync stamps last_scan on every run, disconnected agent included — it reads the indexer's stored documents, not the host. Counting that as evidence made the nightly reconcile revive exactly the host the Wazuh sync had just deactivated on its keepalive, twice a day, forever. """ now = datetime.now() # Agent 105 six weeks on: keepalive frozen, last_scan fresh. stale_agent = Asset(hostname="n1", status=AssetStatus.ACTIVE, source=AssetSource.WAZUH, wazuh_agent_id="105") stale_agent.last_scan = now - timedelta(hours=6) stale_agent.last_seen = now - timedelta(days=50) # No agent, no keepalive — a Nessus host is only ever known by its scan. nessus_host = Asset(hostname="fw-01", status=AssetStatus.ACTIVE, source=AssetSource.NESSUS) nessus_host.last_scan = now - timedelta(hours=6) nessus_host.last_seen = None db = _DB(Setting(key="asset_inactive_after_days", value="42"), [stale_agent, nessus_host]) reconcile_asset_lifecycle(db) assert stale_agent.status is AssetStatus.INACTIVE, ( "a Wazuh agent silent for 50 days must age out — its fresh last_scan " "is the indexer talking, not the host") assert nessus_host.status is AssetStatus.ACTIVE, ( "a Nessus host has no keepalive; its scan stamp is all the evidence " "there is and must still count") 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_wazuh_last_scan_is_not_liveness() _test_sync_fetches_disconnected_agents() print("OK — offline hosts keep their CVEs inside the six-week grace window") if __name__ == "__main__": demo()