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,155 @@
1
+ """Cypher query templates.
2
+
3
+ BH's Cypher endpoint does not accept bound parameters, so every value is
4
+ embedded as an escaped string literal via `lit`. Queries return paths or
5
+ nodes (not scalars) so that results come back as a graph.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Iterable
12
+
13
+ from bhshell.queries.edges import EXEC_EDGES, HAS_SESSION, MEMBER_OF
14
+
15
+
16
+ def lit(value: str) -> str:
17
+ escaped = value.replace("\\", "\\\\").replace("'", "\\'")
18
+ return f"'{escaped}'"
19
+
20
+
21
+ _RETURN_LIMIT = re.compile(r"\s+RETURN\s+(\w+)\s+LIMIT\s+(\d+)\s*$", re.IGNORECASE)
22
+
23
+
24
+ def limit_of(query: str) -> int | None:
25
+ """The LIMIT of a `... RETURN x LIMIT n` query (`None` for other shapes)."""
26
+ m = _RETURN_LIMIT.search(query)
27
+ return int(m.group(2)) if m else None
28
+
29
+
30
+ def to_count(query: str) -> str | None:
31
+ """Turn `... RETURN x LIMIT n` into `... RETURN count(x)` (`None` for other shapes)."""
32
+ m = _RETURN_LIMIT.search(query)
33
+ return f"{query[:m.start()]} RETURN count({m.group(1)})" if m else None
34
+
35
+
36
+ def where(*conds: str) -> str:
37
+ """` WHERE a AND b` from the non-empty conditions, or ''."""
38
+ present = [c for c in conds if c]
39
+ return f" WHERE {' AND '.join(present)}" if present else ""
40
+
41
+
42
+ def domain_condition(alias: str, domain: str) -> str:
43
+ """Restrict `alias` to `domain`: an FQDN matches exactly, a NetBIOS name any FQDN it starts ('' = all)."""
44
+ if not domain:
45
+ return ""
46
+ d = domain.upper()
47
+ if "." in d:
48
+ return f"{alias}.domain = {lit(d)}"
49
+ return f"({alias}.domain = {lit(d)} OR {alias}.domain STARTS WITH {lit(d + '.')})"
50
+
51
+
52
+ def edge_types(edges: Iterable[str]) -> str:
53
+ """`A|B|C`, for a relationship pattern."""
54
+ return "|".join(sorted(edges))
55
+
56
+
57
+ def _label(kind: str | None) -> str:
58
+ return f":{kind}" if kind else ":Base"
59
+
60
+
61
+ def by_objectid(objectid: str) -> str:
62
+ return f"MATCH (n:Base) WHERE n.objectid = {lit(objectid)} RETURN n"
63
+
64
+
65
+ def by_name(conditions: list[str], kind: str | None, limit: int) -> str:
66
+ return f"MATCH (n{_label(kind)}) WHERE {' OR '.join(conditions)} RETURN n LIMIT {limit}"
67
+
68
+
69
+ def name_conditions(sam: str, fqdn: str | None, kind: str | None) -> list[str]:
70
+ """WHERE conditions matching an object by samaccountname or FQDN.
71
+
72
+ BloodHound stores `name` upper-cased as `SAM@DOMAIN` (users, groups)
73
+ or `HOST.DOMAIN` (computers); `samaccountname` keeps the original case.
74
+ """
75
+ upper = sam.upper()
76
+ host = upper.rstrip("$")
77
+ conds: list[str] = []
78
+ if fqdn:
79
+ conds.append(f"n.name = {lit(fqdn.upper())}")
80
+ if kind in (None, "User", "Group"):
81
+ conds.append(f"n.name STARTS WITH {lit(upper + '@')}")
82
+ if kind in (None, "Computer"):
83
+ conds += [
84
+ f"n.name STARTS WITH {lit(host + '.')}",
85
+ f"n.name = {lit(host)}",
86
+ f"n.samaccountname = {lit(host + '$')}",
87
+ ]
88
+ conds.append(f"n.samaccountname = {lit(sam)}")
89
+ return list(dict.fromkeys(conds))
90
+
91
+
92
+ def member_of(objectid: str) -> str:
93
+ return f"MATCH p=(n:Base)-[:{MEMBER_OF}*1..]->(g:Group) WHERE n.objectid = {lit(objectid)} RETURN p"
94
+
95
+
96
+ def members(objectid: str, limit: int) -> str:
97
+ return f"MATCH p=(m:Base)-[:{MEMBER_OF}*1..]->(g:Group) WHERE g.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
98
+
99
+
100
+ def _rel(edges: Iterable[str] | None) -> str:
101
+ return f"[r:{edge_types(edges)}]" if edges else "[r]"
102
+
103
+
104
+ def outgoing_direct(objectid: str, limit: int, edges: Iterable[str] | None = None) -> str:
105
+ """Outgoing edges of the node (all kinds when `edges` is `None`)."""
106
+ return f"MATCH p=(n:Base)-{_rel(edges)}->(t:Base) WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
107
+
108
+
109
+ def outgoing_inherited(objectid: str, limit: int, edges: Iterable[str] | None = None) -> str:
110
+ """Edges held by any group the node is (transitively) a member of."""
111
+ return (
112
+ f"MATCH p=(n:Base)-[:{MEMBER_OF}*1..]->(g:Group)-{_rel(edges)}->(t:Base) "
113
+ f"WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
114
+ )
115
+
116
+
117
+ def incoming(objectid: str, edges: Iterable[str], limit: int) -> str:
118
+ return (
119
+ f"MATCH p=(s:Base)-[r:{edge_types(edges)}]->(n:Base) "
120
+ f"WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
121
+ )
122
+
123
+
124
+ def incoming_any(objectid: str, limit: int) -> str:
125
+ """Fallback for `incoming` when a kind in the filter is unknown to the server."""
126
+ return f"MATCH p=(s:Base)-[r]->(n:Base) WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
127
+
128
+
129
+ def exec_rights_inbound_unrolled(objectid: str, limit: int, edges: Iterable[str] = EXEC_EDGES) -> str:
130
+ return (
131
+ f"MATCH p=(m:Base)-[:{MEMBER_OF}*1..]->(g:Group)-[r:{edge_types(edges)}]->(c:Computer) "
132
+ f"WHERE c.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
133
+ )
134
+
135
+
136
+ def sessions_of_user(objectid: str, limit: int) -> str:
137
+ return (
138
+ f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User) "
139
+ f"WHERE u.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
140
+ )
141
+
142
+
143
+ def sessions_on_computer(objectid: str, limit: int) -> str:
144
+ return (
145
+ f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User) "
146
+ f"WHERE c.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
147
+ )
148
+
149
+
150
+ def sessions_of_group_members(objectid: str, limit: int) -> str:
151
+ return (
152
+ f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User)-[:{MEMBER_OF}*1..]->(g:Group) "
153
+ f"WHERE g.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
154
+ )
155
+
@@ -0,0 +1,136 @@
1
+ """Domain overview and trust analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+ from bhshell.errors import NotFoundError
9
+ from bhshell.models.entities import TIER_ZERO_LABEL, Graph, Node, opt_bool
10
+ from bhshell.queries import cypher as Q
11
+ from bhshell.queries.edges import TRUST_ABUSE_EDGES, TRUST_EDGES
12
+ from bhshell.queries.service import EntityService
13
+
14
+ TRUSTS_QUERY = "MATCH p=(a:Domain)-[r]->(b:Domain) RETURN p"
15
+
16
+
17
+ @dataclass
18
+ class DomainInfo:
19
+ name: str
20
+ sid: str
21
+ functional_level: str = ""
22
+ collected: bool | None = None
23
+ last_collected: str = ""
24
+ counts: dict[str, int | None] = field(default_factory=dict)
25
+
26
+ def as_dict(self) -> dict[str, Any]:
27
+ return {**{k: v for k, v in asdict(self).items() if k != "counts"}, **self.counts}
28
+
29
+
30
+ @dataclass
31
+ class TrustInfo:
32
+ """One trust: `trusting` trusts `trusted`; principals of `trusted` can access `trusting`."""
33
+
34
+ trusting: str
35
+ trusted: str
36
+ kind: str
37
+ trust_type: str = ""
38
+ transitive: bool | None = None
39
+ sid_filtering: bool | None = None
40
+ tgt_delegation: bool | None = None
41
+ abuse: list[str] = field(default_factory=list)
42
+
43
+ @property
44
+ def same_forest(self) -> bool:
45
+ return self.kind == "SameForestTrust"
46
+
47
+ @property
48
+ def issues(self) -> list[str]:
49
+ """Weaknesses the collected data shows.
50
+
51
+ SID filtering is on by default for cross-forest (and external) trusts and off within a
52
+ forest, so a missing `spoofsidhistoryblocked` property on a cross-forest trust is not
53
+ reported: only an explicit `false`, a same-forest trust, or a BloodHound abuse edge is.
54
+ """
55
+ out = [f"abusable: {a}" for a in self.abuse]
56
+ if self.same_forest:
57
+ out.append("same forest: SID filtering not enforced")
58
+ elif self.sid_filtering is False:
59
+ out.append("SID filtering disabled")
60
+ if self.tgt_delegation:
61
+ out.append("TGT delegation enabled")
62
+ return out
63
+
64
+ def as_dict(self) -> dict[str, Any]:
65
+ return {**asdict(self), "abuse": ", ".join(self.abuse), "issues": "; ".join(self.issues)}
66
+
67
+
68
+ def trusts_from_graph(graph: Graph) -> list[TrustInfo]:
69
+ trusts: dict[tuple[str, str], TrustInfo] = {}
70
+ abuse: dict[tuple[str, str], list[str]] = {}
71
+ for e in graph.edges:
72
+ src, dst = graph.nodes[e.source].name, graph.nodes[e.target].name
73
+ if e.kind in TRUST_EDGES:
74
+ trusts[(src, dst)] = TrustInfo(
75
+ trusting=src, trusted=dst, kind=e.kind,
76
+ trust_type=str(e.properties.get("trusttype") or ""),
77
+ transitive=opt_bool(e.properties, "transitive"),
78
+ sid_filtering=opt_bool(e.properties, "spoofsidhistoryblocked"),
79
+ tgt_delegation=opt_bool(e.properties, "tgtdelegation"),
80
+ )
81
+ elif e.kind in TRUST_ABUSE_EDGES:
82
+ # Abuse edges point in the direction of access: trusted -> trusting.
83
+ abuse.setdefault((dst, src), []).append(e.kind)
84
+ for key, kinds in abuse.items():
85
+ trust = trusts.setdefault(key, TrustInfo(trusting=key[0], trusted=key[1], kind="(abuse only)"))
86
+ trust.abuse = sorted(set(kinds))
87
+ return sorted(trusts.values(), key=lambda t: (not t.issues, t.trusting, t.trusted))
88
+
89
+
90
+ class DomainService:
91
+ def __init__(self, entities: EntityService) -> None:
92
+ self.entities = entities
93
+ self.client = entities.client
94
+
95
+ def domains(self) -> list[DomainInfo]:
96
+ nodes = {n.objectid: n for n in self.entities.run_cypher("MATCH (n:Domain) RETURN n").nodes.values()}
97
+ infos: list[DomainInfo] = []
98
+ try:
99
+ listed = (self.client.get("api/v2/available-domains") or {}).get("data") or []
100
+ except NotFoundError:
101
+ listed = []
102
+ seen: set[str] = set()
103
+ for item in listed:
104
+ if item.get("type", "active-directory") != "active-directory":
105
+ continue
106
+ sid = item.get("id", "")
107
+ seen.add(sid)
108
+ infos.append(self._info(item.get("name", sid), sid, nodes.get(sid), item.get("collected")))
109
+ for sid, node in nodes.items():
110
+ if sid not in seen:
111
+ infos.append(self._info(node.name, sid, node, None))
112
+ return sorted(infos, key=lambda d: d.name)
113
+
114
+ def _info(self, name: str, sid: str, node: Node | None, collected: bool | None) -> DomainInfo:
115
+ props = node.properties if node is not None else {}
116
+ info = DomainInfo(
117
+ name=name, sid=sid, collected=collected,
118
+ functional_level=str(props.get("functionallevel") or ""),
119
+ last_collected=str(props.get("lastcollected") or props.get("lastseen") or "")[:19],
120
+ )
121
+ in_domain, enabled = f"n.domain = {Q.lit(name.upper())}", "n.enabled = true"
122
+ info.counts = {
123
+ "users": self._count("(n:User)", in_domain),
124
+ "enabled_users": self._count("(n:User)", in_domain, enabled),
125
+ "computers": self._count("(n:Computer)", in_domain),
126
+ "enabled_computers": self._count("(n:Computer)", in_domain, enabled),
127
+ "groups": self._count("(n:Group)", in_domain),
128
+ "tier_zero": self._count(f"(n:{TIER_ZERO_LABEL})", in_domain),
129
+ }
130
+ return info
131
+
132
+ def _count(self, match: str, *conditions: str) -> int | None:
133
+ return self.entities.count(f"MATCH {match}{Q.where(*conditions)} RETURN count(n)")
134
+
135
+ def trusts(self) -> list[TrustInfo]:
136
+ return trusts_from_graph(self.entities.run_cypher(TRUSTS_QUERY))
@@ -0,0 +1,109 @@
1
+ """Edge (relationship) taxonomy used by the relationship views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ MEMBER_OF = "MemberOf"
8
+ HAS_SESSION = "HasSession"
9
+
10
+ # Direct object control (ACL abuse).
11
+ ACL_EDGES: frozenset[str] = frozenset({
12
+ "GenericAll", "GenericWrite", "WriteDacl", "WriteOwner", "Owns",
13
+ "ForceChangePassword", "AddMember", "AddSelf", "AllExtendedRights",
14
+ "AddKeyCredentialLink", "WriteSPN", "WriteAccountRestrictions",
15
+ "WriteGPLink", "WriteOwnerLimitedRights", "OwnsLimitedRights",
16
+ }) # OwnsRaw / WriteOwnerRaw are intentionally excluded: they duplicate Owns / WriteOwner.
17
+
18
+ # Rights that let the holder re-enable a disabled account (write its userAccountControl, directly
19
+ # or by first granting themselves the right). WriteAccountRestrictions covers userAccountControl.
20
+ ENABLE_EDGES: frozenset[str] = frozenset({
21
+ "GenericAll", "GenericWrite", "WriteAccountRestrictions", "Owns", "WriteDacl", "WriteOwner",
22
+ })
23
+
24
+ # Credential access primitives.
25
+ CREDENTIAL_EDGES: frozenset[str] = frozenset({
26
+ "ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword",
27
+ "DCSync", "GetChanges", "GetChangesAll", "GetChangesInFilteredSet", "HasSIDHistory",
28
+ })
29
+
30
+ # Kerberos delegation.
31
+ DELEGATION_EDGES: frozenset[str] = frozenset({
32
+ "AllowedToDelegate", "AllowedToAct", "AddAllowedToAct", "CoerceToTGT",
33
+ })
34
+
35
+ # AD CS abuse.
36
+ ADCS_EDGES: frozenset[str] = frozenset({
37
+ "Enroll", "ManageCA", "ManageCertificates", "GoldenCert", "WritePKIEnrollmentFlag",
38
+ "WritePKINameFlag", "ADCSESC1", "ADCSESC3", "ADCSESC4", "ADCSESC6a", "ADCSESC6b",
39
+ "ADCSESC9a", "ADCSESC9b", "ADCSESC10a", "ADCSESC10b", "ADCSESC13",
40
+ })
41
+
42
+ # NTLM relay / coercion.
43
+ COERCION_EDGES: frozenset[str] = frozenset({
44
+ "CoerceAndRelayNTLMToSMB", "CoerceAndRelayNTLMToLDAP", "CoerceAndRelayNTLMToLDAPS",
45
+ "CoerceAndRelayNTLMToADCS",
46
+ })
47
+
48
+ # Code execution / logon rights on computers.
49
+ EXEC_EDGES: frozenset[str] = frozenset({"AdminTo", "CanRDP", "ExecuteDCOM", "CanPSRemote", "SQLAdmin"})
50
+
51
+ # Domain trust relationships (`A -[trust]-> B`: A trusts B, principals of B can access A).
52
+ TRUST_EDGES: frozenset[str] = frozenset({"SameForestTrust", "CrossForestTrust"})
53
+ # Trust edges BloodHound adds when a trust's configuration is abusable.
54
+ TRUST_ABUSE_EDGES: frozenset[str] = frozenset({"SpoofSIDHistory", "AbuseTGTDelegation"})
55
+
56
+ # Everything shown by the `outbound` / `inbound` views.
57
+ CONTROL_EDGES: frozenset[str] = ACL_EDGES | CREDENTIAL_EDGES | DELEGATION_EDGES | ADCS_EDGES | COERCION_EDGES
58
+
59
+ # Edges BloodHound treats as traversable for attack-path finding.
60
+ TRAVERSABLE_EDGES: frozenset[str] = (
61
+ (CONTROL_EDGES - {"GetChanges", "GetChangesAll", "GetChangesInFilteredSet", "Enroll", "ManageCA",
62
+ "ManageCertificates", "WritePKIEnrollmentFlag", "WritePKINameFlag"})
63
+ | EXEC_EDGES | TRUST_EDGES | TRUST_ABUSE_EDGES
64
+ | {MEMBER_OF, HAS_SESSION, "Contains", "GPLink", "DCFor", "WriteSPN"}
65
+ )
66
+
67
+ # Hops that need no action: rights flow along them by themselves.
68
+ PASSIVE_EDGES: frozenset[str] = frozenset({MEMBER_OF, "Contains", "GPLink", "DCFor"})
69
+
70
+ # Names accepted by `path --exclude` for groups of edges.
71
+ EDGE_GROUPS: dict[str, frozenset[str]] = {
72
+ "sessions": frozenset({HAS_SESSION}),
73
+ "acl": ACL_EDGES,
74
+ "exec": EXEC_EDGES,
75
+ "delegation": DELEGATION_EDGES,
76
+ "adcs": (ADCS_EDGES | {"CoerceAndRelayNTLMToADCS"}) & TRAVERSABLE_EDGES,
77
+ "relay": COERCION_EDGES,
78
+ "trusts": TRUST_EDGES | TRUST_ABUSE_EDGES,
79
+ }
80
+
81
+ Severity = Literal["critical", "high", "medium", "low"]
82
+ SEVERITIES: tuple[Severity, ...] = ("critical", "high", "medium", "low") # worst first
83
+
84
+ _CRITICAL = frozenset({
85
+ "GenericAll", "WriteDacl", "WriteOwner", "Owns", "DCSync", "GetChangesAll", "AllExtendedRights",
86
+ "AdminTo", "GoldenCert", "ADCSESC1", "ADCSESC3", "ADCSESC4", "ADCSESC6a", "ADCSESC6b",
87
+ "ADCSESC9a", "ADCSESC9b", "ADCSESC10a", "ADCSESC10b", "ADCSESC13", "HasSIDHistory",
88
+ })
89
+ _HIGH = frozenset({
90
+ "GenericWrite", "ForceChangePassword", "AddMember", "AddSelf", "AddKeyCredentialLink",
91
+ "ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword",
92
+ "AllowedToDelegate", "AllowedToAct", "AddAllowedToAct", "WriteAccountRestrictions",
93
+ "CanPSRemote", "SQLAdmin", "WriteGPLink", "ManageCA", "CoerceToTGT",
94
+ *COERCION_EDGES,
95
+ })
96
+
97
+
98
+ def severity(edge: str) -> Severity:
99
+ """Rough offensive impact of an edge, used for colouring."""
100
+ if edge in _CRITICAL:
101
+ return "critical"
102
+ if edge in _HIGH:
103
+ return "high"
104
+ return "medium"
105
+
106
+
107
+ def severity_rank(edge: str) -> int:
108
+ """Sort key: 0 for critical edges, 1 for high, 2 for medium."""
109
+ return SEVERITIES.index(severity(edge))