Files
vulncheck/tests/test_asset_grace_window.py
T
vulncheck 0b71453c48 chore(release): drop personal attribution from comments and docstrings
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.
2026-08-26 15:34:05 +02:00

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()