@ksl101/vulnreaper 0.1.0-beta.0 → 0.1.0-beta.1

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.

Potentially problematic release.


This version of @ksl101/vulnreaper might be problematic. Click here for more details.

package/README.md CHANGED
@@ -95,9 +95,9 @@ vulnreaper --version
95
95
  | `vulnreaper logout` | Ferme la session locale |
96
96
  | `vulnreaper whoami [--json]` | Affiche licence, rôle, statut, permissions |
97
97
  | `vulnreaper admin license\|device\|audit …` | Administration des licences/appareils (rôle `admin`) |
98
- | `vulnreaper ui` | Menu interactif |
98
+ | `vulnreaper ui` | Shell interactif (REPL conversationnel) |
99
99
 
100
- Sans commande, `vulnreaper` ouvre le menu interactif (`ui`).
100
+ Sans commande, `vulnreaper` ouvre le shell interactif (`ui`).
101
101
 
102
102
  ---
103
103
 
@@ -142,7 +142,7 @@ définissent dans la configuration (`vulnreaper config show`).
142
142
 
143
143
  ```
144
144
  vulnreaper/ paquet principal
145
- cli/ point d'entrée, menu interactif, tableaux/dashboard
145
+ cli/ point d'entrée, shell interactif (REPL), thème + composants
146
146
  formats/ parseurs strings, AXML, DEX, ELF, métadonnées IL2CPP
147
147
  android/ import APK, modèle manifest, analyse dex/natif, adb
148
148
  unity/ il2cpp/ détection moteur + adaptateurs de dump
@@ -160,7 +160,7 @@ docs/ architecture, règles, utilisation
160
160
  ## Tests
161
161
 
162
162
  ```bat
163
- python -m pytest tests/ -q :: cœur Python (122 tests)
163
+ python -m pytest tests/ -q :: cœur Python + UI (164 tests)
164
164
  python tests/smoke.py :: scan end-to-end d'un APK synthétique
165
165
  cd server && npm test :: API de licence (11 tests, store mémoire)
166
166
  cd server && VULNREAPER_E2E=1 npm test :: + E2E CLI ↔ API
@@ -181,6 +181,7 @@ prérequis : **[docs/TESTING.md](docs/TESTING.md)**.
181
181
 
182
182
  - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — conception et flux de données
183
183
  - [`docs/USAGE.md`](docs/USAGE.md) — prise en main et workflow bug-bounty
184
+ - [`docs/UI.md`](docs/UI.md) — interface CLI/REPL (thème, composants, raccourcis)
184
185
  - [`docs/RULES.md`](docs/RULES.md) — packs de règles et écriture de règles
185
186
  - [`docs/BETA_TESTERS.md`](docs/BETA_TESTERS.md) — guide des testeurs bêta (npm, licence, gating)
186
187
  - [`docs/TESTING.md`](docs/TESTING.md) — stratégie et couverture des tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksl101/vulnreaper",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.1",
4
4
  "description": "VulnReaper - authorised security auditing & bug-bounty CLI for Android/Unity/IL2CPP game packages",
5
5
  "keywords": [
6
6
  "security",
@@ -1,23 +1,23 @@
1
- """VulnReaper — Android game security research & bug-bounty automation platform.
2
-
3
- A modular, offline-first toolkit for analysing Android/Unity/IL2CPP game
4
- packages: APK/DEX/native/IL2CPP parsing, secret and misconfiguration scanning,
5
- endpoint discovery, and JSON/Markdown/HTML reporting.
6
-
7
- Design invariants
8
- -----------------
9
- * Standard library only (no third-party runtime dependencies).
10
- * Never fabricate results: a missing external tool is reported as ``missing``
11
- and its pipeline stage is ``skipped`` — never simulated.
12
- * Windows-first, POSIX-compatible.
13
- """
14
- from __future__ import annotations
15
-
16
- __all__ = ["__version__", "PRODUCT"]
17
-
18
- __version__ = "0.1.0-beta.0"
19
- PRODUCT = "VulnReaper"
20
-
21
-
22
- def version_string() -> str:
23
- return f"{PRODUCT} {__version__} — Security Research Platform"
1
+ """VulnReaper — Android game security research & bug-bounty automation platform.
2
+
3
+ A modular, offline-first toolkit for analysing Android/Unity/IL2CPP game
4
+ packages: APK/DEX/native/IL2CPP parsing, secret and misconfiguration scanning,
5
+ endpoint discovery, and JSON/Markdown/HTML reporting.
6
+
7
+ Design invariants
8
+ -----------------
9
+ * Standard library only (no third-party runtime dependencies).
10
+ * Never fabricate results: a missing external tool is reported as ``missing``
11
+ and its pipeline stage is ``skipped`` — never simulated.
12
+ * Windows-first, POSIX-compatible.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ __all__ = ["__version__", "PRODUCT"]
17
+
18
+ __version__ = "0.1.0-beta.1"
19
+ PRODUCT = "VulnReaper"
20
+
21
+
22
+ def version_string() -> str:
23
+ return f"{PRODUCT} {__version__} — Security Research Platform"
@@ -1,4 +1,4 @@
1
1
  """Command-line interface."""
2
2
  from __future__ import annotations
3
3
 
4
- __all__ = ["app", "menu"]
4
+ __all__ = ["app"]
@@ -28,6 +28,8 @@ def build_parser() -> argparse.ArgumentParser:
28
28
  parser.add_argument("--offline", action="store_true", help="use cached release information only")
29
29
  parser.add_argument("-v", "--verbose", action="store_true", help="verbose logging")
30
30
  parser.add_argument("-q", "--quiet", action="store_true", help="suppress console logs")
31
+ parser.add_argument("--debug", action="store_true",
32
+ help="verbose console logging and full tracebacks")
31
33
 
32
34
  sub = parser.add_subparsers(dest="command")
33
35
 
@@ -104,7 +106,7 @@ def build_parser() -> argparse.ArgumentParser:
104
106
  p_aa.add_argument("--json", action="store_true")
105
107
 
106
108
  sub.add_parser("doctor", help="environment diagnostics")
107
- sub.add_parser("ui", help="interactive menu")
109
+ sub.add_parser("ui", help="interactive shell (default)")
108
110
  return parser
109
111
 
110
112
 
@@ -117,7 +119,7 @@ def main(argv: list[str] | None = None) -> int:
117
119
  home = config.general.home or str(Config.home_dir())
118
120
  config.general.home = home
119
121
 
120
- configure(home, verbose=args.verbose, quiet=args.quiet)
122
+ configure(home, verbose=args.verbose or args.debug, quiet=args.quiet)
121
123
  log = get_logger()
122
124
  console = make_console(color=config.output.color and not args.no_color)
123
125
 
@@ -160,20 +162,24 @@ def main(argv: list[str] | None = None) -> int:
160
162
  if command == "doctor":
161
163
  return _cmd_doctor(console, config, home)
162
164
  if command == "ui":
163
- from .menu import run_menu
165
+ # The conversational shell: a fast splash, then a prompt. Update
166
+ # checks are NOT run here (they used to print a table on every
167
+ # start); the shell reaches them on demand via /updates.
168
+ from .repl import run_repl
169
+ from .theme import Theme
164
170
  vulnreaper = VulnReaper(config, home=home)
165
171
  vulnreaper.offline = args.offline
166
- try:
167
- if config.tools.check_updates_on_start:
168
- _show_updates(console, vulnreaper, offline=args.offline)
169
- return run_menu(vulnreaper, console)
170
- finally:
171
- vulnreaper.projects.close()
172
+ theme = Theme(color=config.output.color and not args.no_color)
173
+ return run_repl(vulnreaper, config, home, console, theme,
174
+ offline=args.offline)
172
175
  except KeyboardInterrupt:
173
176
  console.print("\ninterrupted")
174
177
  return 130
175
178
  except Exception as exc: # noqa: BLE001 - top-level guard
176
179
  log.exception("command failed")
180
+ if args.debug:
181
+ import traceback
182
+ traceback.print_exc()
177
183
  console.print(console.style(f"error: {type(exc).__name__}: {exc}", "red"))
178
184
  return 1
179
185
 
@@ -0,0 +1,50 @@
1
+ """Pure completion helpers.
2
+
3
+ Kept free of any terminal I/O so they are trivially unit-testable.
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ def common_prefix(items: list[str]) -> str:
9
+ """Longest common prefix of ``items`` (empty when there is none)."""
10
+ if not items:
11
+ return ""
12
+ first, last = min(items), max(items)
13
+ i = 0
14
+ limit = min(len(first), len(last))
15
+ while i < limit and first[i] == last[i]:
16
+ i += 1
17
+ return first[:i]
18
+
19
+
20
+ def current_token(buffer: str) -> str:
21
+ """The token under the cursor for completion purposes (first word here)."""
22
+ return buffer.lstrip().split(" ", 1)[0]
23
+
24
+
25
+ def matches(buffer: str, names: list[str]) -> list[str]:
26
+ token = current_token(buffer)
27
+ if not token:
28
+ return []
29
+ return [name for name in names if name.startswith(token)]
30
+
31
+
32
+ def complete(buffer: str, names: list[str]) -> tuple[str, list[str]]:
33
+ """Return ``(new_buffer, candidates)``.
34
+
35
+ * one candidate -> the token is completed and a trailing space added
36
+ * many candidates -> extend to their common prefix when it is longer
37
+ * none -> buffer unchanged
38
+ """
39
+ candidates = matches(buffer, names)
40
+ if not candidates:
41
+ return buffer, []
42
+ token = current_token(buffer)
43
+ if len(candidates) == 1:
44
+ rest = buffer.lstrip()[len(token):]
45
+ return candidates[0] + " " + rest.lstrip(), candidates
46
+ prefix = common_prefix(candidates)
47
+ if len(prefix) > len(token):
48
+ rest = buffer.lstrip()[len(token):]
49
+ return prefix + rest, candidates
50
+ return buffer, candidates
@@ -0,0 +1,300 @@
1
+ """Slash-command registry for the conversational REPL.
2
+
3
+ Every handler takes the live :class:`~vulnreaper.cli.repl.ReplSession` and the
4
+ raw argument string, and returns an exit code (``0`` success). Engine/licence
5
+ imports are done lazily inside the handlers to keep import order simple and to
6
+ avoid pulling the whole stack in for ``/help``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Callable
13
+
14
+ from ..findings.model import SEVERITIES
15
+ from .intent import split_args
16
+
17
+
18
+ @dataclass
19
+ class Command:
20
+ name: str
21
+ summary: str
22
+ usage: str
23
+ handler: Callable | None = None
24
+ hidden: bool = False
25
+
26
+
27
+ def _opt(tokens: list[str], *flags: str) -> str | None:
28
+ """Return the value following the first present flag."""
29
+ for i, token in enumerate(tokens):
30
+ if token in flags and i + 1 < len(tokens):
31
+ return tokens[i + 1]
32
+ for flag in flags:
33
+ if token.startswith(flag + "="):
34
+ return token.split("=", 1)[1]
35
+ return None
36
+
37
+
38
+ def _positional(tokens: list[str]) -> list[str]:
39
+ out: list[str] = []
40
+ skip = False
41
+ for token in tokens:
42
+ if skip:
43
+ skip = False
44
+ continue
45
+ if token.startswith("-"):
46
+ if "=" not in token and token in ("--profile", "-p", "--project", "--run", "--severity", "-s", "--limit"):
47
+ skip = True
48
+ continue
49
+ out.append(token)
50
+ return out
51
+
52
+
53
+ # ----------------------------------------------------------------- handlers
54
+ def cmd_scan(session, args: str) -> int:
55
+ from .. import gating
56
+ from ..licensing import LicenseError
57
+ from ..engine import DEFAULT_PROFILE, PROFILES
58
+ from . import dashboard
59
+
60
+ tokens = split_args(args)
61
+ positional = _positional(tokens)
62
+ target = positional[0] if positional else ""
63
+ profile = _opt(tokens, "--profile", "-p") or session.config.scan.default_profile
64
+ project = _opt(tokens, "--project") or session.project
65
+
66
+ if profile not in PROFILES:
67
+ session.error(f"unknown profile '{profile}'", "available: " + ", ".join(sorted(PROFILES)))
68
+ return 2
69
+ if not target:
70
+ session.error("usage: /scan <apk> [--profile " + DEFAULT_PROFILE + "]")
71
+ return 2
72
+ if not Path(target).is_file():
73
+ session.error(f"target not found: {target}")
74
+ return 2
75
+ try:
76
+ gating.require(session.config, session.home, "scan")
77
+ except LicenseError as exc:
78
+ session.error(f"license required for 'scan': {exc}",
79
+ "run /license to activate a key")
80
+ return 1
81
+
82
+ session.note(f"scanning {target} (profile={profile}, project={project})")
83
+ result = session.vr.scan(target, profile=profile, project=project)
84
+ dashboard.render_run(session.console, result)
85
+ session.render_findings(result.findings)
86
+ return 0
87
+
88
+
89
+ def cmd_projects(session, args: str) -> int:
90
+ projects = session.vr.projects.list()
91
+ if not projects:
92
+ session.note("(no projects)")
93
+ return 0
94
+ session.console.table(["PROJECT", "RUNS", "ROOT"],
95
+ [[p["name"], str(p["runs"]), p["root"]] for p in projects])
96
+ return 0
97
+
98
+
99
+ def cmd_runs(session, args: str) -> int:
100
+ tokens = split_args(args)
101
+ project = _opt(tokens, "--project") or (_positional(tokens) or [session.project])[0]
102
+ ws = session.vr.projects.ensure(project)
103
+ db = session.vr.projects._db_for(ws.name)
104
+ runs = db.list_runs(ws.name)
105
+ if not runs:
106
+ session.note(f"(no runs for '{project}')")
107
+ return 0
108
+ session.console.table(["RUN", "PROFILE", "STATUS", "TARGET"],
109
+ [[r["id"], r["profile"] or "", r["status"], r["target"] or ""] for r in runs])
110
+ return 0
111
+
112
+
113
+ def cmd_findings(session, args: str) -> int:
114
+ tokens = split_args(args)
115
+ project = _opt(tokens, "--project") or session.project
116
+ run_id = _opt(tokens, "--run")
117
+ severity = _opt(tokens, "--severity", "-s")
118
+ if severity and severity not in SEVERITIES:
119
+ session.error(f"unknown severity '{severity}'", "one of: " + ", ".join(SEVERITIES))
120
+ return 2
121
+ ws = session.vr.projects.ensure(project)
122
+ db = session.vr.projects._db_for(ws.name)
123
+ rows = db.get_findings(run_id=run_id, project=None, severity=severity)
124
+ session.render_findings(rows)
125
+ session.note(f"{len(rows)} finding(s)")
126
+ return 0
127
+
128
+
129
+ def cmd_tools(session, args: str) -> int:
130
+ tokens = split_args(args)
131
+ statuses = session.vr.tools.all_status()
132
+ session.cache_env(statuses)
133
+ rows = [[s.spec.name, session.status_text(s.status), (s.version or "")[:40],
134
+ s.spec.install_hint[:40]] for s in statuses]
135
+ session.console.table(["TOOL", "STATUS", "VERSION", "INSTALL HINT"], rows)
136
+ missing = [s for s in statuses if not s.available]
137
+ session.note(f"{len(statuses) - len(missing)} available, {len(missing)} missing")
138
+ if "--updates" in tokens or "--refresh" in tokens:
139
+ return cmd_updates(session, args)
140
+ return 0
141
+
142
+
143
+ def cmd_rules(session, args: str) -> int:
144
+ tokens = split_args(args)
145
+ limit = _opt(tokens, "--limit")
146
+ rules = sorted(session.vr.rules.rules.values(), key=lambda r: r.id)
147
+ if limit and limit.isdigit():
148
+ rules = rules[: int(limit)]
149
+ session.console.table(["RULE", "SEV", "TARGET", "TITLE"],
150
+ [[r.id, session.severity_text(r.severity), r.target, r.title[:50]] for r in rules])
151
+ session.note(f"{len(session.vr.rules)} rules loaded")
152
+ return 0
153
+
154
+
155
+ def cmd_doctor(session, args: str) -> int:
156
+ import sys
157
+ from ..tools import REGISTRY
158
+
159
+ session.console.section("Environment")
160
+ session.console.kv([
161
+ ("python", sys.version.split()[0]),
162
+ ("platform", sys.platform),
163
+ ("home", session.home),
164
+ ("project", session.project),
165
+ ])
166
+ session.console.section("External tools")
167
+ statuses = session.vr.tools.all_status()
168
+ session.cache_env(statuses)
169
+ for s in statuses:
170
+ if s.available:
171
+ session.out(f" {session.status_text('ok')} {s.spec.name} {(s.version or '')[:40]}")
172
+ else:
173
+ session.out(f" {session.status_text('missing')} {s.spec.name} {s.spec.install_hint}")
174
+ session.console.section("Rules")
175
+ session.note(f"{len(session.vr.rules)} rules loaded from {len(REGISTRY)} tool specs")
176
+ return 0
177
+
178
+
179
+ def cmd_updates(session, args: str) -> int:
180
+ from ..updates import UpdateChecker
181
+
182
+ tokens = split_args(args)
183
+ refresh = "--refresh" in tokens
184
+ offline = session.offline
185
+ session.console.section("Tool updates (stable releases)")
186
+ session.note("Checking official sources..." if not offline else "Reading cached release information...",
187
+ muted=True)
188
+ results = UpdateChecker(session.vr.tools, session.vr.projects.home).check_all(
189
+ refresh=refresh, offline=offline)
190
+ session.console.table(["TOOL", "INSTALLED", "LATEST", "STATUS", "SOURCE"],
191
+ [[r.name, r.installed or "?", r.latest or "?", r.status,
192
+ "stale cache" if r.stale else "cache" if r.cached else "upstream"]
193
+ for r in results])
194
+ for result in results:
195
+ if result.error:
196
+ session.note(f" {result.name}: {result.error}", muted=True)
197
+ session.note("Release checks do not modify installed tools.", muted=True)
198
+ return 1 if any(r.status == "check_failed" for r in results) else 0
199
+
200
+
201
+ def cmd_license(session, args: str) -> int:
202
+ from ..licensing import LicenseError
203
+
204
+ key = _opt(split_args(args), "--key")
205
+ if key:
206
+ try:
207
+ lic = session.client.activate(key).license or {}
208
+ except LicenseError as exc:
209
+ session.error(f"activation failed: {exc}")
210
+ return 1
211
+ session.success("device activated")
212
+ session.console.kv([
213
+ ("license", str(lic.get("id", "?"))),
214
+ ("role", str(lic.get("role", "?"))),
215
+ ("status", str(lic.get("status", "active"))),
216
+ ("permissions", ", ".join(lic.get("permissions") or [])),
217
+ ])
218
+ return 0
219
+
220
+ if not session.client.has_session():
221
+ session.note("no active session")
222
+ session.note("activate with: /license --key VR-XXXX", muted=True)
223
+ return 0
224
+ try:
225
+ data = session.client.me()
226
+ except LicenseError as exc:
227
+ session.error(str(exc))
228
+ return 1
229
+ lic = data.get("license", data)
230
+ last_check = ""
231
+ session_file = Path(session.home) / "session.json"
232
+ try:
233
+ last_check = f"{session_file.stat().st_mtime:.0f}"
234
+ except OSError:
235
+ pass
236
+ session.console.kv([
237
+ ("license", str(lic.get("id", "?"))),
238
+ ("role", str(lic.get("role", "?"))),
239
+ ("status", session.status_text(str(lic.get("status", "?")))),
240
+ ("device", str(lic.get("device_id", lic.get("deviceId", "?")))),
241
+ ("permissions", ", ".join(lic.get("permissions") or [])),
242
+ ("last check", session.human_time(last_check)),
243
+ ])
244
+ return 0
245
+
246
+
247
+ def cmd_help(session, args: str) -> int:
248
+ from .components import palette
249
+
250
+ detail = args.strip().lstrip("/")
251
+ if detail:
252
+ command = COMMANDS.get("/" + detail)
253
+ if command is None:
254
+ session.error(f"unknown command '/{detail}'")
255
+ return 2
256
+ session.out(f"{session.theme.style(command.name, 'brand', 'bold')} {command.summary}")
257
+ if command.usage:
258
+ session.note(command.usage, muted=True)
259
+ return 0
260
+ session.out(session.theme.style("Available commands", "brand", "bold"))
261
+ for name, summary in palette.COMMANDS:
262
+ session.out(f" {session.theme.style(name.ljust(11), 'info')} {session.theme.style(summary, 'text')}")
263
+ session.note("Type a command or plain English (e.g. 'scan app.apk', 'quit').", muted=True)
264
+ return 0
265
+
266
+
267
+ def cmd_clear(session, args: str) -> int:
268
+ return _CLEAR
269
+
270
+
271
+ def cmd_exit(session, args: str) -> int:
272
+ return _EXIT
273
+
274
+
275
+ # ----------------------------------------------------------------- registry
276
+ _CLEAR = 1000
277
+ _EXIT = 1001
278
+
279
+ COMMANDS: dict[str, Command] = {
280
+ "/scan": Command("/scan", "Scan an APK or package", "/scan <apk> [--profile apk-static] [--project NAME]", cmd_scan),
281
+ "/projects": Command("/projects", "List projects", "/projects", cmd_projects),
282
+ "/runs": Command("/runs", "List runs for a project", "/runs [PROJECT]", cmd_runs),
283
+ "/findings": Command("/findings", "Show findings", "/findings [--run ID] [--severity SEV]", cmd_findings),
284
+ "/tools": Command("/tools", "External tools status", "/tools [--updates]", cmd_tools),
285
+ "/rules": Command("/rules", "List detection rules", "/rules [--limit N]", cmd_rules),
286
+ "/doctor": Command("/doctor", "Environment diagnostics", "/doctor", cmd_doctor),
287
+ "/updates": Command("/updates", "Check tool updates", "/updates [--refresh]", cmd_updates),
288
+ "/license": Command("/license", "Licence and device status", "/license [--key VR-XXXX]", cmd_license),
289
+ "/help": Command("/help", "Show available commands", "/help [command]", cmd_help),
290
+ "/clear": Command("/clear", "Clear the screen", "/clear", cmd_clear),
291
+ "/exit": Command("/exit", "Quit VulnReaper", "/exit", cmd_exit),
292
+ }
293
+
294
+
295
+ def names() -> list[str]:
296
+ return list(COMMANDS)
297
+
298
+
299
+ def get(name: str) -> Command | None:
300
+ return COMMANDS.get(name.lower())
@@ -0,0 +1,11 @@
1
+ """Reusable, renderer-only UI components.
2
+
3
+ Each component is a pure function (or small class) that takes a
4
+ :class:`~vulnreaper.cli.theme.Theme` and returns styled strings. Components
5
+ never touch the engine or the licence system - they only format data.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from . import banner, error, finding, palette, spinner, status
10
+
11
+ __all__ = ["banner", "error", "finding", "palette", "spinner", "status"]
@@ -0,0 +1,81 @@
1
+ """Responsive ASCII banner for VulnReaper.
2
+
3
+ Three tiers, chosen from the terminal width:
4
+
5
+ * ``full`` - five-row block logotype (>= 100 columns)
6
+ * ``compact`` - three-row box logotype (80-99 columns)
7
+ * ``minimal`` - single plain word (narrow / ``TERM=dumb`` / no Unicode)
8
+
9
+ The block logotype is our own, built from a small legible glyph set - it is
10
+ NOT a copy of any other product's banner. Colour is applied as a vertical
11
+ violet -> cyan gradient by the theme.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .. import terminal
16
+ from ..theme import Theme, visible_len
17
+
18
+ # Own block font, 5 rows tall, 7 columns wide per glyph (thickness 2 strokes).
19
+ _FULL_FONT: dict[str, list[str]] = {
20
+ "V": ["██ ██", "██ ██", "██ ██", " ██ ██ ", " ███ "],
21
+ "U": ["██ ██", "██ ██", "██ ██", "██ ██", " █████ "],
22
+ "L": ["██ ", "██ ", "██ ", "██ ", "███████"],
23
+ "N": ["██ ██", "███ ██", "██ █ ██", "██ ███", "██ ██"],
24
+ "R": ["██████ ", "██ ██", "██████ ", "██ ██ ", "██ ██"],
25
+ "E": ["███████", "██ ", "█████ ", "██ ", "███████"],
26
+ "A": [" █████ ", "██ ██", "███████", "██ ██", "██ ██"],
27
+ "P": ["██████ ", "██ ██", "██████ ", "██ ", "██ "],
28
+ }
29
+
30
+ # Own compact box-drawing logotype, 3 rows tall.
31
+ _COMPACT = [
32
+ "\u2566 \u2566\u2566 \u2566\u2566 \u2554\u2557\u2554\u2566\u2550\u2557\u2554\u2550\u2557\u2554\u2550\u2557\u2554\u2550\u2557\u2566\u2554\u2550\u2557\u2566\u2550\u2557",
33
+ "\u255a\u2557\u2554\u255d\u2551 \u2551\u2551 \u2551\u2551\u2551\u2560\u2566\u255d\u2551\u2563 \u2560\u2550\u2563\u2560\u2550\u2563\u2551\u2551\u2563 \u2560\u2566\u255d",
34
+ " \u255a\u255d \u255a\u2550\u255d\u255a\u2550\u255d\u255d\u255a\u255d\u2569\u255a\u2550\u255a\u2550\u255d\u2569 \u2569\u2569 \u2569\u2569\u255a\u2550\u255d\u2569\u255a\u2550",
35
+ ]
36
+
37
+ WORD = "VULNREAPER"
38
+ TAGLINE = "AI-assisted Security Research Platform"
39
+
40
+
41
+ def logotype_lines(tier: str) -> list[str]:
42
+ """Return the raw (unstyled) banner lines for a tier."""
43
+ if tier == "full":
44
+ rows = ["", "", "", "", ""]
45
+ for index, letter in enumerate(WORD):
46
+ glyph = _FULL_FONT[letter]
47
+ for r in range(5):
48
+ rows[r] += glyph[r]
49
+ if index != len(WORD) - 1:
50
+ rows[r] += " "
51
+ return rows
52
+ if tier == "compact":
53
+ return list(_COMPACT)
54
+ return [WORD]
55
+
56
+
57
+ def render(theme: Theme, cols: int | None = None) -> list[str]:
58
+ """Return styled banner lines that never exceed the terminal width."""
59
+ cols = terminal.columns() if cols is None else cols
60
+ tier = terminal.banner_tier(cols)
61
+ lines = logotype_lines(tier)
62
+ if terminal.ascii_mode() and tier != "minimal":
63
+ # The block glyphs need Unicode; fall back to the plain word.
64
+ tier = "minimal"
65
+ lines = [WORD]
66
+ styled = theme.gradient(lines, "brand", "info")
67
+ width = max(visible_len(line) for line in lines)
68
+ if width >= cols:
69
+ # Extremely narrow terminal: guarantee no overflow.
70
+ return [theme.style(WORD, "brand")]
71
+ return styled
72
+
73
+
74
+ def tagline(theme: Theme) -> list[str]:
75
+ """Return the version/wordmark tagline lines."""
76
+ from ... import __version__
77
+
78
+ return [
79
+ theme.style(f"{WORD} {__version__}", "brand"),
80
+ theme.style(TAGLINE, "muted"),
81
+ ]
@@ -0,0 +1,29 @@
1
+ """Error rendering.
2
+
3
+ One place decides how an error looks: a coloured glyph, the message, and an
4
+ optional hint line for the usual remediation ("run vulnreaper login ...").
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .. import terminal
9
+ from ..theme import Theme
10
+
11
+
12
+ def render_error(theme: Theme, message: str, hint: str = "") -> list[str]:
13
+ glyph = theme.style(terminal.symbols()["cross"], "error")
14
+ lines = [f"{glyph} {theme.style(message, 'error')}"]
15
+ if hint:
16
+ lines.append(f" {theme.style(hint, 'muted')}")
17
+ return lines
18
+
19
+
20
+ def render_warning(theme: Theme, message: str, hint: str = "") -> list[str]:
21
+ glyph = theme.style(terminal.symbols()["diamond_o"], "warning")
22
+ lines = [f"{glyph} {theme.style(message, 'warning')}"]
23
+ if hint:
24
+ lines.append(f" {theme.style(hint, 'muted')}")
25
+ return lines
26
+
27
+
28
+ def render_success(theme: Theme, message: str) -> str:
29
+ return f"{theme.style(terminal.symbols()['check'], 'success')} {theme.style(message, 'text')}"