Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
109 lines
4.4 KiB
Python
109 lines
4.4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""
|
|
Behavioral and timing analysis for DECNET attacker profiles.
|
|
|
|
This module is the orchestrator: it composes the topical sub-modules
|
|
(`timing`, `classify`, `tools`, `phases`, `fingerprint`) into the single
|
|
`attacker_behavior` record persisted by the profiler worker.
|
|
|
|
The individual detectors live in sibling modules:
|
|
- `timing.py` — inter-arrival-time statistics
|
|
- `classify.py` — behavior bucket (beaconing / scanning / …)
|
|
- `tools.py` — C2 beacon cadence + HTTP-header tool attribution
|
|
- `phases.py` — recon → exfil phase sequencing
|
|
- `fingerprint.py` — sniffer + prober TCP/OS fingerprint rollup
|
|
|
|
Their public symbols are re-exported here for backward compatibility with
|
|
callers and tests that import directly from `decnet.profiler.behavioral`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from decnet.correlation.parser import LogEvent
|
|
from decnet.telemetry import traced as _traced, get_tracer as _get_tracer
|
|
|
|
from .classify import classify_behavior
|
|
from .fingerprint import sniffer_rollup
|
|
from .phases import phase_sequence
|
|
from .timing import timing_stats
|
|
from .tools import detect_tools_from_headers, guess_tool, guess_tools
|
|
|
|
__all__ = [
|
|
"build_behavior_record",
|
|
"classify_behavior",
|
|
"detect_tools_from_headers",
|
|
"guess_tool",
|
|
"guess_tools",
|
|
"phase_sequence",
|
|
"sniffer_rollup",
|
|
"timing_stats",
|
|
]
|
|
|
|
|
|
@_traced("profiler.build_behavior_record")
|
|
def build_behavior_record(events: list[LogEvent]) -> dict[str, Any]:
|
|
"""
|
|
Build the dict to persist in the `attacker_behavior` table.
|
|
|
|
Callers (profiler worker) pre-serialize JSON-typed fields; we do the
|
|
JSON encoding here to keep the repo layer schema-agnostic.
|
|
"""
|
|
# Timing stats are computed across *all* events (not filtered), because
|
|
# a C2 beacon often reuses the same "connection" event_type on each
|
|
# check-in. Filtering would throw that signal away.
|
|
stats = timing_stats(events)
|
|
services = {e.service for e in events}
|
|
behavior = classify_behavior(stats, len(services))
|
|
rollup = sniffer_rollup(events)
|
|
phase = phase_sequence(events)
|
|
|
|
# Combine beacon-timing tool matches with header-based detections.
|
|
beacon_tools = guess_tools(stats.get("mean_iat_s"), stats.get("cv"))
|
|
header_tools = detect_tools_from_headers(events)
|
|
all_tools: list[str] = list(dict.fromkeys(beacon_tools + header_tools)) # dedup, preserve order
|
|
|
|
# Promote TCP-level scanner identification to tool_guesses.
|
|
# p0f fingerprints nmap from the TCP handshake alone — this fires even
|
|
# when no HTTP service is present, making it far more reliable than the
|
|
# header-based path for raw port scans.
|
|
if rollup["os_guess"] == "nmap" and "nmap" not in all_tools:
|
|
all_tools.insert(0, "nmap")
|
|
|
|
# Beacon-specific projection: only surface interval/jitter when we've
|
|
# classified the flow as beaconing (otherwise these numbers are noise).
|
|
beacon_interval_s: float | None = None
|
|
beacon_jitter_pct: float | None = None
|
|
if behavior == "beaconing":
|
|
beacon_interval_s = stats.get("mean_iat_s")
|
|
cv = stats.get("cv")
|
|
beacon_jitter_pct = round(cv * 100, 2) if cv is not None else None
|
|
|
|
_tracer = _get_tracer("profiler")
|
|
with _tracer.start_as_current_span("profiler.behavior_summary") as _span:
|
|
_span.set_attribute("behavior_class", behavior)
|
|
_span.set_attribute("os_guess", rollup["os_guess"] or "unknown")
|
|
_span.set_attribute("tool_count", len(all_tools))
|
|
_span.set_attribute("event_count", stats.get("event_count", 0))
|
|
if all_tools:
|
|
_span.set_attribute("tools", ",".join(all_tools))
|
|
|
|
kex_list = rollup.get("kex_order_raw") or []
|
|
ssh_banners = rollup.get("ssh_client_banners") or []
|
|
return {
|
|
"os_guess": rollup["os_guess"],
|
|
"hop_distance": rollup["hop_distance"],
|
|
"tcp_fingerprint": json.dumps(rollup["tcp_fingerprint"]),
|
|
"kex_order_raw": json.dumps(kex_list) if kex_list else None,
|
|
"ssh_client_banners": json.dumps(ssh_banners) if ssh_banners else None,
|
|
"retransmit_count": rollup["retransmit_count"],
|
|
"behavior_class": behavior,
|
|
"beacon_interval_s": beacon_interval_s,
|
|
"beacon_jitter_pct": beacon_jitter_pct,
|
|
"tool_guesses": json.dumps(all_tools),
|
|
"timing_stats": json.dumps(stats),
|
|
"phase_sequence": json.dumps(phase),
|
|
}
|