Files
awoooi/apps/api/tests/test_wazuh_alertmanager_integration.py

716 lines
25 KiB
Python

from __future__ import annotations
# ruff: noqa: E402, I001
import importlib.util
import json
import os
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import yaml
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
from src.services.awooop_ansible_audit_service import (
_catalog_hints,
build_ansible_decision_audit_payload,
get_ansible_catalog_item,
preflight_ansible_candidate_generation,
)
from src.services.awooop_ansible_post_verifier import postconditions_for_catalog
from src.services.awooop_ansible_check_mode_service import (
build_ansible_apply_command,
build_ansible_check_mode_claim_input,
build_ansible_check_mode_command,
)
from src.core.config import settings
from src.services.controlled_alert_target_router import resolve_typed_alert_target
from src.services.incident_service import classify_alert_early
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = (
ROOT
/ "infra"
/ "ansible"
/ "files"
/ "wazuh"
/ "custom-awoooi-alertmanager.py"
)
PLAYBOOK = (
ROOT
/ "infra"
/ "ansible"
/ "playbooks"
/ "wazuh-alertmanager-integration.yml"
)
BROKER_DEPLOYMENT = (
ROOT / "k8s" / "awoooi-prod" / "08-deployment-ansible-executor-broker.yaml"
)
WORKER_DEPLOYMENT = ROOT / "k8s" / "awoooi-prod" / "08-deployment-worker.yaml"
CATALOG_ID = "ansible:wazuh-alertmanager-integration"
def _current_wazuh_source_recurrence(
observed_at: str | None = None,
) -> dict[str, object]:
return {
"schema_version": "alert_source_recurrence_receipt_v1",
"verified": True,
"fingerprint": "wazuh-source-fingerprint-5710",
"occurrence_id": "wazuh-source-occurrence-5710",
"observed_at": observed_at or datetime.now(UTC).isoformat(),
"identity_anchor": "awooop_conversation_event:wazuh-5710",
"canonical_asset_id": "service:wazuh-manager",
"source": "awooop_conversation_event",
}
def _load_bridge_module():
spec = importlib.util.spec_from_file_location("wazuh_awoooi_bridge", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _alert(*, agent_id: str = "017") -> dict:
return {
"timestamp": "2026-07-15T05:30:00Z",
"manager": {"name": "wazuh-manager-internal"},
"agent": {
"id": agent_id,
"name": "private-endpoint-name",
"ip": "192.168.0.55",
},
"rule": {
"id": "5710",
"level": 12,
"description": "private security evidence",
"groups": ["authentication_failures", "sshd"],
"mitre": {"id": ["T1110", "not-a-technique"]},
},
"full_log": "raw source event must remain in Wazuh",
"data": {"srcip": "203.0.113.7"},
}
def test_wazuh_bridge_emits_only_public_safe_stable_metadata() -> None:
bridge = _load_bridge_module()
payload = bridge.build_alertmanager_payload(
_alert(),
now=datetime(2026, 7, 15, 5, 30, tzinfo=UTC),
)
encoded = json.dumps(payload, sort_keys=True)
labels = payload["alerts"][0]["labels"]
assert labels["alertname"] == "WazuhSecurityEvent_5710"
assert labels["component"] == "wazuh-manager"
assert labels["severity"] == "critical"
assert labels["wazuh_rule_groups"] == "authentication_failures,sshd"
assert labels["mitre_techniques"] == "T1110"
assert labels["deployment"].startswith("wazuh-agent-")
assert labels["raw_payload_persisted"] == "false"
assert labels["active_response_requested"] == "false"
for forbidden in (
"private-endpoint-name",
"192.168.0.55",
"203.0.113.7",
"private security evidence",
"raw source event must remain in Wazuh",
"wazuh-manager-internal",
):
assert forbidden not in encoded
def test_wazuh_bridge_fingerprint_clusters_same_rule_and_agent() -> None:
bridge = _load_bridge_module()
first = bridge.build_alertmanager_payload(_alert(agent_id="017"))
second = bridge.build_alertmanager_payload(_alert(agent_id="017"))
other_agent = bridge.build_alertmanager_payload(_alert(agent_id="018"))
assert first["groupKey"] == second["groupKey"]
assert first["groupKey"] != other_agent["groupKey"]
assert (
first["alerts"][0]["labels"]["deployment"]
!= other_agent["alerts"][0]["labels"]["deployment"]
)
@pytest.mark.parametrize(
"url",
[
"http://awoooi.wooo.work/api/v1/webhooks/alertmanager",
"https://example.com/api/v1/webhooks/alertmanager",
"https://awoooi.wooo.work/api/v1/webhooks/alertmanager?raw=1",
"https://user:pass@awoooi.wooo.work/api/v1/webhooks/alertmanager",
],
)
def test_wazuh_bridge_rejects_non_allowlisted_hook_urls(url: str) -> None:
bridge = _load_bridge_module()
with pytest.raises(ValueError, match="hook_url_not_allowlisted"):
bridge._validated_hook_url(url)
def test_wazuh_integration_catalog_and_rollback_contract_are_bounded() -> None:
catalog = get_ansible_catalog_item(CATALOG_ID)
playbook = yaml.safe_load(PLAYBOOK.read_text(encoding="utf-8"))
text = PLAYBOOK.read_text(encoding="utf-8")
conditions = postconditions_for_catalog(CATALOG_ID)
assert catalog is not None
assert catalog["inventory_hosts"] == ["host_112"]
assert catalog["risk_level"] == "high"
assert catalog["auto_apply_enabled"] is True
assert catalog["approval_required"] is False
assert catalog["supports_check_mode"] is True
assert catalog["catalog_revision"] == "2026-07-15-wazuh-alert-ingress-v2"
assert playbook[0]["hosts"] == "host_112"
self_test_task = playbook[0]["tasks"][0]
assert self_test_task["delegate_to"] == "localhost"
assert self_test_task["become"] is False
assert self_test_task["ansible.builtin.command"]["argv"][0] == (
"{{ ansible_playbook_python }}"
)
assert "backup: true" in text
assert "remote_src: true" in text
assert "wazuh_privileged_convergence_capability_missing" in text
assert "/var/lib/awoooi-wazuh-alert-ingress/receipt.env" in text
assert "raw_payload_persisted=0" in text
assert "active_response_enabled=0" in text
assert "wazuh_alertmanager_integration_rolled_back" in text
assert "slurp:" not in text
assert "api_key>" not in text
assert "ansible_become_pass" not in text
assert "raw.githubusercontent.com" not in text
assert {condition.condition_id for condition in conditions} == {
"host_112_wazuh_alertmanager_script",
"host_112_wazuh_alertmanager_config",
"host_112_wazuh_manager_runtime_after_ingress_convergence",
"host_112_wazuh_integrator_runtime",
"host_112_awoooi_alertmanager_ingress_reachable",
}
def test_wazuh_become_password_projection_is_path_only_and_optional(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
password_file = tmp_path / "become-password"
password_file.write_text("test-only-secret-value\n", encoding="utf-8")
monkeypatch.setattr(
settings,
"AWOOOP_ANSIBLE_BECOME_PASSWORD_FILE",
str(password_file),
)
spec = build_ansible_check_mode_command(
playbook_path="infra/ansible/playbooks/wazuh-alertmanager-integration.yml",
inventory_hosts=("host_112",),
playbook_root=ROOT / "infra" / "ansible",
check_mode_ssh_key_path=tmp_path / "ssh-key",
check_mode_known_hosts_path=tmp_path / "known-hosts",
)
manifest = yaml.safe_load(BROKER_DEPLOYMENT.read_text(encoding="utf-8"))
volume = next(
item
for item in manifest["spec"]["template"]["spec"]["volumes"]
if item["name"] == "host112-become-password"
)
assert spec.env["ANSIBLE_BECOME_PASSWORD_FILE"] == str(password_file)
assert "test-only-secret-value" not in " ".join(spec.command)
assert volume["secret"]["optional"] is True
assert volume["secret"]["items"] == [
{"key": "HOST112_ANSIBLE_BECOME_PASSWORD", "path": "password"}
]
assert "test-only-secret-value" not in BROKER_DEPLOYMENT.read_text(
encoding="utf-8"
)
apply_spec = build_ansible_apply_command(
playbook_path="infra/ansible/playbooks/wazuh-alertmanager-integration.yml",
inventory_hosts=("host_112",),
playbook_root=ROOT / "infra" / "ansible",
check_mode_ssh_key_path=tmp_path / "ssh-key",
check_mode_known_hosts_path=tmp_path / "known-hosts",
)
assert apply_spec.env["ANSIBLE_BECOME_PASSWORD_FILE"] == str(password_file)
assert "test-only-secret-value" not in " ".join(apply_spec.command)
unrelated_spec = build_ansible_check_mode_command(
playbook_path="infra/ansible/playbooks/wazuh-manager-posture-readback.yml",
inventory_hosts=("host_112",),
playbook_root=ROOT / "infra" / "ansible",
check_mode_ssh_key_path=tmp_path / "ssh-key",
check_mode_known_hosts_path=tmp_path / "known-hosts",
)
assert "ANSIBLE_BECOME_PASSWORD_FILE" not in unrelated_spec.env
def test_wazuh_ingress_convergence_uses_only_the_mutating_catalog() -> None:
route = resolve_typed_alert_target(
alertname="WazuhAlertmanagerIntegrationConvergence",
target_resource="wazuh-manager",
namespace="awoooi",
labels={"service": "wazuh-manager", "tool": "wazuh"},
)
hints = _catalog_hints(
{
"incident_id": "IWZ-INGRESS-2026071500",
"alertname": "WazuhAlertmanagerIntegrationConvergence",
"alert_category": "security",
"affected_services": ["wazuh-manager", "siem"],
"signals": [
{
"alert_name": "WazuhAlertmanagerIntegrationConvergence",
"labels": {
"service": "wazuh-manager",
"component": "wazuh-manager",
"tool": "wazuh",
},
}
],
},
None,
)
assert route["canonical_asset_id"] == "service:wazuh-manager"
assert route["allowed_catalog_ids"] == [CATALOG_ID]
assert [item["catalog_id"] for item in hints["candidates"]] == [CATALOG_ID]
@pytest.mark.asyncio
async def test_wazuh_ingress_scheduler_records_one_high_risk_controlled_candidate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.jobs import awooop_ansible_candidate_backfill_job as job
class _Mappings:
def first(self):
return None
class _Result:
def mappings(self):
return _Mappings()
class _Db:
async def execute(self, statement, params):
assert json.loads(params["catalog_match"]) == [
{"catalog_id": CATALOG_ID}
]
assert params["project_id"] == "awoooi"
assert params["work_item_id"] == "P0-03-WAZUH-ALERT-INGRESS"
assert params["proposal_source"] == (
"iwooos_wazuh_alert_ingress_scheduler"
)
assert params["run_namespace"] == (
"awoooi:iwooos:wazuh-alert-ingress"
)
assert params["source_fingerprint"] == (
"wazuh-source-fingerprint-5710"
)
assert params["source_occurrence_id"] == (
"wazuh-source-occurrence-5710"
)
query = str(statement)
assert "candidate.input ->> 'project_id' = :project_id" in query
assert "candidate.input ->> 'work_item_id' = :work_item_id" in query
assert "candidate.input ->> 'proposal_source' = :proposal_source" in query
assert "candidate.input ->> 'run_namespace' = :run_namespace" in query
assert (
"candidate.input #>> '{source_recurrence,fingerprint}' = :source_fingerprint"
in query
)
assert (
"candidate.input #>> '{source_recurrence,occurrence_id}' = :source_occurrence_id"
in query
)
return _Result()
@asynccontextmanager
async def fake_db_context(project_id: str):
assert project_id == "awoooi"
yield _Db()
recorder = AsyncMock(return_value=True)
monkeypatch.setattr(job, "get_db_context", fake_db_context)
monkeypatch.setattr(
job.settings,
"ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR",
True,
)
monkeypatch.setattr(
job.settings,
"IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS",
6,
)
monkeypatch.setattr(
job,
"now_taipei",
MagicMock(return_value=datetime.fromisoformat("2026-07-15T05:59:59+08:00")),
)
result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=recorder,
evidence_collector=AsyncMock(return_value=MagicMock(snapshot_id="wazuh-ingress")),
evidence_verifier=AsyncMock(return_value=True),
incident_binder=AsyncMock(return_value=True),
source_recurrence=_current_wazuh_source_recurrence(
"2026-07-15T05:59:58+08:00"
),
)
assert result["status"] == "queued_for_controlled_executor"
assert result["work_item_id"] == "P0-03-WAZUH-ALERT-INGRESS"
recorded = recorder.await_args.kwargs
assert recorded["incident"]["alertname"] == (
"WazuhAlertmanagerIntegrationConvergence"
)
assert recorded["incident"]["incident_id"] == "IWZ-INGRESS-2026071500"
assert len(recorded["incident"]["incident_id"]) <= 30
assert recorded["proposal_data"]["risk_level"] == "high"
assert recorded["proposal_data"]["execution_priority"] == 0
assert recorded["incident"]["source_receipt_ref"] == (
"scheduled-wazuh-alert-ingress:2026071500"
)
candidate = build_ansible_decision_audit_payload(
incident=recorded["incident"],
proposal_data=recorded["proposal_data"],
decision_path="repair_candidate_controlled_queue",
not_used_reason="test",
)
assert candidate is not None
candidate["input"]["automation_run_id"] = result["automation_run_id"]
claim = build_ansible_check_mode_claim_input(
source_candidate_op_id=result["automation_run_id"],
candidate_input=candidate["input"],
observed_now=datetime.fromisoformat("2026-07-15T05:59:59+08:00"),
)
assert claim["project_id"] == "awoooi"
assert claim["trace_id"] == claim["run_id"] == result["automation_run_id"]
assert claim["work_item_id"] == "P0-03-WAZUH-ALERT-INGRESS"
assert claim["run_namespace"] == "awoooi:iwooos:wazuh-alert-ingress"
assert claim["source_recurrence"]["verified"] is True
mismatched_candidate = {**candidate["input"], "work_item_id": "other-lane"}
with pytest.raises(
ValueError,
match="scheduled_candidate_identity_chain_not_verified",
):
build_ansible_check_mode_claim_input(
source_candidate_op_id=result["automation_run_id"],
candidate_input=mismatched_candidate,
observed_now=datetime.fromisoformat("2026-07-15T05:59:59+08:00"),
)
stale_candidate = {
**candidate["input"],
"source_recurrence": _current_wazuh_source_recurrence(
"2026-07-15T05:30:00+08:00"
),
}
with pytest.raises(
ValueError,
match="scheduled_candidate_identity_chain_not_verified",
):
build_ansible_check_mode_claim_input(
source_candidate_op_id=result["automation_run_id"],
candidate_input=stale_candidate,
observed_now=datetime.fromisoformat("2026-07-15T05:59:59+08:00"),
)
write_not_acknowledged = (
await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=AsyncMock(return_value=False),
evidence_collector=AsyncMock(
return_value=MagicMock(snapshot_id="wazuh-ingress")
),
evidence_verifier=AsyncMock(return_value=True),
incident_binder=AsyncMock(return_value=True),
source_recurrence=_current_wazuh_source_recurrence(
"2026-07-15T05:59:58+08:00"
),
)
)
assert write_not_acknowledged["status"] == "candidate_write_not_acknowledged"
assert write_not_acknowledged["existing"] == 0
assert write_not_acknowledged["active_blockers"] == [
"candidate_write_not_acknowledged"
]
@pytest.mark.asyncio
async def test_wazuh_ingress_scheduler_fails_closed_without_current_recurrence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.jobs import awooop_ansible_candidate_backfill_job as job
recorder = AsyncMock(return_value=True)
collector = AsyncMock(return_value=MagicMock(snapshot_id="must-not-run"))
verifier = AsyncMock(return_value=True)
binder = AsyncMock(return_value=True)
monkeypatch.setattr(
job.settings,
"ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR",
True,
)
result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=recorder,
evidence_collector=collector,
evidence_verifier=verifier,
incident_binder=binder,
)
assert result == {
"status": "blocked_current_source_recurrence_not_verified",
"queued": 0,
"existing": 0,
"evidence_ready": 0,
"work_item_id": "P0-03-WAZUH-ALERT-INGRESS",
"drift_work_item_id": "DRIFT-WAZUH-SOURCE-RECURRENCE",
"controlled_apply_allowed": False,
"active_blockers": ["current_source_recurrence_not_verified"],
}
recorder.assert_not_awaited()
collector.assert_not_awaited()
verifier.assert_not_awaited()
binder.assert_not_awaited()
unrelated_recurrence = {
**_current_wazuh_source_recurrence(),
"canonical_asset_id": "service:sentry",
}
unrelated = (
await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=recorder,
evidence_collector=collector,
evidence_verifier=verifier,
incident_binder=binder,
source_recurrence=unrelated_recurrence,
)
)
assert unrelated["status"] == (
"blocked_current_source_recurrence_not_verified"
)
assert unrelated["controlled_apply_allowed"] is False
@pytest.mark.asyncio
async def test_wazuh_scheduler_audit_preflight_requires_source_recurrence() -> None:
run_id = "00000000-0000-0000-0000-000000000571"
incident = {
"incident_id": "IWZ-INGRESS-2026071500",
"project_id": "awoooi",
"severity": "high",
"alertname": "WazuhAlertmanagerIntegrationConvergence",
"alert_category": "security",
"affected_services": ["wazuh-manager", "siem"],
"trace_id": run_id,
"run_id": run_id,
"work_item_id": "P0-03-WAZUH-ALERT-INGRESS",
"run_namespace": "awoooi:iwooos:wazuh-alert-ingress",
"signals": [{"labels": {"service": "wazuh-manager"}}],
}
result = await preflight_ansible_candidate_generation(
incident=incident,
proposal_data={
"source": "iwooos_wazuh_alert_ingress_scheduler",
"risk_level": "high",
"action": "converge_wazuh_alertmanager_integration",
},
decision_path="repair_candidate_controlled_queue",
not_used_reason="test",
project_id="awoooi",
)
assert result["status"] == "current_source_recurrence_not_verified"
assert result["should_collect_evidence"] is False
@pytest.mark.asyncio
async def test_wazuh_ingress_scheduler_reports_unresolved_payload_before_recording(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.jobs import awooop_ansible_candidate_backfill_job as job
class _Mappings:
def first(self):
return None
class _Result:
def mappings(self):
return _Mappings()
class _Db:
async def execute(self, _statement, _params):
return _Result()
@asynccontextmanager
async def fake_db_context(_project_id: str):
yield _Db()
recorder = AsyncMock(return_value=False)
monkeypatch.setattr(job, "get_db_context", fake_db_context)
monkeypatch.setattr(
job.settings,
"ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR",
True,
)
monkeypatch.setattr(
job,
"build_ansible_decision_audit_payload",
MagicMock(return_value=None),
)
result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=recorder,
evidence_collector=AsyncMock(return_value=MagicMock(snapshot_id="wazuh-ingress")),
evidence_verifier=AsyncMock(return_value=True),
incident_binder=AsyncMock(return_value=True),
source_recurrence=_current_wazuh_source_recurrence(),
)
assert result["status"] == "blocked_canonical_asset_or_ansible_catalog_unresolved"
assert result["existing"] == 0
assert result["active_blockers"] == [
"canonical_asset_or_ansible_catalog_unresolved"
]
recorder.assert_not_awaited()
@pytest.mark.asyncio
async def test_wazuh_ingress_scheduler_blocks_orphan_candidate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.jobs import awooop_ansible_candidate_backfill_job as job
class _Mappings:
def first(self):
return None
class _Result:
def mappings(self):
return _Mappings()
class _Db:
async def execute(self, _statement, _params):
return _Result()
@asynccontextmanager
async def fake_db_context(_project_id: str):
yield _Db()
recorder = AsyncMock(return_value=True)
collector = AsyncMock(return_value=MagicMock(snapshot_id="must-not-run"))
monkeypatch.setattr(job, "get_db_context", fake_db_context)
monkeypatch.setattr(
job.settings,
"ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR",
True,
)
result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once(
recorder=recorder,
evidence_collector=collector,
evidence_verifier=AsyncMock(return_value=True),
incident_binder=AsyncMock(return_value=False),
source_recurrence=_current_wazuh_source_recurrence(),
)
assert result["status"] == "blocked_scheduled_incident_write_not_acknowledged"
assert result["active_blockers"] == [
"scheduled_incident_write_or_readback_not_acknowledged"
]
collector.assert_not_awaited()
recorder.assert_not_awaited()
@pytest.mark.asyncio
async def test_wazuh_scheduled_incident_bind_is_idempotent_and_public_safe(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.jobs import awooop_ansible_candidate_backfill_job as job
calls: list[tuple[str, dict]] = []
class _Mappings:
def first(self):
return {
"incident_id": "IWZ-INGRESS-2026071500",
"project_id": "awoooi",
"status": "INVESTIGATING",
"alertname": "WazuhAlertmanagerIntegrationConvergence",
"notification_type": "TYPE-5S",
"alert_category": "security",
"signal_count": 1,
"affected_service_count": 2,
}
class _Result:
def mappings(self):
return _Mappings()
class _Db:
async def execute(self, statement, params):
calls.append((str(statement), dict(params)))
return _Result()
@asynccontextmanager
async def fake_db_context(project_id: str):
assert project_id == "awoooi"
yield _Db()
monkeypatch.setattr(job, "get_db_context", fake_db_context)
incident = job._wazuh_ingress_incident(
automation_run_id="00000000-0000-0000-0000-000000000112",
bucket_ref="2026071500",
)
assert await job.ensure_iwooos_wazuh_scheduled_incident(
incident=incident,
project_id="awoooi",
) is True
assert "ON CONFLICT (incident_id) DO NOTHING" in calls[0][0]
inserted = calls[0][1]
assert inserted["notification_type"] == "TYPE-5S"
assert inserted["severity"] == "P1"
encoded = json.dumps(inserted, default=str, sort_keys=True)
assert "sensor-agent" in encoded
assert "raw" not in encoded.lower()
assert "secret" not in encoded.lower()
def test_wazuh_ingress_scheduler_worker_mounts_canonical_service_registry() -> None:
deployment = yaml.safe_load(WORKER_DEPLOYMENT.read_text(encoding="utf-8"))
pod_spec = deployment["spec"]["template"]["spec"]
container = pod_spec["containers"][0]
mount = next(
item for item in container["volumeMounts"] if item["name"] == "service-registry"
)
volume = next(item for item in pod_spec["volumes"] if item["name"] == "service-registry")
assert mount == {
"name": "service-registry",
"mountPath": "/app/ops/config/service-registry.yaml",
"subPath": "service-registry.yaml",
"readOnly": True,
}
assert volume["configMap"]["name"] == "service-registry"
def test_wazuh_security_event_uses_secops_lifecycle() -> None:
assert classify_alert_early(
"WazuhSecurityEvent_5710",
"critical",
{"tool": "wazuh"},
) == ("secops", "TYPE-5S")