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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user