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:
2026-11-01 11:54:35 +01:00
parent 1d413e58ba
commit 4364d2f8e6
7 changed files with 338 additions and 4 deletions
+42 -3
View File
@@ -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;")
+3
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+49
View File
@@ -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,
*,
+202
View File
@@ -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")