huntkit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- huntkit/__init__.py +4 -0
- huntkit/__main__.py +8 -0
- huntkit/ai/__init__.py +42 -0
- huntkit/ai/advisor.py +133 -0
- huntkit/ai/providers.py +200 -0
- huntkit/app/__init__.py +13 -0
- huntkit/app/config_cmd.py +65 -0
- huntkit/app/context.py +50 -0
- huntkit/app/main.py +1166 -0
- huntkit/app/plugins_cmd.py +79 -0
- huntkit/app/workspace_cmd.py +62 -0
- huntkit/core/__init__.py +6 -0
- huntkit/core/cache.py +67 -0
- huntkit/core/config.py +272 -0
- huntkit/core/logger.py +66 -0
- huntkit/core/pacing.py +71 -0
- huntkit/core/runner.py +117 -0
- huntkit/core/state.py +154 -0
- huntkit/core/workspace.py +145 -0
- huntkit/enrich/__init__.py +39 -0
- huntkit/enrich/asn.py +139 -0
- huntkit/enrich/engine.py +167 -0
- huntkit/enrich/favicon.py +91 -0
- huntkit/enrich/mmh3.py +69 -0
- huntkit/fingerprint/__init__.py +26 -0
- huntkit/fingerprint/engine.py +83 -0
- huntkit/fingerprint/rules.py +170 -0
- huntkit/history/__init__.py +38 -0
- huntkit/history/diff.py +95 -0
- huntkit/history/store.py +133 -0
- huntkit/intel/__init__.py +48 -0
- huntkit/intel/engine.py +233 -0
- huntkit/intel/signals.py +826 -0
- huntkit/knowledge/__init__.py +27 -0
- huntkit/knowledge/playbooks.py +452 -0
- huntkit/monitor/__init__.py +24 -0
- huntkit/monitor/runner.py +111 -0
- huntkit/notify/__init__.py +16 -0
- huntkit/notify/webhook.py +73 -0
- huntkit/pipeline.py +302 -0
- huntkit/plugins/__init__.py +36 -0
- huntkit/plugins/amass.py +25 -0
- huntkit/plugins/arjun.py +18 -0
- huntkit/plugins/assetfinder.py +18 -0
- huntkit/plugins/base.py +220 -0
- huntkit/plugins/chaos.py +20 -0
- huntkit/plugins/dalfox.py +24 -0
- huntkit/plugins/dnsx.py +19 -0
- huntkit/plugins/ffuf.py +34 -0
- huntkit/plugins/findomain.py +18 -0
- huntkit/plugins/gau.py +18 -0
- huntkit/plugins/hakrawler.py +19 -0
- huntkit/plugins/httpx.py +30 -0
- huntkit/plugins/katana.py +23 -0
- huntkit/plugins/naabu.py +22 -0
- huntkit/plugins/nmap.py +39 -0
- huntkit/plugins/nuclei.py +27 -0
- huntkit/plugins/registry.py +117 -0
- huntkit/plugins/subfinder.py +21 -0
- huntkit/plugins/waybackurls.py +29 -0
- huntkit/plugins/whatweb.py +19 -0
- huntkit/py.typed +0 -0
- huntkit/report/__init__.py +103 -0
- huntkit/report/builder.py +145 -0
- huntkit/report/render.py +323 -0
- huntkit/scanner/__init__.py +28 -0
- huntkit/scanner/runner.py +239 -0
- huntkit/secrets/__init__.py +34 -0
- huntkit/secrets/patterns.py +172 -0
- huntkit/secrets/scanner.py +133 -0
- huntkit/shots/__init__.py +29 -0
- huntkit/shots/runner.py +186 -0
- huntkit/tui/__init__.py +13 -0
- huntkit/tui/dashboard.py +160 -0
- huntkit/utils/__init__.py +6 -0
- huntkit/utils/filesystem.py +109 -0
- huntkit/utils/process.py +104 -0
- huntkit/utils/severity.py +49 -0
- huntkit/utils/terminal.py +88 -0
- huntkit/utils/validators.py +99 -0
- huntkit-0.3.0.dist-info/METADATA +340 -0
- huntkit-0.3.0.dist-info/RECORD +86 -0
- huntkit-0.3.0.dist-info/WHEEL +5 -0
- huntkit-0.3.0.dist-info/entry_points.txt +2 -0
- huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- huntkit-0.3.0.dist-info/top_level.txt +1 -0
huntkit/history/diff.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Diff two snapshots — what appeared and what disappeared since last run.
|
|
2
|
+
|
|
3
|
+
The delta is the whole point of re-running a program: new subdomains to probe,
|
|
4
|
+
freshly-live URLs, and above all *new findings*. :func:`diff_snapshots`
|
|
5
|
+
compares two :class:`~huntkit.history.store.Snapshot` objects per category;
|
|
6
|
+
:func:`current_vs` compares a saved baseline against the workspace as it stands
|
|
7
|
+
right now, so ``huntkit diff`` answers "what changed since the baseline?".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from ..core.workspace import Workspace
|
|
15
|
+
from .store import CATEGORIES, Snapshot, capture
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CategoryDiff:
|
|
20
|
+
name: str
|
|
21
|
+
added: list[str] = field(default_factory=list)
|
|
22
|
+
removed: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def changed(self) -> bool:
|
|
26
|
+
return bool(self.added or self.removed)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DiffReport:
|
|
31
|
+
program: str
|
|
32
|
+
base_id: str
|
|
33
|
+
base_time: str
|
|
34
|
+
new_id: str
|
|
35
|
+
new_time: str
|
|
36
|
+
categories: list[CategoryDiff] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def by_name(self, name: str) -> CategoryDiff:
|
|
39
|
+
for c in self.categories:
|
|
40
|
+
if c.name == name:
|
|
41
|
+
return c
|
|
42
|
+
return CategoryDiff(name)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def changed(self) -> list[CategoryDiff]:
|
|
46
|
+
return [c for c in self.categories if c.changed]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def has_changes(self) -> bool:
|
|
50
|
+
return bool(self.changed)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_added(self) -> int:
|
|
54
|
+
return sum(len(c.added) for c in self.categories)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def total_removed(self) -> int:
|
|
58
|
+
return sum(len(c.removed) for c in self.categories)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def new_findings(self) -> list[str]:
|
|
62
|
+
return self.by_name("findings").added
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def diff_snapshots(old: Snapshot, new: Snapshot, program: str) -> DiffReport:
|
|
66
|
+
"""Per-category added/removed between two snapshots (old → new)."""
|
|
67
|
+
cats: list[CategoryDiff] = []
|
|
68
|
+
for name in CATEGORIES:
|
|
69
|
+
old_set = set(old.get(name))
|
|
70
|
+
new_set = set(new.get(name))
|
|
71
|
+
cats.append(
|
|
72
|
+
CategoryDiff(
|
|
73
|
+
name=name,
|
|
74
|
+
added=sorted(new_set - old_set),
|
|
75
|
+
removed=sorted(old_set - new_set),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return DiffReport(
|
|
79
|
+
program=program,
|
|
80
|
+
base_id=old.id,
|
|
81
|
+
base_time=old.generated,
|
|
82
|
+
new_id=new.id,
|
|
83
|
+
new_time=new.generated,
|
|
84
|
+
categories=cats,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def current_vs(ws: Workspace, baseline: Snapshot) -> tuple[DiffReport, Snapshot]:
|
|
89
|
+
"""Diff the workspace as it stands now against ``baseline``.
|
|
90
|
+
|
|
91
|
+
Returns the report and the freshly-captured (still unsaved) snapshot, so a
|
|
92
|
+
caller can persist it as the next baseline if asked.
|
|
93
|
+
"""
|
|
94
|
+
now = capture(ws)
|
|
95
|
+
return diff_snapshots(baseline, now, ws.program), now
|
huntkit/history/store.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Snapshots — freeze a program's result set so later runs can be diffed.
|
|
2
|
+
|
|
3
|
+
Bug bounty is not one pass; it is the same scope re-run over weeks. The signal
|
|
4
|
+
that matters is the *delta*: a subdomain that appeared yesterday, a URL that
|
|
5
|
+
went live, a finding that is new since the last sweep. This layer captures the
|
|
6
|
+
current state of a workspace's result files into a timestamped snapshot under
|
|
7
|
+
``history/`` so :mod:`huntkit.history.diff` can compare two points in time.
|
|
8
|
+
|
|
9
|
+
Pure and offline — it only reads artifacts already on disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..core.workspace import Workspace
|
|
20
|
+
|
|
21
|
+
HISTORY_DIR = "history"
|
|
22
|
+
_FINDINGS_JSON = "scans/findings.json"
|
|
23
|
+
|
|
24
|
+
# The result sets a snapshot freezes, in pipeline order. Each text category
|
|
25
|
+
# maps to a deduped result file; ``findings`` is flattened from the scan JSON.
|
|
26
|
+
CATEGORIES: tuple[str, ...] = ("subdomains", "resolved", "live", "ports", "urls", "findings")
|
|
27
|
+
_FILE_SOURCES: dict[str, str] = {
|
|
28
|
+
"subdomains": "recon/subdomains.txt",
|
|
29
|
+
"resolved": "recon/resolved.txt",
|
|
30
|
+
"live": "recon/live.txt",
|
|
31
|
+
"ports": "recon/ports.txt",
|
|
32
|
+
"urls": "urls/urls.txt",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Snapshot:
|
|
38
|
+
"""One frozen point in time: the sorted, deduped items per category."""
|
|
39
|
+
|
|
40
|
+
id: str # sortable stamp, e.g. 20261027-163000
|
|
41
|
+
generated: str # ISO-8601 UTC
|
|
42
|
+
items: dict[str, list[str]] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def counts(self) -> dict[str, int]:
|
|
46
|
+
return {cat: len(self.items.get(cat, [])) for cat in CATEGORIES}
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def total(self) -> int:
|
|
50
|
+
return sum(len(v) for v in self.items.values())
|
|
51
|
+
|
|
52
|
+
def get(self, category: str) -> list[str]:
|
|
53
|
+
return self.items.get(category, [])
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict:
|
|
56
|
+
return {"id": self.id, "generated": self.generated, "items": self.items}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict) -> Snapshot:
|
|
60
|
+
raw = data.get("items", {})
|
|
61
|
+
items = {cat: list(raw.get(cat, [])) for cat in CATEGORIES}
|
|
62
|
+
return cls(id=data.get("id", ""), generated=data.get("generated", ""), items=items)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _findings(ws: Workspace) -> list[str]:
|
|
66
|
+
"""Flatten the combined scan findings into a sorted, deduped list."""
|
|
67
|
+
path = ws.path(_FINDINGS_JSON)
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return []
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except (ValueError, OSError):
|
|
73
|
+
return []
|
|
74
|
+
seen: set[str] = set()
|
|
75
|
+
for lines in data.get("tools", {}).values():
|
|
76
|
+
seen.update(ln for ln in lines if ln.strip())
|
|
77
|
+
return sorted(seen)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def capture(ws: Workspace) -> Snapshot:
|
|
81
|
+
"""Freeze the workspace's current result sets into an unsaved snapshot."""
|
|
82
|
+
items: dict[str, list[str]] = {}
|
|
83
|
+
for cat, rel in _FILE_SOURCES.items():
|
|
84
|
+
items[cat] = sorted(dict.fromkeys(ws.read_lines(rel)))
|
|
85
|
+
items["findings"] = _findings(ws)
|
|
86
|
+
return Snapshot(
|
|
87
|
+
id=time.strftime("%Y%m%d-%H%M%S", time.gmtime()),
|
|
88
|
+
generated=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
89
|
+
items=items,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save(ws: Workspace, snap: Snapshot) -> Path:
|
|
94
|
+
"""Persist a snapshot under ``history/<id>.json`` and return its path."""
|
|
95
|
+
outdir = Path(ws.path(HISTORY_DIR))
|
|
96
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path = outdir / f"{snap.id}.json"
|
|
98
|
+
path.write_text(json.dumps(snap.to_dict(), indent=2), encoding="utf-8")
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load(path: Path) -> Snapshot:
|
|
103
|
+
return Snapshot.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def list_snapshots(ws: Workspace) -> list[Snapshot]:
|
|
107
|
+
"""All saved snapshots, oldest first (sorted by id)."""
|
|
108
|
+
outdir = Path(ws.path(HISTORY_DIR))
|
|
109
|
+
if not outdir.exists():
|
|
110
|
+
return []
|
|
111
|
+
snaps = []
|
|
112
|
+
for p in outdir.glob("*.json"):
|
|
113
|
+
try:
|
|
114
|
+
snaps.append(load(p))
|
|
115
|
+
except (ValueError, OSError):
|
|
116
|
+
continue
|
|
117
|
+
return sorted(snaps, key=lambda s: s.id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def latest(ws: Workspace) -> Snapshot | None:
|
|
121
|
+
snaps = list_snapshots(ws)
|
|
122
|
+
return snaps[-1] if snaps else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def find(ws: Workspace, snapshot_id: str) -> Snapshot | None:
|
|
126
|
+
"""Look up a snapshot by exact id, or None."""
|
|
127
|
+
path = Path(ws.path(HISTORY_DIR)) / f"{snapshot_id}.json"
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return load(path)
|
|
132
|
+
except (ValueError, OSError):
|
|
133
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Intelligence layer — turn recon output into scored, prioritised leads.
|
|
2
|
+
|
|
3
|
+
The engine reads a workspace's recon artifacts, runs them through a declarative
|
|
4
|
+
:mod:`~huntkit.intel.signals` catalog, scores each host, and rolls the signals
|
|
5
|
+
up into prioritised attack paths. It's a feature layer (like the pipeline):
|
|
6
|
+
it builds on ``core`` and ``utils`` but is independent of the plugin registry,
|
|
7
|
+
so it stays pure and easy to test.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .engine import (
|
|
13
|
+
AttackPath,
|
|
14
|
+
HostIntel,
|
|
15
|
+
IntelReport,
|
|
16
|
+
Priority,
|
|
17
|
+
analyze,
|
|
18
|
+
save_report,
|
|
19
|
+
)
|
|
20
|
+
from .signals import (
|
|
21
|
+
Rule,
|
|
22
|
+
Severity,
|
|
23
|
+
Signal,
|
|
24
|
+
scan_signals,
|
|
25
|
+
signals_from_findings,
|
|
26
|
+
signals_from_labels,
|
|
27
|
+
signals_from_params,
|
|
28
|
+
signals_from_paths,
|
|
29
|
+
signals_from_ports,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AttackPath",
|
|
34
|
+
"HostIntel",
|
|
35
|
+
"IntelReport",
|
|
36
|
+
"Priority",
|
|
37
|
+
"Rule",
|
|
38
|
+
"Severity",
|
|
39
|
+
"Signal",
|
|
40
|
+
"analyze",
|
|
41
|
+
"save_report",
|
|
42
|
+
"scan_signals",
|
|
43
|
+
"signals_from_findings",
|
|
44
|
+
"signals_from_labels",
|
|
45
|
+
"signals_from_params",
|
|
46
|
+
"signals_from_paths",
|
|
47
|
+
"signals_from_ports",
|
|
48
|
+
]
|
huntkit/intel/engine.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Intelligence engine — recon output -> scored, prioritised attack surface.
|
|
2
|
+
|
|
3
|
+
Reads a workspace's recon artifacts, runs them through the signal catalog,
|
|
4
|
+
groups the findings per host, scores each host Low -> Critical, and rolls the
|
|
5
|
+
signals up into prioritised **attack paths** (which bug-class playbook to run,
|
|
6
|
+
and where). Pure analysis over data already on disk — it never touches the
|
|
7
|
+
target.
|
|
8
|
+
|
|
9
|
+
The layout paths it reads mirror :data:`huntkit.pipeline.STAGE_FILES`; they are
|
|
10
|
+
duplicated here as small constants so the intelligence layer stays independent
|
|
11
|
+
of the pipeline (and trivially testable without the plugin registry).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from collections import Counter, defaultdict
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import IntEnum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ..core.workspace import Workspace
|
|
24
|
+
from ..knowledge.playbooks import titles as _playbook_titles
|
|
25
|
+
from ..utils import filesystem as fs
|
|
26
|
+
from .signals import Severity, Signal, scan_signals, url_host
|
|
27
|
+
|
|
28
|
+
# Workspace-relative recon artifacts the engine consumes.
|
|
29
|
+
_LIVE = "recon/live.txt"
|
|
30
|
+
_URLS = "urls/urls.txt"
|
|
31
|
+
_PORTS = "recon/ports.txt"
|
|
32
|
+
_SUBS = "recon/subdomains.txt"
|
|
33
|
+
_RESOLVED = "recon/resolved.txt"
|
|
34
|
+
# Active-scan output (written by the scanner layer); optional. Named here as a
|
|
35
|
+
# constant rather than imported, so intel stays independent of the scanner.
|
|
36
|
+
_FINDINGS = "scans/findings.json"
|
|
37
|
+
|
|
38
|
+
# Human names for the bug-class playbook ids. The knowledge base owns the
|
|
39
|
+
# playbook catalog, so names come from there — one source of truth, and an
|
|
40
|
+
# attack path in a report links straight to `huntkit playbook <id>`.
|
|
41
|
+
PLAYBOOK_NAMES: dict[str, str] = _playbook_titles()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Priority(IntEnum):
|
|
45
|
+
LOW = 1
|
|
46
|
+
MEDIUM = 2
|
|
47
|
+
HIGH = 3
|
|
48
|
+
CRITICAL = 4
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def label(self) -> str:
|
|
52
|
+
return self.name.capitalize()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def style(self) -> str: # matches the theme keys in utils.terminal
|
|
56
|
+
return self.name.lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _priority(signals: list[Signal]) -> Priority:
|
|
60
|
+
"""Host priority = worst single signal, nudged up by breadth of surface.
|
|
61
|
+
|
|
62
|
+
A Critical signal makes the host Critical outright; otherwise a High
|
|
63
|
+
signal (or enough stacked medium/low ones) makes it High, and so on. The
|
|
64
|
+
aggregate thresholds mean a host with many medium issues still rises.
|
|
65
|
+
"""
|
|
66
|
+
if not signals:
|
|
67
|
+
return Priority.LOW
|
|
68
|
+
top = max(s.severity for s in signals)
|
|
69
|
+
total = sum(int(s.severity) for s in signals)
|
|
70
|
+
if top >= Severity.CRITICAL:
|
|
71
|
+
return Priority.CRITICAL
|
|
72
|
+
if top >= Severity.HIGH or total >= 8:
|
|
73
|
+
return Priority.HIGH
|
|
74
|
+
if top >= Severity.MEDIUM or total >= 4:
|
|
75
|
+
return Priority.MEDIUM
|
|
76
|
+
return Priority.LOW
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class HostIntel:
|
|
81
|
+
host: str
|
|
82
|
+
signals: list[Signal]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def score(self) -> int:
|
|
86
|
+
return sum(int(s.severity) for s in self.signals)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def priority(self) -> Priority:
|
|
90
|
+
return _priority(self.signals)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def playbooks(self) -> list[str]:
|
|
94
|
+
"""Suggested playbooks, most-severe signal first, de-duplicated."""
|
|
95
|
+
ordered: list[str] = []
|
|
96
|
+
for s in sorted(self.signals, key=lambda x: -int(x.severity)):
|
|
97
|
+
for pb in s.playbooks:
|
|
98
|
+
if pb not in ordered:
|
|
99
|
+
ordered.append(pb)
|
|
100
|
+
return ordered
|
|
101
|
+
|
|
102
|
+
def ranked_signals(self) -> list[Signal]:
|
|
103
|
+
return sorted(self.signals, key=lambda s: (-int(s.severity), s.category, s.id))
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict:
|
|
106
|
+
return {
|
|
107
|
+
"host": self.host,
|
|
108
|
+
"priority": self.priority.label,
|
|
109
|
+
"score": self.score,
|
|
110
|
+
"playbooks": self.playbooks,
|
|
111
|
+
"signals": [s.to_dict() for s in self.ranked_signals()],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class AttackPath:
|
|
117
|
+
"""One bug-class playbook and everywhere the recon points it."""
|
|
118
|
+
|
|
119
|
+
playbook: str
|
|
120
|
+
name: str
|
|
121
|
+
severity: Severity # worst contributing signal
|
|
122
|
+
hosts: list[str]
|
|
123
|
+
signals: list[Signal]
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict:
|
|
126
|
+
return {
|
|
127
|
+
"playbook": self.playbook,
|
|
128
|
+
"name": self.name,
|
|
129
|
+
"severity": self.severity.label,
|
|
130
|
+
"hosts": self.hosts,
|
|
131
|
+
"signal_count": len(self.signals),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class IntelReport:
|
|
137
|
+
program: str
|
|
138
|
+
hosts: list[HostIntel]
|
|
139
|
+
generated: float = field(default_factory=time.time)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def signals(self) -> list[Signal]:
|
|
143
|
+
return [s for h in self.hosts for s in h.signals]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def summary(self) -> dict[str, int]:
|
|
147
|
+
"""Host counts per priority, always highest-first and fully populated."""
|
|
148
|
+
counts = Counter(h.priority for h in self.hosts)
|
|
149
|
+
return {p.label: counts.get(p, 0) for p in sorted(Priority, reverse=True)}
|
|
150
|
+
|
|
151
|
+
def attack_paths(self) -> list[AttackPath]:
|
|
152
|
+
groups: dict[str, list[Signal]] = defaultdict(list)
|
|
153
|
+
for s in self.signals:
|
|
154
|
+
for pb in s.playbooks:
|
|
155
|
+
groups[pb].append(s)
|
|
156
|
+
paths = [
|
|
157
|
+
AttackPath(
|
|
158
|
+
playbook=pb,
|
|
159
|
+
name=PLAYBOOK_NAMES.get(pb, pb),
|
|
160
|
+
severity=max(s.severity for s in sigs),
|
|
161
|
+
hosts=sorted({s.host for s in sigs}),
|
|
162
|
+
signals=sigs,
|
|
163
|
+
)
|
|
164
|
+
for pb, sigs in groups.items()
|
|
165
|
+
]
|
|
166
|
+
paths.sort(key=lambda p: (-int(p.severity), -len(p.hosts), p.playbook))
|
|
167
|
+
return paths
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict:
|
|
170
|
+
return {
|
|
171
|
+
"program": self.program,
|
|
172
|
+
"generated": self.generated,
|
|
173
|
+
"summary": self.summary,
|
|
174
|
+
"hosts": [h.to_dict() for h in self.hosts],
|
|
175
|
+
"attack_paths": [p.to_dict() for p in self.attack_paths()],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _host_pool(ws: Workspace, live: list[str]) -> list[str]:
|
|
180
|
+
"""Every hostname worth label-analysis: subdomains, resolved, live hosts."""
|
|
181
|
+
pool: set[str] = set(ws.read_lines(_SUBS)) | set(ws.read_lines(_RESOLVED))
|
|
182
|
+
for url in live:
|
|
183
|
+
host = url_host(url)
|
|
184
|
+
if host:
|
|
185
|
+
pool.add(host)
|
|
186
|
+
return sorted(h.lower() for h in pool if h.strip())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _read_findings(ws: Workspace) -> dict[str, list[str]]:
|
|
190
|
+
"""Scanner findings keyed by tool, or empty if no scan has run / file is bad."""
|
|
191
|
+
path = ws.path(_FINDINGS)
|
|
192
|
+
if not path.exists():
|
|
193
|
+
return {}
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(fs.read_text(path))
|
|
196
|
+
except (ValueError, OSError):
|
|
197
|
+
return {}
|
|
198
|
+
tools = data.get("tools") if isinstance(data, dict) else None
|
|
199
|
+
if not isinstance(tools, dict):
|
|
200
|
+
return {}
|
|
201
|
+
return {
|
|
202
|
+
str(tool): [str(x) for x in lines]
|
|
203
|
+
for tool, lines in tools.items()
|
|
204
|
+
if isinstance(lines, list)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def analyze(ws: Workspace) -> IntelReport:
|
|
209
|
+
"""Score a workspace's recon surface into a prioritised intel report."""
|
|
210
|
+
live = ws.read_lines(_LIVE)
|
|
211
|
+
urls = ws.read_lines(_URLS)
|
|
212
|
+
ports = ws.read_lines(_PORTS)
|
|
213
|
+
hosts = _host_pool(ws, live)
|
|
214
|
+
url_pool = list(dict.fromkeys(live + urls)) # dedupe, keep order
|
|
215
|
+
findings = _read_findings(ws)
|
|
216
|
+
|
|
217
|
+
signals = scan_signals(ports=ports, urls=url_pool, hosts=hosts, findings=findings)
|
|
218
|
+
|
|
219
|
+
by_host: dict[str, list[Signal]] = defaultdict(list)
|
|
220
|
+
for s in signals:
|
|
221
|
+
by_host[s.host].append(s)
|
|
222
|
+
|
|
223
|
+
intel_hosts = [HostIntel(host=h, signals=sigs) for h, sigs in by_host.items()]
|
|
224
|
+
intel_hosts.sort(key=lambda h: (-int(h.priority), -h.score, h.host))
|
|
225
|
+
return IntelReport(program=ws.program, hosts=intel_hosts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def save_report(ws: Workspace, report: IntelReport) -> Path:
|
|
229
|
+
"""Persist the report as scans/intel.json for later reporting; record count."""
|
|
230
|
+
path = ws.path("scans", "intel.json")
|
|
231
|
+
fs.write_text(path, json.dumps(report.to_dict(), indent=2))
|
|
232
|
+
ws.state.set_count("intel_signals", len(report.signals))
|
|
233
|
+
return path
|