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.
Files changed (59) hide show
  1. bhshell/__init__.py +3 -0
  2. bhshell/__main__.py +3 -0
  3. bhshell/cli.py +55 -0
  4. bhshell/client/__init__.py +0 -0
  5. bhshell/client/auth.py +74 -0
  6. bhshell/client/client.py +337 -0
  7. bhshell/config.py +162 -0
  8. bhshell/errors.py +73 -0
  9. bhshell/library/asrep-roastable-users.cyp +3 -0
  10. bhshell/library/dcsync-principals.cyp +3 -0
  11. bhshell/library/domain-admins-members.cyp +3 -0
  12. bhshell/library/domain-trusts.cyp +3 -0
  13. bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
  14. bhshell/library/kerberoastable-users.cyp +3 -0
  15. bhshell/library/owned-objects.cyp +3 -0
  16. bhshell/library/owned-to-tier-zero.cyp +3 -0
  17. bhshell/library/sid-history.cyp +3 -0
  18. bhshell/library/tier-zero-users.cyp +3 -0
  19. bhshell/library/unconstrained-delegation.cyp +3 -0
  20. bhshell/models/__init__.py +0 -0
  21. bhshell/models/entities.py +402 -0
  22. bhshell/models/results.py +83 -0
  23. bhshell/queries/__init__.py +0 -0
  24. bhshell/queries/cypher.py +155 -0
  25. bhshell/queries/domains.py +136 -0
  26. bhshell/queries/edges.py +109 -0
  27. bhshell/queries/hunt.py +675 -0
  28. bhshell/queries/library.py +94 -0
  29. bhshell/queries/marking.py +121 -0
  30. bhshell/queries/paths.py +369 -0
  31. bhshell/queries/search.py +196 -0
  32. bhshell/queries/service.py +529 -0
  33. bhshell/render/__init__.py +0 -0
  34. bhshell/render/export.py +79 -0
  35. bhshell/render/graph.py +326 -0
  36. bhshell/render/layout.py +138 -0
  37. bhshell/render/png.py +44 -0
  38. bhshell/render/styles.py +5 -0
  39. bhshell/render/svg.py +495 -0
  40. bhshell/render/views.py +337 -0
  41. bhshell/shell/__init__.py +0 -0
  42. bhshell/shell/app.py +189 -0
  43. bhshell/shell/commands/__init__.py +0 -0
  44. bhshell/shell/commands/base.py +60 -0
  45. bhshell/shell/commands/connection.py +236 -0
  46. bhshell/shell/commands/cypher.py +97 -0
  47. bhshell/shell/commands/domains.py +80 -0
  48. bhshell/shell/commands/entities.py +88 -0
  49. bhshell/shell/commands/hunt.py +151 -0
  50. bhshell/shell/commands/library.py +56 -0
  51. bhshell/shell/commands/marking.py +59 -0
  52. bhshell/shell/commands/paths.py +166 -0
  53. bhshell/shell/commands/search.py +125 -0
  54. bhshell-0.1.0.dist-info/METADATA +134 -0
  55. bhshell-0.1.0.dist-info/RECORD +59 -0
  56. bhshell-0.1.0.dist-info/WHEEL +5 -0
  57. bhshell-0.1.0.dist-info/entry_points.txt +2 -0
  58. bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. 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
@@ -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