114 lines
4.3 KiB
Python
Executable file
114 lines
4.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Compare ported Go poll detectors with the Python reference oracle."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from enodia_sentinel import __version__, event # noqa: E402
|
|
from enodia_sentinel.config import Config # noqa: E402
|
|
from enodia_sentinel.detectors import ( # noqa: E402
|
|
credential_access,
|
|
deleted_exe,
|
|
egress,
|
|
input_snooper,
|
|
ld_preload,
|
|
reverse_shell,
|
|
stealth_network,
|
|
)
|
|
from enodia_sentinel.system import Socket, SystemState # noqa: E402
|
|
|
|
HOST = "parity-host"
|
|
TIMESTAMP = "2026-07-10T00:00:00-07:00"
|
|
|
|
|
|
class FixtureProcess(SimpleNamespace):
|
|
"""Duck-typed Python Process with Go-fixture stdio socket semantics."""
|
|
|
|
def stdio_socket_inode(self):
|
|
# Match Process.stdio_socket_inode: descriptors 0/1/2 only, in order.
|
|
for fd in ("0", "1", "2"):
|
|
target = self.fd_targets.get(fd, "")
|
|
if target.startswith("socket:[") and target.endswith("]"):
|
|
try:
|
|
return int(target[8:-1])
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
fixture = ROOT / "tests/fixtures/go/process-detectors.json"
|
|
fixture_data = json.loads(fixture.read_text())
|
|
env = os.environ.copy()
|
|
env.pop("ENODIA_CONFIG", None)
|
|
env["GOCACHE"] = "/tmp/enodia-go-cache"
|
|
command = [
|
|
"go", "run", "./cmd/enodia-sentinel-go",
|
|
"--config", str(ROOT / "tests/fixtures/go/no-such-config.toml"),
|
|
"--once", "--fixture", str(fixture),
|
|
"--host", HOST, "--timestamp", TIMESTAMP,
|
|
]
|
|
result = subprocess.run(
|
|
command, cwd=ROOT / "go-agent", env=env,
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
if result.returncode:
|
|
print(result.stderr, file=sys.stderr, end="")
|
|
return result.returncode
|
|
go_events = [json.loads(line) for line in result.stdout.splitlines() if line]
|
|
go_alerts = [record for record in go_events if record["event_type"] == "alert"]
|
|
|
|
processes = []
|
|
for raw_process in fixture_data["processes"]:
|
|
process = dict(raw_process)
|
|
process.setdefault("environ", {})
|
|
process.setdefault("fd_targets", {})
|
|
processes.append(FixtureProcess(**process))
|
|
sockets = [Socket(**item) for item in fixture_data.get("sockets", [])]
|
|
cfg = Config()
|
|
state = SystemState(processes=processes, sockets=sockets)
|
|
alerts = list(reverse_shell.detect(state, cfg))
|
|
with patch.object(ld_preload, "Path") as path_class:
|
|
preload = path_class.return_value
|
|
preload.is_file.return_value = bool(fixture_data.get("ld_preload"))
|
|
preload.read_text.return_value = fixture_data.get("ld_preload", "")
|
|
alerts.extend(ld_preload.detect(state, cfg))
|
|
alerts.extend(deleted_exe.detect(state, cfg))
|
|
alerts.extend(input_snooper.detect(state, cfg))
|
|
alerts.extend(credential_access.detect(state, cfg))
|
|
alerts.extend(stealth_network.detect(state, cfg))
|
|
alerts.extend(egress.detect(state, cfg))
|
|
python_alerts = [
|
|
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
|
|
for alert in alerts
|
|
]
|
|
if go_alerts != python_alerts:
|
|
print("Go/Python alert parity mismatch", file=sys.stderr)
|
|
print(json.dumps({"go": go_alerts, "python": python_alerts}, indent=2), file=sys.stderr)
|
|
return 1
|
|
|
|
status_events = [record for record in go_events if record["event_type"] == "status"]
|
|
if len(status_events) != 1 or status_events[0].get("schema") != "enodia.event.v1":
|
|
print("Go sidecar did not emit one enodia.event.v1 status record", file=sys.stderr)
|
|
return 1
|
|
status = status_events[0].get("status", {})
|
|
if status.get("schema") != "enodia.status.v1" or status.get("version") != __version__:
|
|
print("Go status schema/version drifted from Python", file=sys.stderr)
|
|
return 1
|
|
signatures = sorted({record["alert"]["signature"] for record in go_alerts})
|
|
print(f"parity ok: {len(go_alerts)} alerts across {', '.join(signatures)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|