build 4.6 Demo
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Optional, lazily loaded hash and endpoint enrichment."""
|
||||
|
||||
from .hash_crack import HashCrack, crack_hash
|
||||
from .url_probe import UrlProbe, probe_url
|
||||
from .engine import EnrichmentEngine
|
||||
|
||||
__all__ = [
|
||||
"HashCrack", "crack_hash",
|
||||
"UrlProbe", "probe_url",
|
||||
"EnrichmentEngine",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Coordinate optional enrichment and progress callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
from ..artifacts import HashArtifact, UrlArtifact
|
||||
from .hash_crack import HashCrack, crack_hash
|
||||
from .url_probe import UrlProbe, probe_url
|
||||
|
||||
# progress(done, total, item) -> None
|
||||
ProgressCb = Optional[Callable[[int, int, object], None]]
|
||||
|
||||
|
||||
class EnrichmentEngine:
|
||||
"""Run hash and endpoint enrichment for artifact lists."""
|
||||
|
||||
def crack_hashes(self, artifacts: Sequence[HashArtifact], *,
|
||||
progress: ProgressCb = None, **crack_kwargs) -> list[HashCrack]:
|
||||
results: list[HashCrack] = []
|
||||
total = len(artifacts)
|
||||
for i, art in enumerate(artifacts, 1):
|
||||
res = crack_hash(art, **crack_kwargs)
|
||||
results.append(res)
|
||||
if progress:
|
||||
progress(i, total, res)
|
||||
return results
|
||||
|
||||
def probe_urls(self, artifacts: Sequence[UrlArtifact], *,
|
||||
progress: ProgressCb = None, active: bool = False,
|
||||
**probe_kwargs) -> list[UrlProbe]:
|
||||
results: list[UrlProbe] = []
|
||||
total = len(artifacts)
|
||||
for i, art in enumerate(artifacts, 1):
|
||||
res = probe_url(art, active=active, **probe_kwargs)
|
||||
results.append(res)
|
||||
if progress:
|
||||
progress(i, total, res)
|
||||
return results
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Run bounded offline hashcat attacks against supported capture artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional, Sequence, Union
|
||||
|
||||
from ..artifacts import HashArtifact, identify_hash_mode
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashCrack:
|
||||
value: str
|
||||
hashcat_mode: Optional[int] = None
|
||||
hash_format: Optional[str] = None
|
||||
cracked: bool = False
|
||||
plaintext: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
# pending | cracked | not_cracked | timeout | error | unsupported
|
||||
state: str = "pending"
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"value": self.value, "hashcat_mode": self.hashcat_mode,
|
||||
"hash_format": self.hash_format, "cracked": self.cracked,
|
||||
"plaintext": self.plaintext, "duration_seconds": self.duration_seconds,
|
||||
"state": self.state, "error": self.error,
|
||||
}
|
||||
|
||||
|
||||
# Keep the bundled demo list first; authorized deployments may add a system list.
|
||||
DEFAULT_WORDLISTS = (
|
||||
str(Path(__file__).resolve().parents[3] / "demo_files" / "demo_wordlist.txt"),
|
||||
"/usr/share/wordlists/rockyou.txt",
|
||||
)
|
||||
|
||||
|
||||
def build_crack_command(hashcat_path: str, mode: int, hash_file: str,
|
||||
wordlists: Sequence[str], potfile: str) -> list[str]:
|
||||
"""Build the hashcat dictionary-attack command."""
|
||||
cmd = [hashcat_path, "-m", str(mode), "-a", "0", "-D", "1,2",
|
||||
hash_file, *wordlists, "--potfile-path", potfile, "--force"]
|
||||
return cmd
|
||||
|
||||
|
||||
def build_show_command(hashcat_path: str, mode: int, hash_file: str,
|
||||
potfile: str) -> list[str]:
|
||||
"""Build the hashcat command used to read cracked results."""
|
||||
return [hashcat_path, "-m", str(mode), "--show", "--potfile-path", potfile, hash_file]
|
||||
|
||||
|
||||
def parse_show_output(stdout: str) -> Optional[str]:
|
||||
"""Extract plaintext from ``hashcat --show`` output."""
|
||||
if not stdout:
|
||||
return None
|
||||
for line in stdout.strip().splitlines():
|
||||
if ":" in line:
|
||||
return line.rsplit(":", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def crack_hash(
|
||||
artifact: Union[HashArtifact, str],
|
||||
*,
|
||||
wordlists: Sequence[str] = DEFAULT_WORDLISTS,
|
||||
hashcat_path: str = "hashcat",
|
||||
timeout: int = 300,
|
||||
potfile: Optional[str] = None,
|
||||
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
||||
which: Callable[[str], Optional[str]] = shutil.which,
|
||||
) -> HashCrack:
|
||||
"""Crack one artifact and convert runtime failures into a result state."""
|
||||
if isinstance(artifact, HashArtifact):
|
||||
value = artifact.value
|
||||
mode = artifact.hashcat_mode
|
||||
fmt = artifact.hash_format
|
||||
else:
|
||||
value = str(artifact)
|
||||
mode = None
|
||||
fmt = None
|
||||
|
||||
if mode is None:
|
||||
mode, fmt = identify_hash_mode(value, hint_format=fmt)
|
||||
|
||||
result = HashCrack(value=value, hashcat_mode=mode, hash_format=fmt)
|
||||
|
||||
if mode is None:
|
||||
result.state = "unsupported"
|
||||
result.error = "No supported hashcat mode was detected"
|
||||
return result
|
||||
|
||||
if which(hashcat_path) is None:
|
||||
result.state = "error"
|
||||
result.error = "hashcat is not installed or not available on PATH"
|
||||
return result
|
||||
|
||||
available = [w for w in wordlists if os.path.exists(w)]
|
||||
if not available:
|
||||
result.state = "error"
|
||||
result.error = "No wordlist is available"
|
||||
return result
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="hashcrack_")
|
||||
hash_file = os.path.join(tmpdir, "hash.txt")
|
||||
pot = potfile or os.path.join(tmpdir, "hashcat.potfile")
|
||||
try:
|
||||
with open(hash_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(value.strip() + "\n")
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
runner(build_crack_command(hashcat_path, mode, hash_file, available, pot),
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
result.state = "timeout"
|
||||
result.duration_seconds = round(time.time() - start, 2)
|
||||
result.error = f"hashcat timed out after {timeout}s"
|
||||
return result
|
||||
|
||||
show = runner(build_show_command(hashcat_path, mode, hash_file, pot),
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
result.duration_seconds = round(time.time() - start, 2)
|
||||
|
||||
plaintext = parse_show_output(getattr(show, "stdout", "") or "")
|
||||
if plaintext is not None:
|
||||
result.cracked = True
|
||||
result.plaintext = plaintext
|
||||
result.state = "cracked"
|
||||
else:
|
||||
result.state = "not_cracked"
|
||||
return result
|
||||
except Exception as exc: # Enrichment failures must not stop capture analysis.
|
||||
result.state = "error"
|
||||
result.error = str(exc)[:300]
|
||||
return result
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Optionally enrich observed endpoints with DNS, port, and TLS checks.
|
||||
|
||||
Passive capture evidence remains authoritative. Active checks are opt-in and
|
||||
limited to a small port set because operators must have authorization to scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional, Sequence, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..artifacts import UrlArtifact
|
||||
|
||||
DEFAULT_PORTS = (22, 80, 443, 8000, 8080)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlProbe:
|
||||
url: str
|
||||
host: str
|
||||
resolved_ips: list[str] = field(default_factory=list)
|
||||
open_ports: list[int] = field(default_factory=list)
|
||||
tls_versions: list[str] = field(default_factory=list)
|
||||
cipher_suites: list[str] = field(default_factory=list)
|
||||
certificate: Optional[str] = None
|
||||
active: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"url": self.url, "host": self.host,
|
||||
"resolved_ips": self.resolved_ips, "open_ports": self.open_ports,
|
||||
"tls_versions": self.tls_versions, "cipher_suites": self.cipher_suites,
|
||||
"certificate": self.certificate, "active": self.active, "error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def host_of(url: str) -> str:
|
||||
"""Reduce a URL or domain value to its hostname."""
|
||||
s = str(url).strip()
|
||||
if "://" not in s:
|
||||
s = "//" + s
|
||||
netloc = urlparse(s).netloc or ""
|
||||
host = netloc.split("@")[-1].split(":")[0]
|
||||
return host or str(url).strip()
|
||||
|
||||
|
||||
def probe_url(
|
||||
artifact: Union[UrlArtifact, str],
|
||||
*,
|
||||
active: bool = False,
|
||||
ports: Sequence[int] = DEFAULT_PORTS,
|
||||
dns_resolver: Optional[Callable[[str], list]] = None,
|
||||
port_scanner: Optional[Callable[[str, Sequence[int]], list]] = None,
|
||||
tls_scanner: Optional[Callable[[str], dict]] = None,
|
||||
) -> UrlProbe:
|
||||
"""Return passive observations and optionally perform authorized active checks."""
|
||||
if isinstance(artifact, UrlArtifact):
|
||||
url = artifact.value
|
||||
probe = UrlProbe(url=url, host=host_of(url))
|
||||
probe.open_ports = sorted(artifact.observed_ports)
|
||||
probe.tls_versions = sorted(artifact.observed_tls)
|
||||
else:
|
||||
url = str(artifact)
|
||||
probe = UrlProbe(url=url, host=host_of(url))
|
||||
|
||||
if not active:
|
||||
return probe
|
||||
|
||||
probe.active = True
|
||||
host = probe.host
|
||||
try:
|
||||
resolver = dns_resolver or _default_dns_resolver
|
||||
probe.resolved_ips = resolver(host) or []
|
||||
except Exception as exc:
|
||||
probe.error = f"DNS: {exc}"
|
||||
|
||||
try:
|
||||
scanner = port_scanner or _default_port_scanner
|
||||
found = scanner(host, ports) or []
|
||||
probe.open_ports = sorted(set(probe.open_ports) | set(found))
|
||||
except Exception as exc:
|
||||
probe.error = (probe.error or "") + f" Ports: {exc}"
|
||||
|
||||
try:
|
||||
tls = tls_scanner or _default_tls_scanner
|
||||
info = tls(host) or {}
|
||||
if info.get("tls_version"):
|
||||
probe.tls_versions = sorted(set(probe.tls_versions) | {info["tls_version"]})
|
||||
if info.get("cipher"):
|
||||
probe.cipher_suites = sorted(set(probe.cipher_suites) | {info["cipher"]})
|
||||
probe.certificate = info.get("certificate")
|
||||
except Exception as exc:
|
||||
probe.error = (probe.error or "") + f" TLS: {exc}"
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lazy defaults are imported only when active checks are requested.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _default_dns_resolver(host: str) -> list[str]:
|
||||
import dns.resolver # lazy
|
||||
answers = dns.resolver.resolve(host, "A")
|
||||
return [str(r) for r in answers]
|
||||
|
||||
|
||||
def _default_port_scanner(host: str, ports: Sequence[int]) -> list[int]:
|
||||
import nmap # lazy
|
||||
nm = nmap.PortScanner()
|
||||
port_arg = ",".join(str(p) for p in ports)
|
||||
# TCP connect scanning works without raw-socket privileges; -Pn skips ICMP discovery.
|
||||
nm.scan(hosts=host, arguments=f"-sT -Pn -p {port_arg} --max-retries 1 --host-timeout 20s")
|
||||
open_ports: set[int] = set()
|
||||
# nmap indexes results by resolved address, so iterate over every returned host.
|
||||
for h in nm.all_hosts():
|
||||
try:
|
||||
for port in nm[h].all_tcp():
|
||||
if nm[h]["tcp"][port]["state"] == "open":
|
||||
open_ports.add(int(port))
|
||||
except Exception:
|
||||
continue
|
||||
return sorted(open_ports)
|
||||
|
||||
|
||||
def _default_tls_scanner(host: str) -> dict:
|
||||
import socket
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
with socket.create_connection((host, 443), timeout=5) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
cipher = ssock.cipher()
|
||||
return {
|
||||
"tls_version": ssock.version(),
|
||||
"cipher": cipher[0] if cipher else None,
|
||||
"certificate": "Present" if cert else "Not Present",
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
Reference in New Issue
Block a user