fix(wazuh): don't move a new host onto an old asset via a reused agent id
Agents that re-enroll on a fresh Wazuh 5 server get ids from 001 again. The asset sync matched by agent id first, so the new 001 inherited the old 001's asset, findings and history. The id now stops matching when the agent's name is a different host than the asset's (case and FQDN vs short name tolerated) and its dateAdd is later than the registration stored on the asset (new column wazuh_registered_at, migration 066), or later than its last keepalive on rows synced before 066. Either condition alone keeps the asset, so a rename by another source and a migrated identity stay put. The old asset is unlinked with an audit entry, and the agent goes through the hostname stage. The vulnerability sync applies the same rule and skips such an agent until the next asset sync.
This commit is contained in:
+42
-3
@@ -2528,7 +2528,7 @@ breaks the case the hostname stage exists for.
|
||||
| XenServer | `xen_host_uuid` | …another `xen_host_uuid` | XAPI host UUID survives a rename in XenCenter |
|
||||
| IGEL | `igel_unit_id`, then `igel_serial` | …another `igel_unit_id`, or any `netdisco_device_ip` | `unitID` = `mac`; the serial (`deviceSerialNumber`) catches a board/NIC swap, but only takes an asset whose unit ID is absent from the same run — two UD Pockets on one PC share a host serial (055) |
|
||||
| Intune | `intune_device_id` + `intune_serial` | the asset carries another `intune_serial` | the device id is an *enrolment* fact and changes on re-enrol; the serial is the hardware (054) |
|
||||
| Wazuh | `wazuh_agent_id` | the name is held by an agent **this same run reports** | authd `<force>` replaces a same-named agent, so a re-registered host returns under a new id and the old one is *gone from the list* |
|
||||
| Wazuh | `wazuh_agent_id`, unless the id was reused (below) | the name is held by an agent **this same run reports** | authd `<force>` replaces a same-named agent, so a re-registered host returns under a new id and the old one is *gone from the list* |
|
||||
| Defender | `defender_machine_id` (fill-only), `aad_device_id` | — no pin guard, see below | no serial in the machines API, and several machine records legitimately map to one asset |
|
||||
|
||||
Three rules hold everywhere: an asset with **no** pin of that source stays
|
||||
@@ -2548,6 +2548,40 @@ Netdisco never consolidate with each other: a thin client is not a network
|
||||
device, so a name or address shared across the two is two devices
|
||||
(`asset_matching._not_pinned_by`, field report 2026-09-15).
|
||||
|
||||
**A reused Wazuh agent id is not a pin.** Agents that re-enroll on a fresh
|
||||
Wazuh server (5.0 is a new deployment, see "Wazuh 5.0") get ids from 001
|
||||
again, and the new 001 is a different host than the old 001.
|
||||
`asset_lifecycle.unlink_reused_wazuh_id` drops the id from the asset when
|
||||
**both** of these hold:
|
||||
|
||||
- the asset's name and the agent's `name` are different hosts
|
||||
(`asset_matching.different_hosts`: case, FQDN against short name, and a
|
||||
nameless side do not count as different), and
|
||||
- the agent's `dateAdd` is later than the registration we know: the
|
||||
`wazuh_registered_at` stored by earlier syncs (066), or on a row synced
|
||||
before 066, its `wazuh_last_seen`. No registration of the same agent can
|
||||
come after a keepalive of that agent.
|
||||
|
||||
Either condition alone keeps the asset. A different name with the same
|
||||
registration is a rename by another source (Intune renames the asset, the
|
||||
agent keeps its enrolment name). The same name with a newer registration is a
|
||||
migrated identity, or a host that re-enrolled and happened to get its own
|
||||
id back. The stored date matters because the usual migration runs both
|
||||
servers in parallel: host B enrolls on the new server and gets 001 while host
|
||||
A, the old 001, still checks in with the old one, so A's last keepalive is
|
||||
*later* than B's `dateAdd`. Only the stored date catches that. The fallback
|
||||
catches a cutover where the old server was switched off first. The asset sync
|
||||
and the Wazuh vulnerability sync both apply the rule (the latter skips the
|
||||
agent until the next asset sync places it). An unlink writes an
|
||||
`ASSET_UPDATED` audit entry "Wazuh agent id … reused by another host" and a
|
||||
WARNING, and is counted as `wazuh_id_reused` in the asset sync stats. The
|
||||
agent then goes through the hostname stage like any new agent. The old asset
|
||||
keeps its history and findings, ages out on `last_seen`, and is re-adopted by
|
||||
name when its own host re-enrolls. Not covered: a reused id whose new host
|
||||
has the same name as the old one, and a manual rename that happens to match
|
||||
the new host. A name match on a reused id is treated as the same host
|
||||
(tests: `tests/test_wazuh_reused_agent_id.py`).
|
||||
|
||||
Renames: Netdisco, vCenter, XenServer, IGEL and Intune adopt the name the source reports
|
||||
when the asset was matched on a pin. That goes through
|
||||
`asset_lifecycle.rename`, which logs it (INFO) and writes an `ASSET_UPDATED`
|
||||
@@ -3934,8 +3968,13 @@ Other changes on 5:
|
||||
**Migrate the agent identities.** Carry `client.keys` and the `global.db`
|
||||
agent rows over, or run `tools/migration/wazuh-migrate-identity.py`; both
|
||||
keep the agent IDs. Agents that re-enroll on a fresh 5 server get new IDs
|
||||
starting again at 001, and the asset sync matches by agent ID first. It
|
||||
would move host A's history onto the new 001.
|
||||
starting again at 001. The sync detects a reused ID when the agent's name
|
||||
differs from the asset's and its `dateAdd` is later than the one stored on
|
||||
the asset ("A reused Wazuh agent id is not a pin" in "What identifies a
|
||||
device, per source"). It cannot tell a reused ID whose new host carries the
|
||||
old host's name. Run one Wazuh asset sync on 4.x after deploying revision
|
||||
066, so every asset has its `dateAdd` stored before the cutover; without it,
|
||||
only a cutover with the old server already off is detected.
|
||||
|
||||
Known gaps:
|
||||
- An agent upgraded to 5 whose inventory is already synced, but whose first
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add assets.wazuh_registered_at
|
||||
|
||||
Revision ID: 066
|
||||
Revises: 065
|
||||
Create Date: 2026-11-01 10:00:00.000000
|
||||
|
||||
The Wazuh agent's `dateAdd`. Agents that re-enroll on a fresh Wazuh server
|
||||
get ids from 001 again; without the registration date the asset sync cannot
|
||||
tell the new 001 from the old one (asset_lifecycle.unlink_reused_wazuh_id).
|
||||
Filled by the next Wazuh sync. Idempotent (IF NOT EXISTS), same shape as
|
||||
053–065.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "066"
|
||||
down_revision = "065"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS wazuh_registered_at TIMESTAMP;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS wazuh_registered_at;")
|
||||
@@ -165,6 +165,9 @@ class Asset(Base, TimestampMixin):
|
||||
# two software lists is current — `audit_events.asset_inventory` needs that.
|
||||
wazuh_last_seen = Column(DateTime, nullable=True)
|
||||
intune_last_seen = Column(DateTime, nullable=True)
|
||||
# The agent's `dateAdd`: tells a reused agent id (fresh Wazuh server,
|
||||
# ids from 001 again) from the agent that held it (066).
|
||||
wazuh_registered_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Zusätzliche Informationen
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
+10
-1
@@ -1236,6 +1236,7 @@ def _sync_wazuh_assets(db: Session) -> dict:
|
||||
parse_source_timestamp,
|
||||
status_for_last_seen,
|
||||
threshold_days,
|
||||
unlink_reused_wazuh_id,
|
||||
)
|
||||
grace_days = threshold_days(db)
|
||||
|
||||
@@ -1251,6 +1252,7 @@ def _sync_wazuh_assets(db: Session) -> dict:
|
||||
|
||||
stats["total_from_wazuh"] = len(agents)
|
||||
stats["never_connected_skipped"] = 0
|
||||
stats["wazuh_id_reused"] = 0
|
||||
# Every agent id this run reports — the discriminator for the
|
||||
# hostname fallback below.
|
||||
live_agent_ids = {a.get("id") for a in agents if a.get("id")}
|
||||
@@ -1276,8 +1278,13 @@ def _sync_wazuh_assets(db: Session) -> dict:
|
||||
os_version = os_info.get("version")
|
||||
|
||||
# Check if exists by Wazuh ID
|
||||
registered = parse_source_timestamp(agent.get("dateAdd"))
|
||||
asset = db.query(Asset).filter(Asset.wazuh_agent_id == agent_id).first()
|
||||
|
||||
# …unless a fresh Wazuh server handed the id to another host.
|
||||
if asset and unlink_reused_wazuh_id(db, asset, hostname, registered):
|
||||
stats["wazuh_id_reused"] += 1
|
||||
asset = None
|
||||
|
||||
if not asset and hostname:
|
||||
# Check by hostname to avoid duplicates if manually added
|
||||
# …but only if the name is free. This stage is what keeps
|
||||
@@ -1315,6 +1322,7 @@ def _sync_wazuh_assets(db: Session) -> dict:
|
||||
# host after the agent died.
|
||||
advance_last_seen(asset, last_keepalive or datetime.now(), "wazuh")
|
||||
asset.wazuh_last_seen = last_keepalive
|
||||
asset.wazuh_registered_at = registered or asset.wazuh_registered_at
|
||||
asset.source = AssetSource.WAZUH
|
||||
# Audited, not a bare assignment: this line revives an
|
||||
# asset the reconcile deactivated one sync earlier (agent
|
||||
@@ -1350,6 +1358,7 @@ def _sync_wazuh_assets(db: Session) -> dict:
|
||||
last_seen=first_seen,
|
||||
last_seen_source="wazuh",
|
||||
wazuh_last_seen=last_keepalive,
|
||||
wazuh_registered_at=registered,
|
||||
)
|
||||
|
||||
# Handle default group
|
||||
|
||||
@@ -2077,6 +2077,11 @@ def _run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
|
||||
if not asset:
|
||||
continue # Skip if asset not in DB
|
||||
# A reused id is another host's: its CVEs wait for the asset sync.
|
||||
from app.services.asset_lifecycle import parse_source_timestamp, unlink_reused_wazuh_id
|
||||
if unlink_reused_wazuh_id(db, asset, agent.get("name"),
|
||||
parse_source_timestamp(agent.get("dateAdd"))):
|
||||
continue
|
||||
|
||||
# Refresh OS name/build from the agent record. Only the separate
|
||||
# ASSET sync did this before, so a host running just the vuln sync
|
||||
|
||||
@@ -529,6 +529,55 @@ def unlink_gone_wazuh_agents(db: Session, seen_ids: set) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def unlink_reused_wazuh_id(db: Session, asset: Asset, agent_name: Optional[str],
|
||||
registered: Optional[datetime]) -> bool:
|
||||
"""Drop `asset`'s agent id if the agent now behind it is another host.
|
||||
|
||||
Agents that re-enroll on a fresh Wazuh server get ids from 001 again, and
|
||||
the id stage moved the new 001 onto the old 001's asset with its findings.
|
||||
Both must hold: the names differ (FQDN vs short and case tolerated), and
|
||||
the agent was registered after the one we know — after its stored
|
||||
`dateAdd`, or on a row without one, after its last keepalive, which a
|
||||
registration of that same agent can never be. A name alone is no
|
||||
evidence (Intune renames the asset, the agent keeps its enrolment name);
|
||||
a newer date alone neither (a migrated identity may carry a new one).
|
||||
Unlinked, the agent goes through the hostname stage like any new agent;
|
||||
the old asset ages out on `last_seen` or is re-adopted by name when its
|
||||
host re-enrols. Caller commits.
|
||||
"""
|
||||
from app.services.asset_matching import different_hosts
|
||||
|
||||
known = asset.wazuh_registered_at or asset.wazuh_last_seen
|
||||
if not (registered and known and registered > known
|
||||
and different_hosts(agent_name, asset.hostname)):
|
||||
return False
|
||||
old_id = asset.wazuh_agent_id
|
||||
logger.warning("asset %s (%s): Wazuh agent id %s reused by '%s' (registered %s, "
|
||||
"known %s) — unlinked", asset.id, asset.hostname, old_id,
|
||||
agent_name, registered, known)
|
||||
asset.wazuh_agent_id = None
|
||||
asset.wazuh_registered_at = None
|
||||
try:
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
db.add(AuditLog(
|
||||
user_id=None,
|
||||
event_type=AuditEventType.ASSET_UPDATED,
|
||||
event_description=(f"Wazuh agent id {old_id} reused by another host "
|
||||
f"({agent_name}), unlinked from {asset.hostname}")[:500],
|
||||
resource_type="asset",
|
||||
resource_id=str(asset.id),
|
||||
old_value=old_id,
|
||||
new_value=json.dumps({"wazuh_agent_id": None, "agent_name": agent_name,
|
||||
"registered": str(registered), "known": str(known)}),
|
||||
timestamp=datetime.now(),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("wazuh id-reuse audit failed for %s: %s", asset.id, e)
|
||||
# The unique id must be free before the agent's own asset takes it.
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def reconcile_nessus_by_seen_ids(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""A reused Wazuh agent id does not move a new host onto an old asset.
|
||||
|
||||
Agents that re-enroll on a fresh Wazuh 5 server (identities not migrated) get
|
||||
ids from 001 again. The sync matched by agent id first, so the new host that
|
||||
got 001 inherited the old 001's asset, findings and history.
|
||||
|
||||
Run: python tests/test_wazuh_reused_agent_id.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus # noqa: E402
|
||||
from app.models.audit_log import AuditLog # noqa: E402
|
||||
from app.routers import assets as assets_router # noqa: E402
|
||||
from app.services.asset_lifecycle import parse_source_timestamp # noqa: E402
|
||||
|
||||
|
||||
_NOW = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _stamp(days_ago):
|
||||
return (_NOW - timedelta(days=days_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _sync(db, agents):
|
||||
class _Client:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get_agents(self, limit, offset):
|
||||
return [] if offset else agents
|
||||
|
||||
def get_cluster_nodes(self):
|
||||
return []
|
||||
|
||||
cfg = '{"api_url": "https://wazuh.test", "username": "u", "password": "p"}'
|
||||
with mock.patch.object(assets_router, "WazuhClient", _Client), \
|
||||
mock.patch("app.auth.setting_crypto.read_setting_value", return_value=cfg):
|
||||
return assets_router._sync_wazuh_assets(db)
|
||||
|
||||
|
||||
def _db():
|
||||
db = sessionmaker(bind=create_engine("sqlite://"))()
|
||||
Base.metadata.create_all(db.get_bind())
|
||||
return db
|
||||
|
||||
|
||||
def _agent(name, registered_days_ago, keepalive_days_ago=0):
|
||||
return {"id": "001", "name": name, "status": "active",
|
||||
"dateAdd": _stamp(registered_days_ago),
|
||||
"lastKeepAlive": _stamp(keepalive_days_ago)}
|
||||
|
||||
|
||||
def _old_asset(db, **kw):
|
||||
a = Asset(hostname="host-a", source=AssetSource.WAZUH, status=AssetStatus.ACTIVE,
|
||||
wazuh_agent_id="001", last_seen=datetime.now() - timedelta(days=1),
|
||||
wazuh_last_seen=datetime.now() - timedelta(days=1), **kw)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
return a
|
||||
|
||||
|
||||
def test_reused_id_after_parallel_migration_is_a_new_host():
|
||||
# The common migration: host-b re-enrolled on the new server (and got
|
||||
# 001) while host-a still checked in with the old server for days.
|
||||
db = _db()
|
||||
old = _old_asset(db, wazuh_registered_at=parse_source_timestamp(_stamp(400)))
|
||||
|
||||
stats = _sync(db, [_agent("host-b", registered_days_ago=5)])
|
||||
|
||||
assert old.wazuh_agent_id is None
|
||||
assert old.wazuh_registered_at is None
|
||||
assert old.hostname == "host-a"
|
||||
new = db.query(Asset).filter(Asset.wazuh_agent_id == "001").one()
|
||||
assert new.id != old.id and new.hostname == "host-b"
|
||||
assert new.wazuh_registered_at == parse_source_timestamp(_stamp(5))
|
||||
assert stats["wazuh_id_reused"] == 1
|
||||
assert db.query(AuditLog).filter(AuditLog.resource_id == str(old.id),
|
||||
AuditLog.event_description.contains("reused")).count() == 1
|
||||
|
||||
|
||||
def test_reused_id_on_a_row_from_before_the_column():
|
||||
# No stored registration yet: the last keepalive the old agent sent is
|
||||
# the evidence. A registration after it cannot be the same agent.
|
||||
db = _db()
|
||||
old = _old_asset(db)
|
||||
|
||||
_sync(db, [_agent("host-b", registered_days_ago=0)])
|
||||
|
||||
assert old.wazuh_agent_id is None
|
||||
assert db.query(Asset).filter(Asset.wazuh_agent_id == "001").one().hostname == "host-b"
|
||||
|
||||
|
||||
def test_reused_id_adopts_the_new_hosts_existing_asset():
|
||||
db = _db()
|
||||
old = _old_asset(db, wazuh_registered_at=parse_source_timestamp(_stamp(400)))
|
||||
b = Asset(hostname="HOST-B", source=AssetSource.INTUNE, status=AssetStatus.ACTIVE,
|
||||
intune_device_id="dev-b")
|
||||
db.add(b)
|
||||
db.commit()
|
||||
|
||||
_sync(db, [_agent("host-b", registered_days_ago=5)])
|
||||
|
||||
assert old.wazuh_agent_id is None
|
||||
assert b.wazuh_agent_id == "001"
|
||||
|
||||
|
||||
def test_renamed_by_another_source_keeps_its_agent():
|
||||
# Intune renamed the asset; the agent still carries its enrolment name.
|
||||
# Same registration date: same agent, same host.
|
||||
db = _db()
|
||||
reg = parse_source_timestamp(_stamp(400))
|
||||
asset = _old_asset(db, wazuh_registered_at=reg)
|
||||
asset.hostname = "host-a-renamed"
|
||||
db.commit()
|
||||
|
||||
stats = _sync(db, [_agent("host-a", registered_days_ago=400)])
|
||||
|
||||
assert asset.wazuh_agent_id == "001"
|
||||
assert asset.hostname == "host-a-renamed"
|
||||
assert db.query(Asset).count() == 1
|
||||
assert stats["wazuh_id_reused"] == 0
|
||||
|
||||
|
||||
def test_renamed_row_from_before_the_column_keeps_its_agent():
|
||||
db = _db()
|
||||
asset = _old_asset(db)
|
||||
asset.hostname = "host-a-renamed"
|
||||
db.commit()
|
||||
|
||||
_sync(db, [_agent("host-a", registered_days_ago=400)])
|
||||
|
||||
assert asset.wazuh_agent_id == "001"
|
||||
assert asset.wazuh_registered_at == parse_source_timestamp(_stamp(400))
|
||||
assert db.query(Asset).count() == 1
|
||||
|
||||
|
||||
def test_migrated_identity_with_a_new_date_keeps_its_asset():
|
||||
# Same name, newer registration (identity carried to the new server):
|
||||
# the name vouches for the host.
|
||||
db = _db()
|
||||
asset = _old_asset(db, wazuh_registered_at=parse_source_timestamp(_stamp(400)))
|
||||
|
||||
_sync(db, [_agent("HOST-A.corp.example", registered_days_ago=3)])
|
||||
|
||||
assert asset.wazuh_agent_id == "001"
|
||||
assert db.query(Asset).count() == 1
|
||||
|
||||
|
||||
def test_normal_sync_stores_the_registration():
|
||||
db = _db()
|
||||
asset = _old_asset(db)
|
||||
|
||||
_sync(db, [_agent("host-a", registered_days_ago=400)])
|
||||
|
||||
assert asset.wazuh_agent_id == "001"
|
||||
assert asset.wazuh_registered_at == parse_source_timestamp(_stamp(400))
|
||||
|
||||
|
||||
def test_vulnerability_sync_does_not_read_a_reused_id_onto_the_old_host():
|
||||
# The vulnerability sync may run before any asset sync has seen the new
|
||||
# server; it looks up by agent id as well.
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
from app.models.setting import Setting
|
||||
from app.routers import vulnerabilities as vulns_router
|
||||
|
||||
db = _db()
|
||||
db.add(Setting(key="wazuh_config", value='{"api_url": "https://wazuh.test", '
|
||||
'"username": "u", "password": "p", "indexer_url": "https://wazuh.test:9200"}'))
|
||||
old = _old_asset(db, wazuh_registered_at=parse_source_timestamp(_stamp(400)))
|
||||
asked = []
|
||||
with mock.patch.object(WazuhClient, "get_agents",
|
||||
lambda self, **kw: [_agent("host-b", registered_days_ago=5)]), \
|
||||
mock.patch.object(WazuhClient, "get_vulnerabilities",
|
||||
lambda self, agent_id: asked.append(agent_id) or []):
|
||||
vulns_router._run_wazuh_vulnerability_sync(db)
|
||||
|
||||
assert asked == []
|
||||
assert old.wazuh_agent_id is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, fn in list(globals().items()):
|
||||
if name.startswith("test_"):
|
||||
fn()
|
||||
print("ok")
|
||||
Reference in New Issue
Block a user