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,196 @@
1
+ """Attribute search (`search`): find objects whose properties match a set of filters.
2
+
3
+ A filter is `ATTR OP VALUE` (`hasspn=true`, `name~svc`, `pwdlastset<2020-01-01`), a bare
4
+ `ATTR` (true) or `!ATTR` (false or missing). Filters are ANDed and compiled to one
5
+ `MATCH (n:Kind) WHERE ... RETURN n LIMIT n` query; every value is embedded via `Q.lit`
6
+ and attribute names are restricted to identifiers, so nothing typed reaches the query unescaped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import time
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ from bhshell.errors import BHError, CypherDisabledError, UnsupportedFeature
19
+ from bhshell.models.entities import Graph, Node
20
+ from bhshell.models.results import Column
21
+ from bhshell.queries import cypher as Q
22
+ from bhshell.queries.service import EntityService
23
+
24
+ Value = str | int | float | bool | None
25
+
26
+ # Longest operators first, so `>=` is not read as `>` followed by `=VALUE`.
27
+ OPERATORS = ("!=", ">=", "<=", "!~", "=", "~", ">", "<")
28
+ _FILTER_RE = re.compile(rf"^(?P<neg>!?)(?P<attr>[A-Za-z_][A-Za-z0-9_]*)"
29
+ rf"(?:(?P<op>{'|'.join(map(re.escape, OPERATORS))})(?P<value>.*))?$", re.DOTALL)
30
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
31
+ _DAYS_AGO_RE = re.compile(r"^(\d+)d$", re.IGNORECASE)
32
+ _NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$")
33
+
34
+ # Properties BloodHound stores as epoch seconds.
35
+ DATE_PROPS = frozenset({"pwdlastset", "lastlogon", "lastlogontimestamp", "whencreated", "lastseen", "lastcollected"})
36
+ # Common properties, offered for tab completion.
37
+ COMMON_PROPS = (
38
+ "name", "samaccountname", "displayname", "description", "email", "domain", "objectid", "distinguishedname",
39
+ "enabled", "admincount", "hasspn", "dontreqpreauth", "passwordnotreqd", "pwdneverexpires",
40
+ "unconstraineddelegation", "trustedtoauth", "sensitive", "pwdlastset", "lastlogon", "lastlogontimestamp",
41
+ "whencreated", "operatingsystem", "haslaps", "isdc", "title", "homedirectory", "userpassword",
42
+ )
43
+ # Attributes already shown by the base columns.
44
+ _BASE_ATTRS = frozenset({"name", "kind", "domain", "objectid", "enabled"})
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Filter:
49
+ attr: str
50
+ op: str
51
+ value: Value
52
+
53
+
54
+ def _parse_value(raw: str, op: str, now: float) -> Value:
55
+ if raw.lower() == "null":
56
+ return None
57
+ if raw.lower() in ("true", "false"):
58
+ return raw.lower() == "true"
59
+ if _NUMBER_RE.match(raw):
60
+ return float(raw) if "." in raw else int(raw)
61
+ if op in (">", "<", ">=", "<="):
62
+ if _DATE_RE.match(raw):
63
+ try:
64
+ return int(datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC).timestamp())
65
+ except ValueError as exc:
66
+ raise BHError(f"Invalid date '{raw}' (use YYYY-MM-DD)") from exc
67
+ if m := _DAYS_AGO_RE.match(raw):
68
+ return int(now - int(m.group(1)) * 86400)
69
+ raise BHError(f"'{op}' needs a number, a date (YYYY-MM-DD) or an age (90d), not '{raw}'")
70
+ return raw
71
+
72
+
73
+ def parse_filter(text: str, now: float | None = None) -> Filter:
74
+ """Parse one filter token: `ATTR`, `!ATTR` or `ATTR OP VALUE`."""
75
+ m = _FILTER_RE.match(text.strip())
76
+ if not m:
77
+ raise BHError(f"Invalid filter '{text}' (expected ATTR, !ATTR or ATTR OP VALUE with OP one of "
78
+ f"{' '.join(OPERATORS)})")
79
+ attr, op = m.group("attr").lower(), m.group("op")
80
+ if op is None:
81
+ return Filter(attr, "!=" if m.group("neg") else "=", True)
82
+ if m.group("neg"):
83
+ raise BHError(f"Invalid filter '{text}': '!' only negates a bare attribute (!{attr})")
84
+ value = _parse_value(m.group("value"), op, time.time() if now is None else now)
85
+ if value is None and op not in ("=", "!="):
86
+ raise BHError(f"Invalid filter '{text}': null only works with = and !=")
87
+ if isinstance(value, bool) and op not in ("=", "!="):
88
+ raise BHError(f"Invalid filter '{text}': true/false only work with = and !=")
89
+ if op in ("~", "!~") and not isinstance(value, str):
90
+ value = str(m.group("value"))
91
+ return Filter(attr, op, value)
92
+
93
+
94
+ def _glob(prop: str, pattern: str) -> str:
95
+ """Case-insensitive match of a string property against `pattern` (`*` at either end only)."""
96
+ body = pattern.strip("*")
97
+ if "*" in body:
98
+ raise BHError(f"'*' is only supported at the start or end of a value ('{pattern}'); use 'cypher'")
99
+ lowered, value = f"toLower({prop})", Q.lit(body.lower())
100
+ starts, ends = pattern.startswith("*"), pattern.endswith("*") and len(pattern) > 1
101
+ if starts and ends:
102
+ return f"{lowered} CONTAINS {value}"
103
+ if starts:
104
+ return f"{lowered} ENDS WITH {value}"
105
+ if ends:
106
+ return f"{lowered} STARTS WITH {value}"
107
+ return f"{lowered} = {value}"
108
+
109
+
110
+ def condition(f: Filter, alias: str = "n") -> str:
111
+ prop = f"{alias}.{f.attr}"
112
+ if f.value is None:
113
+ return f"{prop} IS {'NOT ' if f.op == '!=' else ''}NULL"
114
+ if isinstance(f.value, bool):
115
+ if f.op == "=":
116
+ return f"{prop} = {str(f.value).lower()}"
117
+ # Missing counts as "not this value": '!enabled' also finds objects without the property.
118
+ return f"({prop} = {str(not f.value).lower()} OR {prop} IS NULL)"
119
+ if f.op in ("~", "!~"):
120
+ cond = f"toLower({prop}) CONTAINS {Q.lit(str(f.value).lower())}"
121
+ return cond if f.op == "~" else f"NOT ({cond})"
122
+ if isinstance(f.value, (int, float)):
123
+ if f.op == "=": # numeric-looking strings ('samaccountname=1234') are stored as text
124
+ return f"({prop} = {f.value} OR {prop} = {Q.lit(str(f.value))})"
125
+ if f.op == "!=":
126
+ return f"({prop} <> {f.value} AND {prop} <> {Q.lit(str(f.value))})"
127
+ return f"{prop} {f.op} {f.value}"
128
+ cond = _glob(prop, f.value)
129
+ return cond if f.op == "=" else f"NOT ({cond})"
130
+
131
+
132
+ @dataclass
133
+ class SearchOptions:
134
+ filters: list[Filter] = field(default_factory=list)
135
+ kind: str | None = None
136
+ domain: str = ""
137
+ limit: int = 5000
138
+
139
+ def query(self) -> str:
140
+ conds = [condition(f) for f in self.filters]
141
+ label = f":{self.kind}" if self.kind else ":Base"
142
+ return f"MATCH (n{label}){Q.where(*conds, Q.domain_condition('n', self.domain))} RETURN n LIMIT {self.limit}"
143
+
144
+ def extra_attrs(self, columns: Iterable[str] = ()) -> list[str]:
145
+ """Attributes shown after the base columns: the filtered ones, then `columns`."""
146
+ attrs = [f.attr for f in self.filters] + [c.lower() for c in columns]
147
+ return [a for a in dict.fromkeys(attrs) if a not in _BASE_ATTRS]
148
+
149
+
150
+ @dataclass
151
+ class SearchResult:
152
+ query: str
153
+ nodes: list[Node]
154
+ attrs: list[str]
155
+ truncated: bool = False
156
+ total: int | None = None
157
+
158
+ @property
159
+ def columns(self) -> list[Column]:
160
+ cols = [Column("Type", "kind", "kind"), Column("Name", "name"), Column("Domain", "domain"),
161
+ Column("Enabled", "enabled", "bool")]
162
+ cols += [Column(a, a, self._fmt(a)) for a in self.attrs]
163
+ return [*cols, Column("T0", "tier_zero", "t0"), Column("Owned", "owned", "owned")]
164
+
165
+ def _fmt(self, attr: str) -> str:
166
+ if attr in DATE_PROPS:
167
+ return "date"
168
+ values = [n.properties[attr] for n in self.nodes if n.properties.get(attr) is not None]
169
+ return "bool" if values and all(isinstance(v, bool) for v in values) else ""
170
+
171
+ def rows(self) -> list[dict[str, Any]]:
172
+ return [{"kind": n.kind, "name": n.name, "domain": n.domain, "objectid": n.objectid,
173
+ "enabled": n.properties.get("enabled"), "tier_zero": n.tier_zero, "owned": n.owned,
174
+ **{a: n.properties.get(a) for a in self.attrs}} for n in self.nodes]
175
+
176
+ def as_dict(self) -> dict[str, Any]:
177
+ return {"query": self.query, "count": len(self.nodes), "total": self.total, "truncated": self.truncated,
178
+ "results": [n.as_dict() for n in self.nodes]}
179
+
180
+
181
+ def _sorted(graph: Graph) -> list[Node]:
182
+ return sorted(graph.nodes.values(), key=lambda n: (n.kind, n.name))
183
+
184
+
185
+ class SearchService:
186
+ def __init__(self, entities: EntityService) -> None:
187
+ self.entities = entities
188
+
189
+ def search(self, opts: SearchOptions, columns: Iterable[str] = ()) -> SearchResult:
190
+ query = opts.query()
191
+ try:
192
+ graph = self.entities.run_cypher(query)
193
+ except CypherDisabledError as exc:
194
+ raise UnsupportedFeature("'search' needs the Cypher endpoint") from exc
195
+ truncated, total = self.entities.truncation(graph)
196
+ return SearchResult(query, _sorted(graph), opts.extra_attrs(columns), truncated, total)