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,56 @@
|
|
|
1
|
+
"""Saved query library command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from cmd2 import Choices, Cmd2ArgumentParser, with_argparser
|
|
8
|
+
|
|
9
|
+
from bhshell.errors import NotFoundError
|
|
10
|
+
from bhshell.models.results import Column
|
|
11
|
+
from bhshell.queries.library import QueryLibrary, query_dirs, user_dir
|
|
12
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _query_parser() -> Cmd2ArgumentParser:
|
|
16
|
+
p = Cmd2ArgumentParser(description=f"Saved Cypher queries: bundled, {user_dir()}/*.cyp, the folders in "
|
|
17
|
+
"'config query_dirs', and queries saved on the server.")
|
|
18
|
+
sub = p.add_subparsers(dest="action", required=True, metavar="{list,show,run}")
|
|
19
|
+
sub.add_parser("list", help="list available queries")
|
|
20
|
+
show = sub.add_parser("show", help="print a query")
|
|
21
|
+
show.add_argument("name", choices_provider=LibraryCommands.query_names)
|
|
22
|
+
run = sub.add_parser("run", help="run a query")
|
|
23
|
+
run.add_argument("name", choices_provider=LibraryCommands.query_names)
|
|
24
|
+
add_output_args(run)
|
|
25
|
+
return p
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LibraryCommands(BaseCommands):
|
|
29
|
+
DEFAULT_CATEGORY = "Analysis"
|
|
30
|
+
|
|
31
|
+
def _library(self) -> QueryLibrary:
|
|
32
|
+
return QueryLibrary(self.app.client, extra_dirs=query_dirs(self.app.config.query_dirs))
|
|
33
|
+
|
|
34
|
+
def query_names(self) -> Choices:
|
|
35
|
+
"""Tab-completion provider (local queries only, to stay fast)."""
|
|
36
|
+
return Choices.from_values(self._library().load(include_server=False))
|
|
37
|
+
|
|
38
|
+
@with_argparser(_query_parser)
|
|
39
|
+
def do_query(self, args: argparse.Namespace) -> None:
|
|
40
|
+
"""List, show or run saved Cypher queries."""
|
|
41
|
+
app = self.app
|
|
42
|
+
library = self._library()
|
|
43
|
+
queries = library.load()
|
|
44
|
+
if args.action == "list":
|
|
45
|
+
rows = [{"name": q.name, "source": q.source, "description": q.description} for q in queries.values()]
|
|
46
|
+
app.ui.table("Saved queries", [Column("Name", "name"), Column("Source", "source"),
|
|
47
|
+
Column("Description", "description")], rows, notes=library.notes)
|
|
48
|
+
return
|
|
49
|
+
query = queries.get(args.name)
|
|
50
|
+
if query is None:
|
|
51
|
+
raise NotFoundError(f"No saved query named '{args.name}'", hint="Run 'query list'.")
|
|
52
|
+
if args.action == "show":
|
|
53
|
+
app.poutput(query.query)
|
|
54
|
+
return
|
|
55
|
+
app.require_auth()
|
|
56
|
+
self.show_graph(app.entities.run_cypher(query.query), as_json=args.json, output=args.output)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Owned / high-value (Tier Zero) marking commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from cmd2 import Cmd2ArgumentParser, with_argparser
|
|
8
|
+
|
|
9
|
+
from bhshell.queries.marking import Tag
|
|
10
|
+
from bhshell.render import export
|
|
11
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
12
|
+
|
|
13
|
+
_LABEL = {"owned": "Owned", "tier0": "Tier Zero / High Value"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _marking_parser(tag: Tag) -> Cmd2ArgumentParser:
|
|
17
|
+
label = _LABEL[tag]
|
|
18
|
+
p = Cmd2ArgumentParser(description=f"List or change {label} objects.")
|
|
19
|
+
sub = p.add_subparsers(dest="action", required=True, metavar="{list,add,remove}")
|
|
20
|
+
ls = sub.add_parser("list", help=f"list {label} objects")
|
|
21
|
+
add_output_args(ls)
|
|
22
|
+
for action in ("add", "remove"):
|
|
23
|
+
a = sub.add_parser(action, help=f"{action} an object {'to' if action == 'add' else 'from'} {label}")
|
|
24
|
+
a.add_argument("name", help="samaccountname, NAME@DOMAIN, DOMAIN\\NAME or SID",
|
|
25
|
+
choices_provider=BaseCommands.recent_names)
|
|
26
|
+
a.add_argument("-t", "--type", choices=["user", "computer", "group"], help="object type (default: any)")
|
|
27
|
+
a.add_argument("-d", "--domain", help="restrict the lookup to a domain")
|
|
28
|
+
return p
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MarkingCommands(BaseCommands):
|
|
32
|
+
DEFAULT_CATEGORY = "Marking"
|
|
33
|
+
|
|
34
|
+
def _run(self, args: argparse.Namespace, tag: Tag) -> None:
|
|
35
|
+
app = self.app
|
|
36
|
+
app.require_auth()
|
|
37
|
+
if args.action == "list":
|
|
38
|
+
nodes = app.marking.list(tag)
|
|
39
|
+
if args.json:
|
|
40
|
+
app.poutput(export.to_json([n.as_dict() for n in nodes]))
|
|
41
|
+
else:
|
|
42
|
+
app.ui.nodes(nodes, _LABEL[tag])
|
|
43
|
+
if args.output:
|
|
44
|
+
self.export(args.output, [n.as_dict() for n in nodes], export.node_rows(nodes))
|
|
45
|
+
return
|
|
46
|
+
kind = args.type.capitalize() if args.type else None
|
|
47
|
+
node = app.entities.resolve(args.name, kind, args.domain)
|
|
48
|
+
app.remember(node)
|
|
49
|
+
app.ui.success(app.marking.mark(node, tag, add=args.action == "add"))
|
|
50
|
+
|
|
51
|
+
@with_argparser(lambda: _marking_parser("owned"))
|
|
52
|
+
def do_owned(self, args: argparse.Namespace) -> None:
|
|
53
|
+
"""List, add or remove owned objects."""
|
|
54
|
+
self._run(args, "owned")
|
|
55
|
+
|
|
56
|
+
@with_argparser(lambda: _marking_parser("tier0"))
|
|
57
|
+
def do_hvt(self, args: argparse.Namespace) -> None:
|
|
58
|
+
"""List, add or remove Tier Zero / high value targets."""
|
|
59
|
+
self._run(args, "tier0")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Attack-path command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import itertools
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cmd2 import Choices, Cmd2ArgumentParser, RawDescriptionCmd2HelpFormatter, with_argparser
|
|
11
|
+
|
|
12
|
+
from bhshell.errors import BHError
|
|
13
|
+
from bhshell.models.entities import Node
|
|
14
|
+
from bhshell.queries.edges import EDGE_GROUPS
|
|
15
|
+
from bhshell.queries.paths import PathResult, PathService
|
|
16
|
+
from bhshell.queries.service import PATH_KINDS
|
|
17
|
+
from bhshell.render import export, png, svg
|
|
18
|
+
from bhshell.render.views import first_n
|
|
19
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _path_parser() -> Cmd2ArgumentParser:
|
|
23
|
+
p = Cmd2ArgumentParser(
|
|
24
|
+
description="Find attack paths.",
|
|
25
|
+
epilog="path SRC DST shortest path between two objects\n"
|
|
26
|
+
"path SRC shortest paths from SRC to any Tier Zero object\n"
|
|
27
|
+
"path SRC [DST] --via X go through X on the way (repeat --via for more steps, in order)\n"
|
|
28
|
+
"path --owned [DST] shortest paths from every owned object to Tier Zero (or to DST)",
|
|
29
|
+
formatter_class=RawDescriptionCmd2HelpFormatter,
|
|
30
|
+
)
|
|
31
|
+
p.add_argument("src", nargs="?", help="source object (user, computer, group, domain, OU, GPO)",
|
|
32
|
+
choices_provider=BaseCommands.recent_names)
|
|
33
|
+
p.add_argument("dst", nargs="?", help="destination object (default: Tier Zero)",
|
|
34
|
+
choices_provider=BaseCommands.recent_names)
|
|
35
|
+
p.add_argument("--via", action="append", default=[], metavar="NAME", choices_provider=BaseCommands.recent_names,
|
|
36
|
+
help="intermediate step (repeatable, visited in the order given)")
|
|
37
|
+
p.add_argument("--owned", action="store_true", help="start from every owned object")
|
|
38
|
+
p.add_argument("--exclude", action="append", default=[], metavar="EDGE", type=_edge_group,
|
|
39
|
+
choices_provider=lambda _self: Choices.from_values(sorted(EDGE_GROUPS)),
|
|
40
|
+
help="skip these edges/groups: " + ", ".join(sorted(EDGE_GROUPS)) + " (repeatable)")
|
|
41
|
+
p.add_argument("-d", "--domain", help="restrict name resolution to a domain")
|
|
42
|
+
p.add_argument("--include-disabled", action="store_true",
|
|
43
|
+
help="allow paths through disabled objects that cannot be re-enabled")
|
|
44
|
+
p.add_argument("-g", "--graph", action="store_true",
|
|
45
|
+
help="draw the paths as a graph (asks which ones when there are several)")
|
|
46
|
+
p.add_argument("-p", "--pick", type=_selection, metavar="N",
|
|
47
|
+
help="only these paths, numbered as in the list: 2, 1,3 or 2-4")
|
|
48
|
+
p.add_argument("-n", "--max-paths", type=int, default=20, metavar="N", help="paths shown (default 20, 0 = all)")
|
|
49
|
+
add_output_args(p, formats=(".json", ".csv", ".svg", ".png"))
|
|
50
|
+
p.add_argument("--svg-layout", choices=("auto", "horizontal", "vertical", "wrapped"), default="auto",
|
|
51
|
+
help="SVG/PNG layout (default auto: whichever prints largest on a portrait page; "
|
|
52
|
+
"wrapped: rows of hops, like lines of text)")
|
|
53
|
+
p.add_argument("--png-scale", type=_scale, default=2.0, metavar="N",
|
|
54
|
+
help="PNG pixels per SVG pixel (default 2: sharp in print, same size in Word)")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _edge_group(text: str) -> frozenset[str]:
|
|
59
|
+
try:
|
|
60
|
+
return EDGE_GROUPS[text.lower()]
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise argparse.ArgumentTypeError(
|
|
63
|
+
f"unknown edge group {text!r} (choose from {', '.join(sorted(EDGE_GROUPS))})") from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _scale(text: str) -> float:
|
|
67
|
+
try:
|
|
68
|
+
value = float(text)
|
|
69
|
+
except ValueError:
|
|
70
|
+
raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
|
|
71
|
+
if not 1 <= value <= 8:
|
|
72
|
+
raise argparse.ArgumentTypeError("must be between 1 and 8")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _selection(text: str) -> list[int]:
|
|
77
|
+
"""Parse `2`, `1,3` or `2-4` into path numbers."""
|
|
78
|
+
numbers: list[int] = []
|
|
79
|
+
try:
|
|
80
|
+
for part in text.replace(" ", "").split(","):
|
|
81
|
+
first, _, last = part.partition("-")
|
|
82
|
+
numbers += range(int(first), int(last or first) + 1)
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise argparse.ArgumentTypeError(f"not a path selection: {text!r} (use e.g. 2, 1,3 or 2-4)") from None
|
|
85
|
+
if not numbers or min(numbers) < 1:
|
|
86
|
+
raise argparse.ArgumentTypeError(f"path numbers start at 1: {text!r}")
|
|
87
|
+
return list(dict.fromkeys(numbers))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pick(result: PathResult, numbers: list[int]) -> PathResult:
|
|
91
|
+
if max(numbers) > len(result.paths):
|
|
92
|
+
raise BHError(f"There are only {len(result.paths)} path(s); cannot pick {max(numbers)}")
|
|
93
|
+
picked = [result.paths[n - 1] for n in numbers]
|
|
94
|
+
note = f"Showing path {', '.join(map(str, numbers))} of {len(result.paths)}."
|
|
95
|
+
return replace(result, paths=picked, notes=[*result.notes, note], numbers=numbers)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PathCommands(BaseCommands):
|
|
99
|
+
DEFAULT_CATEGORY = "Analysis"
|
|
100
|
+
|
|
101
|
+
@with_argparser(_path_parser)
|
|
102
|
+
def do_path(self, args: argparse.Namespace) -> None:
|
|
103
|
+
"""Find attack paths between objects or towards Tier Zero."""
|
|
104
|
+
app = self.app
|
|
105
|
+
app.require_auth()
|
|
106
|
+
exclude = frozenset().union(*args.exclude) if args.exclude else frozenset()
|
|
107
|
+
service = PathService(app.entities, include_disabled=args.include_disabled, exclude=exclude)
|
|
108
|
+
if args.owned:
|
|
109
|
+
if args.src or args.via:
|
|
110
|
+
raise BHError("--owned takes no SRC or --via (an optional DST is allowed)")
|
|
111
|
+
if args.dst:
|
|
112
|
+
result = service.owned_to(self._resolve(args.dst, args.domain))
|
|
113
|
+
else:
|
|
114
|
+
result = service.owned_to_tier_zero()
|
|
115
|
+
elif not args.src:
|
|
116
|
+
raise BHError("Give a SRC object or --owned")
|
|
117
|
+
else:
|
|
118
|
+
src = self._resolve(args.src, args.domain)
|
|
119
|
+
waypoints = [self._resolve(name, args.domain) for name in args.via]
|
|
120
|
+
dst = self._resolve(args.dst, args.domain) if args.dst else None
|
|
121
|
+
steps = [src, *waypoints] + ([dst] if dst else [])
|
|
122
|
+
for a, b in itertools.pairwise(steps):
|
|
123
|
+
if a.objectid == b.objectid:
|
|
124
|
+
raise BHError(f"{a.name} appears twice in a row in the route")
|
|
125
|
+
if waypoints:
|
|
126
|
+
result = service.route(src, waypoints, dst)
|
|
127
|
+
elif dst:
|
|
128
|
+
result = service.shortest(src, dst)
|
|
129
|
+
else:
|
|
130
|
+
result = service.to_tier_zero(src)
|
|
131
|
+
if args.pick:
|
|
132
|
+
result = _pick(result, args.pick)
|
|
133
|
+
elif args.graph and not args.json and len(result.paths) > 1 and app.can_ask():
|
|
134
|
+
app.ui.paths(result, args.max_paths)
|
|
135
|
+
shown = len(first_n(result.paths, args.max_paths))
|
|
136
|
+
answer = app.read_input(f"Path(s) to draw [1-{shown}, e.g. 2, 1,3 or 2-4; Enter = all]: ").strip()
|
|
137
|
+
if answer:
|
|
138
|
+
try:
|
|
139
|
+
numbers = _selection(answer)
|
|
140
|
+
except argparse.ArgumentTypeError as exc:
|
|
141
|
+
raise BHError(str(exc)) from exc
|
|
142
|
+
result = _pick(result, numbers)
|
|
143
|
+
if args.json:
|
|
144
|
+
app.poutput(export.to_json(result.as_dict()))
|
|
145
|
+
elif args.graph:
|
|
146
|
+
app.ui.path_graph(result, args.max_paths)
|
|
147
|
+
else:
|
|
148
|
+
app.ui.paths(result, args.max_paths)
|
|
149
|
+
if args.output and args.output.lower().endswith((".svg", ".png")):
|
|
150
|
+
shown = first_n(result.paths, args.max_paths)
|
|
151
|
+
out = Path(args.output).expanduser()
|
|
152
|
+
document = svg.render(result, shown, args.svg_layout)
|
|
153
|
+
if out.suffix.lower() == ".png":
|
|
154
|
+
out.write_bytes(png.from_svg(document, args.png_scale))
|
|
155
|
+
else:
|
|
156
|
+
out.write_text(document, encoding="utf-8")
|
|
157
|
+
app.ui.success(f"Diagram of {len(shown)} path(s) written to {out}")
|
|
158
|
+
elif args.output:
|
|
159
|
+
rows = result.rows()
|
|
160
|
+
fields = ["path", "hop", "source", "edge", "target", "target_kind", "tier_zero"]
|
|
161
|
+
self.export(args.output, result.as_dict(), (fields, rows))
|
|
162
|
+
|
|
163
|
+
def _resolve(self, name: str, domain: str | None) -> Node:
|
|
164
|
+
node = self.app.entities.resolve(name, domain=domain, kinds=PATH_KINDS)
|
|
165
|
+
self.app.remember(node)
|
|
166
|
+
return node
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Attribute search command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
from cmd2 import (
|
|
9
|
+
Choices,
|
|
10
|
+
Cmd2ArgparseError,
|
|
11
|
+
Cmd2ArgumentParser,
|
|
12
|
+
RawDescriptionCmd2HelpFormatter,
|
|
13
|
+
with_argparser,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from bhshell.errors import BHError
|
|
17
|
+
from bhshell.models.entities import PRIMARY_KINDS
|
|
18
|
+
from bhshell.queries.search import (
|
|
19
|
+
COMMON_PROPS,
|
|
20
|
+
SearchOptions,
|
|
21
|
+
SearchService,
|
|
22
|
+
parse_filter,
|
|
23
|
+
)
|
|
24
|
+
from bhshell.render import export
|
|
25
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
26
|
+
|
|
27
|
+
_KINDS = {k.lower(): k for k in PRIMARY_KINDS}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _kind(value: str) -> str:
|
|
31
|
+
try:
|
|
32
|
+
return _KINDS[value.lower()]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise argparse.ArgumentTypeError(f"unknown type '{value}' (one of: {', '.join(PRIMARY_KINDS)})") from None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _filter_names() -> list[str]:
|
|
38
|
+
return [*COMMON_PROPS, *(f"!{p}" for p in COMMON_PROPS)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _search_parser() -> Cmd2ArgumentParser:
|
|
42
|
+
p = Cmd2ArgumentParser(
|
|
43
|
+
description="List objects whose properties match every FILTER.",
|
|
44
|
+
epilog="Filters (ANDed):\n"
|
|
45
|
+
" ATTR true hasspn\n"
|
|
46
|
+
" !ATTR false or unset !enabled\n"
|
|
47
|
+
" ATTR=VALUE equals samaccountname=svc_sql\n"
|
|
48
|
+
" ATTR!=VALUE differs\n"
|
|
49
|
+
" ATTR~TEXT contains description~pass\n"
|
|
50
|
+
" ATTR!~TEXT doesn't contain\n"
|
|
51
|
+
" ATTR<VALUE > <= >= compare pwdlastset<2020-01-01 lastlogontimestamp<90d\n\n"
|
|
52
|
+
"Text matching ignores case; '*' at either end of a value is a wildcard (name=svc_*).\n"
|
|
53
|
+
"VALUE can be true/false, null (unset), a number, a date (YYYY-MM-DD) or an age (90d =\n"
|
|
54
|
+
"90 days ago). Quote filters containing spaces: 'description~service account'.\n\n"
|
|
55
|
+
"Examples:\n"
|
|
56
|
+
" search -t user hasspn enabled\n"
|
|
57
|
+
" search -t computer operatingsystem~2012 -d CORP\n"
|
|
58
|
+
" search -t user pwdneverexpires 'pwdlastset<5000d' -c lastlogon -o old.csv",
|
|
59
|
+
formatter_class=RawDescriptionCmd2HelpFormatter,
|
|
60
|
+
)
|
|
61
|
+
p.add_argument("filters", nargs="*", metavar="FILTER", help="property filter (see below)",
|
|
62
|
+
choices_provider=lambda _self: Choices.from_values(_filter_names()))
|
|
63
|
+
p.add_argument("-t", "--type", type=_kind, metavar="TYPE",
|
|
64
|
+
choices_provider=lambda _self: Choices.from_values(PRIMARY_KINDS),
|
|
65
|
+
help="object type (User, Computer, Group, OU, GPO, ...; default: any)")
|
|
66
|
+
p.add_argument("-d", "--domain", help="restrict to a domain (FQDN or NetBIOS)")
|
|
67
|
+
p.add_argument("-c", "--columns", action="append", default=[], metavar="ATTR[,ATTR...]",
|
|
68
|
+
help="extra properties to show (filtered properties are always shown)")
|
|
69
|
+
p.add_argument("-l", "--limit", type=int, metavar="N", help="max results fetched (default: config max_results)")
|
|
70
|
+
p.add_argument("-n", "--max-rows", type=int, metavar="N",
|
|
71
|
+
help="max table rows shown (default: config display_rows; 0 = all)")
|
|
72
|
+
add_output_args(p)
|
|
73
|
+
return p
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _uncut(args: argparse.Namespace) -> argparse.Namespace:
|
|
77
|
+
"""Re-read the arguments when cmd2 cut them at '>', '|' or ';'.
|
|
78
|
+
|
|
79
|
+
Redirection is disabled, but cmd2 still ends the argument list at those characters, which would
|
|
80
|
+
silently drop the rest of `lastlogon>=90d enabled`.
|
|
81
|
+
"""
|
|
82
|
+
stmt = args.cmd2_statement
|
|
83
|
+
if not (stmt.redirector or stmt.terminator):
|
|
84
|
+
return args
|
|
85
|
+
try:
|
|
86
|
+
return _search_parser().parse_args(shlex.split(stmt.raw.split(None, 1)[1]))
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise BHError(f"Cannot parse arguments: {exc}") from exc
|
|
89
|
+
except SystemExit as exc: # usage error: argparse already printed it
|
|
90
|
+
raise Cmd2ArgparseError from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SearchCommands(BaseCommands):
|
|
94
|
+
DEFAULT_CATEGORY = "Entities"
|
|
95
|
+
|
|
96
|
+
@with_argparser(_search_parser)
|
|
97
|
+
def do_search(self, args: argparse.Namespace) -> None:
|
|
98
|
+
"""List objects matching property filters (e.g. 'search -t user hasspn enabled')."""
|
|
99
|
+
app = self.app
|
|
100
|
+
args = _uncut(args)
|
|
101
|
+
if not (args.filters or args.type or args.domain):
|
|
102
|
+
raise BHError("Give at least one FILTER, -t TYPE or -d DOMAIN (see 'help search')")
|
|
103
|
+
if args.limit is not None and args.limit < 1:
|
|
104
|
+
raise BHError("--limit must be at least 1")
|
|
105
|
+
opts = SearchOptions(filters=[parse_filter(f) for f in args.filters], kind=args.type,
|
|
106
|
+
domain=args.domain or "", limit=args.limit or app.config.max_results)
|
|
107
|
+
columns = [c.strip() for spec in args.columns for c in spec.split(",") if c.strip()]
|
|
108
|
+
app.require_auth()
|
|
109
|
+
result = SearchService(app.entities).search(opts, columns)
|
|
110
|
+
for node in result.nodes:
|
|
111
|
+
app.remember(node)
|
|
112
|
+
|
|
113
|
+
if args.json:
|
|
114
|
+
app.poutput(export.to_json(result.as_dict()))
|
|
115
|
+
else:
|
|
116
|
+
notes = [f"Filters: {' AND '.join(args.filters)}"] if args.filters else []
|
|
117
|
+
if result.truncated:
|
|
118
|
+
total = f" of {result.total:,}" if result.total is not None else ""
|
|
119
|
+
notes.append(f"Only the first {opts.limit:,}{total} results were fetched (raise --limit).")
|
|
120
|
+
app.ui.table(f"Search: {opts.kind or 'any type'}", result.columns, result.rows(),
|
|
121
|
+
max_rows=self.max_rows(args), notes=notes)
|
|
122
|
+
if args.output:
|
|
123
|
+
fields = [c.attr for c in result.columns]
|
|
124
|
+
fields.insert(3, "objectid")
|
|
125
|
+
self.export(args.output, result.as_dict(), (fields, result.rows()))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bhshell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive shell for BloodHound
|
|
5
|
+
Author-email: kr0no <kr0no@ka0labs.net>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kr0no/bloodhound-shell
|
|
8
|
+
Project-URL: Issues, https://github.com/kr0no/bloodhound-shell/issues
|
|
9
|
+
Project-URL: Author, https://kr0no.me
|
|
10
|
+
Keywords: bloodhound,bloodhound-ce,active-directory,attack-paths,cypher,pentest,red-team,security,cli,shell
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: cmd2<5,>=4.3
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Requires-Dist: requests>=2.31
|
|
30
|
+
Requires-Dist: resvg-py>=0.5
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
33
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# bhshell
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/bhshell/)
|
|
39
|
+
[](https://pypi.org/project/bhshell/)
|
|
40
|
+
[](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
|
|
41
|
+
[](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
An interactive command-line shell for BloodHound. Look up Active Directory objects,
|
|
44
|
+
hunt for weaknesses and find attack paths from the terminal, without opening the web UI.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
Requires Python 3.11+ and a BloodHound CE v9 server (tested on v9.7.1).
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install bhshell # recommended
|
|
52
|
+
# or
|
|
53
|
+
pip install bhshell
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
$ bhshell --url http://127.0.0.1:8080
|
|
60
|
+
|
|
61
|
+
[bh: shell] > login -u admin
|
|
62
|
+
[+] Logged in as admin on 127.0.0.1:8080
|
|
63
|
+
|
|
64
|
+
[bh: shell] > hunt
|
|
65
|
+
Severity Check Count Example Description
|
|
66
|
+
critical dcsync 2 SVC_SQL@CORP.LOCAL Non-Tier-Zero principals with replication rights
|
|
67
|
+
high kerberoastable 2 JDOE@CORP.LOCAL Kerberoastable users
|
|
68
|
+
...
|
|
69
|
+
[*] Details: 'hunt CHECK' (e.g. 'hunt kerberoastable'), or 'hunt --all'.
|
|
70
|
+
|
|
71
|
+
[bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
|
|
72
|
+
|
|
73
|
+
[bh: shell] > path --owned -g
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The same commands run non-interactively for scripting: `bhshell -c "hunt --json" > hunt.json`.
|
|
77
|
+
|
|
78
|
+
## What it does
|
|
79
|
+
|
|
80
|
+
| Command | Shows |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `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 |
|
|
83
|
+
| `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
|
|
84
|
+
| `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
|
+
| `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 |
|
|
86
|
+
| `cypher`, `query` | Raw Cypher, and a library of saved queries: bundled, your own `.cyp` files (`~/.bhshell/queries/` or any folder set with `config query_dirs`) and those saved on the server |
|
|
87
|
+
| `owned`, `hvt` | Mark or unmark owned and Tier Zero objects |
|
|
88
|
+
| `domains`, `trusts` | Per-domain counts, and the trust map with SID filtering status |
|
|
89
|
+
|
|
90
|
+
To add your own query, save it as a `.cyp` file in `~/.bhshell/queries/` and run it by name:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ cat ~/.bhshell/queries/stale-admins.cyp
|
|
94
|
+
// description: Enabled adminCount users who have not logged on for a year
|
|
95
|
+
MATCH (u:User) WHERE u.admincount = true AND u.enabled = true
|
|
96
|
+
AND u.lastlogontimestamp < (datetime().epochseconds - 31536000) RETURN u
|
|
97
|
+
|
|
98
|
+
[bh: shell] > query run stale-admins -o stale-admins.csv
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [Query library](https://github.com/kr0no/bloodhound-shell/blob/main/docs/USAGE.md#query-library) for the format and for
|
|
102
|
+
keeping queries in other folders (a team repository, one folder per engagement).
|
|
103
|
+
|
|
104
|
+
Most commands take `--json` and `-o FILE` (JSON or CSV). Login works with a password (plus MFA) or
|
|
105
|
+
an API token; secrets are prompted for or read from the environment, never passed as arguments.
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
The full usage guide (every command, view flag, search filter, path option, hunt check and
|
|
110
|
+
configuration setting) is in [docs/USAGE.md](https://github.com/kr0no/bloodhound-shell/blob/main/docs/USAGE.md).
|
|
111
|
+
Inside the shell, run `help` or `help <command>`.
|
|
112
|
+
|
|
113
|
+
## Contributing
|
|
114
|
+
|
|
115
|
+
Bug reports and pull requests are welcome at
|
|
116
|
+
[github.com/kr0no/bloodhound-shell/issues](https://github.com/kr0no/bloodhound-shell/issues).
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/kr0no/bloodhound-shell.git
|
|
120
|
+
cd bloodhound-shell
|
|
121
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
122
|
+
pip install -e '.[dev]'
|
|
123
|
+
python -m pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The tests mock the BloodHound API with `responses`, so no BloodHound server is needed.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Distributed under the MIT License. See
|
|
131
|
+
[LICENSE](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE) for details.
|
|
132
|
+
|
|
133
|
+
BloodHound is a trademark of SpecterOps. bhshell is an independent project and is not affiliated
|
|
134
|
+
with or endorsed by SpecterOps.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
bhshell/__init__.py,sha256=n8SMTm85bIm5dpDJikW_zJjQZPgMT4FcZ-GmvKO48tA,76
|
|
2
|
+
bhshell/__main__.py,sha256=zu6PVRedmvjjW2Sw8F-W3cMXcD6G5_trj8p4GdVkPRI,55
|
|
3
|
+
bhshell/cli.py,sha256=Hjy5c0bc7ntBteP2q_SK5FnOm1-vjqDx_86P00YOAMA,2123
|
|
4
|
+
bhshell/config.py,sha256=qXOeWgTerpgZAK0spewO57qJn3fqkeOPF3XySp7ROO4,5121
|
|
5
|
+
bhshell/errors.py,sha256=ZAYQp51ERF9VN47obi43OGVrizLiHXRerFZFwwYrDvg,1847
|
|
6
|
+
bhshell/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
bhshell/client/auth.py,sha256=w2o-u7CYCfgJF1uKOv6l6fJK4rwhS4m1pzC-_jz_q-g,2503
|
|
8
|
+
bhshell/client/client.py,sha256=s5R5lgwl25AdHE8-9zbmXikzWQriOdlCq8QL7TIYc8w,12543
|
|
9
|
+
bhshell/library/asrep-roastable-users.cyp,sha256=Iee5Ll4mMBTx_5OEx3eeNAlEWOghziQo-g5Y7fiSff0,172
|
|
10
|
+
bhshell/library/dcsync-principals.cyp,sha256=HsNxJ71svTZTdwvgtYMPyqDEfxkQxWbjNwnEE8GcW-M,173
|
|
11
|
+
bhshell/library/domain-admins-members.cyp,sha256=zz10dAxSza6ZNQLSz06OEkVRoYIU_V3KLu6AtGsPtbw,189
|
|
12
|
+
bhshell/library/domain-trusts.cyp,sha256=BUE17dPNPnEE4-uoVEzeq60j-F_Sz1s9MnJdalDVHKY,140
|
|
13
|
+
bhshell/library/gpos-linked-to-domain-controllers.cyp,sha256=vFhfVW06oOV2Z2K4jm40JHMKKlEgBy3hoTskUHMr4fs,216
|
|
14
|
+
bhshell/library/kerberoastable-users.cyp,sha256=pi66OnFXQCR_JXjbJV_cZUvbXQk4ZHl0SiOIRd86rr0,156
|
|
15
|
+
bhshell/library/owned-objects.cyp,sha256=y13goTwj7NCNHP9pBZup_Dc8zvrNEiywNEwTWYJkczg,92
|
|
16
|
+
bhshell/library/owned-to-tier-zero.cyp,sha256=nEHSLzU1WRVKifbP1LkL82uJM00fy-Px8I1lL5iJ_OY,227
|
|
17
|
+
bhshell/library/sid-history.cyp,sha256=rZCgg6Lz-thCU_p7On1kJFanM91uc7A59IXCr81MaSo,147
|
|
18
|
+
bhshell/library/tier-zero-users.cyp,sha256=-ISf4QzQ4v-RtHyr2A66lTOCksvKgGhDWTnYpuoJihM,133
|
|
19
|
+
bhshell/library/unconstrained-delegation.cyp,sha256=6BFxFRHoJ57pHqDcjsLwdzWfUQnCcUPNekIndXPuI28,199
|
|
20
|
+
bhshell/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
bhshell/models/entities.py,sha256=VqlC-wPJ_mTdTKwRS1WEr11RBNkTYKc7aaJzWxTPRUA,15155
|
|
22
|
+
bhshell/models/results.py,sha256=ea9HiAKVuyMI7JVQzjDmoEtLDUIs4HfmpFf5dIh3kv0,2159
|
|
23
|
+
bhshell/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
bhshell/queries/cypher.py,sha256=97G_ynpiAtYb_Kf3IAsV7hcnf1KXOIMso-A7lQMb7Ow,5420
|
|
25
|
+
bhshell/queries/domains.py,sha256=Ob6FGCPIAVfFiuQtytD0xN_ipPjuVvjIHrsrTO7p8AU,5572
|
|
26
|
+
bhshell/queries/edges.py,sha256=5Iexg7VS_RYvsYXMb7quznbGed4FjQGMY9o4pyLp9v4,4687
|
|
27
|
+
bhshell/queries/hunt.py,sha256=xkPi499zV6HzaTQotUvMrGILyQCEzsK7lbFO54zp9mc,35125
|
|
28
|
+
bhshell/queries/library.py,sha256=4zfvI3CxG90_HeDSiR36gASLAfpy3bSEE516GLTo--Q,3732
|
|
29
|
+
bhshell/queries/marking.py,sha256=iL17u8sVl5q_-y21D0vafkq89pWqw9cObjw_5jkOOnc,5849
|
|
30
|
+
bhshell/queries/paths.py,sha256=dI83FisHXN9Zh0UdyzmormNML9W3ocoLvp7rwMHpki0,18762
|
|
31
|
+
bhshell/queries/search.py,sha256=BKgINanmdsp-62fdrgeRoP9VF-na7W69Cg2GJAso5Ww,8609
|
|
32
|
+
bhshell/queries/service.py,sha256=tdSTxv1CUKVmbvggYgXqb6fp0nfoIOnyq8O5Ow--qCE,24560
|
|
33
|
+
bhshell/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
+
bhshell/render/export.py,sha256=y7jtGMF9hVEGXMMRr059e14R_uRFGQttYQHCeFJPzeo,2911
|
|
35
|
+
bhshell/render/graph.py,sha256=Hk950xYCqWhgoJSLhic9vkLT3ILKmSQvtqxJB4MOHes,14728
|
|
36
|
+
bhshell/render/layout.py,sha256=UV-FF421SxnnTvhc755utRgZozJ_JHhN4De__eRoT2A,5763
|
|
37
|
+
bhshell/render/png.py,sha256=iJnkKzY_03LGhlL072rzC_amfMJcQyOuNxx2uJfLD-8,1544
|
|
38
|
+
bhshell/render/styles.py,sha256=cdxIDXjx9PvqlhyJgMaobLrdhU-_l5PmbKxsmsR3Plk,348
|
|
39
|
+
bhshell/render/svg.py,sha256=7KiXEQcb3tdfkXUdRzyHF40jJeFVwyO36k6JM31Mris,24560
|
|
40
|
+
bhshell/render/views.py,sha256=DkrcKlKwr1W-PUAuOMnNB-hw1AGK8rc0yNvvhyMI-Kw,15254
|
|
41
|
+
bhshell/shell/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
|
+
bhshell/shell/app.py,sha256=q6j5LVLsaGIE3V18pDkkx30OO_Ar6W41jkskInhtkRI,7734
|
|
43
|
+
bhshell/shell/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
|
+
bhshell/shell/commands/base.py,sha256=kDe-betW_HlyTjDeO1E7BS5J3efI7zKSKy4LcuvErks,2339
|
|
45
|
+
bhshell/shell/commands/connection.py,sha256=2ScEPftqtBAG7bKFJ2Dl7KdvOx6PB6THYVIoHeguhhk,9802
|
|
46
|
+
bhshell/shell/commands/cypher.py,sha256=qub9u7jgP8nXz_JMmpijU1o0JPEGTWRsSj6yaiZ_G18,3266
|
|
47
|
+
bhshell/shell/commands/domains.py,sha256=HvCRbJc8XihzqIgiBQFTI73jcS6qjEK8okUM2Lc-xy0,3335
|
|
48
|
+
bhshell/shell/commands/entities.py,sha256=l6FBMYJky31NI0DYYE3zSLUeqDtXWprE6qsSN_R8iOY,4088
|
|
49
|
+
bhshell/shell/commands/hunt.py,sha256=mbW9OTYOCyM4UI9Y9UfBJJJ3Gjgeraup7je0xotKwJI,6891
|
|
50
|
+
bhshell/shell/commands/library.py,sha256=SR9UwFc0FRENLTpkoMXYAhoPEWGuSvQmoZlKrUBrNOY,2455
|
|
51
|
+
bhshell/shell/commands/marking.py,sha256=re0an3vMhLrq89_Sj0CFwKlvx7f2pb-2Ox40YfV18Ks,2414
|
|
52
|
+
bhshell/shell/commands/paths.py,sha256=oqdaR1VxQWAzBaWlrmtxHehpbE2UECgtpcQF3GBGahg,8211
|
|
53
|
+
bhshell/shell/commands/search.py,sha256=Mmaf6ekSOKszxw3hUeLI1v5IC89O3GMJqWqmCXGYGoM,5680
|
|
54
|
+
bhshell-0.1.0.dist-info/licenses/LICENSE,sha256=C-WjLV0cwkkudk4FX4LGQqaiRtlt442o8gRVlCsdANA,1062
|
|
55
|
+
bhshell-0.1.0.dist-info/METADATA,sha256=Q5b4SSVftYbVI2k_WIyo-LR30JVzIov7pOr367lPeH4,5942
|
|
56
|
+
bhshell-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
57
|
+
bhshell-0.1.0.dist-info/entry_points.txt,sha256=E9K8CHFYC7Kbbt-QESeY5S2LsWXxbLXthz0REEFK9nY,45
|
|
58
|
+
bhshell-0.1.0.dist-info/top_level.txt,sha256=Ajj2nWqEd-LNnHF6KFuzwwmXq76LZuRFdzRaTqb9rg0,8
|
|
59
|
+
bhshell-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kr0no
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bhshell
|