"""Extract endpoint and crackable hash artifacts from a capture context. Extraction is independent of Django, pyshark, and I/O. Active endpoint probing and hash cracking remain isolated in ``analyzer.enrich``. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Optional from .context import CaptureContext # --------------------------------------------------------------------------- # # Artifact data models # --------------------------------------------------------------------------- # @dataclass class UrlArtifact: """A domain, host, or URL observed in the capture.""" value: str kind: str = "unknown" # "dns" | "sni" | "http_host" | "payload" src_ip: Optional[str] = None dst_ip: Optional[str] = None timestamp: Optional[float] = None packet_number: Optional[int] = None # Passive observations are preferred over active probing results. observed_ports: set[int] = field(default_factory=set) observed_tls: set[str] = field(default_factory=set) def to_dict(self) -> dict: return { "value": self.value, "kind": self.kind, "src_ip": self.src_ip, "dst_ip": self.dst_ip, "timestamp": self.timestamp, "packet_number": self.packet_number, "observed_ports": sorted(self.observed_ports), "observed_tls": sorted(self.observed_tls), } @dataclass class HashArtifact: """A hash or crackable authentication artifact from the capture.""" value: str # Raw hash or complete hashcat line hashcat_mode: Optional[int] = None hash_format: Optional[str] = None # Human-readable format name kind: str = "raw_hex" # "raw_hex" | "netntlmv2" | "krb5asrep" | "krb5tgs" | "wpa" | "digest" account: Optional[str] = None src_ip: Optional[str] = None dst_ip: Optional[str] = None timestamp: Optional[float] = None packet_number: Optional[int] = None def to_dict(self) -> dict: return { "value": self.value, "hashcat_mode": self.hashcat_mode, "hash_format": self.hash_format, "kind": self.kind, "account": self.account, "src_ip": self.src_ip, "dst_ip": self.dst_ip, "timestamp": self.timestamp, "packet_number": self.packet_number, } # --------------------------------------------------------------------------- # # Hashcat mode detection # --------------------------------------------------------------------------- # # Hex length to hashcat mode and format name. _LEN_MODES = { 32: (0, "md5"), 40: (100, "sha1"), 56: (300, "sha224"), 64: (1400, "sha256"), 96: (6100, "sha384"), 128: (1700, "sha512"), } # Structured format name to hashcat mode. _FORMAT_MODES = { "netntlmv1": 5500, "netntlmv2": 5600, "krb5asrep": 18200, "krb5tgs": 13100, "wpa": 22000, "eapol": 22000, } _NETNTLMV2_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{16}:[0-9a-fA-F]{32}:[0-9a-fA-F]+$") _NETNTLMV1_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{48}:[0-9a-fA-F]{48}:[0-9a-fA-F]{16}$") def identify_hash_mode(value: str, hint_format: Optional[str] = None): """Return a hashcat mode and format using structure before hex length.""" v = (value or "").strip() if not v: return None, None if hint_format: base = str(hint_format).split("(")[0].strip().lower() if base in _FORMAT_MODES: return _FORMAT_MODES[base], hint_format low = v.lower() if low.startswith("$krb5asrep$"): return 18200, "krb5asrep" if low.startswith("$krb5tgs$"): return 13100, "krb5tgs" if low.startswith("$wpapsk$") or low.startswith("wpa*") or low.startswith("$wpa$"): return 22000, "wpa" if _NETNTLMV2_RE.match(v): return 5600, "NetNTLMv2" if _NETNTLMV1_RE.match(v): return 5500, "NetNTLMv1" if all(c in "0123456789abcdefABCDEF" for c in v) and len(v) in _LEN_MODES: return _LEN_MODES[len(v)] return None, None def _kind_from_mode(mode: Optional[int]) -> str: return { 5500: "netntlmv1", 5600: "netntlmv2", 18200: "krb5asrep", 13100: "krb5tgs", 22000: "wpa", }.get(mode, "raw_hex") # --------------------------------------------------------------------------- # # Endpoint extraction # --------------------------------------------------------------------------- # _URL_RE = re.compile( r"\b(?:" r"(?:https?://|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})" # URL or www host r"|" r"(?:[a-zA-Z0-9-]+\.){2,}[a-zA-Z]{2,6}" # Bare multi-label domain r"|" r"https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}" # URL with IPv4 host r")" r"(?:/[a-zA-Z0-9._%/-]*)?\b" ) def _normalize_host(value: Optional[str]) -> Optional[str]: if not value: return None v = str(value).strip().lower() v = v.rstrip(".") return v or None def extract_urls(ctx: CaptureContext) -> list[UrlArtifact]: """Collect endpoints and attach passively observed ports and TLS versions.""" by_value: dict[str, UrlArtifact] = {} def add(value, kind, **kw) -> Optional[UrlArtifact]: v = _normalize_host(value) if not v: return None art = by_value.get(v) if art is None: art = UrlArtifact(value=v, kind=kind, **kw) by_value[v] = art return art for q in ctx.dns_queries: add(q.query_name, "dns", src_ip=q.src_ip, dst_ip=q.dst_ip, timestamp=q.timestamp, packet_number=q.packet_number) for t in ctx.tls_sessions: if t.sni: art = add(t.sni, "sni", src_ip=t.src_ip, dst_ip=t.dst_ip, timestamp=t.timestamp, packet_number=t.packet_number) if art: if t.dst_port: art.observed_ports.add(t.dst_port) if t.version: art.observed_tls.add(t.version) for r in ctx.http_requests: if r.host: art = add(r.host, "http_host", src_ip=r.src_ip, dst_ip=r.dst_ip, timestamp=r.timestamp, packet_number=r.packet_number) if art and r.dst_port: art.observed_ports.add(r.dst_port) for p in ctx.text_payloads: text = p.get("text") or "" for m in _URL_RE.findall(text): add(m, "payload", src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"), timestamp=p.get("timestamp"), packet_number=p.get("packet_number")) return list(by_value.values()) # --------------------------------------------------------------------------- # # Hash extraction # --------------------------------------------------------------------------- # _HASH_HEX_RE = re.compile(r"\b[a-fA-F0-9]{32,64}\b") def extract_hashes(ctx: CaptureContext) -> list[HashArtifact]: """Collect structured authentication material and raw hexadecimal hashes.""" out: list[HashArtifact] = [] seen: set[str] = set() def add(value, *, mode, fmt, kind, **kw) -> None: if not value or value in seen: return seen.add(value) out.append(HashArtifact(value=value, hashcat_mode=mode, hash_format=fmt, kind=kind, **kw)) # Structured material already reconstructed by the packet extractor. for am in ctx.auth_material: if getattr(am, "hashcat", None): mode = am.hashcat_mode fmt = am.hash_format if mode is None: mode, fmt = identify_hash_mode(am.hashcat, hint_format=fmt) add(am.hashcat, mode=mode, fmt=fmt, kind=_kind_from_mode(mode), account=am.account, src_ip=am.src_ip, dst_ip=am.dst_ip, timestamp=am.timestamp, packet_number=am.packet_number) # Raw 32–64 character hex values use a mode only when the length is known. for p in ctx.text_payloads: text = p.get("text") or "" for m in _HASH_HEX_RE.findall(text): mode, fmt = identify_hash_mode(m) add(m.lower(), mode=mode, fmt=fmt, kind="raw_hex", src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"), timestamp=p.get("timestamp"), packet_number=p.get("packet_number")) return out