bhshell 0.1.0__tar.gz → 0.1.1__tar.gz

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 (81) hide show
  1. {bhshell-0.1.0/bhshell.egg-info → bhshell-0.1.1}/PKG-INFO +8 -3
  2. {bhshell-0.1.0 → bhshell-0.1.1}/README.md +7 -2
  3. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/__init__.py +1 -1
  4. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/client.py +6 -0
  5. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/errors.py +2 -2
  6. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/models/entities.py +25 -4
  7. bhshell-0.1.1/bhshell/models/results.py +180 -0
  8. bhshell-0.1.1/bhshell/queries/containers.py +437 -0
  9. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/cypher.py +78 -1
  10. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/edges.py +10 -2
  11. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/hunt.py +2 -2
  12. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/service.py +59 -6
  13. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/export.py +5 -2
  14. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/views.py +11 -7
  15. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/entities.py +48 -5
  16. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/paths.py +4 -3
  17. {bhshell-0.1.0 → bhshell-0.1.1/bhshell.egg-info}/PKG-INFO +8 -3
  18. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/SOURCES.txt +2 -0
  19. {bhshell-0.1.0 → bhshell-0.1.1}/docs/USAGE.md +113 -8
  20. bhshell-0.1.1/tests/test_containers.py +307 -0
  21. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_shell.py +9 -0
  22. bhshell-0.1.0/bhshell/models/results.py +0 -83
  23. {bhshell-0.1.0 → bhshell-0.1.1}/LICENSE +0 -0
  24. {bhshell-0.1.0 → bhshell-0.1.1}/MANIFEST.in +0 -0
  25. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/__main__.py +0 -0
  26. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/cli.py +0 -0
  27. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/__init__.py +0 -0
  28. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/client/auth.py +0 -0
  29. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/config.py +0 -0
  30. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/asrep-roastable-users.cyp +0 -0
  31. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/dcsync-principals.cyp +0 -0
  32. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/domain-admins-members.cyp +0 -0
  33. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/domain-trusts.cyp +0 -0
  34. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
  35. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/kerberoastable-users.cyp +0 -0
  36. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/owned-objects.cyp +0 -0
  37. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/owned-to-tier-zero.cyp +0 -0
  38. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/sid-history.cyp +0 -0
  39. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/tier-zero-users.cyp +0 -0
  40. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/library/unconstrained-delegation.cyp +0 -0
  41. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/models/__init__.py +0 -0
  42. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/__init__.py +0 -0
  43. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/domains.py +0 -0
  44. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/library.py +0 -0
  45. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/marking.py +0 -0
  46. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/paths.py +0 -0
  47. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/queries/search.py +0 -0
  48. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/__init__.py +0 -0
  49. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/graph.py +0 -0
  50. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/layout.py +0 -0
  51. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/png.py +0 -0
  52. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/styles.py +0 -0
  53. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/render/svg.py +0 -0
  54. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/__init__.py +0 -0
  55. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/app.py +0 -0
  56. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/__init__.py +0 -0
  57. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/base.py +0 -0
  58. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/connection.py +0 -0
  59. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/cypher.py +0 -0
  60. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/domains.py +0 -0
  61. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/hunt.py +0 -0
  62. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/library.py +0 -0
  63. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/marking.py +0 -0
  64. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell/shell/commands/search.py +0 -0
  65. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/dependency_links.txt +0 -0
  66. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/entry_points.txt +0 -0
  67. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/requires.txt +0 -0
  68. {bhshell-0.1.0 → bhshell-0.1.1}/bhshell.egg-info/top_level.txt +0 -0
  69. {bhshell-0.1.0 → bhshell-0.1.1}/pyproject.toml +0 -0
  70. {bhshell-0.1.0 → bhshell-0.1.1}/setup.cfg +0 -0
  71. {bhshell-0.1.0 → bhshell-0.1.1}/tests/__init__.py +0 -0
  72. {bhshell-0.1.0 → bhshell-0.1.1}/tests/conftest.py +0 -0
  73. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_analysis.py +0 -0
  74. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_client.py +0 -0
  75. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_disabled_paths.py +0 -0
  76. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_graph.py +0 -0
  77. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_hunt.py +0 -0
  78. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_png.py +0 -0
  79. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_search.py +0 -0
  80. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_service.py +0 -0
  81. {bhshell-0.1.0 → bhshell-0.1.1}/tests/test_svg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bhshell
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Interactive shell for BloodHound
5
5
  Author-email: kr0no <kr0no@ka0labs.net>
6
6
  License-Expression: MIT
@@ -35,8 +35,8 @@ Dynamic: license-file
35
35
 
36
36
  # bhshell
37
37
 
38
- [![PyPI version](https://img.shields.io/pypi/v/bhshell.svg)](https://pypi.org/project/bhshell/)
39
- [![Python versions](https://img.shields.io/pypi/pyversions/bhshell.svg)](https://pypi.org/project/bhshell/)
38
+ ![PyPI Status](https://img.shields.io/pypi/status/bhshell)
39
+ [![PyPI version](https://img.shields.io/pypi/v/bhshell)](https://pypi.org/project/bhshell/)
40
40
  [![CI](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
41
41
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
42
42
 
@@ -70,6 +70,10 @@ $ bhshell --url http://127.0.0.1:8080
70
70
 
71
71
  [bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
72
72
 
73
+ [bh: shell] > ou corp.local -t
74
+
75
+ [bh: shell] > computer WEB01 -G
76
+
73
77
  [bh: shell] > path --owned -g
74
78
  ```
75
79
 
@@ -80,6 +84,7 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
80
84
  | Command | Shows |
81
85
  |---|---|
82
86
  | `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
87
+ | `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
83
88
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
84
89
  | `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
85
90
  | `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
@@ -1,7 +1,7 @@
1
1
  # bhshell
2
2
 
3
- [![PyPI version](https://img.shields.io/pypi/v/bhshell.svg)](https://pypi.org/project/bhshell/)
4
- [![Python versions](https://img.shields.io/pypi/pyversions/bhshell.svg)](https://pypi.org/project/bhshell/)
3
+ ![PyPI Status](https://img.shields.io/pypi/status/bhshell)
4
+ [![PyPI version](https://img.shields.io/pypi/v/bhshell)](https://pypi.org/project/bhshell/)
5
5
  [![CI](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
7
7
 
@@ -35,6 +35,10 @@ $ bhshell --url http://127.0.0.1:8080
35
35
 
36
36
  [bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
37
37
 
38
+ [bh: shell] > ou corp.local -t
39
+
40
+ [bh: shell] > computer WEB01 -G
41
+
38
42
  [bh: shell] > path --owned -g
39
43
  ```
40
44
 
@@ -45,6 +49,7 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
45
49
  | Command | Shows |
46
50
  |---|---|
47
51
  | `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
52
+ | `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
48
53
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
49
54
  | `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
50
55
  | `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
@@ -1,3 +1,3 @@
1
1
  """bhshell - an interactive shell for BloodHound."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -314,6 +314,12 @@ class BHClient:
314
314
  data = resp.get("data") or {}
315
315
  return data.get("props") or data
316
316
 
317
+ def entity_counts(self, kind: str, objectid: str) -> dict[str, int]:
318
+ """Related-object counts the entity endpoint reports (`users`, `computers`, `gpos`, ...)."""
319
+ resp = self.get(f"api/v2/{_collection(kind)}/{objectid}", counts="true") or {}
320
+ data = resp.get("data") or {}
321
+ return {k: v for k, v in data.items() if isinstance(v, int) and not isinstance(v, bool)}
322
+
317
323
  def entity_related(
318
324
  self, kind: str, objectid: str, relation: str, *, limit: int | None = None
319
325
  ) -> list[dict[str, Any]]:
@@ -67,7 +67,7 @@ class UnsupportedFeature(BHError):
67
67
  class AmbiguousMatchError(BHError):
68
68
  hint = "Qualify the name: 'jdoe@corp.local', 'CORP\\jdoe', '--domain CORP.LOCAL' or use the SID."
69
69
 
70
- def __init__(self, query: str, candidates: Sequence[Node]) -> None:
71
- super().__init__(f"'{query}' matches {len(candidates)} objects")
70
+ def __init__(self, query: str, candidates: Sequence[Node], *, hint: str = "") -> None:
71
+ super().__init__(f"'{query}' matches {len(candidates)} objects", hint=hint)
72
72
  self.query = query
73
73
  self.candidates = list(candidates)
@@ -318,16 +318,21 @@ class ADObject:
318
318
 
319
319
  @staticmethod
320
320
  def wrap(node: Node) -> ADObject:
321
- cls = {"User": User, "Computer": Computer, "Group": Group}.get(node.kind, ADObject)
322
- return cls(node)
321
+ kinds = {"User": User, "Computer": Computer, "Group": Group,
322
+ "OU": DirectoryContainer, "Container": DirectoryContainer, "Domain": DirectoryContainer}
323
+ return kinds.get(node.kind, ADObject)(node)
323
324
 
324
325
  def _common_head(self) -> list[SummaryField]:
325
- return [
326
+ oid = "Object ID (GUID)" if self.node.kind in ("OU", "Container", "GPO") else "Object ID (SID)"
327
+ fields = [
326
328
  SummaryField("Name", self.node.name),
327
329
  SummaryField("Type", self.node.kind),
328
- SummaryField("Object ID (SID)", self.node.objectid),
330
+ SummaryField(oid, self.node.objectid),
329
331
  SummaryField("Domain", self.node.domain),
330
332
  ]
333
+ if self.props.get("distinguishedname"):
334
+ fields.append(SummaryField("Distinguished Name", self.props["distinguishedname"]))
335
+ return fields
331
336
 
332
337
  def _common_tail(self) -> list[SummaryField]:
333
338
  return [
@@ -400,3 +405,19 @@ class Group(ADObject):
400
405
  SummaryField("AdminCount", admincount, "bool", _risk_if(admincount, "medium")),
401
406
  *self._common_tail(),
402
407
  ]
408
+
409
+
410
+ class DirectoryContainer(ADObject):
411
+ """An OU, a container or a domain: the nodes of the directory tree."""
412
+
413
+ def summary(self) -> list[SummaryField]:
414
+ p = self.props
415
+ fields = self._common_head()
416
+ if self.node.kind == "Domain":
417
+ fields.append(SummaryField("Functional Level", p.get("functionallevel") or ""))
418
+ if self.node.kind == "OU":
419
+ blocks = opt_bool(p, "blocksinheritance")
420
+ fields.append(SummaryField("Blocks GPO Inheritance", blocks, "bool", _risk_if(blocks, "info")))
421
+ if p.get("whencreated"):
422
+ fields.append(SummaryField("Created", p["whencreated"], "date"))
423
+ return [*fields, *self._common_tail()]
@@ -0,0 +1,180 @@
1
+ """Result containers returned by the query layer and consumed by renderers/exporters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ from bhshell.models.entities import ADObject, Node, SummaryField
9
+
10
+ Backend = Literal["cypher", "rest"]
11
+
12
+
13
+ class Row:
14
+ """A view row (a dataclass): `as_dict` is what is exported, `as_display` what the table shows."""
15
+
16
+ def as_dict(self) -> dict[str, Any]:
17
+ return asdict(self)
18
+
19
+ def as_display(self) -> dict[str, Any]:
20
+ return self.as_dict()
21
+
22
+
23
+ @dataclass
24
+ class RelationshipRow(Row):
25
+ """One relationship in a view.
26
+
27
+ Not every view fills every field; `Column` definitions on the
28
+ owning `ViewResult` select which ones are displayed.
29
+ """
30
+
31
+ principal: str = ""
32
+ principal_kind: str = ""
33
+ edge: str = ""
34
+ target: str = ""
35
+ target_kind: str = ""
36
+ via: str = ""
37
+ depth: int | None = None
38
+ tier_zero: bool = False
39
+ owned: bool = False
40
+ objectid: str = ""
41
+
42
+
43
+ def _with_enabled(total: int, enabled: int) -> str:
44
+ return f"{total} ({enabled})" if total else "0"
45
+
46
+
47
+ @dataclass
48
+ class ContainerRow(Row):
49
+ """One OU or container of a `tree` view; the counts cover everything below it."""
50
+
51
+ name: str
52
+ kind: str
53
+ depth: int
54
+ # The name indented under its parent, for display.
55
+ label: str = ""
56
+ users: int | None = None
57
+ enabled_users: int | None = None
58
+ computers: int | None = None
59
+ enabled_computers: int | None = None
60
+ groups: int | None = None
61
+ tier_zero_below: int | None = None
62
+ owned_below: int | None = None
63
+ gpo_links: int = 0
64
+ blocks_inheritance: bool | None = None
65
+ tier_zero: bool = False
66
+ owned: bool = False
67
+ distinguishedname: str = ""
68
+ objectid: str = ""
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return {k: v for k, v in asdict(self).items() if k != "label"}
72
+
73
+ def as_display(self) -> dict[str, Any]:
74
+ out = asdict(self)
75
+ for key in ("users", "computers"):
76
+ total, enabled = out[key], out[f"enabled_{key}"]
77
+ out[key] = None if total is None else _with_enabled(total, enabled or 0)
78
+ return out
79
+
80
+
81
+ @dataclass
82
+ class ContainedRow(Row):
83
+ """An object below an OU or container (`contents` view)."""
84
+
85
+ name: str
86
+ kind: str
87
+ enabled: bool | None = None
88
+ # Containers between the explored one and the object ("direct" when it is a direct child).
89
+ location: str = ""
90
+ depth: int = 1
91
+ tier_zero: bool = False
92
+ owned: bool = False
93
+ distinguishedname: str = ""
94
+ objectid: str = ""
95
+
96
+
97
+ @dataclass
98
+ class GPOLinkRow(Row):
99
+ """A GPO linked to the object or to a container above it (`gpos` view)."""
100
+
101
+ gpo: str
102
+ linked_to: str
103
+ linked_kind: str
104
+ enforced: bool | None = None
105
+ # `None` when unknown (REST fallback).
106
+ applies: bool | None = True
107
+ # Why it does not apply ("inheritance blocked at X"), why the link adds nothing ("duplicate: ..."), or ''.
108
+ reason: str = ""
109
+ # Principals outside Tier Zero that can edit the GPO.
110
+ editable_by: list[str] = field(default_factory=list)
111
+ tier_zero: bool = False
112
+ owned: bool = False
113
+ objectid: str = ""
114
+
115
+ def as_display(self) -> dict[str, Any]:
116
+ out = asdict(self)
117
+ if self.applies is None:
118
+ out["applies"] = None
119
+ else:
120
+ out["applies"] = ("yes" if self.applies else "no") + (f" ({self.reason})" if self.reason else "")
121
+ shown = self.editable_by[:3]
122
+ if len(self.editable_by) > len(shown):
123
+ shown = [*shown, f"+{len(self.editable_by) - len(shown)} more"]
124
+ out["editable_by"] = shown
125
+ return out
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Column:
130
+ """A table column: `attr` is a row key; `fmt` picks the cell formatting (see `Renderer.fmt_value`)."""
131
+
132
+ header: str
133
+ attr: str
134
+ fmt: str = ""
135
+
136
+
137
+ @dataclass
138
+ class ViewResult:
139
+ """The outcome of one relationship view (groups, outbound, ...)."""
140
+
141
+ key: str
142
+ title: str
143
+ columns: list[Column]
144
+ rows: list[Any] = field(default_factory=list) # `Row`s; `RelationshipRow`s for relationship views
145
+ backend: Backend = "cypher"
146
+ notes: list[str] = field(default_factory=list)
147
+
148
+ def as_dict(self) -> dict[str, Any]:
149
+ return {
150
+ "view": self.key,
151
+ "title": self.title,
152
+ "backend": self.backend,
153
+ "notes": self.notes,
154
+ "rows": [r.as_dict() for r in self.rows],
155
+ }
156
+
157
+
158
+ @dataclass
159
+ class EntityReport:
160
+ """A summary and/or a set of relationship views for one entity."""
161
+
162
+ node: Node
163
+ show_summary: bool = True
164
+ views: list[ViewResult] = field(default_factory=list)
165
+ # Summary fields computed by queries (OU contents, linked GPOs, ...), shown after the node's own.
166
+ extra: list[SummaryField] = field(default_factory=list)
167
+
168
+ @property
169
+ def entity(self) -> ADObject:
170
+ return ADObject.wrap(self.node)
171
+
172
+ def summary(self) -> list[SummaryField]:
173
+ return [*self.entity.summary(), *self.extra]
174
+
175
+ def as_dict(self) -> dict[str, Any]:
176
+ out: dict[str, Any] = {"entity": self.node.as_dict()}
177
+ if self.show_summary:
178
+ out["summary"] = {f.label: f.value for f in self.summary()}
179
+ out["views"] = {v.key: v.as_dict() for v in self.views}
180
+ return out