refactor: prober auto-discovers attackers from log stream

Remove --probe-targets from deploy. The prober now tails the JSON log
file and automatically discovers attacker IPs, JARM-probing each on
common C2 ports (443, 8443, 8080, 4443, 50050, etc.).

- Deploy spawns prober automatically (like collector), no manual targets
- `decnet probe` runs in foreground, --daemon detaches to background
- Worker tracks probed (ip, port) pairs to avoid redundant scans
- Empty JARM hashes (no TLS server) are silently skipped
- 80 prober tests (jarm + worker discovery + bounty extraction)
This commit is contained in:
2026-04-14 12:22:20 -04:00
parent ce2699455b
commit 5585e4ec58
4 changed files with 378 additions and 85 deletions

View File

@@ -120,8 +120,6 @@ def deploy(
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"), config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"), api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"),
api_port: int = typer.Option(8000, "--api-port", help="Port for the backend API"), api_port: int = typer.Option(8000, "--api-port", help="Port for the backend API"),
probe_targets: Optional[str] = typer.Option(None, "--probe-targets", help="Comma-separated ip:port pairs for JARM active probing (e.g. 10.0.0.1:443,10.0.0.2:8443)"),
probe_interval: int = typer.Option(300, "--probe-interval", help="Seconds between JARM probe cycles (default: 300)"),
) -> None: ) -> None:
"""Deploy deckies to the LAN.""" """Deploy deckies to the LAN."""
import os import os
@@ -298,18 +296,16 @@ def deploy(
except (FileNotFoundError, subprocess.SubprocessError): except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]") console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")
if probe_targets and not dry_run: if effective_log_file and not dry_run:
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
console.print(f"[bold cyan]Starting DECNET-PROBER[/] → targets: {probe_targets}") console.print("[bold cyan]Starting DECNET-PROBER[/] (auto-discovers attackers from log stream)")
try: try:
_prober_args = [ _prober_args = [
sys.executable, "-m", "decnet.cli", "probe", sys.executable, "-m", "decnet.cli", "probe",
"--targets", probe_targets, "--daemon",
"--interval", str(probe_interval), "--log-file", str(effective_log_file),
] ]
if effective_log_file:
_prober_args.extend(["--log-file", str(effective_log_file)])
subprocess.Popen( # nosec B603 subprocess.Popen( # nosec B603
_prober_args, _prober_args,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
@@ -323,17 +319,28 @@ def deploy(
@app.command() @app.command()
def probe( def probe(
targets: str = typer.Option(..., "--targets", "-t", help="Comma-separated ip:port pairs to JARM fingerprint"), log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path for RFC 5424 syslog + .json output (reads attackers from .json, writes results to both)"),
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path for RFC 5424 syslog + .json output"),
interval: int = typer.Option(300, "--interval", "-i", help="Seconds between probe cycles (default: 300)"), interval: int = typer.Option(300, "--interval", "-i", help="Seconds between probe cycles (default: 300)"),
timeout: float = typer.Option(5.0, "--timeout", help="Per-probe TCP timeout in seconds"), timeout: float = typer.Option(5.0, "--timeout", help="Per-probe TCP timeout in seconds"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background (used by deploy, no console output)"),
) -> None: ) -> None:
"""Run JARM active fingerprinting against target hosts.""" """JARM-fingerprint all attackers discovered in the log stream."""
import asyncio import asyncio
from decnet.prober import prober_worker from decnet.prober import prober_worker
log.info("probe command invoked targets=%s interval=%d", targets, interval)
console.print(f"[bold cyan]DECNET-PROBER starting[/] → {targets}") if daemon:
asyncio.run(prober_worker(log_file, targets, interval=interval, timeout=timeout)) # Suppress console output when running as background daemon
import os
log.info("probe daemon starting log_file=%s interval=%d", log_file, interval)
asyncio.run(prober_worker(log_file, interval=interval, timeout=timeout))
else:
log.info("probe command invoked log_file=%s interval=%d", log_file, interval)
console.print(f"[bold cyan]DECNET-PROBER[/] watching {log_file} for attackers (interval: {interval}s)")
console.print("[dim]Press Ctrl+C to stop[/]")
try:
asyncio.run(prober_worker(log_file, interval=interval, timeout=timeout))
except KeyboardInterrupt:
console.print("\n[yellow]DECNET-PROBER stopped.[/]")
@app.command() @app.command()

View File

@@ -1,10 +1,12 @@
""" """
DECNET-PROBER standalone worker. DECNET-PROBER standalone worker.
Runs as a detached host-level process. Probes targets on a configurable Runs as a detached host-level process. Discovers attacker IPs by tailing the
interval and writes results as RFC 5424 syslog + JSON to the same log collector's JSON log file, then JARM-probes them on common C2/TLS ports.
files the collector uses. The ingester tails the JSON file and extracts Results are written as RFC 5424 syslog + JSON to the same log files.
JARM bounties automatically.
Target discovery is fully automatic — every unique attacker IP seen in the
log stream gets probed. No manual target list required.
Tech debt: writing directly to the collector's log files couples the Tech debt: writing directly to the collector's log files couples the
prober to the collector's file format. A future refactor should introduce prober to the collector's file format. A future refactor should introduce
@@ -21,10 +23,17 @@ from pathlib import Path
from typing import Any from typing import Any
from decnet.logging import get_logger from decnet.logging import get_logger
from decnet.prober.jarm import jarm_hash from decnet.prober.jarm import JARM_EMPTY_HASH, jarm_hash
logger = get_logger("prober") logger = get_logger("prober")
# ─── Default ports to JARM-probe on each attacker IP ─────────────────────────
# Common C2 callback / TLS server ports (Cobalt Strike, Sliver, Metasploit, etc.)
DEFAULT_PROBE_PORTS: list[int] = [
443, 8443, 8080, 4443, 50050, 2222, 993, 995, 8888, 9001,
]
# ─── RFC 5424 formatting (inline, mirrors templates/*/decnet_logging.py) ───── # ─── RFC 5424 formatting (inline, mirrors templates/*/decnet_logging.py) ─────
_FACILITY_LOCAL0 = 16 _FACILITY_LOCAL0 = 16
@@ -144,100 +153,181 @@ def _write_event(
f.flush() f.flush()
# ─── Target parser ─────────────────────────────────────────────────────────── # ─── Target discovery from log stream ────────────────────────────────────────
def _parse_targets(raw: str) -> list[tuple[str, int]]: def _discover_attackers(json_path: Path, position: int) -> tuple[set[str], int]:
"""Parse 'ip:port,ip:port,...' into a list of (host, port) tuples.""" """
targets: list[tuple[str, int]] = [] Read new JSON log lines from the given position and extract unique
for entry in raw.split(","): attacker IPs. Returns (new_ips, new_position).
entry = entry.strip()
if not entry: Only considers IPs that are not "Unknown" and come from events that
continue indicate real attacker interaction (not prober's own events).
if ":" not in entry: """
logger.warning("prober: skipping malformed target %r (missing port)", entry) new_ips: set[str] = set()
continue
host, _, port_str = entry.rpartition(":") if not json_path.exists():
try: return new_ips, position
port = int(port_str)
if not (1 <= port <= 65535): size = json_path.stat().st_size
raise ValueError if size < position:
targets.append((host, port)) position = 0 # file rotated
except ValueError:
logger.warning("prober: skipping malformed target %r (bad port)", entry) if size == position:
return targets return new_ips, position
with open(json_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(position)
while True:
line = f.readline()
if not line:
break
if not line.endswith("\n"):
break # partial line
try:
record = json.loads(line.strip())
except json.JSONDecodeError:
position = f.tell()
continue
# Skip our own events
if record.get("service") == "prober":
position = f.tell()
continue
ip = record.get("attacker_ip", "Unknown")
if ip != "Unknown" and ip:
new_ips.add(ip)
position = f.tell()
return new_ips, position
# ─── Probe cycle ───────────────────────────────────────────────────────────── # ─── Probe cycle ─────────────────────────────────────────────────────────────
def _probe_cycle( def _probe_cycle(
targets: list[tuple[str, int]], targets: set[str],
probed: dict[str, set[int]],
ports: list[int],
log_path: Path, log_path: Path,
json_path: Path, json_path: Path,
timeout: float = 5.0, timeout: float = 5.0,
) -> None: ) -> None:
for host, port in targets: """
try: Probe all known attacker IPs on the configured ports.
h = jarm_hash(host, port, timeout=timeout)
_write_event( Args:
log_path, json_path, targets: set of attacker IPs to probe
"jarm_fingerprint", probed: dict mapping IP -> set of ports already successfully probed
target_ip=host, ports: list of ports to probe on each IP
target_port=str(port), log_path: RFC 5424 log file
jarm_hash=h, json_path: JSON log file
msg=f"JARM {host}:{port} = {h}", timeout: per-probe TCP timeout
) """
logger.info("prober: JARM %s:%d = %s", host, port, h) for ip in sorted(targets):
except Exception as exc: already_done = probed.get(ip, set())
_write_event( ports_to_probe = [p for p in ports if p not in already_done]
log_path, json_path,
"prober_error", if not ports_to_probe:
severity=_SEVERITY_WARNING, continue
target_ip=host,
target_port=str(port), for port in ports_to_probe:
error=str(exc), try:
msg=f"JARM probe failed for {host}:{port}: {exc}", h = jarm_hash(ip, port, timeout=timeout)
) if h == JARM_EMPTY_HASH:
logger.warning("prober: JARM probe failed %s:%d: %s", host, port, exc) # No TLS server on this port — don't log, don't reprobed
probed.setdefault(ip, set()).add(port)
continue
_write_event(
log_path, json_path,
"jarm_fingerprint",
target_ip=ip,
target_port=str(port),
jarm_hash=h,
msg=f"JARM {ip}:{port} = {h}",
)
logger.info("prober: JARM %s:%d = %s", ip, port, h)
probed.setdefault(ip, set()).add(port)
except Exception as exc:
_write_event(
log_path, json_path,
"prober_error",
severity=_SEVERITY_WARNING,
target_ip=ip,
target_port=str(port),
error=str(exc),
msg=f"JARM probe failed for {ip}:{port}: {exc}",
)
logger.warning("prober: JARM probe failed %s:%d: %s", ip, port, exc)
# Mark as probed to avoid infinite retries
probed.setdefault(ip, set()).add(port)
# ─── Main worker ───────────────────────────────────────────────────────────── # ─── Main worker ─────────────────────────────────────────────────────────────
async def prober_worker( async def prober_worker(
log_file: str, log_file: str,
targets_raw: str,
interval: int = 300, interval: int = 300,
timeout: float = 5.0, timeout: float = 5.0,
ports: list[int] | None = None,
) -> None: ) -> None:
""" """
Main entry point for the standalone prober process. Main entry point for the standalone prober process.
Discovers attacker IPs automatically by tailing the JSON log file,
then JARM-probes each IP on common C2 ports.
Args: Args:
log_file: base path for log files (RFC 5424 to .log, JSON to .json) log_file: base path for log files (RFC 5424 to .log, JSON to .json)
targets_raw: comma-separated ip:port pairs
interval: seconds between probe cycles interval: seconds between probe cycles
timeout: per-probe TCP timeout timeout: per-probe TCP timeout
ports: list of ports to probe (defaults to DEFAULT_PROBE_PORTS)
""" """
targets = _parse_targets(targets_raw) probe_ports = ports or DEFAULT_PROBE_PORTS
if not targets:
logger.error("prober: no valid targets, exiting")
return
log_path = Path(log_file) log_path = Path(log_file)
json_path = log_path.with_suffix(".json") json_path = log_path.with_suffix(".json")
log_path.parent.mkdir(parents=True, exist_ok=True) log_path.parent.mkdir(parents=True, exist_ok=True)
logger.info("prober started targets=%d interval=%ds log=%s", len(targets), interval, log_path) logger.info(
"prober started interval=%ds ports=%s log=%s",
interval, ",".join(str(p) for p in probe_ports), log_path,
)
_write_event( _write_event(
log_path, json_path, log_path, json_path,
"prober_startup", "prober_startup",
target_count=str(len(targets)),
interval=str(interval), interval=str(interval),
msg=f"DECNET-PROBER started with {len(targets)} targets, interval {interval}s", probe_ports=",".join(str(p) for p in probe_ports),
msg=f"DECNET-PROBER started, interval {interval}s, "
f"ports {','.join(str(p) for p in probe_ports)}",
) )
known_attackers: set[str] = set()
probed: dict[str, set[int]] = {} # IP -> set of ports already probed
log_position: int = 0
while True: while True:
await asyncio.to_thread( # Discover new attacker IPs from the log stream
_probe_cycle, targets, log_path, json_path, timeout, new_ips, log_position = await asyncio.to_thread(
_discover_attackers, json_path, log_position,
) )
if new_ips - known_attackers:
fresh = new_ips - known_attackers
known_attackers.update(fresh)
logger.info(
"prober: discovered %d new attacker(s), total=%d",
len(fresh), len(known_attackers),
)
if known_attackers:
await asyncio.to_thread(
_probe_cycle, known_attackers, probed, probe_ports,
log_path, json_path, timeout,
)
await asyncio.sleep(interval) await asyncio.sleep(interval)

View File

@@ -60,15 +60,6 @@ class TestBuildClientHello:
# supported_versions extension type = 0x002B # supported_versions extension type = 0x002B
assert b"\x00\x2b" in data, f"Probe {idx} missing supported_versions" assert b"\x00\x2b" in data, f"Probe {idx} missing supported_versions"
def test_non_tls13_probes_lack_supported_versions(self):
"""Probes 0, 1, 2, 7, 8 should NOT include supported_versions."""
for idx in (0, 1, 2, 7, 8):
data = _build_client_hello(idx, host="example.com")
# Check that 0x002B doesn't appear as extension type
# We need to be more careful here — just check it's not in extensions area
# After session_id, ciphers, compression comes extensions
assert data[0] == 0x16 # sanity
def test_probe_9_includes_alpn_http11(self): def test_probe_9_includes_alpn_http11(self):
data = _build_client_hello(9, host="example.com") data = _build_client_hello(9, host="example.com")
assert b"http/1.1" in data assert b"http/1.1" in data
@@ -129,7 +120,6 @@ class TestParseServerHello:
def test_tls13_via_supported_versions(self): def test_tls13_via_supported_versions(self):
"""When supported_versions extension says TLS 1.3, version should be tls13.""" """When supported_versions extension says TLS 1.3, version should be tls13."""
# supported_versions extension: type=0x002B, length=2, version=0x0304
ext = struct.pack("!HHH", 0x002B, 2, 0x0304) ext = struct.pack("!HHH", 0x002B, 2, 0x0304)
data = _make_server_hello(cipher=0x1301, version=0x0303, extensions=ext) data = _make_server_hello(cipher=0x1301, version=0x0303, extensions=ext)
result = _parse_server_hello(data) result = _parse_server_hello(data)
@@ -153,7 +143,6 @@ class TestParseServerHello:
def test_non_server_hello_returns_separator(self): def test_non_server_hello_returns_separator(self):
"""A Certificate message (type 0x0B) should not parse as ServerHello.""" """A Certificate message (type 0x0B) should not parse as ServerHello."""
# Build a record that's handshake type but has wrong hs type
body = b"\x00" * 40 body = b"\x00" * 40
hs = struct.pack("B", 0x0B) + struct.pack("!I", len(body))[1:] + body hs = struct.pack("B", 0x0B) + struct.pack("!I", len(body))[1:] + body
record = struct.pack("B", 0x16) + struct.pack("!H", 0x0303) + struct.pack("!H", len(hs)) + hs record = struct.pack("B", 0x16) + struct.pack("!H", 0x0303) + struct.pack("!H", len(hs)) + hs

207
tests/test_prober_worker.py Normal file
View File

@@ -0,0 +1,207 @@
"""
Tests for the prober worker — target discovery from the log stream and
probe cycle behavior.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.prober.jarm import JARM_EMPTY_HASH
from decnet.prober.worker import (
DEFAULT_PROBE_PORTS,
_discover_attackers,
_probe_cycle,
_write_event,
)
# ─── _discover_attackers ─────────────────────────────────────────────────────
class TestDiscoverAttackers:
def test_discovers_unique_ips(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "sniffer", "event_type": "tls_client_hello", "attacker_ip": "10.0.0.1", "fields": {}},
{"service": "ssh", "event_type": "login_attempt", "attacker_ip": "10.0.0.2", "fields": {}},
{"service": "sniffer", "event_type": "tls_client_hello", "attacker_ip": "10.0.0.1", "fields": {}}, # dup
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, pos = _discover_attackers(json_file, 0)
assert ips == {"10.0.0.1", "10.0.0.2"}
assert pos > 0
def test_skips_prober_events(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "prober", "event_type": "jarm_fingerprint", "attacker_ip": "10.0.0.99", "fields": {}},
{"service": "ssh", "event_type": "login_attempt", "attacker_ip": "10.0.0.1", "fields": {}},
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert "10.0.0.99" not in ips
assert "10.0.0.1" in ips
def test_skips_unknown_ips(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
records = [
{"service": "sniffer", "event_type": "startup", "attacker_ip": "Unknown", "fields": {}},
]
json_file.write_text("\n".join(json.dumps(r) for r in records) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert len(ips) == 0
def test_handles_missing_file(self, tmp_path: Path):
json_file = tmp_path / "nonexistent.json"
ips, pos = _discover_attackers(json_file, 0)
assert len(ips) == 0
assert pos == 0
def test_resumes_from_position(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
line1 = json.dumps({"service": "ssh", "attacker_ip": "10.0.0.1", "fields": {}}) + "\n"
json_file.write_text(line1)
_, pos1 = _discover_attackers(json_file, 0)
# Append more
with open(json_file, "a") as f:
f.write(json.dumps({"service": "ssh", "attacker_ip": "10.0.0.2", "fields": {}}) + "\n")
ips, pos2 = _discover_attackers(json_file, pos1)
assert ips == {"10.0.0.2"} # only the new one
assert pos2 > pos1
def test_handles_file_rotation(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
# Write enough data to push position well ahead
lines = [json.dumps({"service": "ssh", "attacker_ip": f"10.0.0.{i}", "fields": {}}) + "\n" for i in range(10)]
json_file.write_text("".join(lines))
_, pos = _discover_attackers(json_file, 0)
assert pos > 0
# Simulate rotation — new file is smaller than the old position
json_file.write_text(json.dumps({"service": "ssh", "attacker_ip": "10.0.0.99", "fields": {}}) + "\n")
assert json_file.stat().st_size < pos
ips, new_pos = _discover_attackers(json_file, pos)
assert "10.0.0.99" in ips
def test_handles_malformed_json(self, tmp_path: Path):
json_file = tmp_path / "decnet.json"
json_file.write_text("not valid json\n" + json.dumps({"service": "ssh", "attacker_ip": "10.0.0.1", "fields": {}}) + "\n")
ips, _ = _discover_attackers(json_file, 0)
assert "10.0.0.1" in ips
# ─── _probe_cycle ────────────────────────────────────────────────────────────
class TestProbeCycle:
@patch("decnet.prober.worker.jarm_hash")
def test_probes_new_ips(self, mock_jarm: MagicMock, tmp_path: Path):
mock_jarm.return_value = "c0c" * 10 + "a" * 32 # fake 62-char hash
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, set[int]] = {}
_probe_cycle(targets, probed, [443, 8443], log_path, json_path, timeout=1.0)
assert mock_jarm.call_count == 2 # two ports
assert 443 in probed["10.0.0.1"]
assert 8443 in probed["10.0.0.1"]
@patch("decnet.prober.worker.jarm_hash")
def test_skips_already_probed_ports(self, mock_jarm: MagicMock, tmp_path: Path):
mock_jarm.return_value = "c0c" * 10 + "a" * 32
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, set[int]] = {"10.0.0.1": {443}}
_probe_cycle(targets, probed, [443, 8443], log_path, json_path, timeout=1.0)
# Should only probe 8443 (443 already done)
assert mock_jarm.call_count == 1
mock_jarm.assert_called_once_with("10.0.0.1", 8443, timeout=1.0)
@patch("decnet.prober.worker.jarm_hash")
def test_empty_hash_not_logged(self, mock_jarm: MagicMock, tmp_path: Path):
"""All-zeros JARM hash (no TLS server) should not be written as a jarm_fingerprint event."""
mock_jarm.return_value = JARM_EMPTY_HASH
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, set[int]] = {}
_probe_cycle(targets, probed, [443], log_path, json_path, timeout=1.0)
# Port should be marked as probed
assert 443 in probed["10.0.0.1"]
# But no jarm_fingerprint event should be written
if json_path.exists():
content = json_path.read_text()
assert "jarm_fingerprint" not in content
@patch("decnet.prober.worker.jarm_hash")
def test_exception_marks_port_probed(self, mock_jarm: MagicMock, tmp_path: Path):
mock_jarm.side_effect = OSError("Connection refused")
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, set[int]] = {}
_probe_cycle(targets, probed, [443], log_path, json_path, timeout=1.0)
# Port marked as probed to avoid infinite retries
assert 443 in probed["10.0.0.1"]
@patch("decnet.prober.worker.jarm_hash")
def test_skips_ip_with_all_ports_done(self, mock_jarm: MagicMock, tmp_path: Path):
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
targets = {"10.0.0.1"}
probed: dict[str, set[int]] = {"10.0.0.1": {443, 8443}}
_probe_cycle(targets, probed, [443, 8443], log_path, json_path, timeout=1.0)
assert mock_jarm.call_count == 0
# ─── _write_event ────────────────────────────────────────────────────────────
class TestWriteEvent:
def test_writes_rfc5424_and_json(self, tmp_path: Path):
log_path = tmp_path / "decnet.log"
json_path = tmp_path / "decnet.json"
_write_event(log_path, json_path, "test_event", target_ip="10.0.0.1", msg="test")
assert log_path.exists()
assert json_path.exists()
log_content = log_path.read_text()
assert "test_event" in log_content
assert "decnet@55555" in log_content
json_content = json_path.read_text()
record = json.loads(json_content.strip())
assert record["event_type"] == "test_event"
assert record["service"] == "prober"
assert record["fields"]["target_ip"] == "10.0.0.1"