Comments across the codebase credited one individual by role and, in places, described that person's own machines: which SQL Server versions a host ran, which devices were enrolled, what a particular dashboard showed, how many findings sat open on which server. In a public repository that reads as a profile of someone's unpatched estate. The observations are why the code looks the way it does, so they stay. Every CVE id, version, build number, count and date is preserved, as are the verbatim quotes that motivated specific sort and filter rules — only the attribution changes, to "field report", "observed", "a host". A local variable in tests/test_autodesk_year.py was renamed for the same reason; its value and every assertion around it are byte-identical. PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of organisation this was built for, and a support section pointing at an internal team, both replaced with neutral wording. Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one swaps. detect_changes reports 104 touched symbols and zero affected execution flows, and all 55 test scripts pass. Nothing here needs re-testing.
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""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()
|