bhshell 0.1.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.
- bhshell/__init__.py +3 -0
- bhshell/__main__.py +3 -0
- bhshell/cli.py +55 -0
- bhshell/client/__init__.py +0 -0
- bhshell/client/auth.py +74 -0
- bhshell/client/client.py +337 -0
- bhshell/config.py +162 -0
- bhshell/errors.py +73 -0
- bhshell/library/asrep-roastable-users.cyp +3 -0
- bhshell/library/dcsync-principals.cyp +3 -0
- bhshell/library/domain-admins-members.cyp +3 -0
- bhshell/library/domain-trusts.cyp +3 -0
- bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
- bhshell/library/kerberoastable-users.cyp +3 -0
- bhshell/library/owned-objects.cyp +3 -0
- bhshell/library/owned-to-tier-zero.cyp +3 -0
- bhshell/library/sid-history.cyp +3 -0
- bhshell/library/tier-zero-users.cyp +3 -0
- bhshell/library/unconstrained-delegation.cyp +3 -0
- bhshell/models/__init__.py +0 -0
- bhshell/models/entities.py +402 -0
- bhshell/models/results.py +83 -0
- bhshell/queries/__init__.py +0 -0
- bhshell/queries/cypher.py +155 -0
- bhshell/queries/domains.py +136 -0
- bhshell/queries/edges.py +109 -0
- bhshell/queries/hunt.py +675 -0
- bhshell/queries/library.py +94 -0
- bhshell/queries/marking.py +121 -0
- bhshell/queries/paths.py +369 -0
- bhshell/queries/search.py +196 -0
- bhshell/queries/service.py +529 -0
- bhshell/render/__init__.py +0 -0
- bhshell/render/export.py +79 -0
- bhshell/render/graph.py +326 -0
- bhshell/render/layout.py +138 -0
- bhshell/render/png.py +44 -0
- bhshell/render/styles.py +5 -0
- bhshell/render/svg.py +495 -0
- bhshell/render/views.py +337 -0
- bhshell/shell/__init__.py +0 -0
- bhshell/shell/app.py +189 -0
- bhshell/shell/commands/__init__.py +0 -0
- bhshell/shell/commands/base.py +60 -0
- bhshell/shell/commands/connection.py +236 -0
- bhshell/shell/commands/cypher.py +97 -0
- bhshell/shell/commands/domains.py +80 -0
- bhshell/shell/commands/entities.py +88 -0
- bhshell/shell/commands/hunt.py +151 -0
- bhshell/shell/commands/library.py +56 -0
- bhshell/shell/commands/marking.py +59 -0
- bhshell/shell/commands/paths.py +166 -0
- bhshell/shell/commands/search.py +125 -0
- bhshell-0.1.0.dist-info/METADATA +134 -0
- bhshell-0.1.0.dist-info/RECORD +59 -0
- bhshell-0.1.0.dist-info/WHEEL +5 -0
- bhshell-0.1.0.dist-info/entry_points.txt +2 -0
- bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
- bhshell-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Saved Cypher query library.
|
|
2
|
+
|
|
3
|
+
Sources, later ones overriding earlier ones by name:
|
|
4
|
+
|
|
5
|
+
1. bundled `bhshell/library/*.cyp`
|
|
6
|
+
2. the user's `~/.bhshell/queries/*.cyp`
|
|
7
|
+
3. the folders listed in the `query_dirs` setting, in order
|
|
8
|
+
4. the server's saved queries (`GET /api/v2/saved-queries`), named `server:<name>`
|
|
9
|
+
|
|
10
|
+
A `.cyp` file may start with `// name: ...` and `// description: ...`
|
|
11
|
+
header lines; the rest of the file is the query.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from bhshell.client.client import BHClient
|
|
22
|
+
from bhshell.config import config_home
|
|
23
|
+
from bhshell.errors import BHError
|
|
24
|
+
|
|
25
|
+
BUILTIN_DIR = Path(__file__).resolve().parent.parent / "library"
|
|
26
|
+
Source = Literal["builtin", "user", "server"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SavedQuery:
|
|
31
|
+
name: str
|
|
32
|
+
query: str
|
|
33
|
+
description: str = ""
|
|
34
|
+
source: Source = "builtin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_cyp(text: str, default_name: str, source: Source) -> SavedQuery:
|
|
38
|
+
"""Parse a `.cyp` file: `// key: value` header lines followed by the query."""
|
|
39
|
+
meta: dict[str, str] = {}
|
|
40
|
+
body: list[str] = []
|
|
41
|
+
for line in text.splitlines():
|
|
42
|
+
stripped = line.strip()
|
|
43
|
+
if not body and stripped.startswith("//"):
|
|
44
|
+
key, sep, value = stripped[2:].partition(":")
|
|
45
|
+
if sep:
|
|
46
|
+
meta[key.strip().lower()] = value.strip()
|
|
47
|
+
continue
|
|
48
|
+
if stripped or body:
|
|
49
|
+
body.append(line)
|
|
50
|
+
return SavedQuery(name=meta.get("name") or default_name, query="\n".join(body).strip(),
|
|
51
|
+
description=meta.get("description", ""), source=source)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_dir() -> Path:
|
|
55
|
+
return config_home() / "queries"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def query_dirs(setting: str) -> list[Path]:
|
|
59
|
+
"""The folders of the `query_dirs` setting, separated like PATH (':' on Unix, ';' on Windows)."""
|
|
60
|
+
return [Path(part.strip()).expanduser() for part in setting.split(os.pathsep) if part.strip()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class QueryLibrary:
|
|
65
|
+
client: BHClient
|
|
66
|
+
# Extra folders of `.cyp` files (`config query_dirs`), loaded after the user's own.
|
|
67
|
+
extra_dirs: list[Path] = field(default_factory=list)
|
|
68
|
+
notes: list[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
def load(self, *, include_server: bool = True) -> dict[str, SavedQuery]:
|
|
71
|
+
self.notes = []
|
|
72
|
+
queries: dict[str, SavedQuery] = {}
|
|
73
|
+
local: list[tuple[Path, Source]] = [(BUILTIN_DIR, "builtin"), (user_dir(), "user"),
|
|
74
|
+
*((d, "user") for d in self.extra_dirs)]
|
|
75
|
+
for directory, source in local:
|
|
76
|
+
if directory in self.extra_dirs and not directory.is_dir():
|
|
77
|
+
self.notes.append(f"Query folder {directory} not found (see 'config query_dirs').")
|
|
78
|
+
for path in sorted(directory.glob("*.cyp")) if directory.is_dir() else []:
|
|
79
|
+
try:
|
|
80
|
+
q = parse_cyp(path.read_text(encoding="utf-8"), path.stem, source)
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
self.notes.append(f"Cannot read {path}: {exc.strerror}")
|
|
83
|
+
continue
|
|
84
|
+
if q.query:
|
|
85
|
+
queries[q.name] = q
|
|
86
|
+
if include_server and self.client.is_authenticated:
|
|
87
|
+
try:
|
|
88
|
+
for item in self.client.paginate("api/v2/saved-queries"):
|
|
89
|
+
name = f"server:{item.get('name', item.get('id'))}"
|
|
90
|
+
queries[name] = SavedQuery(name=name, query=item.get("query", ""),
|
|
91
|
+
description=item.get("description", ""), source="server")
|
|
92
|
+
except BHError as exc:
|
|
93
|
+
self.notes.append(f"Server saved queries unavailable: {exc.message}")
|
|
94
|
+
return dict(sorted(queries.items()))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Owned / Tier Zero (high value) marking.
|
|
2
|
+
|
|
3
|
+
Listing matches the `Tag_Owned` / `Tag_Tier_Zero` kinds in Cypher. Marking adds or removes
|
|
4
|
+
selectors through the privilege-zone API (`/api/v2/asset-group-tags`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from bhshell.client.client import BHClient
|
|
12
|
+
from bhshell.errors import ApiError, BHError, CypherDisabledError, NotFoundError, UnsupportedFeature
|
|
13
|
+
from bhshell.models.entities import OWNED_LABEL, TIER_ZERO_LABEL, Node
|
|
14
|
+
from bhshell.queries.service import EntityService
|
|
15
|
+
|
|
16
|
+
Tag = Literal["owned", "tier0"]
|
|
17
|
+
|
|
18
|
+
_KIND_LABEL = {"owned": OWNED_LABEL, "tier0": TIER_ZERO_LABEL}
|
|
19
|
+
_TAG_TYPE = {"owned": 3, "tier0": 1}
|
|
20
|
+
_TAG_NAME = {"owned": "Owned", "tier0": "Tier Zero"}
|
|
21
|
+
_SEED_OBJECT_ID = 1
|
|
22
|
+
_SELECTOR_PREFIX = "bhshell: "
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MarkingService:
|
|
26
|
+
def __init__(self, client: BHClient, entities: EntityService) -> None:
|
|
27
|
+
self.client = client
|
|
28
|
+
self.entities = entities
|
|
29
|
+
|
|
30
|
+
def list(self, tag: Tag) -> list[Node]:
|
|
31
|
+
try:
|
|
32
|
+
graph = self.entities.run_cypher(f"MATCH (n:{_KIND_LABEL[tag]}) RETURN n")
|
|
33
|
+
except CypherDisabledError as exc:
|
|
34
|
+
raise UnsupportedFeature("Listing tagged objects needs the Cypher endpoint") from exc
|
|
35
|
+
return sorted(graph.nodes.values(), key=lambda n: (n.kind, n.name))
|
|
36
|
+
|
|
37
|
+
def mark(self, node: Node, tag: Tag, *, add: bool = True) -> str:
|
|
38
|
+
"""Add or remove `node` from the `tag` group. Returns a human-readable status."""
|
|
39
|
+
tag_obj = self._tag(tag)
|
|
40
|
+
if add:
|
|
41
|
+
return self._add(node, tag_obj)
|
|
42
|
+
return self._remove(node, tag_obj)
|
|
43
|
+
|
|
44
|
+
def _tag(self, tag: Tag) -> dict[str, Any]:
|
|
45
|
+
"""The Owned label, or the Tier Zero tier (the tier with the lowest position)."""
|
|
46
|
+
try:
|
|
47
|
+
resp = self.client.get("api/v2/asset-group-tags") or {}
|
|
48
|
+
except NotFoundError as exc:
|
|
49
|
+
raise UnsupportedFeature("This BH version has no asset-group tags API") from exc
|
|
50
|
+
tags = (resp.get("data") or {}).get("tags") or []
|
|
51
|
+
candidates = sorted((t for t in tags if t.get("type") == _TAG_TYPE[tag]),
|
|
52
|
+
key=lambda t: t.get("position") or 0)
|
|
53
|
+
if not candidates:
|
|
54
|
+
raise NotFoundError(f"The server has no {_TAG_NAME[tag]} tag")
|
|
55
|
+
return candidates[0]
|
|
56
|
+
|
|
57
|
+
def _add(self, node: Node, tag_obj: dict[str, Any]) -> str:
|
|
58
|
+
base = f"api/v2/asset-group-tags/{tag_obj['id']}/selectors"
|
|
59
|
+
tag_name = tag_obj.get("name")
|
|
60
|
+
if _tagged(node, tag_obj):
|
|
61
|
+
return f"{node.name} is already in '{tag_name}'; nothing to add."
|
|
62
|
+
existing = self._selecting(base, node)
|
|
63
|
+
if existing:
|
|
64
|
+
names = ", ".join(_selector_name(s) for s in existing)
|
|
65
|
+
return f"{node.name} is already in '{tag_name}' (selector: {names}); nothing to add."
|
|
66
|
+
body = {
|
|
67
|
+
"name": f"{_SELECTOR_PREFIX}{node.name}",
|
|
68
|
+
"description": "Created by bhshell",
|
|
69
|
+
"seeds": [{"type": _SEED_OBJECT_ID, "value": node.objectid}],
|
|
70
|
+
}
|
|
71
|
+
try:
|
|
72
|
+
self.client.request("POST", base, json_body=body)
|
|
73
|
+
except ApiError as exc:
|
|
74
|
+
if exc.status != 409:
|
|
75
|
+
raise
|
|
76
|
+
raise BHError(f"A selector named '{body['name']}' already exists in '{tag_name}' but does not target "
|
|
77
|
+
f"{node.name}", hint="Rename or delete it in the BH UI, then retry.") from exc
|
|
78
|
+
return f"Added {node.name} to '{tag_name}' (selector created; applied after analysis)."
|
|
79
|
+
|
|
80
|
+
def _selecting(self, base: str, node: Node) -> list[dict[str, Any]]:
|
|
81
|
+
"""Every selector of the tag seeded with `node` (listed in full: callers may delete while iterating)."""
|
|
82
|
+
return [s for s in self.client.paginate(base)
|
|
83
|
+
if node.objectid in [seed.get("value") for seed in s.get("seeds") or []]]
|
|
84
|
+
|
|
85
|
+
def _remove(self, node: Node, tag_obj: dict[str, Any]) -> str:
|
|
86
|
+
"""Delete the selectors seeded with this object alone.
|
|
87
|
+
|
|
88
|
+
A selector that also seeds other objects is left intact so they stay marked.
|
|
89
|
+
"""
|
|
90
|
+
base = f"api/v2/asset-group-tags/{tag_obj['id']}/selectors"
|
|
91
|
+
removed, shared = 0, []
|
|
92
|
+
# Listed in full first: deleting while paginating would shift the offsets and skip selectors.
|
|
93
|
+
for selector in self._selecting(base, node):
|
|
94
|
+
if all(s.get("value") == node.objectid for s in selector.get("seeds") or []):
|
|
95
|
+
self.client.request("DELETE", f"{base}/{selector['id']}")
|
|
96
|
+
removed += 1
|
|
97
|
+
else:
|
|
98
|
+
shared.append(_selector_name(selector))
|
|
99
|
+
tag_name = tag_obj.get("name")
|
|
100
|
+
if not removed:
|
|
101
|
+
if shared:
|
|
102
|
+
raise BHError(f"{node.name} is only selected by selectors that also cover other objects: "
|
|
103
|
+
f"{', '.join(shared)}",
|
|
104
|
+
hint="Edit those selectors in the BH UI to unmark just this object.")
|
|
105
|
+
if _tagged(node, tag_obj):
|
|
106
|
+
raise BHError(f"{node.name} is in '{tag_name}' through a rule-based selector, not one naming it",
|
|
107
|
+
hint="Change that selector in the BH UI (Privilege Zones) to unmark it.")
|
|
108
|
+
raise NotFoundError(f"No selector in '{tag_name}' targets {node.name}")
|
|
109
|
+
message = f"Removed {removed} selector(s) for {node.name} from '{tag_name}'."
|
|
110
|
+
if shared:
|
|
111
|
+
message += f" Left alone, as they also cover other objects: {', '.join(shared)}."
|
|
112
|
+
return message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _selector_name(selector: dict[str, Any]) -> str:
|
|
116
|
+
return str(selector.get("name") or selector.get("id"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tagged(node: Node, tag_obj: dict[str, Any]) -> bool:
|
|
120
|
+
"""Whether the latest analysis put `node` in the tag (selected by any rule, not only by bhshell)."""
|
|
121
|
+
return node.owned if tag_obj.get("type") == _TAG_TYPE["owned"] else node.tier_zero
|
bhshell/queries/paths.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Attack-path finding.
|
|
2
|
+
|
|
3
|
+
* `shortest(src, dst)` uses BH's native `GET /api/v2/graphs/shortest-path`
|
|
4
|
+
(BloodHound's own traversable-edge rules) and falls back to Cypher `shortestPath`.
|
|
5
|
+
* `to_tier_zero(src)` / `owned_to_tier_zero()` use Cypher `allShortestPaths`.
|
|
6
|
+
* `route(src, waypoints, dst)` chains one of the above per leg.
|
|
7
|
+
|
|
8
|
+
A disabled user or computer can only be passed through when the hop reaching it grants the
|
|
9
|
+
right to re-enable it (`ENABLE_EDGES`), unless `include_disabled` is set. BloodHound's Cypher
|
|
10
|
+
cannot express that rule (it rejects `endNode(r).enabled`), so each Cypher search runs twice:
|
|
11
|
+
unfiltered, and avoiding disabled objects altogether (so a longer path around them is still
|
|
12
|
+
found). `_build` applies the exact rule to the union, and to REST results too.
|
|
13
|
+
|
|
14
|
+
Every API returns the union of the paths as one subgraph; `Graph.paths` splits it back into
|
|
15
|
+
individual chains.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import itertools
|
|
21
|
+
import math
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
from bhshell.client.client import route_missing
|
|
27
|
+
from bhshell.errors import ApiError, CypherDisabledError, NotFoundError, UnsupportedFeature
|
|
28
|
+
from bhshell.models.entities import OWNED_LABEL, TIER_ZERO_LABEL, Edge, Graph, Node
|
|
29
|
+
from bhshell.models.results import Backend
|
|
30
|
+
from bhshell.queries import cypher as Q
|
|
31
|
+
from bhshell.queries.edges import ENABLE_EDGES, HAS_SESSION, PASSIVE_EDGES, TRAVERSABLE_EDGES, severity
|
|
32
|
+
from bhshell.queries.service import RAISE_LIMIT, EntityService, truncation_note
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Hop:
|
|
37
|
+
source: Node
|
|
38
|
+
edge: str
|
|
39
|
+
target: Node
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AttackPath:
|
|
44
|
+
hops: list[Hop]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def start(self) -> Node:
|
|
48
|
+
return self.hops[0].source
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def end(self) -> Node:
|
|
52
|
+
return self.hops[-1].target
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self.hops)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def actions(self) -> int:
|
|
59
|
+
"""Hops the operator has to act on (group membership, containment and GPO links come for free)."""
|
|
60
|
+
return sum(h.edge not in PASSIVE_EDGES for h in self.hops)
|
|
61
|
+
|
|
62
|
+
def as_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"start": self.start.name,
|
|
65
|
+
"end": self.end.name,
|
|
66
|
+
"length": len(self),
|
|
67
|
+
"actions": self.actions,
|
|
68
|
+
"hops": [{"source": h.source.name, "edge": h.edge, "target": h.target.name,
|
|
69
|
+
"target_kind": h.target.kind, "tier_zero": h.target.tier_zero} for h in self.hops],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class PathResult:
|
|
75
|
+
title: str
|
|
76
|
+
paths: list[AttackPath] = field(default_factory=list)
|
|
77
|
+
# "mixed" when the legs of a route came from different backends.
|
|
78
|
+
backend: Backend | Literal["mixed"] = "cypher"
|
|
79
|
+
notes: list[str] = field(default_factory=list)
|
|
80
|
+
# Object IDs of the --via steps of a route.
|
|
81
|
+
waypoints: list[str] = field(default_factory=list)
|
|
82
|
+
# Numbers shown next to the paths, when they are a pick from a longer list (default 1, 2, ...).
|
|
83
|
+
numbers: list[int] = field(default_factory=list)
|
|
84
|
+
# Disabled objects whose paths were skipped: they are reached without a right to re-enable them.
|
|
85
|
+
skipped_disabled: list[str] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def shortest(self) -> int:
|
|
89
|
+
return min((len(p) for p in self.paths), default=0)
|
|
90
|
+
|
|
91
|
+
def first_hops(self) -> set[tuple[str, str, str]]:
|
|
92
|
+
return {(p.start.name, p.hops[0].edge, p.hops[0].target.name) for p in self.paths}
|
|
93
|
+
|
|
94
|
+
def as_dict(self) -> dict[str, Any]:
|
|
95
|
+
return {"title": self.title, "backend": self.backend, "notes": self.notes,
|
|
96
|
+
"skipped_disabled": self.skipped_disabled, "paths": [p.as_dict() for p in self.paths]}
|
|
97
|
+
|
|
98
|
+
def rows(self) -> list[dict[str, Any]]:
|
|
99
|
+
"""Flattened rows for CSV export."""
|
|
100
|
+
return [
|
|
101
|
+
{"path": i, "hop": j, "source": h.source.name, "edge": h.edge, "target": h.target.name,
|
|
102
|
+
"target_kind": h.target.kind, "tier_zero": h.target.tier_zero}
|
|
103
|
+
for i, p in enumerate(self.paths, 1) for j, h in enumerate(p.hops, 1)
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _var_length(edges: frozenset[str] | None) -> str:
|
|
108
|
+
return f"[:{Q.edge_types(edges)}*1..]" if edges else "[*1..]"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _no_disabled(*keep: str) -> str:
|
|
112
|
+
"""Cypher condition: path `p` crosses no disabled object, except its start `s` and the `keep` endpoints
|
|
113
|
+
(aliases) the user named.
|
|
114
|
+
|
|
115
|
+
Stricter than the rule `_build` applies (a disabled object reached with a right to re-enable it is
|
|
116
|
+
fine), so it is only used alongside an unfiltered search.
|
|
117
|
+
"""
|
|
118
|
+
exempt = "".join(f" OR n = {alias}" for alias in ("s", *keep))
|
|
119
|
+
return f"all(n IN nodes(p) WHERE n.enabled IS NULL OR n.enabled = true{exempt})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Builds a path query from a relationship pattern and whether to exclude disabled objects.
|
|
123
|
+
QueryBuilder = Callable[[str, bool], str]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PathService:
|
|
127
|
+
def __init__(self, entities: EntityService, *, include_disabled: bool = False,
|
|
128
|
+
exclude: Iterable[str] = ()) -> None:
|
|
129
|
+
self.entities = entities
|
|
130
|
+
self.client = entities.client
|
|
131
|
+
self.include_disabled = include_disabled
|
|
132
|
+
# Edge kinds the paths may not use (techniques the operator cannot or will not use).
|
|
133
|
+
self.exclude = frozenset(exclude)
|
|
134
|
+
self.edges = TRAVERSABLE_EDGES - self.exclude
|
|
135
|
+
|
|
136
|
+
def shortest(self, src: Node, dst: Node) -> PathResult:
|
|
137
|
+
result = PathResult(f"Shortest path: {src.name} → {dst.name}")
|
|
138
|
+
if self.exclude: # the native pathfinder cannot leave edges out
|
|
139
|
+
return self._build(result, self._cypher_shortest(src, dst, result), [src.objectid],
|
|
140
|
+
lambda n: n.objectid == dst.objectid, keep=[dst.objectid])
|
|
141
|
+
try:
|
|
142
|
+
resp = self.client.get("api/v2/graphs/shortest-path", start_node=src.objectid, end_node=dst.objectid)
|
|
143
|
+
graph = Graph.from_cypher((resp or {}).get("data") or {})
|
|
144
|
+
result.backend = "rest"
|
|
145
|
+
except NotFoundError as exc:
|
|
146
|
+
if not route_missing(exc):
|
|
147
|
+
return result # "path not found": a valid, empty answer
|
|
148
|
+
return self._build(result, self._cypher_shortest(src, dst, result), [src.objectid],
|
|
149
|
+
lambda n: n.objectid == dst.objectid, keep=[dst.objectid])
|
|
150
|
+
self._build(result, graph, [src.objectid], lambda n: n.objectid == dst.objectid, keep=[dst.objectid])
|
|
151
|
+
if not result.paths and result.skipped_disabled and self.entities.cypher_available is not False:
|
|
152
|
+
# Every native shortest path crosses a disabled object: look for one around them.
|
|
153
|
+
# The retry notes its own skipped objects; one note then lists both searches'.
|
|
154
|
+
skipped = _skipped_note(result.skipped_disabled)
|
|
155
|
+
retry = PathResult(result.title, backend="cypher", notes=[n for n in result.notes if n != skipped])
|
|
156
|
+
retry.notes.append("The shortest paths cross disabled objects; showing paths around them.")
|
|
157
|
+
try:
|
|
158
|
+
graph = self._cypher_shortest(src, dst, retry)
|
|
159
|
+
except (ApiError, UnsupportedFeature):
|
|
160
|
+
return result
|
|
161
|
+
self._build(retry, graph, [src.objectid], lambda n: n.objectid == dst.objectid, keep=[dst.objectid])
|
|
162
|
+
retry.notes = [n for n in retry.notes if n != _skipped_note(retry.skipped_disabled)]
|
|
163
|
+
retry.skipped_disabled = sorted({*result.skipped_disabled, *retry.skipped_disabled})
|
|
164
|
+
retry.notes.append(_skipped_note(retry.skipped_disabled))
|
|
165
|
+
return retry
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def _cypher_shortest(self, src: Node, dst: Node, result: PathResult) -> Graph:
|
|
169
|
+
ends = (f"s.objectid = {Q.lit(src.objectid)}", f"t.objectid = {Q.lit(dst.objectid)}")
|
|
170
|
+
return self._first_working(lambda rel, filtered: (
|
|
171
|
+
f"MATCH p=shortestPath((s:Base)-{rel}->(t:Base))"
|
|
172
|
+
f"{Q.where(*ends, _no_disabled('t') if filtered else '')} RETURN p"
|
|
173
|
+
), result)
|
|
174
|
+
|
|
175
|
+
def to_tier_zero(self, src: Node) -> PathResult:
|
|
176
|
+
result = PathResult(f"Shortest paths: {src.name} → Tier Zero")
|
|
177
|
+
if src.tier_zero:
|
|
178
|
+
result.notes.append(f"{src.name} is already Tier Zero.")
|
|
179
|
+
return result
|
|
180
|
+
graph = self._shortest_to_tier_zero("(s:Base)", f"s.objectid = {Q.lit(src.objectid)}", result)
|
|
181
|
+
return self._build(result, graph, [src.objectid], lambda n: n.tier_zero and n.objectid != src.objectid)
|
|
182
|
+
|
|
183
|
+
def route(self, src: Node, waypoints: list[Node], dst: Node | None) -> PathResult:
|
|
184
|
+
"""Paths from `src` through each waypoint in order, then to `dst` (Tier Zero when `None`).
|
|
185
|
+
|
|
186
|
+
Every leg is a separate shortest-path search and the legs' paths are chained, so a route
|
|
187
|
+
is shortest between consecutive steps but not necessarily the shortest overall.
|
|
188
|
+
"""
|
|
189
|
+
steps = [src, *waypoints]
|
|
190
|
+
result = PathResult("Shortest route: " + " → ".join(
|
|
191
|
+
[n.name for n in steps] + [dst.name if dst else "Tier Zero"]))
|
|
192
|
+
result.waypoints = [n.objectid for n in waypoints]
|
|
193
|
+
legs: list[PathResult] = []
|
|
194
|
+
for a, b in zip(steps, [*waypoints, dst], strict=True):
|
|
195
|
+
target = b.name if b else "Tier Zero"
|
|
196
|
+
if b is None and a.tier_zero:
|
|
197
|
+
result.notes.append(f"{a.name} is already Tier Zero; the route ends there.")
|
|
198
|
+
break
|
|
199
|
+
leg = self.shortest(a, b) if b else self.to_tier_zero(a)
|
|
200
|
+
result.notes += [f"{a.name} → {target}: {note}" for note in leg.notes]
|
|
201
|
+
if not leg.paths:
|
|
202
|
+
result.notes.append(f"No path from {a.name} to {target}.")
|
|
203
|
+
return result
|
|
204
|
+
legs.append(leg)
|
|
205
|
+
|
|
206
|
+
limit = self.entities.max_results
|
|
207
|
+
combos = itertools.islice(itertools.product(*(leg.paths for leg in legs)), limit)
|
|
208
|
+
result.paths = sorted((AttackPath([h for p in combo for h in p.hops]) for combo in combos), key=_rank)
|
|
209
|
+
total = math.prod(len(leg.paths) for leg in legs)
|
|
210
|
+
if total > limit:
|
|
211
|
+
result.notes.append(f"Only {limit} of {total:,} leg combinations are listed ({RAISE_LIMIT}).")
|
|
212
|
+
backends = {leg.backend for leg in legs}
|
|
213
|
+
result.backend = backends.pop() if len(backends) == 1 else "mixed"
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
def owned_to_tier_zero(self) -> PathResult:
|
|
217
|
+
result = PathResult("Shortest paths: owned objects → Tier Zero")
|
|
218
|
+
graph = self._shortest_to_tier_zero(f"(s:{OWNED_LABEL})", "", result)
|
|
219
|
+
owned = [n for n in graph.nodes.values() if n.owned]
|
|
220
|
+
if not owned:
|
|
221
|
+
result.notes.append("No owned object has a path to Tier Zero (or nothing is marked owned).")
|
|
222
|
+
already = sorted(n.name for n in owned if n.tier_zero)
|
|
223
|
+
if already:
|
|
224
|
+
result.notes.append("Owned objects that are already Tier Zero: " + ", ".join(already))
|
|
225
|
+
return self._build(result, graph, None, lambda n: n.tier_zero)
|
|
226
|
+
|
|
227
|
+
def reaching_tier_zero(self, objectids: Iterable[str]) -> tuple[set[str], list[str]]:
|
|
228
|
+
"""Which of `objectids` have a path to Tier Zero, and notes about the search."""
|
|
229
|
+
ids = sorted(set(objectids))
|
|
230
|
+
if not ids:
|
|
231
|
+
return set(), []
|
|
232
|
+
result = PathResult("")
|
|
233
|
+
cond = f"s.objectid IN [{', '.join(Q.lit(i) for i in ids)}]"
|
|
234
|
+
graph = self._shortest_to_tier_zero("(s:Base)", cond, result)
|
|
235
|
+
return {n.objectid for n in graph.nodes.values() if n.objectid in ids}, result.notes
|
|
236
|
+
|
|
237
|
+
def owned_to(self, dst: Node) -> PathResult:
|
|
238
|
+
"""Shortest paths from every owned object to `dst` (an objective other than Tier Zero)."""
|
|
239
|
+
result = PathResult(f"Shortest paths: owned objects → {dst.name}")
|
|
240
|
+
if dst.owned:
|
|
241
|
+
result.notes.append(f"{dst.name} is already owned.")
|
|
242
|
+
return result
|
|
243
|
+
limit = self.entities.max_results
|
|
244
|
+
graph = self._first_working(lambda rel, filtered: (
|
|
245
|
+
f"MATCH p=allShortestPaths((s:{OWNED_LABEL})-{rel}->(t:Base))"
|
|
246
|
+
f"{Q.where('s <> t', f't.objectid = {Q.lit(dst.objectid)}', _no_disabled('t') if filtered else '')} "
|
|
247
|
+
f"RETURN p LIMIT {limit}"
|
|
248
|
+
), result)
|
|
249
|
+
sources = [n.objectid for n in graph.nodes.values() if n.owned]
|
|
250
|
+
if not sources:
|
|
251
|
+
result.notes.append(f"No owned object has a path to {dst.name} (or nothing is marked owned).")
|
|
252
|
+
return self._build(result, graph, sources, lambda n: n.objectid == dst.objectid, keep=[dst.objectid])
|
|
253
|
+
|
|
254
|
+
def _shortest_to_tier_zero(self, s_match: str, s_cond: str, result: PathResult) -> Graph:
|
|
255
|
+
"""allShortestPaths from the `s` nodes matched by `s_match` and `s_cond` to Tier Zero."""
|
|
256
|
+
limit = self.entities.max_results
|
|
257
|
+
return self._first_working(lambda rel, filtered: (
|
|
258
|
+
f"MATCH p=allShortestPaths({s_match}-{rel}->(t:{TIER_ZERO_LABEL}))"
|
|
259
|
+
f"{Q.where('s <> t', s_cond, _no_disabled() if filtered else '')} RETURN p LIMIT {limit}"
|
|
260
|
+
), result)
|
|
261
|
+
|
|
262
|
+
def _first_working(self, build: QueryBuilder, result: PathResult) -> Graph:
|
|
263
|
+
"""Run with the traversable-edge filter, dropping it if the server rejects it."""
|
|
264
|
+
try:
|
|
265
|
+
return self._search(build, self.edges, result)
|
|
266
|
+
except ApiError:
|
|
267
|
+
graph = self._search(build, None, result)
|
|
268
|
+
result.notes.append("Server rejected the traversable-edge filter; paths may include "
|
|
269
|
+
"non-traversable edges.")
|
|
270
|
+
return graph
|
|
271
|
+
|
|
272
|
+
def _search(self, build: QueryBuilder, edges: frozenset[str] | None, result: PathResult) -> Graph:
|
|
273
|
+
"""The unfiltered search, merged (unless disabled objects are allowed) with one avoiding them."""
|
|
274
|
+
rel = _var_length(edges)
|
|
275
|
+
graph = self._cypher(build(rel, False))
|
|
276
|
+
if self.include_disabled:
|
|
277
|
+
return graph
|
|
278
|
+
try:
|
|
279
|
+
around = self._cypher(build(rel, True))
|
|
280
|
+
except ApiError:
|
|
281
|
+
result.notes.append("Server rejected the disabled-object filter; some longer paths around "
|
|
282
|
+
"disabled objects may be missing.")
|
|
283
|
+
return graph
|
|
284
|
+
merged = Graph.union([graph, around])
|
|
285
|
+
merged.query = graph.query # for the truncation check
|
|
286
|
+
return merged
|
|
287
|
+
|
|
288
|
+
def _cypher(self, query: str) -> Graph:
|
|
289
|
+
try:
|
|
290
|
+
return self.entities.run_cypher(query)
|
|
291
|
+
except CypherDisabledError as exc:
|
|
292
|
+
raise UnsupportedFeature("Path finding needs the Cypher endpoint") from exc
|
|
293
|
+
|
|
294
|
+
def _disabled(self, graph: Graph, result: PathResult) -> set[str]:
|
|
295
|
+
"""Node IDs of disabled objects in `graph`, looking up those whose result lacks properties (REST)."""
|
|
296
|
+
disabled = {nid for nid, n in graph.nodes.items() if n.properties.get("enabled") is False}
|
|
297
|
+
unknown = sorted({n.objectid for n in graph.nodes.values()
|
|
298
|
+
if n.objectid and "enabled" not in n.properties and n.kind in ("User", "Computer")})
|
|
299
|
+
if not unknown or self.entities.cypher_available is False:
|
|
300
|
+
return disabled
|
|
301
|
+
ids = ", ".join(Q.lit(o) for o in unknown)
|
|
302
|
+
try:
|
|
303
|
+
found = self.entities.run_cypher(
|
|
304
|
+
f"MATCH (n:Base) WHERE n.objectid IN [{ids}] AND n.enabled = false RETURN n")
|
|
305
|
+
except (ApiError, CypherDisabledError):
|
|
306
|
+
result.notes.append("Could not check which objects on the paths are disabled.")
|
|
307
|
+
return disabled
|
|
308
|
+
oids = {n.objectid for n in found.nodes.values()}
|
|
309
|
+
for nid, n in graph.nodes.items():
|
|
310
|
+
if n.objectid in oids:
|
|
311
|
+
n.properties["enabled"] = False # REST nodes carry no properties; lets renderers mark them
|
|
312
|
+
disabled.add(nid)
|
|
313
|
+
return disabled
|
|
314
|
+
|
|
315
|
+
def _build(self, result: PathResult, graph: Graph, source_oids: list[str] | None,
|
|
316
|
+
is_target: Callable[[Node], bool], *, keep: Iterable[str] = ()) -> PathResult:
|
|
317
|
+
"""Split `graph` into paths from `source_oids` (`None`: every owned, non-Tier-Zero object).
|
|
318
|
+
|
|
319
|
+
Sources and the `keep` object IDs are never skipped as disabled: the user chose them.
|
|
320
|
+
"""
|
|
321
|
+
full = graph
|
|
322
|
+
if self.exclude: # in case the server rejected the edge filter
|
|
323
|
+
graph = Graph(dict(graph.nodes), [e for e in graph.edges if e.kind not in self.exclude])
|
|
324
|
+
by_oid = {n.objectid: nid for nid, n in graph.nodes.items()}
|
|
325
|
+
if source_oids is None:
|
|
326
|
+
sources = [nid for nid, n in graph.nodes.items() if n.owned and not n.tier_zero]
|
|
327
|
+
else:
|
|
328
|
+
sources = [by_oid[o] for o in source_oids if o in by_oid]
|
|
329
|
+
limit = self.entities.max_results
|
|
330
|
+
if not self.include_disabled:
|
|
331
|
+
exempt = {by_oid[o] for o in keep if o in by_oid} | set(sources)
|
|
332
|
+
disabled = self._disabled(graph, result) - exempt
|
|
333
|
+
|
|
334
|
+
def blocked(e: Edge) -> bool:
|
|
335
|
+
return e.target in disabled and e.kind not in ENABLE_EDGES
|
|
336
|
+
|
|
337
|
+
if any(blocked(e) for e in graph.edges):
|
|
338
|
+
full_targets = [nid for nid, n in graph.nodes.items() if is_target(n)]
|
|
339
|
+
hit = {e.target for chain in graph.paths(sources, full_targets, max_paths=limit)
|
|
340
|
+
for e in chain if blocked(e)}
|
|
341
|
+
result.skipped_disabled = sorted(graph.nodes[nid].name for nid in hit)
|
|
342
|
+
graph = Graph(dict(graph.nodes), [e for e in graph.edges if not blocked(e)])
|
|
343
|
+
if result.skipped_disabled:
|
|
344
|
+
result.notes.append(_skipped_note(result.skipped_disabled))
|
|
345
|
+
targets = [nid for nid, n in graph.nodes.items() if is_target(n)]
|
|
346
|
+
chains = graph.paths(sources, targets, max_paths=limit)
|
|
347
|
+
result.paths = [self._to_path(graph, chain) for chain in chains]
|
|
348
|
+
result.paths.sort(key=_rank)
|
|
349
|
+
truncated, total = self.entities.truncation(full)
|
|
350
|
+
if truncated or len(chains) >= limit:
|
|
351
|
+
result.notes.append(truncation_note(limit, [total if truncated else None]))
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _to_path(graph: Graph, chain: list[Edge]) -> AttackPath:
|
|
356
|
+
return AttackPath([Hop(graph.nodes[e.source], e.kind, graph.nodes[e.target]) for e in chain])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _skipped_note(names: list[str]) -> str:
|
|
360
|
+
return (f"Skipped paths through {len(names)} disabled object(s) reached without a right to re-enable them: "
|
|
361
|
+
f"{', '.join(names)} (--include-disabled shows them).")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _rank(path: AttackPath) -> tuple[int, int, int, int, str, str]:
|
|
365
|
+
"""Fewest actions first (each one is a chance to be detected), then the fewest hops relying on a
|
|
366
|
+
session (a snapshot that may be gone), the shortest, and the fewest non-critical hops."""
|
|
367
|
+
sessions = sum(h.edge == HAS_SESSION for h in path.hops)
|
|
368
|
+
risk = sum(severity(h.edge) != "critical" for h in path.hops)
|
|
369
|
+
return path.actions, sessions, len(path), risk, path.start.name, path.end.name
|