wraith-sec 0.3.3__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.
wraith/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wraith — offensive recon & exploitation pipeline."""
2
+
3
+ __version__ = "0.3.3"
wraith/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from wraith.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
wraith/art/wraith.txt ADDED
@@ -0,0 +1,38 @@
1
+ :========:
2
+ -++-.. ..-++:
3
+ -**: :*+:
4
+ :*+: :**.
5
+ =#- :-. :*+
6
+ #%-=-: .::. ..: =#
7
+ *@@*. :+=*--*=+: .-: *+
8
+ -@*: .=**- -**- --.%:
9
+ :%+ .=##+: :+*- -%.
10
+ .+* =%#= :::: .+*=. *=.
11
+ =# *#- :-*#%@@%#*-: .=#+ #-
12
+ -#- .#+. :*#@@@@%%%@@@@@%+: .+#. -*-
13
+ =+ ## .+=====#%%%%%@#====== ## *-
14
+ :*=.+* =- .-%%-: -- .*+ :=:
15
+ ++ -# :#= .=%##%=. *+ #= ++
16
+ ++ -# -%@%*++*%@* *@%++++#@#. #=.*=
17
+ -=.:*- .++--+%@%.-:.%@%+-=+=. :*:-*-
18
+ -#- *= +#%@#%%#@%#= += -*-
19
+ -%- %= =:*=%+**==+: += -%-
20
+ :+%+ -#: . :--.*==-+-:-- =+. =%+-
21
+ .-*#*--*- :+- :---=+--==++=+.=.-=:=+-=-- = .+*:*#*=.
22
+ :##:. += == *+-:-:::-:::%.--------.::# .** ..+#:
23
+ :#+ :: :=: .- -+.:-::::- *+.=------=-.#. .=- +#-
24
+ +* .*+. :+- *=:=---:.-%.--=-::::= ++ .- *+
25
+ ++ -+: - ..-=.----- #= =:::--:-.:% -: -*. .*+
26
+ .#= . :+: :+=***:**++- -%.---::--=-=*+. .. :+ =#.
27
+ #=. .-+- .. :*#:::-=-=:=+.*= -= :*---::--*+*. .:-:.-+*
28
+ +#=. :**. -+:**=--=:==*=.*--=- :+-+:*===.:=*. -#+. . :*+
29
+ .=#. :-. . -=+=.++=-+-==+- =+----:=-+*:-+.. -+ .+=.
30
+ .#- .::+*==-==*=-+-:=--=- *+:::++++.-+--=**-. :#.
31
+ #+=- .-==+=+**-*%::====+--+*=::.. ==-+-:=:=====.-@***+:-=+=. --+#
32
+ -%= .. :%#-: =%=.-=:=*+:: .::==*-+-::.+# :+%+ .:+= :**
33
+ #: -#= .*+ .:+*-. .:=%-. :%= .*= -- .%
34
+ : =*. -@-.--:. :=+:.#: +- :
35
+ :=: +%-.. := :- :@* =:
36
+ .: += :=: .+: .+*
37
+ +: += .- :*
38
+ == :*
wraith/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """Command-line entrypoint for wraith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from urllib.parse import urlencode, urljoin, urlsplit
12
+
13
+ import wraith.phases # noqa: F401 (importing populates PHASE_REGISTRY)
14
+ from wraith import __version__
15
+ from wraith.core import report
16
+ from wraith.core.console import THEMES, Console
17
+ from wraith.core.context import Workspace
18
+ from wraith.core.engine import Engine
19
+ from wraith.core.models import Severity
20
+ from wraith.core.phase import PHASE_REGISTRY
21
+ from wraith.core.showdown import Showdown
22
+
23
+ _SEVERITY_BY_NAME = {s.label.lower(): s for s in Severity}
24
+
25
+ # Subcommands. Anything else on the command line is treated as a target for the
26
+ # default `run` command, so `wraith example.com` works without typing `run`.
27
+ _COMMANDS = {"run", "showdown", "phases", "shell", "login", "aces"}
28
+
29
+ EXAMPLES = """\
30
+ examples:
31
+ wraith example.com full scan — `run` is the default command
32
+ wraith example.com -p tcp-scan,http-probe only these phases
33
+ wraith example.com -s sessions.json add Broken Access Control / IDOR
34
+ wraith example.com -x high exit 2 if a High+ finding turns up
35
+ wraith showdown toggle showdown mode (reveal on a find; sticks)
36
+ wraith login http://host/login -u alice -p secret -o sessions.json
37
+ wraith shell -l 9001 catch a reverse shell
38
+
39
+ run `wraith phases` to see the pipeline; phases run concurrently by dependency.
40
+ """
41
+
42
+
43
+ class _Help(argparse.RawDescriptionHelpFormatter):
44
+ """Keep the examples block verbatim and give options room to breathe."""
45
+
46
+ def __init__(self, prog):
47
+ super().__init__(prog, max_help_position=30, width=86)
48
+
49
+
50
+ def _with_default_command(argv):
51
+ """Insert `run` when the first non-option token isn't a known subcommand,
52
+ so the common case (`wraith TARGET ...`) needs no subcommand at all."""
53
+ out = list(argv)
54
+ i = 0
55
+ while i < len(out):
56
+ tok = out[i]
57
+ if tok in ("-h", "--help", "--version"):
58
+ return out # let argparse handle these as-is
59
+ if tok == "--theme": # the one global option that takes a value
60
+ i += 2
61
+ continue
62
+ if tok.startswith("-"):
63
+ i += 1
64
+ continue
65
+ if tok not in _COMMANDS: # first bare word is a target -> default to run
66
+ out.insert(i, "run")
67
+ return out
68
+ return out
69
+
70
+
71
+ def _console(args) -> Console:
72
+ c = Console(
73
+ theme=getattr(args, "theme", None),
74
+ color=False if getattr(args, "no_color", False) else None,
75
+ banner=not getattr(args, "no_banner", False),
76
+ )
77
+ if _load_config().get("showdown"): # mode on -> wire it to the console
78
+ c.showdown = Showdown(c)
79
+ return c
80
+
81
+
82
+ # `wraith showdown` flips a mode that sticks between runs, so it's persisted here.
83
+ _CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "wraith" / "config.json"
84
+
85
+
86
+ def _load_config() -> dict:
87
+ try:
88
+ return json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
89
+ except (OSError, ValueError):
90
+ return {}
91
+
92
+
93
+ def _save_config(cfg: dict) -> None:
94
+ try:
95
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
96
+ _CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def _select(names):
102
+ if not names:
103
+ return [cls() for cls in PHASE_REGISTRY.values()]
104
+ chosen = []
105
+ for n in names:
106
+ cls = PHASE_REGISTRY.get(n)
107
+ if not cls:
108
+ raise SystemExit(f"unknown phase: {n} (see `wraith phases`)")
109
+ chosen.append(cls())
110
+ return chosen
111
+
112
+
113
+ def cmd_phases(args) -> None:
114
+ c = _console(args)
115
+ c.banner()
116
+ for name, cls in PHASE_REGISTRY.items():
117
+ deps = ", ".join(sorted(cls.requires)) or "—"
118
+ c.plain(f" {name:<14} requires: {deps}")
119
+ if cls.description:
120
+ c.plain(f" {'':<14} {cls.description}")
121
+ c.plain("")
122
+
123
+
124
+ def _load_sessions(ws, path, console) -> None:
125
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
126
+ if data.get("base_url"):
127
+ ws.meta["base_url"] = data["base_url"]
128
+ if data.get("seeds"):
129
+ ws.meta["seeds"] = data["seeds"]
130
+ for s in data.get("sessions", []):
131
+ ws.add_session(
132
+ name=s["name"],
133
+ role=s.get("role", "low"),
134
+ headers=s.get("headers", {}),
135
+ cookies=s.get("cookies", {}),
136
+ )
137
+ console.info(f"loaded {len(ws.sessions)} session(s) from {path}")
138
+
139
+
140
+ def cmd_run(args) -> None:
141
+ c = _console(args)
142
+ c.banner()
143
+
144
+ phases = _select(args.phases.split(",") if args.phases else None)
145
+ ws = Workspace.create(args.target, base_dir=args.workdir)
146
+ if args.sessions:
147
+ _load_sessions(ws, args.sessions, c)
148
+ if args.wordlist:
149
+ ws.meta["wordlist"] = args.wordlist
150
+ if args.templates:
151
+ ws.meta["templates"] = args.templates
152
+ c.info(f"target {ws.target}")
153
+ c.info(f"workdir {ws.workdir}")
154
+ c.info(f"phases {', '.join(p.name for p in phases)}")
155
+
156
+ engine = Engine(ws, phases, c, concurrency=args.concurrency)
157
+ results = asyncio.run(engine.run())
158
+
159
+ report_md = report.write_markdown(ws, results)
160
+ report_html = report.write_html(ws, results)
161
+ report_json = report.write_json(ws)
162
+ ws.save()
163
+
164
+ c.rule("summary")
165
+ c.good(
166
+ f"hosts {len(ws.hosts)} · services {len(ws.services)} · "
167
+ f"endpoints {len(ws.endpoints)} · findings {len(ws.findings)}"
168
+ )
169
+ c.info(f"workspace {ws.workdir / 'workspace.json'}")
170
+ c.info(f"report {report_md}")
171
+ c.info(f"report {report_html}")
172
+ c.info(f"findings {report_json}")
173
+
174
+ worst = max((f.severity for f in ws.findings), default=Severity.INFO)
175
+ fail = bool(args.fail_on) and bool(ws.findings) and worst >= _SEVERITY_BY_NAME[args.fail_on]
176
+ if fail:
177
+ c.warn(f"findings at/above '{args.fail_on}' (worst: {worst.label}) — exit 2")
178
+
179
+ if c.showdown is not None:
180
+ # Showdown mode owns the ending: reveal, kill-chain, receipts, verdict.
181
+ c.showdown.close(ws)
182
+ else:
183
+ # Plain run: the findings, then the tally.
184
+ c.findings_report(ws.findings)
185
+ counts = {}
186
+ for f in ws.findings:
187
+ counts[f.severity.label] = counts.get(f.severity.label, 0) + 1
188
+ c.severity_summary(counts)
189
+
190
+ if fail:
191
+ sys.exit(2)
192
+
193
+
194
+ def _login_form(html: str, page_url: str):
195
+ """Find the login form on the page and return (action_url, hidden_fields).
196
+
197
+ The login form is the one with a password input. We grab its action (where
198
+ the POST really goes — often /auth/login, not the page URL) and every hidden
199
+ field, so anti-CSRF tokens (ASP.NET __RequestVerificationToken, Django
200
+ csrfmiddlewaretoken, Rails authenticity_token...) ride along automatically.
201
+ Returns (None, {}) when no such form is found.
202
+ """
203
+ import re
204
+
205
+ for fm in re.finditer(r"<form\b([^>]*)>(.*?)</form>", html or "", re.I | re.S):
206
+ attrs, inner = fm.group(1), fm.group(2)
207
+ if not re.search(r'type\s*=\s*["\']password', inner, re.I):
208
+ continue # not the login form
209
+ action_m = re.search(r'action\s*=\s*["\']([^"\']*)', attrs, re.I)
210
+ action = urljoin(page_url, action_m.group(1)) if action_m and action_m.group(1) else page_url
211
+ hidden = {}
212
+ for inp in re.finditer(r"<input\b([^>]*)>", inner, re.I):
213
+ a = inp.group(1)
214
+ if not re.search(r'type\s*=\s*["\']hidden', a, re.I):
215
+ continue
216
+ name = re.search(r'name\s*=\s*["\']([^"\']+)', a, re.I)
217
+ value = re.search(r'value\s*=\s*["\']([^"\']*)', a, re.I)
218
+ if name:
219
+ hidden[name.group(1)] = value.group(1) if value else ""
220
+ return action, hidden
221
+ return None, {}
222
+
223
+
224
+ def cmd_login(args) -> None:
225
+ """Authenticate against a form login and emit a sessions.json snippet."""
226
+ import http.cookiejar
227
+ import ssl
228
+ import urllib.request
229
+
230
+ c = _console(args)
231
+ ctx = ssl.create_default_context()
232
+ ctx.check_hostname = False
233
+ ctx.verify_mode = ssl.CERT_NONE
234
+ jar = http.cookiejar.CookieJar()
235
+ opener = urllib.request.build_opener(
236
+ urllib.request.HTTPSHandler(context=ctx),
237
+ urllib.request.HTTPCookieProcessor(jar),
238
+ )
239
+ ua = {"User-Agent": "wraith/0.1"}
240
+
241
+ # GET the login page first: seeds the session cookie *and* lets us read the
242
+ # form's real action and any hidden token it expects on submit.
243
+ action, hidden = args.url, {}
244
+ try:
245
+ with opener.open(urllib.request.Request(args.url, headers=ua), timeout=10) as resp:
246
+ html = resp.read(200000).decode("utf-8", "ignore")
247
+ found_action, hidden = _login_form(html, args.url)
248
+ if found_action:
249
+ action = found_action
250
+ if hidden:
251
+ c.info(f"form action {action} · carried {len(hidden)} hidden field(s): {', '.join(hidden)}")
252
+ except Exception as exc:
253
+ raise SystemExit(f"could not load login page: {exc}")
254
+
255
+ # hidden fields first, then our credentials and any --data overrides on top.
256
+ fields = dict(hidden)
257
+ fields[args.user_field] = args.username
258
+ fields[args.pass_field] = args.password
259
+ for pair in args.data or []:
260
+ if "=" in pair:
261
+ k, v = pair.split("=", 1)
262
+ fields[k] = v
263
+ body = urlencode(fields).encode()
264
+
265
+ try:
266
+ post = urllib.request.Request(action, data=body, headers={**ua, "Referer": args.url})
267
+ opener.open(post, timeout=10)
268
+ except Exception as exc:
269
+ raise SystemExit(f"login request failed: {exc}")
270
+
271
+ cookies = {ck.name: ck.value for ck in jar}
272
+ if not cookies:
273
+ c.warn("no cookies captured — check the field names/URL")
274
+
275
+ parts = urlsplit(args.url)
276
+ snippet = {
277
+ "base_url": f"{parts.scheme}://{parts.netloc}",
278
+ "seeds": ["/"],
279
+ "sessions": [{"name": args.name, "role": args.role, "cookies": cookies}],
280
+ }
281
+ text = json.dumps(snippet, indent=2)
282
+ if args.output:
283
+ Path(args.output).write_text(text, encoding="utf-8")
284
+ c.good(f"captured {len(cookies)} cookie(s) -> {args.output}")
285
+ else:
286
+ print(text)
287
+
288
+
289
+ def cmd_showdown(args) -> None:
290
+ """Toggle showdown mode on/off — it sticks between runs. While on, every run
291
+ that finds a vulnerability reveals the wraith (more mode behaviour to come)."""
292
+ c = _console(args)
293
+ cfg = _load_config()
294
+ cfg["showdown"] = not cfg.get("showdown", False)
295
+ _save_config(cfg)
296
+ if cfg["showdown"]:
297
+ if c.show_banner:
298
+ c.aces()
299
+ c.good("showdown mode ON — runs now play the catch out (run `wraith showdown` again to turn off)")
300
+ else:
301
+ c.info("showdown mode OFF — wraith runs plain again")
302
+
303
+
304
+ def cmd_aces(args) -> None:
305
+ _console(args).aces()
306
+
307
+
308
+ def cmd_shell(args) -> None:
309
+ from wraith.shell import payloads
310
+ from wraith.shell.handler import ShellServer
311
+
312
+ c = _console(args)
313
+ c.banner()
314
+ try:
315
+ ports = [int(p) for p in args.listen.split(",")]
316
+ except ValueError:
317
+ raise SystemExit("--listen expects comma-separated port numbers")
318
+ lhost = args.lhost or payloads.guess_lhost()
319
+ server = ShellServer(ports, lhost, c)
320
+ try:
321
+ asyncio.run(server.run())
322
+ except KeyboardInterrupt:
323
+ pass
324
+
325
+
326
+ def build_parser() -> argparse.ArgumentParser:
327
+ p = argparse.ArgumentParser(
328
+ prog="wraith",
329
+ description="Offensive recon & exploitation pipeline. Run is the default: `wraith TARGET`.",
330
+ epilog=EXAMPLES,
331
+ formatter_class=_Help,
332
+ )
333
+ p.add_argument("--version", action="version", version=f"wraith {__version__}")
334
+ p.add_argument("--theme", metavar="NAME", choices=list(THEMES),
335
+ help="colour theme: " + " | ".join(THEMES) + " (default: crimson)")
336
+ p.add_argument("--no-color", action="store_true", help="disable coloured output")
337
+ p.add_argument("--no-banner", action="store_true", help="suppress the ASCII banner")
338
+ sub = p.add_subparsers(dest="command", metavar="<command>")
339
+
340
+ def _add_scan_args(sp):
341
+ sp.add_argument("target", help="hostname, IP or URL")
342
+ sp.add_argument("-p", "--phases", metavar="LIST", help="comma-separated subset of phases (default: all)")
343
+ sp.add_argument("-s", "--sessions", metavar="FILE", help="sessions JSON — enables access-control / IDOR")
344
+ sp.add_argument("-w", "--wordlist", metavar="FILE", help="wordlist for content-discovery")
345
+ sp.add_argument("-t", "--templates", metavar="DIR", help="extra template-checks directory")
346
+ sp.add_argument("-x", "--fail-on", metavar="SEV", choices=list(_SEVERITY_BY_NAME),
347
+ help="exit 2 on a finding at/above SEV (info|low|medium|high|critical)")
348
+ sp.add_argument("-c", "--concurrency", metavar="N", type=int, default=8,
349
+ help="max phases running in parallel (default: 8)")
350
+ sp.add_argument("--workdir", metavar="DIR", default="wraith-runs",
351
+ help="output directory (default: wraith-runs)")
352
+
353
+ run = sub.add_parser("run", help="scan a target (default command)", epilog=EXAMPLES,
354
+ formatter_class=_Help, description="Run the phase pipeline against a target.")
355
+ _add_scan_args(run)
356
+ run.set_defaults(func=cmd_run)
357
+
358
+ sd = sub.add_parser("showdown", help="toggle showdown mode on/off (sticks between runs)",
359
+ formatter_class=_Help,
360
+ description="Toggle showdown mode. While it's on, every run reveals the "
361
+ "wraith when it catches a vulnerability. Run it again to turn off.")
362
+ sd.set_defaults(func=cmd_showdown)
363
+
364
+ ph = sub.add_parser("phases", help="list available phases", formatter_class=_Help)
365
+ ph.set_defaults(func=cmd_phases)
366
+
367
+ sh = sub.add_parser("shell", help="reverse-shell handler / post-exploitation console", formatter_class=_Help)
368
+ sh.add_argument("-l", "--listen", metavar="PORTS", default="9001",
369
+ help="comma-separated ports to listen on (default: 9001)")
370
+ sh.add_argument("--lhost", metavar="IP", help="LHOST embedded in generated payloads (auto-detected)")
371
+ sh.set_defaults(func=cmd_shell)
372
+
373
+ lg = sub.add_parser("login", help="grab a session from a form login -> sessions.json", formatter_class=_Help)
374
+ lg.add_argument("url", help="login form URL (GET to seed cookies, POST to submit)")
375
+ lg.add_argument("-u", "--username", required=True)
376
+ lg.add_argument("-p", "--password", required=True)
377
+ lg.add_argument("-o", "--output", metavar="FILE", help="write the sessions.json here (default: stdout)")
378
+ lg.add_argument("--user-field", metavar="NAME", default="username", help="username form field name")
379
+ lg.add_argument("--pass-field", metavar="NAME", default="password", help="password form field name")
380
+ lg.add_argument("--data", metavar="K=V", action="append", help="extra form field (repeatable)")
381
+ lg.add_argument("--name", metavar="NAME", default="user", help="session name for the output")
382
+ lg.add_argument("--role", metavar="ROLE", default="low", help="session role (none/low/med/high)")
383
+ lg.set_defaults(func=cmd_login)
384
+
385
+ egg = sub.add_parser("aces") # easter egg: no help= keeps it out of the listing
386
+ egg.set_defaults(func=cmd_aces)
387
+
388
+ return p
389
+
390
+
391
+ def main(argv=None) -> None:
392
+ argv = sys.argv[1:] if argv is None else list(argv)
393
+ parser = build_parser()
394
+ if not argv: # bare `wraith` -> banner + help, not an error
395
+ Console().banner()
396
+ parser.print_help()
397
+ return
398
+ args = parser.parse_args(_with_default_command(argv))
399
+ if not hasattr(args, "func"): # options but no command
400
+ parser.print_help()
401
+ return
402
+ args.func(args)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()
File without changes