@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 +5 -4
- package/package.json +1 -1
- package/vendor/vulnreaper/__init__.py +23 -23
- package/vendor/vulnreaper/cli/__init__.py +1 -1
- package/vendor/vulnreaper/cli/app.py +15 -9
- package/vendor/vulnreaper/cli/autocomplete.py +50 -0
- package/vendor/vulnreaper/cli/commands.py +300 -0
- package/vendor/vulnreaper/cli/components/__init__.py +11 -0
- package/vendor/vulnreaper/cli/components/banner.py +81 -0
- package/vendor/vulnreaper/cli/components/error.py +29 -0
- package/vendor/vulnreaper/cli/components/finding.py +105 -0
- package/vendor/vulnreaper/cli/components/palette.py +65 -0
- package/vendor/vulnreaper/cli/components/spinner.py +96 -0
- package/vendor/vulnreaper/cli/components/status.py +55 -0
- package/vendor/vulnreaper/cli/intent.py +84 -0
- package/vendor/vulnreaper/cli/prompt.py +354 -0
- package/vendor/vulnreaper/cli/repl.py +243 -0
- package/vendor/vulnreaper/cli/terminal.py +105 -0
- package/vendor/vulnreaper/cli/theme.py +170 -0
- package/vendor/vulnreaper/ui.py +12 -4
- package/vendor/vulnreaper/cli/menu.py +0 -117
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` |
|
|
98
|
+
| `vulnreaper ui` | Shell interactif (REPL conversationnel) |
|
|
99
99
|
|
|
100
|
-
Sans commande, `vulnreaper` ouvre le
|
|
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,
|
|
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 (
|
|
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,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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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')}"
|