Files
IoT_PreTester_V46_Demo/django_project/analyzer/artifacts.py
T
2026-08-19 08:04:59 +02:00

238 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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