"""Run all registered detectors and return a prioritized analysis result.""" from __future__ import annotations import time from dataclasses import dataclass, field from typing import Optional from .context import CaptureContext from .findings import Finding from . import scoring from . import provenance from . import detectors as detectors_pkg @dataclass class AnalysisResult: findings: list[Finding] = field(default_factory=list) summary: dict = field(default_factory=dict) meta: dict = field(default_factory=dict) def to_dict(self) -> dict: return { "meta": self.meta, "summary": _summary_to_dict(self.summary), "findings": [f.to_dict() for f in self.findings], } class AnalysisEngine: def __init__(self, detector_list=None): # Use every registered detector unless a test supplies a subset. self.detectors = detector_list if detector_list is not None else detectors_pkg.build_all() def analyze_context(self, ctx: CaptureContext) -> AnalysisResult: started = time.time() raw: list[Finding] = [] errors: dict[str, str] = {} for det in self.detectors: try: raw.extend(list(det.detect(ctx))) except Exception as exc: # One detector must not abort the full analysis. errors[det.name] = repr(exc) deduped = scoring.dedupe(raw) ordered = scoring.sort_by_priority(deduped) # Attach packet-level provenance before the summary is generated. capture_file = _basename(ctx.path) for f in ordered: _enrich_provenance(f, capture_file) summary = scoring.summarize(ordered) return AnalysisResult( findings=ordered, summary=summary, meta={ "capture_path": ctx.path, "packet_count": ctx.packet_count, "duration_seconds": round(ctx.duration, 3), "detectors_run": [d.name for d in self.detectors], "detector_errors": errors, "analysis_time_seconds": round(time.time() - started, 3), "raw_finding_count": len(raw), "finding_count": len(ordered), }, ) def analyze_pcap(self, path: str, packet_limit: Optional[int] = None) -> AnalysisResult: # Keep context-only analysis usable without pyshark or tshark. from .extract_pyshark import build_context_from_pcap ctx = build_context_from_pcap(path, packet_limit=packet_limit) return self.analyze_context(ctx) def result_from_dict(data: dict) -> AnalysisResult: """Reconstruct a result and recalculate its summary from stored findings.""" findings = [Finding.from_dict(f) for f in data.get("findings", [])] summary = scoring.summarize(findings) meta = data.get("meta", {}) return AnalysisResult(findings=findings, summary=summary, meta=meta) def _summary_to_dict(summary: dict) -> dict: out = dict(summary) if "highlights" in out: out["highlights"] = [f.to_dict() for f in out["highlights"]] if "top" in out: out["top"] = [f.to_dict() for f in out["top"]] return out def _basename(path) -> Optional[str]: if not path: return None import os return os.path.basename(str(path)) or str(path) def _enrich_provenance(f: Finding, capture_file) -> None: """Add capture, artifact, and packet sources without replacing detector data.""" ev = f.evidence if capture_file and not ev.get("capture_file"): ev["capture_file"] = capture_file if "artifact" not in ev: art = provenance.derive_artifact(ev) if art is not None: ev["artifact"] = art if "sources" not in ev: ev["sources"] = provenance.build_sources( related_packets=f.related_packets, related_streams=f.related_streams, src_ip=f.source_ip, dst_ip=f.destination_ip, protocol=f.protocol, port=f.port, timestamp_first_seen=f.timestamp_first_seen, capture=capture_file, ) # Preserve the complete evidence count when the displayed source list is bounded. if f.related_packets and "source_packet_count" not in ev: ev["source_packet_count"] = len(f.related_packets)