localmask 0.9.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.
- cli.py +2437 -0
- licensing.py +486 -0
- localmask/__init__.py +14 -0
- localmask/_edition.py +108 -0
- localmask/ask_local.py +126 -0
- localmask/engine.py +1125 -0
- localmask/gitops.py +368 -0
- localmask/masking.py +122 -0
- localmask/ner_patterns.json +256 -0
- localmask/publish.py +57 -0
- localmask/regex_patterns.json +1882 -0
- localmask/state.py +270 -0
- localmask/vault.py +50 -0
- localmask/vault_store.py +485 -0
- localmask-0.9.0.dist-info/METADATA +356 -0
- localmask-0.9.0.dist-info/RECORD +23 -0
- localmask-0.9.0.dist-info/WHEEL +5 -0
- localmask-0.9.0.dist-info/entry_points.txt +3 -0
- localmask-0.9.0.dist-info/top_level.txt +7 -0
- mcp_server.py +872 -0
- ner_scanner.py +257 -0
- regex_rules_safe.py +436 -0
- server_core.py +790 -0
cli.py
ADDED
|
@@ -0,0 +1,2437 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LocalMask Pro CLI — detect, review, and approve secrets in code repos.
|
|
4
|
+
|
|
5
|
+
The CLI is a thin client that communicates with the LocalMask cloud service.
|
|
6
|
+
All scanning and detection happens server-side. Secrets never leave the service.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
localmask connect https://localmask-pro-xxx.run.app
|
|
10
|
+
localmask scan https://github.com/org/repo --credential-id cred_xxx
|
|
11
|
+
localmask store-token ghp_xxx # store token securely, get credential_id
|
|
12
|
+
localmask set-key anthropic sk-ant-xxx # set AI API key on server
|
|
13
|
+
localmask status # list all scans
|
|
14
|
+
localmask status <scan_id> # single scan status
|
|
15
|
+
localmask review <scan_id> # interactive review via service
|
|
16
|
+
localmask approve-all <scan_id> # approve all detections + submit
|
|
17
|
+
localmask submit <scan_id> # submit for security approval
|
|
18
|
+
localmask publish <scan_id> <target_url> # publish masked repo to git
|
|
19
|
+
localmask ask <scan_id> # interactive AI chat about a scan
|
|
20
|
+
localmask ask <scan_id> "what are the risks" # single question mode
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import urllib.request
|
|
27
|
+
import urllib.error
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
|
|
30
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
31
|
+
CONFIG_DIR = os.path.expanduser("~/.localmask")
|
|
32
|
+
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
33
|
+
|
|
34
|
+
# ── Colors ───────────────────────────────────────────────────────────────────
|
|
35
|
+
RED = "\033[91m"
|
|
36
|
+
GREEN = "\033[92m"
|
|
37
|
+
YELLOW = "\033[93m"
|
|
38
|
+
BLUE = "\033[94m"
|
|
39
|
+
MAGENTA = "\033[95m"
|
|
40
|
+
CYAN = "\033[96m"
|
|
41
|
+
WHITE = "\033[97m"
|
|
42
|
+
BOLD = "\033[1m"
|
|
43
|
+
DIM = "\033[2m"
|
|
44
|
+
UNDERLINE = "\033[4m"
|
|
45
|
+
BG_RED = "\033[41m"
|
|
46
|
+
BG_GREEN = "\033[42m"
|
|
47
|
+
BG_YELLOW = "\033[43m"
|
|
48
|
+
BG_BLUE = "\033[44m"
|
|
49
|
+
RESET = "\033[0m"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
53
|
+
# SERVICE CLIENT — communicates with LocalMask cloud service
|
|
54
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
class ServiceClient:
|
|
57
|
+
"""HTTP client for the LocalMask service API."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, base_url: str):
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
|
|
62
|
+
def _req(self, method: str, path: str, body: dict = None) -> dict:
|
|
63
|
+
url = f"{self.base_url}{path}"
|
|
64
|
+
data = json.dumps(body).encode() if body else None
|
|
65
|
+
req = urllib.request.Request(
|
|
66
|
+
url, data=data, method=method,
|
|
67
|
+
headers={"Content-Type": "application/json"} if data else {},
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
71
|
+
return json.loads(resp.read())
|
|
72
|
+
except urllib.error.HTTPError as e:
|
|
73
|
+
err_body = e.read().decode()
|
|
74
|
+
try:
|
|
75
|
+
err = json.loads(err_body)
|
|
76
|
+
raise SystemExit(f"{RED}Error {e.code}: {err.get('error', err_body)}{RESET}")
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
raise SystemExit(f"{RED}Error {e.code}: {err_body[:200]}{RESET}")
|
|
79
|
+
except urllib.error.URLError as e:
|
|
80
|
+
raise SystemExit(f"{RED}Connection failed: {e.reason}{RESET}")
|
|
81
|
+
|
|
82
|
+
def health(self) -> dict:
|
|
83
|
+
return self._req("GET", "/health")
|
|
84
|
+
|
|
85
|
+
def store_credential(self, token: str, label: str = "") -> dict:
|
|
86
|
+
return self._req("POST", "/api/credentials", {
|
|
87
|
+
"token": token, "label": label,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
def scan(self, repo_url, credential_id="", org="default",
|
|
91
|
+
sensitivity="standard", submitted_by="developer"):
|
|
92
|
+
return self._req("POST", "/api/repos/scan", {
|
|
93
|
+
"repo_url": repo_url, "credential_id": credential_id, "org": org,
|
|
94
|
+
"sensitivity": sensitivity, "submitted_by": submitted_by,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
def list_repos(self, org=""):
|
|
98
|
+
qs = f"?org={org}" if org else ""
|
|
99
|
+
return self._req("GET", f"/api/repos{qs}")
|
|
100
|
+
|
|
101
|
+
def get_scan(self, scan_id):
|
|
102
|
+
return self._req("GET", f"/api/repos/{scan_id}")
|
|
103
|
+
|
|
104
|
+
def get_detections(self, scan_id):
|
|
105
|
+
return self._req("GET", f"/api/repos/{scan_id}/detections")
|
|
106
|
+
|
|
107
|
+
def post_review(self, scan_id, decisions, reviewer="developer"):
|
|
108
|
+
return self._req("POST", f"/api/repos/{scan_id}/review", {
|
|
109
|
+
"decisions": decisions, "reviewer": reviewer,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
def submit(self, scan_id, submitted_by="developer"):
|
|
113
|
+
return self._req("POST", f"/api/repos/{scan_id}/submit", {
|
|
114
|
+
"submitted_by": submitted_by,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
def ask(self, scan_id, question, provider="anthropic",
|
|
118
|
+
model="claude-sonnet-4-5", source="memory", git_url=""):
|
|
119
|
+
body = {
|
|
120
|
+
"question": question, "provider": provider,
|
|
121
|
+
"model": model, "only_findings": True,
|
|
122
|
+
"source": source,
|
|
123
|
+
}
|
|
124
|
+
if git_url:
|
|
125
|
+
body["git_url"] = git_url
|
|
126
|
+
return self._req("POST", f"/api/repos/{scan_id}/ask", body)
|
|
127
|
+
|
|
128
|
+
def ask_reset(self, scan_id):
|
|
129
|
+
return self._req("POST", f"/api/repos/{scan_id}/ask/reset", {})
|
|
130
|
+
|
|
131
|
+
def set_key(self, provider: str, key: str) -> dict:
|
|
132
|
+
return self._req("POST", "/api/settings", {provider: key})
|
|
133
|
+
|
|
134
|
+
def get_keys(self) -> dict:
|
|
135
|
+
return self._req("GET", "/api/settings")
|
|
136
|
+
|
|
137
|
+
def publish(self, scan_id: str, target_url: str,
|
|
138
|
+
token: str = "", credential_id: str = "",
|
|
139
|
+
username: str = "") -> dict:
|
|
140
|
+
body = {"target_url": target_url}
|
|
141
|
+
if credential_id:
|
|
142
|
+
body["credential_id"] = credential_id
|
|
143
|
+
elif token:
|
|
144
|
+
body["token"] = token
|
|
145
|
+
if username:
|
|
146
|
+
body["username"] = username
|
|
147
|
+
return self._req("POST", f"/api/repos/{scan_id}/publish", body)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_config() -> dict:
|
|
151
|
+
if os.path.exists(CONFIG_FILE):
|
|
152
|
+
with open(CONFIG_FILE) as f:
|
|
153
|
+
return json.load(f)
|
|
154
|
+
return {}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _save_config(cfg: dict):
|
|
158
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
159
|
+
with open(CONFIG_FILE, "w") as f:
|
|
160
|
+
json.dump(cfg, f, indent=2)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _publish_policy() -> str:
|
|
164
|
+
"""'review' (default) → publishing/auto-republish needs an approved review.
|
|
165
|
+
'auto' → detections are auto-approved and the masked mirror publishes
|
|
166
|
+
without a manual gate."""
|
|
167
|
+
p = _load_config().get("publish_policy", "review")
|
|
168
|
+
return p if p in ("review", "auto") else "review"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _pending_count(scan: dict) -> int:
|
|
172
|
+
"""Detections not yet decided (approved/rejected) — i.e. unreviewed."""
|
|
173
|
+
return sum(1 for d in scan.get("detections", [])
|
|
174
|
+
if d.get("decision") in (None, "pending"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _approve_all_local(scan_id: str) -> dict:
|
|
178
|
+
"""Approve every detection and mark the scan approved-for-publish. Returns
|
|
179
|
+
the scan. Used by `approve-all`, by `review` when nothing is left pending,
|
|
180
|
+
and by publish/sync under the 'auto' policy."""
|
|
181
|
+
from server_core import _get_or_load_scan, _persist_scan
|
|
182
|
+
scan = _get_or_load_scan(scan_id)
|
|
183
|
+
if not scan:
|
|
184
|
+
return {}
|
|
185
|
+
for d in scan.get("detections", []):
|
|
186
|
+
if d.get("decision") in (None, "pending"):
|
|
187
|
+
d["decision"] = "approved"
|
|
188
|
+
scan["status"] = "approved"
|
|
189
|
+
scan["reviewed_by"] = scan.get("reviewed_by") or "developer"
|
|
190
|
+
_persist_scan(scan_id)
|
|
191
|
+
return scan
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_approved(scan: dict) -> bool:
|
|
195
|
+
return scan.get("status") == "approved" and _pending_count(scan) == 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _get_client() -> ServiceClient:
|
|
199
|
+
cfg = _load_config()
|
|
200
|
+
url = cfg.get("service_url")
|
|
201
|
+
if not url:
|
|
202
|
+
raise SystemExit(
|
|
203
|
+
f"{RED}Not connected to a service. Run:{RESET}\n"
|
|
204
|
+
f" localmask connect <service-url>\n"
|
|
205
|
+
)
|
|
206
|
+
return ServiceClient(url)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_connected() -> bool:
|
|
210
|
+
# The free edition is local-only — never route to a service. This stops a
|
|
211
|
+
# stale service_url (e.g. left over from a prior/Pro install) from hijacking
|
|
212
|
+
# local scans. Pro/Team/Enterprise may still use a hosted server.
|
|
213
|
+
try:
|
|
214
|
+
from localmask._edition import has_capability
|
|
215
|
+
if not has_capability("web_ui"):
|
|
216
|
+
return False
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
return bool(_load_config().get("service_url"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _local_engine():
|
|
223
|
+
"""In-process engine — lets scan/status/publish/sync/teach work with no
|
|
224
|
+
server (the free edition ships no web server). Returns a LocalMaskEngine."""
|
|
225
|
+
from server_core import LocalMaskEngine
|
|
226
|
+
return LocalMaskEngine()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _count_occurrences(src: str, value: str) -> int:
|
|
230
|
+
"""How many times the literal value appears across the scanned files —
|
|
231
|
+
the ground truth for 'is this value actually in the code'. Best-effort:
|
|
232
|
+
walks text files under src, skips .git and unreadable/binary files."""
|
|
233
|
+
if not value:
|
|
234
|
+
return 0
|
|
235
|
+
root = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
|
|
236
|
+
if not os.path.isdir(root):
|
|
237
|
+
return 0
|
|
238
|
+
total = 0
|
|
239
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
240
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
241
|
+
for fn in filenames:
|
|
242
|
+
fp = os.path.join(dirpath, fn)
|
|
243
|
+
try:
|
|
244
|
+
if os.path.getsize(fp) > 5_000_000:
|
|
245
|
+
continue
|
|
246
|
+
with open(fp, "r", errors="ignore") as f:
|
|
247
|
+
total += f.read().count(value)
|
|
248
|
+
except (OSError, ValueError):
|
|
249
|
+
continue
|
|
250
|
+
return total
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _publish_error_help(err: str, target_url: str, had_token: bool):
|
|
254
|
+
"""Turn a git push failure into a clear, actionable message (no traceback)."""
|
|
255
|
+
low = err.lower()
|
|
256
|
+
print(f" {RED}✗ Publish failed.{RESET}")
|
|
257
|
+
_auth = ("authentication failed", "invalid username or token",
|
|
258
|
+
"password authentication", "403", "could not read username",
|
|
259
|
+
"could not read password", "terminal prompts disabled",
|
|
260
|
+
"authentication required")
|
|
261
|
+
if any(s in low for s in _auth):
|
|
262
|
+
print(f" {YELLOW}Authentication was rejected by the remote.{RESET}")
|
|
263
|
+
if not had_token:
|
|
264
|
+
print(f" {DIM}No token was used.{RESET} GitHub/GitLab need a Personal "
|
|
265
|
+
f"Access Token with {BOLD}repo{RESET} scope to push. Either:")
|
|
266
|
+
print(f" 1) {CYAN}localmask store-token{RESET} "
|
|
267
|
+
f"{DIM}(type it hidden → get a credential id){RESET}")
|
|
268
|
+
print(f" {CYAN}localmask publish <scan> {target_url} -c <cred_id>{RESET}")
|
|
269
|
+
print(f" 2) or a one-off: {CYAN}localmask publish <scan> {target_url} "
|
|
270
|
+
f"--token <PAT>{RESET} {DIM}(leaks into shell history){RESET}")
|
|
271
|
+
else:
|
|
272
|
+
print(f" {DIM}The token was rejected — it may be expired, revoked, or "
|
|
273
|
+
f"missing the {RESET}{BOLD}repo{RESET}{DIM} scope. Create a fresh "
|
|
274
|
+
f"PAT and store it again with `localmask store-token`.{RESET}")
|
|
275
|
+
elif "not found" in low or "repository not found" in low or "does not exist" in low:
|
|
276
|
+
print(f" {YELLOW}The target repo doesn't exist yet.{RESET} Create an "
|
|
277
|
+
f"{BOLD}empty{RESET} repo at {CYAN}{target_url}{RESET} first "
|
|
278
|
+
f"(no README), then re-run publish.")
|
|
279
|
+
else:
|
|
280
|
+
print(f" {DIM}{err[:300]}{RESET}")
|
|
281
|
+
print(f" {DIM}Nothing was pushed; your secrets never left this machine.{RESET}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _print_grant_guide(target_url: str, scan_id: str):
|
|
285
|
+
"""Simple next-steps: two ways to let the AI read the masked code. LocalMask
|
|
286
|
+
never hands the AI any credentials."""
|
|
287
|
+
print(f"\n {BOLD}Two ways to let your AI read the masked code:{RESET}")
|
|
288
|
+
print(f" {DIM}(It only ever sees ~[TOKEN]~ placeholders — no real secrets.){RESET}\n")
|
|
289
|
+
print(f" {BOLD}A) The AI reads the published masked git mirror{RESET}")
|
|
290
|
+
print(f" Give the AI its {BOLD}own{RESET} read access to {CYAN}{target_url}{RESET}")
|
|
291
|
+
print(f" (read-only collaborator, deploy key, or GitHub/GitLab App), then it")
|
|
292
|
+
print(f" {BOLD}clones/pulls that repo{RESET} — a copy on the AI's side, separate")
|
|
293
|
+
print(f" from your real code. LocalMask never shares your git token.")
|
|
294
|
+
print(f" {DIM}After you change code:{RESET} {CYAN}localmask sync {scan_id}{RESET} "
|
|
295
|
+
f"{DIM}re-masks and{RESET}")
|
|
296
|
+
print(f" {DIM}re-pushes the mirror (once approved); the AI does{RESET} "
|
|
297
|
+
f"{CYAN}git pull{RESET} {DIM}to get it.{RESET}\n")
|
|
298
|
+
print(f" {BOLD}B) The AI reads live from LocalMask — nothing published{RESET}")
|
|
299
|
+
print(f" In your AI editor's MCP config, the assistant calls LocalMask's")
|
|
300
|
+
print(f" {CYAN}get_detections{RESET} / {CYAN}get_file_masked{RESET} tools. No git repo, no")
|
|
301
|
+
print(f" pull — LocalMask serves the {BOLD}current{RESET} masked content each call")
|
|
302
|
+
print(f" (run {CYAN}localmask sync {scan_id}{RESET} {DIM}after code changes so it's fresh).{RESET}")
|
|
303
|
+
print(f"\n {DIM}The difference:{RESET} (A) the AI holds its own git copy and "
|
|
304
|
+
f"{BOLD}pulls{RESET} to update;")
|
|
305
|
+
print(f" (B) LocalMask streams the masked files live, always current, no repo.\n")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_ASK_DEFAULT_MODELS = {
|
|
309
|
+
"anthropic": "claude-sonnet-4-5", "openai": "gpt-4o", "gemini": "gemini-1.5-pro",
|
|
310
|
+
"grok": "grok-2-latest", "xai": "grok-2-latest", "groq": "llama-3.3-70b-versatile",
|
|
311
|
+
"together": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
312
|
+
"meta": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "openrouter": "openai/gpt-4o",
|
|
313
|
+
}
|
|
314
|
+
_ASK_KEY_ENVS = {
|
|
315
|
+
"anthropic": ["ANTHROPIC_API_KEY"], "openai": ["OPENAI_API_KEY"],
|
|
316
|
+
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], "grok": ["XAI_API_KEY"],
|
|
317
|
+
"xai": ["XAI_API_KEY"], "groq": ["GROQ_API_KEY"], "together": ["TOGETHER_API_KEY"],
|
|
318
|
+
"openrouter": ["OPENROUTER_API_KEY"],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _local_ask(args):
|
|
323
|
+
"""Bring-your-own-key AI Q&A in the free edition. Masking + rehydration are
|
|
324
|
+
100% local; only masked tokens go to the provider you choose with your key."""
|
|
325
|
+
from localmask.state import _new_session, _get_or_load_scan
|
|
326
|
+
from localmask import ask_local
|
|
327
|
+
scan = _get_or_load_scan(args.scan_id)
|
|
328
|
+
if not scan:
|
|
329
|
+
print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
|
|
330
|
+
|
|
331
|
+
provider = args.provider.lower()
|
|
332
|
+
# resolve key: --api-key > provider-specific env > generic env > local store
|
|
333
|
+
key = args.api_key
|
|
334
|
+
for env in _ASK_KEY_ENVS.get(provider, []) + ["LOCALMASK_AI_KEY"]:
|
|
335
|
+
if not key:
|
|
336
|
+
key = os.environ.get(env, "")
|
|
337
|
+
if not key and provider != "dry":
|
|
338
|
+
from localmask.vault_store import get_local_ai_key
|
|
339
|
+
key = get_local_ai_key(provider) or ""
|
|
340
|
+
if provider != "dry" and not key:
|
|
341
|
+
envs = " / ".join(_ASK_KEY_ENVS.get(provider, ["LOCALMASK_AI_KEY"]))
|
|
342
|
+
print(f"{RED}No API key for {provider}.{RESET} Save one with "
|
|
343
|
+
f"`localmask set-key {provider}` (stored encrypted, typed hidden), "
|
|
344
|
+
f"or pass --api-key, or set {envs}.")
|
|
345
|
+
sys.exit(1)
|
|
346
|
+
model = args.model or _ASK_DEFAULT_MODELS.get(provider, "gpt-4o")
|
|
347
|
+
question = args.question or "Review this repository and flag the top security risks."
|
|
348
|
+
|
|
349
|
+
# ── Source = git: the AI reads the private masked git itself. LocalMask
|
|
350
|
+
# sends ONLY the masked question + the repo URL — no repo content from
|
|
351
|
+
# memory, no git credentials. (Use when the AI/agent has its own read
|
|
352
|
+
# access to the published masked mirror.) ──────────────────────────────
|
|
353
|
+
if getattr(args, "source", "memory") == "git":
|
|
354
|
+
git_url = args.git_url or scan.get("publish_target", "")
|
|
355
|
+
if not git_url:
|
|
356
|
+
print(f"{RED}No masked git URL.{RESET} Publish the mirror first "
|
|
357
|
+
f"(`localmask publish {args.scan_id} <url>`) or pass --git-url.")
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
# Hydrate the vault only (value→token) so we can mask the question by
|
|
360
|
+
# found keys — we do NOT scan or load repo files here.
|
|
361
|
+
session = _new_session(scan["repo_url"], temp=False)
|
|
362
|
+
print(f" {CYAN}[LOCAL]{RESET} masking your question by found keys; the "
|
|
363
|
+
f"AI reads {CYAN}{git_url}{RESET} itself.")
|
|
364
|
+
print(f" {DIM}Only the masked question + the URL leave — no repo "
|
|
365
|
+
f"content, no keys.{RESET}", flush=True)
|
|
366
|
+
if provider == "dry":
|
|
367
|
+
from localmask.masking import _mask_text
|
|
368
|
+
print(f" {DIM}[dry-run] masked question →{RESET} "
|
|
369
|
+
f"{_mask_text(session, question)}")
|
|
370
|
+
return
|
|
371
|
+
try:
|
|
372
|
+
answer = ask_local.ask_over_git(session, question, git_url,
|
|
373
|
+
provider, key, model,
|
|
374
|
+
base_url=args.base_url)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
print(f"{RED}Ask failed: {e}{RESET}"); sys.exit(1)
|
|
377
|
+
print(f"\n{answer}\n")
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# ── Source = memory (default): mask the whole repo locally and include it in
|
|
381
|
+
# the prompt (self-contained; works with any AI, no git access needed).
|
|
382
|
+
print(f" {CYAN}[LOCAL]{RESET} masking repo, asking {provider} ({model}) "
|
|
383
|
+
f"with your key — only masked tokens leave...", flush=True)
|
|
384
|
+
session = _new_session(scan["repo_url"], temp=False)
|
|
385
|
+
from localmask.engine import _scan_dir
|
|
386
|
+
src = scan["repo_url"]
|
|
387
|
+
src = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
|
|
388
|
+
_scan_dir(session, src)
|
|
389
|
+
if provider == "dry":
|
|
390
|
+
print(f" {DIM}[dry-run] would ask: {question}{RESET}"); return
|
|
391
|
+
try:
|
|
392
|
+
answer = ask_local.ask(session, question, provider, key, model,
|
|
393
|
+
base_url=args.base_url)
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(f"{RED}Ask failed: {e}{RESET}"); sys.exit(1)
|
|
396
|
+
print(f"\n{answer}\n")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
400
|
+
# DETECTION MODEL (remote only — values never leave server)
|
|
401
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
402
|
+
|
|
403
|
+
class Detection:
|
|
404
|
+
"""Single detection instance (metadata only — no real secret values)."""
|
|
405
|
+
__slots__ = ("id", "token", "det_type", "line", "confidence",
|
|
406
|
+
"context_lines", "file", "decision",
|
|
407
|
+
"confidence_override", "reason", "timestamp")
|
|
408
|
+
|
|
409
|
+
def __init__(self, det_id, token, det_type, line, confidence, file,
|
|
410
|
+
context_lines=None):
|
|
411
|
+
self.id = det_id
|
|
412
|
+
self.token = token
|
|
413
|
+
self.det_type = det_type
|
|
414
|
+
self.line = line
|
|
415
|
+
self.confidence = confidence
|
|
416
|
+
self.context_lines = context_lines or []
|
|
417
|
+
self.file = file
|
|
418
|
+
self.decision = None
|
|
419
|
+
self.confidence_override = None
|
|
420
|
+
self.reason = None
|
|
421
|
+
self.timestamp = None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
425
|
+
# HIERARCHICAL REVIEWER — remote service mode only
|
|
426
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
427
|
+
|
|
428
|
+
class HierarchicalReviewer:
|
|
429
|
+
"""Interactive 3-level detection reviewer: Type -> Instances -> Single."""
|
|
430
|
+
|
|
431
|
+
def __init__(self, detections: list, repo_label: str = "",
|
|
432
|
+
save_callback=None, repo_root: str = "", mode: str = "SERVICE",
|
|
433
|
+
teach_callback=None):
|
|
434
|
+
self.repo_label = repo_label
|
|
435
|
+
self.repo_root = repo_root or os.getcwd()
|
|
436
|
+
self.detections = detections
|
|
437
|
+
self._save_callback = save_callback
|
|
438
|
+
self._teach_callback = teach_callback
|
|
439
|
+
self.mode = mode
|
|
440
|
+
self._regroup()
|
|
441
|
+
|
|
442
|
+
def _regroup(self):
|
|
443
|
+
"""(Re)build the type groupings from self.detections."""
|
|
444
|
+
self.types = {}
|
|
445
|
+
for d in self.detections:
|
|
446
|
+
self.types.setdefault(d.det_type, []).append(d)
|
|
447
|
+
# Sort types by avg confidence (highest first)
|
|
448
|
+
self.type_order = sorted(
|
|
449
|
+
self.types.keys(),
|
|
450
|
+
key=lambda t: -(sum(d.confidence for d in self.types[t]) / len(self.types[t]))
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def _file_link(self, rel_path: str, line: int = 0) -> str:
|
|
454
|
+
"""Return clickable file link for VS Code terminal (cmd+click)."""
|
|
455
|
+
# Use relative path — VS Code terminal resolves it against cwd
|
|
456
|
+
return f"./{rel_path}:{line}"
|
|
457
|
+
|
|
458
|
+
def _clear(self):
|
|
459
|
+
print("\033[2J\033[H", end="", flush=True)
|
|
460
|
+
|
|
461
|
+
def _conf_bar(self, conf, width=18):
|
|
462
|
+
filled = int(conf * width)
|
|
463
|
+
return f"{YELLOW}{'█' * filled}{'░' * (width - filled)}{RESET}"
|
|
464
|
+
|
|
465
|
+
def _conf_color(self, conf):
|
|
466
|
+
if conf >= 0.9: return RED
|
|
467
|
+
if conf >= 0.7: return YELLOW
|
|
468
|
+
return DIM
|
|
469
|
+
|
|
470
|
+
def _status_icon(self, det):
|
|
471
|
+
if det.decision is True: return f"{GREEN}✓{RESET}"
|
|
472
|
+
if det.decision is False: return f"{RED}✗{RESET}"
|
|
473
|
+
return f"{DIM}→{RESET}"
|
|
474
|
+
|
|
475
|
+
def _input(self, prompt):
|
|
476
|
+
try:
|
|
477
|
+
return input(prompt).strip()
|
|
478
|
+
except (EOFError, KeyboardInterrupt):
|
|
479
|
+
return "q"
|
|
480
|
+
|
|
481
|
+
def _save_decisions(self):
|
|
482
|
+
if self._save_callback:
|
|
483
|
+
self._save_callback(self.detections)
|
|
484
|
+
|
|
485
|
+
def _teach_missed(self):
|
|
486
|
+
"""Prompt for a secret the scanner missed, teach it, and reload the
|
|
487
|
+
detection list so the newly-masked value shows up immediately."""
|
|
488
|
+
self._clear()
|
|
489
|
+
print(f"\n{BOLD} Teach a missed secret{RESET}\n")
|
|
490
|
+
print(f" {DIM}Paste the exact value the scanner should have masked "
|
|
491
|
+
f"(blank to cancel).{RESET}\n")
|
|
492
|
+
value = self._input(f" {BOLD}→ Value: {RESET}")
|
|
493
|
+
if not value:
|
|
494
|
+
return
|
|
495
|
+
subtype = self._input(
|
|
496
|
+
f" {BOLD}→ Token type{RESET} {DIM}(e.g. API_KEY, PASSWORD; "
|
|
497
|
+
f"default SECRET): {RESET}") or "SECRET"
|
|
498
|
+
print(f"\n {DIM}Teaching + re-scanning…{RESET}")
|
|
499
|
+
try:
|
|
500
|
+
fresh, hits, added = self._teach_callback(value, subtype)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
print(f" {RED}✗ Teach failed: {e}{RESET}")
|
|
503
|
+
self._input(" Press Enter to continue...")
|
|
504
|
+
return
|
|
505
|
+
if fresh is not None:
|
|
506
|
+
self.detections = fresh
|
|
507
|
+
self._regroup()
|
|
508
|
+
tok = subtype.upper().replace(" ", "_")
|
|
509
|
+
occ = f"{hits} occurrence" + ("s" if hits != 1 else "")
|
|
510
|
+
if hits and added:
|
|
511
|
+
print(f" {GREEN}✓ Found {occ} — {added} added to this review, "
|
|
512
|
+
f"masked as a {tok} token.{RESET}")
|
|
513
|
+
elif hits:
|
|
514
|
+
print(f" {YELLOW}⚠ Found {occ} in the source, but no new detection "
|
|
515
|
+
f"was added{RESET} — it may be inside an already-masked secret "
|
|
516
|
+
f"or in a skipped file.")
|
|
517
|
+
else:
|
|
518
|
+
print(f" {YELLOW}⚠ Not found in the scanned code{RESET} — "
|
|
519
|
+
f"check the value (whitespace/quotes?). Saved for future scans.")
|
|
520
|
+
self._input(" Press Enter to continue...")
|
|
521
|
+
|
|
522
|
+
# ── LEVEL 1: Type Selection ─────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
def show_type_summary(self):
|
|
525
|
+
while True:
|
|
526
|
+
self._clear()
|
|
527
|
+
total = len(self.detections)
|
|
528
|
+
reviewed = sum(1 for d in self.detections if d.decision is not None)
|
|
529
|
+
approved = sum(1 for d in self.detections if d.decision is True)
|
|
530
|
+
rejected = sum(1 for d in self.detections if d.decision is False)
|
|
531
|
+
|
|
532
|
+
print(f"\n{BOLD}{'═' * 70}{RESET}")
|
|
533
|
+
print(f"{BOLD} LocalMask Pro — Interactive Review{RESET} {MAGENTA}[{self.mode}]{RESET}")
|
|
534
|
+
print(f"{BOLD}{'═' * 70}{RESET}")
|
|
535
|
+
print(f" {DIM}Repo:{RESET} {self.repo_label}")
|
|
536
|
+
print(f" {DIM}Total:{RESET} {total} detections across {len(self.types)} types")
|
|
537
|
+
print(f" {DIM}Progress:{RESET} {reviewed}/{total} "
|
|
538
|
+
f"{GREEN}✓{approved}{RESET} {RED}✗{rejected}{RESET} "
|
|
539
|
+
f"{DIM}→{total - reviewed} pending{RESET}")
|
|
540
|
+
print(f"{BOLD}{'═' * 70}{RESET}\n")
|
|
541
|
+
|
|
542
|
+
for i, t in enumerate(self.type_order, 1):
|
|
543
|
+
dets = self.types[t]
|
|
544
|
+
count = len(dets)
|
|
545
|
+
avg_conf = sum(d.confidence for d in dets) / count
|
|
546
|
+
done = sum(1 for d in dets if d.decision is not None)
|
|
547
|
+
bar = self._conf_bar(avg_conf)
|
|
548
|
+
|
|
549
|
+
status = ""
|
|
550
|
+
if done == count:
|
|
551
|
+
status = f" {GREEN}[done]{RESET}"
|
|
552
|
+
elif done > 0:
|
|
553
|
+
status = f" {YELLOW}[{done}/{count}]{RESET}"
|
|
554
|
+
|
|
555
|
+
print(f" {BOLD}[{i:>2}]{RESET} {CYAN}{t:<30}{RESET} "
|
|
556
|
+
f"{count:>3} detections {bar} {avg_conf:.0%}{status}")
|
|
557
|
+
|
|
558
|
+
teach_hint = " [T]each a missed secret" if self._teach_callback else ""
|
|
559
|
+
print(f"\n {DIM}Navigation: [1-{len(self.type_order)}] select type "
|
|
560
|
+
f"[S]ave{teach_hint} [Q]uit{RESET}")
|
|
561
|
+
print()
|
|
562
|
+
|
|
563
|
+
choice = self._input(f" {BOLD}→ Choose: {RESET}")
|
|
564
|
+
|
|
565
|
+
if choice.lower() == "q":
|
|
566
|
+
self._save_decisions()
|
|
567
|
+
where = "locally" if self.mode == "LOCAL" else "to service"
|
|
568
|
+
print(f"\n {GREEN}✓ Decisions saved {where}{RESET}\n")
|
|
569
|
+
return
|
|
570
|
+
if choice.lower() == "s":
|
|
571
|
+
self._save_decisions()
|
|
572
|
+
print(f"\n {GREEN}✓ Saved!{RESET}")
|
|
573
|
+
self._input(" Press Enter to continue...")
|
|
574
|
+
continue
|
|
575
|
+
if choice.lower() == "t" and self._teach_callback:
|
|
576
|
+
self._teach_missed()
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
idx = int(choice) - 1
|
|
581
|
+
if 0 <= idx < len(self.type_order):
|
|
582
|
+
self.show_instances(self.type_order[idx])
|
|
583
|
+
except ValueError:
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
# ── LEVEL 2: Instance List ──────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
def show_instances(self, det_type):
|
|
589
|
+
dets = self.types[det_type]
|
|
590
|
+
|
|
591
|
+
while True:
|
|
592
|
+
self._clear()
|
|
593
|
+
count = len(dets)
|
|
594
|
+
done = sum(1 for d in dets if d.decision is not None)
|
|
595
|
+
avg_conf = sum(d.confidence for d in dets) / count
|
|
596
|
+
|
|
597
|
+
print(f"\n{BOLD}{'═' * 70}{RESET}")
|
|
598
|
+
print(f"{BOLD} {det_type}{RESET} — {count} detections "
|
|
599
|
+
f"avg {avg_conf:.0%} conf [{done}/{count} reviewed]")
|
|
600
|
+
print(f"{'═' * 70}{RESET}\n")
|
|
601
|
+
|
|
602
|
+
print(f" {DIM}{'#':<4} {'St':>2} {'File:Line':<38} {'Conf':>5} Note{RESET}")
|
|
603
|
+
print(f" {'─' * 64}")
|
|
604
|
+
|
|
605
|
+
for i, d in enumerate(dets, 1):
|
|
606
|
+
icon = self._status_icon(d)
|
|
607
|
+
cc = self._conf_color(d.confidence)
|
|
608
|
+
link = self._file_link(d.file, d.line)
|
|
609
|
+
|
|
610
|
+
note = ""
|
|
611
|
+
if d.decision is True:
|
|
612
|
+
note = f"{GREEN}(approved){RESET}"
|
|
613
|
+
elif d.decision is False:
|
|
614
|
+
note = f"{RED}(rejected){RESET}"
|
|
615
|
+
elif d.confidence < 0.6:
|
|
616
|
+
note = f"{YELLOW}[LOW!]{RESET}"
|
|
617
|
+
|
|
618
|
+
print(f" {i:<4} {icon} {link} "
|
|
619
|
+
f"{cc}{d.confidence:.0%}{RESET} {note}")
|
|
620
|
+
|
|
621
|
+
print(f"\n {DIM}Navigation: [J]ump to # [A]pprove all [R]eject all [B]ack{RESET}")
|
|
622
|
+
print()
|
|
623
|
+
|
|
624
|
+
choice = self._input(f" {BOLD}→ Action: {RESET}")
|
|
625
|
+
|
|
626
|
+
if choice.lower() in ("b", "q"):
|
|
627
|
+
return
|
|
628
|
+
if choice.lower() == "a":
|
|
629
|
+
for d in dets:
|
|
630
|
+
if d.decision is None:
|
|
631
|
+
d.decision = True
|
|
632
|
+
d.timestamp = datetime.now().isoformat()
|
|
633
|
+
print(f" {GREEN}✓ Approved all pending in {det_type}{RESET}")
|
|
634
|
+
self._input(" Press Enter to continue...")
|
|
635
|
+
continue
|
|
636
|
+
if choice.lower() == "r":
|
|
637
|
+
for d in dets:
|
|
638
|
+
if d.decision is None:
|
|
639
|
+
d.decision = False
|
|
640
|
+
d.timestamp = datetime.now().isoformat()
|
|
641
|
+
print(f" {RED}✗ Rejected all pending in {det_type}{RESET}")
|
|
642
|
+
self._input(" Press Enter to continue...")
|
|
643
|
+
continue
|
|
644
|
+
if choice.lower() == "j":
|
|
645
|
+
num = self._input(f" {BOLD}→ Jump to #: {RESET}")
|
|
646
|
+
try:
|
|
647
|
+
idx = int(num) - 1
|
|
648
|
+
if 0 <= idx < len(dets):
|
|
649
|
+
self.review_instance(dets, idx)
|
|
650
|
+
except ValueError:
|
|
651
|
+
pass
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
idx = int(choice) - 1
|
|
656
|
+
if 0 <= idx < len(dets):
|
|
657
|
+
self.review_instance(dets, idx)
|
|
658
|
+
except ValueError:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
# ── LEVEL 3: Single Instance Review ─────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
def review_instance(self, dets, idx):
|
|
664
|
+
while True:
|
|
665
|
+
if idx < 0:
|
|
666
|
+
idx = 0
|
|
667
|
+
if idx >= len(dets):
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
d = dets[idx]
|
|
671
|
+
self._clear()
|
|
672
|
+
|
|
673
|
+
icon = self._status_icon(d)
|
|
674
|
+
print(f"\n{BOLD}{'═' * 70}{RESET}")
|
|
675
|
+
print(f" {BOLD}#{d.det_type}{RESET} {icon} "
|
|
676
|
+
f"{DIM}#{idx + 1}/{len(dets)}{RESET} — {CYAN}{d.id}{RESET}")
|
|
677
|
+
print(f"{'═' * 70}{RESET}")
|
|
678
|
+
print(f" {DIM}File:{RESET} {self._file_link(d.file, d.line)}")
|
|
679
|
+
print(f" {DIM}Pattern:{RESET} {d.det_type}")
|
|
680
|
+
cc = self._conf_color(d.confidence)
|
|
681
|
+
print(f" {DIM}Confidence:{RESET} {cc}{d.confidence:.0%}{RESET}")
|
|
682
|
+
print()
|
|
683
|
+
|
|
684
|
+
self._show_context(d, context_lines=3)
|
|
685
|
+
|
|
686
|
+
print(f"\n {DIM}Will be masked as:{RESET} {GREEN}{d.token}{RESET}")
|
|
687
|
+
|
|
688
|
+
if d.decision is not None:
|
|
689
|
+
status = f"{GREEN}APPROVED{RESET}" if d.decision else f"{RED}REJECTED{RESET}"
|
|
690
|
+
print(f" {DIM}Current status:{RESET} {status}")
|
|
691
|
+
if d.reason:
|
|
692
|
+
print(f" {DIM}Reason:{RESET} {d.reason}")
|
|
693
|
+
|
|
694
|
+
print(f"\n {DIM}Actions:{RESET}")
|
|
695
|
+
print(f" {GREEN}[Y]{RESET}es approve {RED}[N]{RESET}o reject "
|
|
696
|
+
f"[E]dit confidence [R]eason")
|
|
697
|
+
print(f" [C]ontext expand "
|
|
698
|
+
f"[→] Next [←] Prev [B]ack")
|
|
699
|
+
print()
|
|
700
|
+
|
|
701
|
+
choice = self._input(
|
|
702
|
+
f" {BOLD}Instance {idx + 1}/{len(dets)} in {d.det_type} → {RESET}"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if choice.lower() == "y":
|
|
706
|
+
d.decision = True
|
|
707
|
+
d.timestamp = datetime.now().isoformat()
|
|
708
|
+
print(f" {GREEN}✓ Approved{RESET}")
|
|
709
|
+
idx += 1
|
|
710
|
+
if idx >= len(dets):
|
|
711
|
+
self._input(" All done in this type! Press Enter...")
|
|
712
|
+
return
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
if choice.lower() == "n":
|
|
716
|
+
d.decision = False
|
|
717
|
+
d.timestamp = datetime.now().isoformat()
|
|
718
|
+
reason = self._input(f" {DIM}Reason (optional): {RESET}")
|
|
719
|
+
if reason:
|
|
720
|
+
d.reason = reason
|
|
721
|
+
print(f" {RED}✗ Rejected{RESET}")
|
|
722
|
+
idx += 1
|
|
723
|
+
if idx >= len(dets):
|
|
724
|
+
self._input(" All done in this type! Press Enter...")
|
|
725
|
+
return
|
|
726
|
+
continue
|
|
727
|
+
|
|
728
|
+
if choice.lower() == "e":
|
|
729
|
+
new_conf = self._input(f" {DIM}New confidence (0-100): {RESET}")
|
|
730
|
+
try:
|
|
731
|
+
val = int(new_conf) / 100.0
|
|
732
|
+
if 0 <= val <= 1:
|
|
733
|
+
d.confidence_override = val
|
|
734
|
+
d.confidence = val
|
|
735
|
+
print(f" {YELLOW}Confidence set to {val:.0%}{RESET}")
|
|
736
|
+
except ValueError:
|
|
737
|
+
pass
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
if choice.lower() == "r":
|
|
741
|
+
reason = self._input(f" {DIM}Reason: {RESET}")
|
|
742
|
+
if reason:
|
|
743
|
+
d.reason = reason
|
|
744
|
+
print(f" {DIM}Reason saved{RESET}")
|
|
745
|
+
continue
|
|
746
|
+
|
|
747
|
+
if choice.lower() == "c":
|
|
748
|
+
self._clear()
|
|
749
|
+
print(f"\n {BOLD}{d.file}:{d.line}{RESET} — expanded context\n")
|
|
750
|
+
self._show_context(d, context_lines=20)
|
|
751
|
+
self._input("\n Press Enter to go back...")
|
|
752
|
+
continue
|
|
753
|
+
|
|
754
|
+
if choice in ("", "l", "right", "]"):
|
|
755
|
+
idx += 1
|
|
756
|
+
if idx >= len(dets):
|
|
757
|
+
self._input(" End of list! Press Enter...")
|
|
758
|
+
return
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
if choice in ("h", "left", "["):
|
|
762
|
+
idx -= 1
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
if choice.lower() in ("b", "q"):
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
def _show_context(self, det, context_lines=3):
|
|
769
|
+
"""Show source code context from API metadata."""
|
|
770
|
+
max_width = 60
|
|
771
|
+
|
|
772
|
+
if not det.context_lines:
|
|
773
|
+
print(f" {DIM}(no context available){RESET}")
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
lines_data = det.context_lines
|
|
777
|
+
print(f" ┌{'─' * max_width}┐")
|
|
778
|
+
for cl in lines_data:
|
|
779
|
+
lineno = cl["lineno"]
|
|
780
|
+
text = cl["text"]
|
|
781
|
+
if len(text) > max_width - 8:
|
|
782
|
+
text = text[:max_width - 11] + "..."
|
|
783
|
+
if cl.get("is_target"):
|
|
784
|
+
marker = f" {RED}← DETECTED{RESET}"
|
|
785
|
+
print(f" │{BG_RED}{WHITE}{lineno:>4} │ {text:<{max_width - 7}}{RESET}│{marker}")
|
|
786
|
+
else:
|
|
787
|
+
print(f" │{DIM}{lineno:>4}{RESET} │ {text:<{max_width - 7}}│")
|
|
788
|
+
print(f" └{'─' * max_width}┘")
|
|
789
|
+
|
|
790
|
+
def run(self):
|
|
791
|
+
"""Entry point — start the review."""
|
|
792
|
+
if not self.detections:
|
|
793
|
+
print(f"\n {GREEN}✓ No detections to review — repo looks clean!{RESET}\n")
|
|
794
|
+
return
|
|
795
|
+
self.show_type_summary()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
799
|
+
# STATUS DISPLAY
|
|
800
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
801
|
+
|
|
802
|
+
STATUS_COLORS = {
|
|
803
|
+
"draft": DIM, "submitted": YELLOW, "under_review": BLUE,
|
|
804
|
+
"approved": GREEN, "rejected": RED, "published": GREEN,
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
def print_scan_list(repos):
|
|
808
|
+
"""Print a table of all scans."""
|
|
809
|
+
print(f"\n{BOLD}{'═' * 80}{RESET}")
|
|
810
|
+
print(f"{BOLD} LocalMask Pro — Scanned Repositories{RESET}")
|
|
811
|
+
print(f"{BOLD}{'═' * 80}{RESET}\n")
|
|
812
|
+
|
|
813
|
+
if not repos:
|
|
814
|
+
print(f" {DIM}No scans found. Run: localmask scan <repo-url>{RESET}\n")
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
print(f" {DIM}{'Scan ID':<28} {'Status':<14} {'Dets':>5} {'By':<12} Repo{RESET}")
|
|
818
|
+
print(f" {'─' * 76}")
|
|
819
|
+
|
|
820
|
+
for r in repos:
|
|
821
|
+
sc = STATUS_COLORS.get(r["status"], DIM)
|
|
822
|
+
repo_short = r["repo_url"]
|
|
823
|
+
if len(repo_short) > 30:
|
|
824
|
+
repo_short = "..." + repo_short[-27:]
|
|
825
|
+
print(f" {r['scan_id']:<28} {sc}{r['status']:<14}{RESET} "
|
|
826
|
+
f"{r['detection_count']:>5} {r.get('submitted_by', ''):<12} {repo_short}")
|
|
827
|
+
|
|
828
|
+
print(f"\n {DIM}Use: localmask review <scan_id>{RESET}\n")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def print_scan_detail(scan):
|
|
832
|
+
"""Print detailed status of a single scan."""
|
|
833
|
+
sc = STATUS_COLORS.get(scan["status"], DIM)
|
|
834
|
+
|
|
835
|
+
print(f"\n{BOLD}{'═' * 70}{RESET}")
|
|
836
|
+
print(f"{BOLD} Scan: {scan['scan_id']}{RESET}")
|
|
837
|
+
print(f"{BOLD}{'═' * 70}{RESET}")
|
|
838
|
+
print(f" {DIM}Repo:{RESET} {scan['repo_url']}")
|
|
839
|
+
print(f" {DIM}Status:{RESET} {sc}{BOLD}{scan['status'].upper()}{RESET}")
|
|
840
|
+
print(f" {DIM}Submitted:{RESET} {scan.get('submitted_by', '-')}")
|
|
841
|
+
print(f" {DIM}Reviewed:{RESET} {scan.get('reviewed_by') or '-'}")
|
|
842
|
+
if scan.get("review_comment"):
|
|
843
|
+
print(f" {DIM}Comment:{RESET} {scan['review_comment']}")
|
|
844
|
+
print(f" {DIM}Created:{RESET} {scan['created_at'][:19]}")
|
|
845
|
+
|
|
846
|
+
stats = scan.get("summary_stats", {})
|
|
847
|
+
print(f"\n {DIM}Files:{RESET} {stats.get('total_files', 0)}")
|
|
848
|
+
print(f" {DIM}Detections:{RESET} {stats.get('total_detections', 0)}")
|
|
849
|
+
print(f" {DIM}Reviewed:{RESET} {scan.get('decisions_made', 0)}/{stats.get('total_detections', 0)}")
|
|
850
|
+
|
|
851
|
+
by_type = stats.get("by_type", {})
|
|
852
|
+
if by_type:
|
|
853
|
+
print(f"\n {BOLD}By Type:{RESET}")
|
|
854
|
+
for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
|
|
855
|
+
bar = "█" * min(count * 2, 30)
|
|
856
|
+
print(f" {t:<30} {count:>3} {YELLOW}{bar}{RESET}")
|
|
857
|
+
|
|
858
|
+
# Status workflow
|
|
859
|
+
steps = ["draft", "submitted", "under_review", "approved"]
|
|
860
|
+
current = scan["status"]
|
|
861
|
+
print(f"\n {BOLD}Workflow:{RESET}")
|
|
862
|
+
line = " "
|
|
863
|
+
for i, step in enumerate(steps):
|
|
864
|
+
if step == current:
|
|
865
|
+
line += f" {BG_BLUE}{WHITE} {step.upper()} {RESET}"
|
|
866
|
+
elif steps.index(current) > i if current in steps else False:
|
|
867
|
+
line += f" {GREEN}✓ {step}{RESET}"
|
|
868
|
+
else:
|
|
869
|
+
line += f" {DIM}○ {step}{RESET}"
|
|
870
|
+
if i < len(steps) - 1:
|
|
871
|
+
line += f" {DIM}→{RESET}"
|
|
872
|
+
if current == "rejected":
|
|
873
|
+
line = f" {RED}✗ REJECTED{RESET}"
|
|
874
|
+
elif current == "published":
|
|
875
|
+
line += f" {DIM}→{RESET} {GREEN}✓ PUBLISHED{RESET}"
|
|
876
|
+
print(line)
|
|
877
|
+
print(f"\n{'═' * 70}\n")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
881
|
+
# INIT — set up LocalMask in a repo
|
|
882
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
883
|
+
|
|
884
|
+
def _find_localmask_paths() -> tuple[str, str]:
|
|
885
|
+
"""Auto-detect mcp_server.py and python paths."""
|
|
886
|
+
# Check common locations
|
|
887
|
+
candidates = [
|
|
888
|
+
os.path.dirname(os.path.abspath(__file__)), # same dir as cli.py
|
|
889
|
+
os.path.expanduser("~/.localmask"),
|
|
890
|
+
"/usr/local/lib/localmask",
|
|
891
|
+
]
|
|
892
|
+
mcp_path = ""
|
|
893
|
+
for d in candidates:
|
|
894
|
+
p = os.path.join(d, "mcp_server.py")
|
|
895
|
+
if os.path.isfile(p):
|
|
896
|
+
mcp_path = p
|
|
897
|
+
break
|
|
898
|
+
|
|
899
|
+
python_path = ""
|
|
900
|
+
if mcp_path:
|
|
901
|
+
venv_python = os.path.join(os.path.dirname(mcp_path), "venv", "bin", "python3")
|
|
902
|
+
if os.path.isfile(venv_python):
|
|
903
|
+
python_path = venv_python
|
|
904
|
+
if not python_path:
|
|
905
|
+
python_path = sys.executable
|
|
906
|
+
|
|
907
|
+
return mcp_path, python_path
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _gitignore_entries() -> list[str]:
|
|
911
|
+
"""Lines to add to .gitignore for LocalMask config files."""
|
|
912
|
+
return [
|
|
913
|
+
"# LocalMask Pro — IDE integration (local only, not published)",
|
|
914
|
+
".mcp.json",
|
|
915
|
+
"CLAUDE.md",
|
|
916
|
+
".vscode/mcp.json",
|
|
917
|
+
".github/copilot-instructions.md",
|
|
918
|
+
"localmask.sh",
|
|
919
|
+
]
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _ensure_gitignore(repo_dir: str):
|
|
923
|
+
"""Add LocalMask entries to .gitignore if not already present."""
|
|
924
|
+
gitignore_path = os.path.join(repo_dir, ".gitignore")
|
|
925
|
+
existing = ""
|
|
926
|
+
if os.path.isfile(gitignore_path):
|
|
927
|
+
existing = open(gitignore_path).read()
|
|
928
|
+
|
|
929
|
+
entries = _gitignore_entries()
|
|
930
|
+
to_add = [e for e in entries if e not in existing and not e.startswith("#")]
|
|
931
|
+
# Also check the comment
|
|
932
|
+
if entries[0] not in existing:
|
|
933
|
+
to_add = entries # add all including the comment
|
|
934
|
+
|
|
935
|
+
if not to_add:
|
|
936
|
+
return False # already present
|
|
937
|
+
|
|
938
|
+
with open(gitignore_path, "a") as f:
|
|
939
|
+
if existing and not existing.endswith("\n"):
|
|
940
|
+
f.write("\n")
|
|
941
|
+
f.write("\n".join(to_add) + "\n")
|
|
942
|
+
return True
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
_CLAUDE_MD_TEMPLATE = """\
|
|
946
|
+
# MANDATORY: Use LocalMask MCP Tools
|
|
947
|
+
|
|
948
|
+
This project has LocalMask Pro configured as an MCP server.
|
|
949
|
+
|
|
950
|
+
## CRITICAL SECURITY RULES
|
|
951
|
+
|
|
952
|
+
1. **NEVER read .env, config/settings.py, or any file that may contain secrets directly.** You MUST use the LocalMask MCP tools instead.
|
|
953
|
+
2. **NEVER run grep, cat, or any command to search for secrets, passwords, API keys, or credentials.** The MCP tools handle this securely.
|
|
954
|
+
3. **NEVER display real secret values.** Only display masked tokens like `~[PASSWORD_0]~`.
|
|
955
|
+
4. **ALWAYS use the MCP tools listed below** for any task involving secrets, scanning, reviewing, publishing, or security.
|
|
956
|
+
|
|
957
|
+
The MCP tools ensure that real secret values NEVER leave this machine. If you read files directly or run grep, you will expose production secrets to the cloud.
|
|
958
|
+
|
|
959
|
+
## MCP Tools Available
|
|
960
|
+
|
|
961
|
+
### Scanning
|
|
962
|
+
- **`scan_repo`** — Scan for secrets. Use `source: "."` for current repo.
|
|
963
|
+
- **`sync_repo`** — Re-scan after git changes. Preserves existing reviews.
|
|
964
|
+
|
|
965
|
+
### Interactive Review (preferred — runs locally, no tokens consumed)
|
|
966
|
+
- **`get_review_queue`** — Overview of all detection types with pending/approved/rejected counts and confidence.
|
|
967
|
+
- **`open_review_ui`** — Tells the developer to run the interactive terminal reviewer. No secrets leave the machine.
|
|
968
|
+
- **`review_detection`** — Approve or reject one detection by `det_id` (for quick fixes after terminal review).
|
|
969
|
+
|
|
970
|
+
### Bulk Operations
|
|
971
|
+
- **`get_detections`** — Full detection summary with samples (grouped by type).
|
|
972
|
+
- **`bulk_review`** — Approve/reject multiple detections at once. Pass `decisions` as JSON string.
|
|
973
|
+
|
|
974
|
+
### Other
|
|
975
|
+
- **`get_file_masked`** — View a file with secrets replaced by tokens.
|
|
976
|
+
- **`teach_value`** — Teach a missed secret. Pass `scan_id`, `value`, `action` ("mask"), `subtype`.
|
|
977
|
+
- **`submit_for_review`** — Submit scan for security team review.
|
|
978
|
+
- **`approve_scan`** — Security manager approves the scan.
|
|
979
|
+
- **`publish_masked_repo`** — Push masked repo to git remote. Needs `scan_id`, `target_url`, `username`.
|
|
980
|
+
- **`ask_about_scan`** — Ask AI about the code. AI only sees masked content.
|
|
981
|
+
|
|
982
|
+
## Workflow — ALWAYS follow these steps in order
|
|
983
|
+
|
|
984
|
+
1. **Scan**: `scan_repo(source=".")` → get `scan_id`
|
|
985
|
+
2. **Review queue**: `get_review_queue(scan_id)` → show the developer a summary table of detection types
|
|
986
|
+
3. **Interactive review** (in terminal — no tokens, no cloud):
|
|
987
|
+
- Call `open_review_ui(scan_id)` — this gives the developer a terminal command to run
|
|
988
|
+
- Tell the developer to run the command in their terminal
|
|
989
|
+
- The terminal UI lets them approve/reject each detection with full code context
|
|
990
|
+
- When they're done, call `get_review_queue(scan_id)` to check results
|
|
991
|
+
- If the developer spotted a missed secret, use `teach_value` then `sync_repo` to re-scan
|
|
992
|
+
4. **Preview**: Use `get_file_masked` on key files to show the developer the masked output.
|
|
993
|
+
5. **Publish**: Only after developer confirms → `publish_masked_repo`
|
|
994
|
+
|
|
995
|
+
**IMPORTANT**: NEVER skip the interactive review. NEVER approve-all and publish in one shot. Show each detection and let the developer decide.
|
|
996
|
+
|
|
997
|
+
## Privacy Model
|
|
998
|
+
|
|
999
|
+
- Scanning runs 100% locally (regex + NER + local LLM)
|
|
1000
|
+
- Real values are stripped from all MCP responses
|
|
1001
|
+
- AI only sees masked tokens like `~[DB_CONNECTION_URL_3]~`
|
|
1002
|
+
- Token vault stays on the developer's machine
|
|
1003
|
+
- `ask_about_scan` responses are rehydrated locally
|
|
1004
|
+
"""
|
|
1005
|
+
|
|
1006
|
+
_COPILOT_MD_TEMPLATE = """\
|
|
1007
|
+
# LocalMask Pro — Copilot Instructions
|
|
1008
|
+
|
|
1009
|
+
## MANDATORY: Use MCP Tools
|
|
1010
|
+
|
|
1011
|
+
This project has LocalMask Pro installed as an MCP server. You MUST call the MCP tools directly — NEVER use shell commands, CLI wrappers, or try to scan files yourself.
|
|
1012
|
+
|
|
1013
|
+
When the user asks about secrets, scanning, reviewing, publishing, or masking — use the MCP tools listed below. Do NOT run `./localmask.sh`, `python cli.py`, or any shell command as a fallback.
|
|
1014
|
+
|
|
1015
|
+
## Tool Quick Reference
|
|
1016
|
+
|
|
1017
|
+
| Action | MCP Tool | Required Args |
|
|
1018
|
+
|--------|----------|---------------|
|
|
1019
|
+
| Scan repo | `scan_repo` | `source: "."` |
|
|
1020
|
+
| Review queue overview | `get_review_queue` | `scan_id` |
|
|
1021
|
+
| Launch terminal reviewer | `open_review_ui` | `scan_id` |
|
|
1022
|
+
| Approve/reject one | `review_detection` | `scan_id`, `det_id`, `decision` |
|
|
1023
|
+
| List all detections | `get_detections` | `scan_id` |
|
|
1024
|
+
| View masked file | `get_file_masked` | `scan_id`, `path` |
|
|
1025
|
+
| Bulk approve/reject | `bulk_review` | `scan_id`, `decisions` (JSON string) |
|
|
1026
|
+
| Submit to security team | `submit_for_review` | `scan_id` |
|
|
1027
|
+
| Publish masked repo | `publish_masked_repo` | `scan_id`, `target_url`, `username` |
|
|
1028
|
+
| Ask AI about code | `ask_about_scan` | `scan_id`, `question` |
|
|
1029
|
+
| Re-scan after changes | `sync_repo` | `scan_id` |
|
|
1030
|
+
|
|
1031
|
+
## Full Workflow
|
|
1032
|
+
|
|
1033
|
+
1. `scan_repo(source=".")` — scan current workspace (100% local)
|
|
1034
|
+
2. `get_review_queue(scan_id)` — show detection types overview
|
|
1035
|
+
3. `open_review_ui(scan_id)` → developer reviews in terminal → `get_review_queue()` to check results
|
|
1036
|
+
4. `submit_for_review(scan_id)` — submit for security team review
|
|
1037
|
+
5. `publish_masked_repo(scan_id, target_url, username)` — push masked repo
|
|
1038
|
+
6. `ask_about_scan(scan_id, question)` — AI sees only masked code
|
|
1039
|
+
|
|
1040
|
+
## Privacy Architecture
|
|
1041
|
+
|
|
1042
|
+
All scanning runs locally. Real values are stripped from all responses.
|
|
1043
|
+
AI only sees masked tokens. Token vault stays on developer's machine.
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def cmd_init(args):
|
|
1048
|
+
"""Initialize LocalMask in the current repo."""
|
|
1049
|
+
repo_dir = os.getcwd()
|
|
1050
|
+
|
|
1051
|
+
# Check if we're in a git repo
|
|
1052
|
+
if not os.path.isdir(os.path.join(repo_dir, ".git")):
|
|
1053
|
+
print(f" {RED}✗ Not a git repository. Run 'localmask init' from your repo root.{RESET}")
|
|
1054
|
+
sys.exit(1)
|
|
1055
|
+
|
|
1056
|
+
# Resolve paths
|
|
1057
|
+
mcp_path = args.mcp_path
|
|
1058
|
+
python_path = args.python_path
|
|
1059
|
+
if not mcp_path or not python_path:
|
|
1060
|
+
auto_mcp, auto_python = _find_localmask_paths()
|
|
1061
|
+
mcp_path = mcp_path or auto_mcp
|
|
1062
|
+
python_path = python_path or auto_python
|
|
1063
|
+
|
|
1064
|
+
if not mcp_path:
|
|
1065
|
+
print(f" {RED}✗ Could not find mcp_server.py. Use --mcp-path to specify.{RESET}")
|
|
1066
|
+
sys.exit(1)
|
|
1067
|
+
|
|
1068
|
+
server_url = args.server
|
|
1069
|
+
org = args.org
|
|
1070
|
+
do_claude = args.claude_code and not args.no_claude_code
|
|
1071
|
+
do_copilot = args.copilot and not args.no_copilot
|
|
1072
|
+
do_vscode = args.vscode and not args.no_vscode
|
|
1073
|
+
|
|
1074
|
+
print(f"\n {BOLD}LocalMask Pro — Init{RESET}")
|
|
1075
|
+
print(f" {DIM}Repository:{RESET} {repo_dir}")
|
|
1076
|
+
print(f" {DIM}MCP server:{RESET} {mcp_path}")
|
|
1077
|
+
print(f" {DIM}Python:{RESET} {python_path}")
|
|
1078
|
+
print(f" {DIM}Server URL:{RESET} {server_url}")
|
|
1079
|
+
print(f" {DIM}Org:{RESET} {org}")
|
|
1080
|
+
print()
|
|
1081
|
+
|
|
1082
|
+
created = []
|
|
1083
|
+
|
|
1084
|
+
# 1. .mcp.json (Claude Code)
|
|
1085
|
+
if do_claude:
|
|
1086
|
+
mcp_json = {
|
|
1087
|
+
"mcpServers": {
|
|
1088
|
+
"localmask": {
|
|
1089
|
+
"command": python_path,
|
|
1090
|
+
"args": [mcp_path],
|
|
1091
|
+
"env": {
|
|
1092
|
+
"LOCALMASK_SERVER": server_url,
|
|
1093
|
+
"LOCALMASK_ORG": org,
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
path = os.path.join(repo_dir, ".mcp.json")
|
|
1099
|
+
with open(path, "w") as f:
|
|
1100
|
+
json.dump(mcp_json, f, indent=2)
|
|
1101
|
+
f.write("\n")
|
|
1102
|
+
created.append(".mcp.json")
|
|
1103
|
+
|
|
1104
|
+
# 2. CLAUDE.md
|
|
1105
|
+
if do_claude:
|
|
1106
|
+
path = os.path.join(repo_dir, "CLAUDE.md")
|
|
1107
|
+
with open(path, "w") as f:
|
|
1108
|
+
f.write(_CLAUDE_MD_TEMPLATE)
|
|
1109
|
+
created.append("CLAUDE.md")
|
|
1110
|
+
|
|
1111
|
+
# 3. .vscode/mcp.json
|
|
1112
|
+
if do_vscode:
|
|
1113
|
+
vscode_dir = os.path.join(repo_dir, ".vscode")
|
|
1114
|
+
os.makedirs(vscode_dir, exist_ok=True)
|
|
1115
|
+
vscode_mcp = {
|
|
1116
|
+
"servers": {
|
|
1117
|
+
"localmask": {
|
|
1118
|
+
"type": "stdio",
|
|
1119
|
+
"command": python_path,
|
|
1120
|
+
"args": [mcp_path],
|
|
1121
|
+
"env": {
|
|
1122
|
+
"LOCALMASK_SERVER": server_url,
|
|
1123
|
+
"LOCALMASK_ORG": org,
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
path = os.path.join(vscode_dir, "mcp.json")
|
|
1129
|
+
with open(path, "w") as f:
|
|
1130
|
+
json.dump(vscode_mcp, f, indent=2)
|
|
1131
|
+
f.write("\n")
|
|
1132
|
+
created.append(".vscode/mcp.json")
|
|
1133
|
+
|
|
1134
|
+
# Also ensure settings.json has MCP access
|
|
1135
|
+
settings_path = os.path.join(vscode_dir, "settings.json")
|
|
1136
|
+
settings = {}
|
|
1137
|
+
if os.path.isfile(settings_path):
|
|
1138
|
+
try:
|
|
1139
|
+
settings = json.loads(open(settings_path).read())
|
|
1140
|
+
except Exception:
|
|
1141
|
+
pass
|
|
1142
|
+
if "chat.mcp.access" not in settings:
|
|
1143
|
+
settings["chat.mcp.access"] = "all"
|
|
1144
|
+
with open(settings_path, "w") as f:
|
|
1145
|
+
json.dump(settings, f, indent=2)
|
|
1146
|
+
f.write("\n")
|
|
1147
|
+
if ".vscode/settings.json" not in created:
|
|
1148
|
+
created.append(".vscode/settings.json")
|
|
1149
|
+
|
|
1150
|
+
# 4. .github/copilot-instructions.md
|
|
1151
|
+
if do_copilot:
|
|
1152
|
+
gh_dir = os.path.join(repo_dir, ".github")
|
|
1153
|
+
os.makedirs(gh_dir, exist_ok=True)
|
|
1154
|
+
path = os.path.join(gh_dir, "copilot-instructions.md")
|
|
1155
|
+
with open(path, "w") as f:
|
|
1156
|
+
f.write(_COPILOT_MD_TEMPLATE)
|
|
1157
|
+
created.append(".github/copilot-instructions.md")
|
|
1158
|
+
|
|
1159
|
+
# 5. Update .gitignore
|
|
1160
|
+
if _ensure_gitignore(repo_dir):
|
|
1161
|
+
created.append(".gitignore (updated)")
|
|
1162
|
+
|
|
1163
|
+
# Print results
|
|
1164
|
+
for f in created:
|
|
1165
|
+
print(f" {GREEN}✓{RESET} {f}")
|
|
1166
|
+
|
|
1167
|
+
print(f"\n {GREEN}✓ LocalMask initialized{RESET}")
|
|
1168
|
+
print(f" {DIM}Config files are in .gitignore — they stay local, never published.{RESET}")
|
|
1169
|
+
print(f"\n {BOLD}Next steps:{RESET}")
|
|
1170
|
+
print(f" 1. Reload VS Code ({DIM}Cmd+Shift+P → Developer: Reload Window{RESET})")
|
|
1171
|
+
print(f" 2. Open the chat panel and ask: {CYAN}Scan this repo for secrets{RESET}")
|
|
1172
|
+
print()
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1176
|
+
# MAIN — command routing
|
|
1177
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1178
|
+
|
|
1179
|
+
def main():
|
|
1180
|
+
parser = argparse.ArgumentParser(
|
|
1181
|
+
prog="localmask",
|
|
1182
|
+
description="LocalMask Pro CLI — service client for secret detection and masking",
|
|
1183
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1184
|
+
epilog="""
|
|
1185
|
+
Setup:
|
|
1186
|
+
localmask init Initialize LocalMask in current repo
|
|
1187
|
+
localmask connect <url> Connect to LocalMask service
|
|
1188
|
+
localmask store-token <token> Store git token securely, get credential_id
|
|
1189
|
+
localmask set-key <provider> <key> Set AI API key (anthropic, openai, gemini)
|
|
1190
|
+
localmask activate <license-key> Activate a Pro/Enterprise license
|
|
1191
|
+
localmask license Show current license tier and usage
|
|
1192
|
+
|
|
1193
|
+
Workflow:
|
|
1194
|
+
localmask scan <repo-url> Scan repo via service
|
|
1195
|
+
localmask status [scan_id] List scans or show scan detail
|
|
1196
|
+
localmask review <scan_id> Interactive review via service
|
|
1197
|
+
localmask submit <scan_id> Submit for security approval
|
|
1198
|
+
localmask approve-all <scan_id> Approve all detections + submit
|
|
1199
|
+
localmask publish <scan_id> <url> Publish masked repo to a git remote
|
|
1200
|
+
localmask ask <scan_id> [question] Ask AI about a scan (interactive or single question)
|
|
1201
|
+
|
|
1202
|
+
Git Integration:
|
|
1203
|
+
localmask sync <scan_id> Re-scan after git updates, preserve tokens
|
|
1204
|
+
localmask hook <scan_id> [-r path] Install git hook for auto-sync on commits
|
|
1205
|
+
|
|
1206
|
+
All scanning happens server-side. The CLI never has access to secret values.
|
|
1207
|
+
The AI only sees masked content — real secrets are replaced with tokens.
|
|
1208
|
+
""",
|
|
1209
|
+
)
|
|
1210
|
+
sub = parser.add_subparsers(dest="command")
|
|
1211
|
+
|
|
1212
|
+
# init
|
|
1213
|
+
init_p = sub.add_parser("init", help="Initialize LocalMask in current repo")
|
|
1214
|
+
init_p.add_argument("--server", default="http://localhost:8000",
|
|
1215
|
+
help="LocalMask server URL (default: http://localhost:8000)")
|
|
1216
|
+
init_p.add_argument("--org", default="my-organization",
|
|
1217
|
+
help="Organization ID (default: my-organization)")
|
|
1218
|
+
init_p.add_argument("--mcp-path", default="",
|
|
1219
|
+
help="Path to mcp_server.py (auto-detected if not set)")
|
|
1220
|
+
init_p.add_argument("--python-path", default="",
|
|
1221
|
+
help="Path to Python with MCP deps (auto-detected if not set)")
|
|
1222
|
+
init_p.add_argument("--claude-code", action="store_true", default=True,
|
|
1223
|
+
help="Generate .mcp.json + CLAUDE.md (default: on)")
|
|
1224
|
+
init_p.add_argument("--no-claude-code", action="store_true",
|
|
1225
|
+
help="Skip Claude Code config")
|
|
1226
|
+
init_p.add_argument("--copilot", action="store_true", default=True,
|
|
1227
|
+
help="Generate .github/copilot-instructions.md (default: on)")
|
|
1228
|
+
init_p.add_argument("--no-copilot", action="store_true",
|
|
1229
|
+
help="Skip Copilot config")
|
|
1230
|
+
init_p.add_argument("--vscode", action="store_true", default=True,
|
|
1231
|
+
help="Generate .vscode/mcp.json (default: on)")
|
|
1232
|
+
init_p.add_argument("--no-vscode", action="store_true",
|
|
1233
|
+
help="Skip VS Code MCP config")
|
|
1234
|
+
|
|
1235
|
+
# connect
|
|
1236
|
+
conn_p = sub.add_parser("connect", help="Connect to LocalMask service")
|
|
1237
|
+
conn_p.add_argument("url", help="Service URL (e.g. https://localmask-pro-xxx.run.app)")
|
|
1238
|
+
|
|
1239
|
+
# store-token
|
|
1240
|
+
store_p = sub.add_parser("store-token",
|
|
1241
|
+
help="Store a git token (free: encrypted locally; typed hidden)")
|
|
1242
|
+
store_p.add_argument("token", nargs="?", default="",
|
|
1243
|
+
help="Git PAT. Omit to type it hidden (recommended — "
|
|
1244
|
+
"an argument leaks into shell history).")
|
|
1245
|
+
store_p.add_argument("--label", default="", help="Optional label for this credential")
|
|
1246
|
+
|
|
1247
|
+
# scan
|
|
1248
|
+
scan_p = sub.add_parser("scan", help="Scan a repo for secrets")
|
|
1249
|
+
scan_p.add_argument("repo_url", help="Git repo URL (https://github.com/org/repo)")
|
|
1250
|
+
scan_p.add_argument("--sensitivity", "-s", default="standard",
|
|
1251
|
+
choices=["minimal", "standard", "strict"])
|
|
1252
|
+
scan_p.add_argument("--credential-id", "-c", default="",
|
|
1253
|
+
help="Credential ID from store-token (for private repos)")
|
|
1254
|
+
scan_p.add_argument("--org", default="default", help="Organization ID")
|
|
1255
|
+
|
|
1256
|
+
# status
|
|
1257
|
+
status_p = sub.add_parser("status", help="Show scan status")
|
|
1258
|
+
status_p.add_argument("scan_id", nargs="?", help="Scan ID (omit to list all)")
|
|
1259
|
+
status_p.add_argument("--org", default="", help="Filter by org")
|
|
1260
|
+
|
|
1261
|
+
# review
|
|
1262
|
+
review_p = sub.add_parser("review", help="Interactive hierarchical review")
|
|
1263
|
+
review_p.add_argument("scan_id", help="Scan ID from scan command")
|
|
1264
|
+
|
|
1265
|
+
# review-local (reads from file, no HTTP server needed)
|
|
1266
|
+
review_local_p = sub.add_parser("review-local",
|
|
1267
|
+
help="Interactive review from local file (MCP mode)")
|
|
1268
|
+
review_local_p.add_argument("scan_id", help="Scan ID from scan command")
|
|
1269
|
+
|
|
1270
|
+
# submit
|
|
1271
|
+
submit_p = sub.add_parser("submit", help="Submit scan for security approval")
|
|
1272
|
+
submit_p.add_argument("scan_id", help="Scan ID")
|
|
1273
|
+
|
|
1274
|
+
# approve-all
|
|
1275
|
+
approve_p = sub.add_parser("approve-all", help="Approve all detections + submit")
|
|
1276
|
+
approve_p.add_argument("scan_id", help="Scan ID")
|
|
1277
|
+
|
|
1278
|
+
# grant-ai — give an AI its own read-only access to the masked mirror
|
|
1279
|
+
grant_p = sub.add_parser(
|
|
1280
|
+
"grant-ai",
|
|
1281
|
+
help="Give an AI read-only access to the masked mirror (deploy key)")
|
|
1282
|
+
grant_p.add_argument("scan_id", help="Scan ID whose published mirror to grant on")
|
|
1283
|
+
grant_p.add_argument("--repo", default="",
|
|
1284
|
+
help="Masked repo URL (default: the scan's publish target)")
|
|
1285
|
+
grant_p.add_argument("--title", default="localmask-ai-readonly",
|
|
1286
|
+
help="Deploy-key title shown on GitHub")
|
|
1287
|
+
grant_p.add_argument("--collaborator", default="",
|
|
1288
|
+
help="Grant an existing account (the AI's own bot user) "
|
|
1289
|
+
"read-only — transfers NOTHING to the AI")
|
|
1290
|
+
grant_p.add_argument("--public", action="store_true",
|
|
1291
|
+
help="Make the masked mirror public (tokens only) — the "
|
|
1292
|
+
"AI reads it with no credential at all")
|
|
1293
|
+
|
|
1294
|
+
# set-key
|
|
1295
|
+
# config — read/set local settings (e.g. the publish approval policy)
|
|
1296
|
+
cfg_p = sub.add_parser("config",
|
|
1297
|
+
help="View or change settings (e.g. publish-policy)")
|
|
1298
|
+
cfg_p.add_argument("key", nargs="?", default="",
|
|
1299
|
+
help="Setting name, e.g. publish-policy")
|
|
1300
|
+
cfg_p.add_argument("value", nargs="?", default="",
|
|
1301
|
+
help="New value, e.g. review | auto")
|
|
1302
|
+
|
|
1303
|
+
key_p = sub.add_parser("set-key",
|
|
1304
|
+
help="Save an AI provider API key (free: encrypted locally; typed hidden)")
|
|
1305
|
+
key_p.add_argument("provider",
|
|
1306
|
+
choices=["anthropic", "openai", "gemini", "grok", "xai",
|
|
1307
|
+
"groq", "together", "meta", "openrouter"],
|
|
1308
|
+
help="AI provider name")
|
|
1309
|
+
key_p.add_argument("key", nargs="?", default="",
|
|
1310
|
+
help="API key. Omit to type it hidden (recommended — an "
|
|
1311
|
+
"argument leaks into shell history).")
|
|
1312
|
+
|
|
1313
|
+
# teach — add a secret the scanner missed (or ignore a false positive),
|
|
1314
|
+
# persisted so it applies on every future scan/sync of this repo.
|
|
1315
|
+
teach_p = sub.add_parser(
|
|
1316
|
+
"teach", help="Teach a missed secret (or ignore a false positive)")
|
|
1317
|
+
teach_p.add_argument("scan_id", help="Scan ID to apply the value to")
|
|
1318
|
+
teach_p.add_argument("value", help="The exact secret value the scanner missed")
|
|
1319
|
+
teach_p.add_argument("--subtype", "-s", default="SECRET",
|
|
1320
|
+
help="Token type/name for the masked value (e.g. API_KEY)")
|
|
1321
|
+
teach_p.add_argument("--allow", action="store_true",
|
|
1322
|
+
help="Instead of masking, mark this value as a false "
|
|
1323
|
+
"positive (never mask it)")
|
|
1324
|
+
|
|
1325
|
+
# publish
|
|
1326
|
+
pub_p = sub.add_parser("publish", help="Publish masked repo to a git remote")
|
|
1327
|
+
pub_p.add_argument("scan_id", help="Scan ID of an approved scan")
|
|
1328
|
+
pub_p.add_argument("target_url", help="Target git repo URL to push masked code to")
|
|
1329
|
+
pub_p.add_argument("--token", "-t", default="",
|
|
1330
|
+
help="Git PAT for pushing (or use --credential-id)")
|
|
1331
|
+
pub_p.add_argument("--credential-id", "-c", default="",
|
|
1332
|
+
help="Credential ID from store-token")
|
|
1333
|
+
pub_p.add_argument("--username", "-u", default="", help="Git username")
|
|
1334
|
+
pub_p.add_argument("--force", "-f", action="store_true",
|
|
1335
|
+
help="Publish even if the review isn't approved (one-off)")
|
|
1336
|
+
pub_p.add_argument("--yes", "-y", action="store_true",
|
|
1337
|
+
help="Create the masked repo without asking, if missing")
|
|
1338
|
+
pub_p.add_argument("--public", action="store_true",
|
|
1339
|
+
help="If creating the masked repo, make it public (default: private)")
|
|
1340
|
+
|
|
1341
|
+
# activate
|
|
1342
|
+
act_p = sub.add_parser("activate", help="Activate a LocalMask Pro license key")
|
|
1343
|
+
act_p.add_argument("license_key", help="License key (format: LM-TIER-key-checksum)")
|
|
1344
|
+
|
|
1345
|
+
# license
|
|
1346
|
+
sub.add_parser("license", help="Show current license tier and usage")
|
|
1347
|
+
|
|
1348
|
+
# sync
|
|
1349
|
+
sync_p = sub.add_parser("sync", help="Re-scan repo after git updates, preserve tokens & decisions")
|
|
1350
|
+
sync_p.add_argument("scan_id", help="Scan ID of a previously scanned repo")
|
|
1351
|
+
sync_p.add_argument("--credential-id", "-c", default="", help="Credential ID for private repos")
|
|
1352
|
+
|
|
1353
|
+
# hook
|
|
1354
|
+
hook_p = sub.add_parser("hook", help="Install git hook for auto-sync on commits")
|
|
1355
|
+
hook_p.add_argument("scan_id", help="Scan ID to sync on each commit")
|
|
1356
|
+
hook_p.add_argument("--repo", "-r", default=".", help="Path to git repo (default: current dir)")
|
|
1357
|
+
hook_p.add_argument("--type", "-t", default="post-commit",
|
|
1358
|
+
choices=["post-commit", "pre-push"],
|
|
1359
|
+
help="Hook type (default: post-commit)")
|
|
1360
|
+
|
|
1361
|
+
# ask
|
|
1362
|
+
ask_p = sub.add_parser("ask", help="Ask any AI about a scan (masked content only, your key)")
|
|
1363
|
+
ask_p.add_argument("scan_id", help="Scan ID to ask about")
|
|
1364
|
+
ask_p.add_argument("question", nargs="?", default="",
|
|
1365
|
+
help="Question (omit for interactive mode)")
|
|
1366
|
+
ask_p.add_argument("--provider", "-p", default="anthropic",
|
|
1367
|
+
help="anthropic | openai | gemini | grok | groq | together | meta | openrouter | dry")
|
|
1368
|
+
ask_p.add_argument("--api-key", "-k", default="",
|
|
1369
|
+
help="Your provider API key (or env: LOCALMASK_AI_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY / XAI_API_KEY)")
|
|
1370
|
+
ask_p.add_argument("--base-url", default="",
|
|
1371
|
+
help="Custom OpenAI-compatible endpoint (self-host, OpenRouter, etc.)")
|
|
1372
|
+
ask_p.add_argument("--model", "-m", default="",
|
|
1373
|
+
help="Model name (defaults per provider)")
|
|
1374
|
+
ask_p.add_argument("--source", "-s", default="memory",
|
|
1375
|
+
choices=["memory", "git"],
|
|
1376
|
+
help="Read from platform memory (default) or published masked git repo")
|
|
1377
|
+
ask_p.add_argument("--git-url", default="",
|
|
1378
|
+
help="Git URL of published masked repo (for --source git)")
|
|
1379
|
+
|
|
1380
|
+
# Local, no-key: turn an AI's tokenized answer back into real values.
|
|
1381
|
+
rehy_p = sub.add_parser("rehydrate",
|
|
1382
|
+
help="Rehydrate ~[TOKEN]~ back to real values (local, no AI key)")
|
|
1383
|
+
rehy_p.add_argument("scan_id", help="Scan ID whose vault to use")
|
|
1384
|
+
rehy_p.add_argument("file", nargs="?", default="",
|
|
1385
|
+
help="File to rehydrate (default: read stdin)")
|
|
1386
|
+
|
|
1387
|
+
maskt_p = sub.add_parser("mask-text",
|
|
1388
|
+
help="Mask secrets in arbitrary text using a scan's vault (local)")
|
|
1389
|
+
maskt_p.add_argument("scan_id", help="Scan ID whose vault to use")
|
|
1390
|
+
maskt_p.add_argument("file", nargs="?", default="",
|
|
1391
|
+
help="File to mask (default: read stdin)")
|
|
1392
|
+
|
|
1393
|
+
exp_p = sub.add_parser("export",
|
|
1394
|
+
help="Write the masked repo to a local folder the AI can read (no keys/permissions)")
|
|
1395
|
+
exp_p.add_argument("scan_id", help="Scan ID to export")
|
|
1396
|
+
exp_p.add_argument("output_dir", help="Folder to write masked files into")
|
|
1397
|
+
|
|
1398
|
+
proxy_p = sub.add_parser("proxy",
|
|
1399
|
+
help="Run the local AI proxy (prompt firewall, Pro)")
|
|
1400
|
+
proxy_p.add_argument("--port", type=int, default=None,
|
|
1401
|
+
help="Listen port (default 8100 or proxy.yaml)")
|
|
1402
|
+
proxy_p.add_argument("--use-model", action="store_true",
|
|
1403
|
+
help="Use the local AI model for masking (higher recall, slower)")
|
|
1404
|
+
|
|
1405
|
+
args = parser.parse_args()
|
|
1406
|
+
|
|
1407
|
+
if not args.command:
|
|
1408
|
+
parser.print_help()
|
|
1409
|
+
sys.exit(1)
|
|
1410
|
+
|
|
1411
|
+
if args.command == "proxy":
|
|
1412
|
+
if args.port:
|
|
1413
|
+
os.environ["LOCALMASK_PROXY_PORT"] = str(args.port)
|
|
1414
|
+
if getattr(args, "use_model", False):
|
|
1415
|
+
os.environ["LOCALMASK_PROXY_USE_MODEL"] = "true"
|
|
1416
|
+
try:
|
|
1417
|
+
from localmask.proxy import main as proxy_main
|
|
1418
|
+
except ImportError:
|
|
1419
|
+
print("The AI proxy is a Pro feature and isn't included in this "
|
|
1420
|
+
"edition.\nUpgrade at https://localmaskpro.com")
|
|
1421
|
+
sys.exit(1)
|
|
1422
|
+
proxy_main()
|
|
1423
|
+
return
|
|
1424
|
+
|
|
1425
|
+
# ── rehydrate (local, no key) ────────────────────────────────────────────
|
|
1426
|
+
if args.command == "rehydrate":
|
|
1427
|
+
from localmask.state import _new_session, _get_or_load_scan
|
|
1428
|
+
from localmask.masking import _rehydrate
|
|
1429
|
+
scan = _get_or_load_scan(args.scan_id)
|
|
1430
|
+
if not scan:
|
|
1431
|
+
print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
|
|
1432
|
+
session = _new_session(scan["repo_url"], temp=False) # hydrates vault
|
|
1433
|
+
text = open(args.file).read() if args.file else sys.stdin.read()
|
|
1434
|
+
sys.stdout.write(_rehydrate(session, text))
|
|
1435
|
+
return
|
|
1436
|
+
|
|
1437
|
+
# ── mask-text (local) ────────────────────────────────────────────────────
|
|
1438
|
+
if args.command == "mask-text":
|
|
1439
|
+
from localmask.state import _new_session, _get_or_load_scan
|
|
1440
|
+
from localmask.engine import _scan_file
|
|
1441
|
+
scan = _get_or_load_scan(args.scan_id)
|
|
1442
|
+
if not scan:
|
|
1443
|
+
print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
|
|
1444
|
+
session = _new_session(scan["repo_url"], temp=False)
|
|
1445
|
+
text = open(args.file).read() if args.file else sys.stdin.read()
|
|
1446
|
+
sys.stdout.write(_scan_file(session, text, "input.txt")["masked"])
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
# ── export masked repo to a local folder (AI reads it, no keys) ──────────
|
|
1450
|
+
if args.command == "export":
|
|
1451
|
+
from localmask.state import _new_session, _get_or_load_scan
|
|
1452
|
+
from localmask.engine import _scan_dir
|
|
1453
|
+
from localmask.gitops import _should_publish
|
|
1454
|
+
scan = _get_or_load_scan(args.scan_id)
|
|
1455
|
+
if not scan:
|
|
1456
|
+
print(f"{RED}Scan not found: {args.scan_id}{RESET}"); sys.exit(1)
|
|
1457
|
+
session = _new_session(scan["repo_url"], temp=False)
|
|
1458
|
+
src = scan["repo_url"]
|
|
1459
|
+
src = src if os.path.isabs(src) else os.path.join(os.getcwd(), src)
|
|
1460
|
+
_scan_dir(session, src)
|
|
1461
|
+
out = os.path.expanduser(args.output_dir)
|
|
1462
|
+
written = 0
|
|
1463
|
+
for rel, d in session["files"].items():
|
|
1464
|
+
if not _should_publish(rel):
|
|
1465
|
+
continue
|
|
1466
|
+
path = os.path.join(out, rel)
|
|
1467
|
+
os.makedirs(os.path.dirname(path) or out, exist_ok=True)
|
|
1468
|
+
with open(path, "w") as f:
|
|
1469
|
+
f.write(d["masked"])
|
|
1470
|
+
written += 1
|
|
1471
|
+
print(f" {GREEN}✓ Exported {written} masked files to {out}{RESET}")
|
|
1472
|
+
print(f" {DIM}Point your AI tool / agent at this folder — it reads the "
|
|
1473
|
+
f"masked code with no keys, no repo permissions, no secrets.{RESET}")
|
|
1474
|
+
return
|
|
1475
|
+
|
|
1476
|
+
# ── ask (local, bring-your-own-key, any provider) ────────────────────────
|
|
1477
|
+
if args.command == "ask" and not _is_connected():
|
|
1478
|
+
_local_ask(args)
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
# ── hosted-only commands: give a clear local-mode message (not a raw
|
|
1482
|
+
# "not connected" error) plus the free-edition alternative. ────────────
|
|
1483
|
+
if args.command == "store-token" and not _is_connected():
|
|
1484
|
+
import getpass
|
|
1485
|
+
token = (args.token or "").strip()
|
|
1486
|
+
if not token or token == "-":
|
|
1487
|
+
try:
|
|
1488
|
+
token = getpass.getpass(" Paste git token (hidden): ").strip()
|
|
1489
|
+
except (EOFError, KeyboardInterrupt):
|
|
1490
|
+
token = ""
|
|
1491
|
+
if not token:
|
|
1492
|
+
print(f" {RED}✗ No token provided.{RESET}")
|
|
1493
|
+
return
|
|
1494
|
+
from localmask.vault_store import store_local_credential
|
|
1495
|
+
cid = store_local_credential(token, args.label or "")
|
|
1496
|
+
print(f" {GREEN}✓ Stored (encrypted, local — 0600 SQLite){RESET}")
|
|
1497
|
+
print(f" {DIM}Credential ID:{RESET} {CYAN}{cid}{RESET}")
|
|
1498
|
+
print(f" {DIM}Use it:{RESET} localmask publish <scan> <url> -c {cid}")
|
|
1499
|
+
print(f" {DIM}or:{RESET} localmask scan <private-url> -c {cid}")
|
|
1500
|
+
if token.startswith("ghp_") and args.token:
|
|
1501
|
+
print(f" {YELLOW}⚠ You passed the token on the command line — it's "
|
|
1502
|
+
f"now in your shell history.{RESET} Run store-token with no "
|
|
1503
|
+
f"argument next time so it's typed hidden; and rotate this one.")
|
|
1504
|
+
return
|
|
1505
|
+
|
|
1506
|
+
# set-key (free): store the AI provider key encrypted locally so `ask`
|
|
1507
|
+
# reuses it. Typed hidden so it never lands in shell history.
|
|
1508
|
+
if args.command == "set-key" and not _is_connected():
|
|
1509
|
+
import getpass
|
|
1510
|
+
provider = args.provider.lower()
|
|
1511
|
+
key = (args.key or "").strip()
|
|
1512
|
+
passed_as_arg = bool(key)
|
|
1513
|
+
if not key or key == "-":
|
|
1514
|
+
try:
|
|
1515
|
+
key = getpass.getpass(f" Paste {provider} API key (hidden): ").strip()
|
|
1516
|
+
except (EOFError, KeyboardInterrupt):
|
|
1517
|
+
key = ""
|
|
1518
|
+
if not key:
|
|
1519
|
+
print(f" {RED}✗ No key provided.{RESET}")
|
|
1520
|
+
return
|
|
1521
|
+
from localmask.vault_store import set_local_ai_key
|
|
1522
|
+
set_local_ai_key(provider, key)
|
|
1523
|
+
print(f" {GREEN}✓ Saved {provider} key (encrypted, local — 0600 SQLite){RESET}")
|
|
1524
|
+
print(f" {DIM}Use it:{RESET} localmask ask <scan> \"...\" --provider {provider}")
|
|
1525
|
+
if passed_as_arg:
|
|
1526
|
+
print(f" {YELLOW}⚠ You passed the key on the command line — it's now "
|
|
1527
|
+
f"in your shell history.{RESET} Run `set-key {provider}` with no "
|
|
1528
|
+
f"key next time (typed hidden), and rotate this one.")
|
|
1529
|
+
return
|
|
1530
|
+
|
|
1531
|
+
# config (local): view or set settings, e.g. the publish approval policy.
|
|
1532
|
+
if args.command == "config":
|
|
1533
|
+
key = (args.key or "").replace("-", "_")
|
|
1534
|
+
if not key:
|
|
1535
|
+
cfg = _load_config()
|
|
1536
|
+
print(f" {BOLD}Settings{RESET} {DIM}({CONFIG_FILE}){RESET}")
|
|
1537
|
+
print(f" publish-policy = {CYAN}{_publish_policy()}{RESET} "
|
|
1538
|
+
f"{DIM}(review = approve before publishing · auto = "
|
|
1539
|
+
f"auto-approve + auto-publish){RESET}")
|
|
1540
|
+
return
|
|
1541
|
+
if key == "publish_policy":
|
|
1542
|
+
val = (args.value or "").lower()
|
|
1543
|
+
if val not in ("review", "auto"):
|
|
1544
|
+
print(f" {RED}✗ publish-policy must be 'review' or 'auto'.{RESET}")
|
|
1545
|
+
return
|
|
1546
|
+
cfg = _load_config(); cfg["publish_policy"] = val; _save_config(cfg)
|
|
1547
|
+
print(f" {GREEN}✓ publish-policy = {val}{RESET}")
|
|
1548
|
+
if val == "auto":
|
|
1549
|
+
print(f" {DIM}Detections auto-approve; sync/hook auto-republish "
|
|
1550
|
+
f"the masked mirror on every change.{RESET}")
|
|
1551
|
+
else:
|
|
1552
|
+
print(f" {DIM}Publishing now requires an approved review "
|
|
1553
|
+
f"(localmask review / approve-all).{RESET}")
|
|
1554
|
+
return
|
|
1555
|
+
print(f" {RED}✗ Unknown setting '{args.key}'.{RESET} Known: publish-policy")
|
|
1556
|
+
return
|
|
1557
|
+
|
|
1558
|
+
# grant-ai (local): give an AI its OWN read-only access to the masked mirror
|
|
1559
|
+
# via a per-repo SSH deploy key. LocalMask never shares your git token.
|
|
1560
|
+
if args.command == "grant-ai":
|
|
1561
|
+
from server_core import _get_or_load_scan
|
|
1562
|
+
from localmask.gitops import add_readonly_deploy_key, parse_git_target
|
|
1563
|
+
url = args.repo
|
|
1564
|
+
if not url:
|
|
1565
|
+
sc = _get_or_load_scan(args.scan_id)
|
|
1566
|
+
url = sc.get("publish_target", "") if sc else ""
|
|
1567
|
+
if not url:
|
|
1568
|
+
print(f" {RED}✗ No masked repo URL.{RESET} Publish the mirror first "
|
|
1569
|
+
f"(`localmask publish {args.scan_id} <url>`) or pass --repo.")
|
|
1570
|
+
return
|
|
1571
|
+
# ── Mode: public — no credential transferred at all (masked = safe) ──
|
|
1572
|
+
if args.public:
|
|
1573
|
+
from localmask.gitops import set_repo_public
|
|
1574
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Making the masked mirror public "
|
|
1575
|
+
f"(tokens only, no secrets)…", flush=True)
|
|
1576
|
+
r = set_repo_public(url)
|
|
1577
|
+
if not r.get("ok"):
|
|
1578
|
+
print(f" {RED}✗ {r['message']}{RESET}"); return
|
|
1579
|
+
print(f" {GREEN}✓ Public.{RESET} Any AI reads it with "
|
|
1580
|
+
f"{BOLD}no credential{RESET} — nothing is transferred.")
|
|
1581
|
+
print(f" {DIM}The AI just clones:{RESET} git clone {r['https_url']}")
|
|
1582
|
+
return
|
|
1583
|
+
# ── Mode: collaborator — the AI uses its OWN account; nothing sent ────
|
|
1584
|
+
if args.collaborator:
|
|
1585
|
+
from localmask.gitops import add_readonly_collaborator
|
|
1586
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Granting {CYAN}{args.collaborator}"
|
|
1587
|
+
f"{RESET} read-only on the masked mirror…", flush=True)
|
|
1588
|
+
r = add_readonly_collaborator(url, args.collaborator)
|
|
1589
|
+
if not r.get("ok"):
|
|
1590
|
+
print(f" {RED}✗ {r['message']}{RESET}"); return
|
|
1591
|
+
print(f" {GREEN}✓ Read-only access granted to "
|
|
1592
|
+
f"{args.collaborator}{RESET} {DIM}(their account, this repo "
|
|
1593
|
+
f"only){RESET}")
|
|
1594
|
+
print(f" {DIM}Nothing was transferred to the AI{RESET} — it signs in "
|
|
1595
|
+
f"with its own credentials and clones:")
|
|
1596
|
+
print(f" git clone {r['ssh_url']}")
|
|
1597
|
+
print(f" {DIM}(the invite may need one accept by that account.){RESET}")
|
|
1598
|
+
return
|
|
1599
|
+
# ── Default: dedicated per-repo read-only deploy key ─────────────────
|
|
1600
|
+
tgt = parse_git_target(url)
|
|
1601
|
+
safe = f"{tgt[1]}-{tgt[2]}" if tgt else "ai"
|
|
1602
|
+
key_path = os.path.join(CONFIG_DIR, "keys", f"{safe}-ai")
|
|
1603
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Granting an AI read-only access to "
|
|
1604
|
+
f"{CYAN}{url}{RESET}…", flush=True)
|
|
1605
|
+
res = add_readonly_deploy_key(url, key_path, args.title)
|
|
1606
|
+
if not res.get("ok"):
|
|
1607
|
+
print(f" {RED}✗ {res['message']}{RESET}")
|
|
1608
|
+
return
|
|
1609
|
+
note = "already granted" if res.get("already") else "granted"
|
|
1610
|
+
print(f" {GREEN}✓ Read-only access {note}{RESET} "
|
|
1611
|
+
f"{DIM}(a per-repo deploy key — not your git token){RESET}")
|
|
1612
|
+
print(f" {DIM}Scope:{RESET} this key works {BOLD}only{RESET} for "
|
|
1613
|
+
f"{CYAN}{res['owner']}/{res['repo']}{RESET} — it can't touch your "
|
|
1614
|
+
f"other repos or your account, and it's read-only.\n")
|
|
1615
|
+
print(f" {BOLD}Hand your AI/agent these two things:{RESET}")
|
|
1616
|
+
print(f" {DIM}1) its private key:{RESET} {CYAN}{res['key_path']}{RESET}")
|
|
1617
|
+
print(f" {DIM}2) the clone command:{RESET}")
|
|
1618
|
+
print(f" {CYAN}{res['clone_cmd']}{RESET}\n")
|
|
1619
|
+
print(f" {DIM}The AI clones the masked mirror (tokens only, no secrets), "
|
|
1620
|
+
f"reads it, and authenticates as itself.{RESET}")
|
|
1621
|
+
print(f" {DIM}Keep it fresh:{RESET} localmask sync {args.scan_id} "
|
|
1622
|
+
f"{DIM}(re-masks + re-pushes; the AI does git pull){RESET}")
|
|
1623
|
+
print(f" {DIM}Revoke anytime:{RESET} gh repo deploy-key list --repo "
|
|
1624
|
+
f"{res['owner']}/{res['repo']} → gh repo deploy-key delete <id>\n")
|
|
1625
|
+
return
|
|
1626
|
+
|
|
1627
|
+
# approve-all (local): approve every detection and mark the scan approved,
|
|
1628
|
+
# so it can be published under the 'review' policy.
|
|
1629
|
+
if args.command == "approve-all" and not _is_connected():
|
|
1630
|
+
from server_core import _get_or_load_scan
|
|
1631
|
+
if not _get_or_load_scan(args.scan_id):
|
|
1632
|
+
print(f" {RED}✗ Scan not found: {args.scan_id}{RESET}")
|
|
1633
|
+
return
|
|
1634
|
+
scan = _approve_all_local(args.scan_id)
|
|
1635
|
+
n = len(scan.get("detections", []))
|
|
1636
|
+
print(f" {GREEN}✓ Approved all {n} detections{RESET} — scan is "
|
|
1637
|
+
f"{BOLD}approved{RESET} and ready to publish.")
|
|
1638
|
+
print(f" {DIM}Publish:{RESET} localmask publish {args.scan_id} <git-url>")
|
|
1639
|
+
return
|
|
1640
|
+
|
|
1641
|
+
if args.command == "submit" and not _is_connected():
|
|
1642
|
+
_hint = {
|
|
1643
|
+
"submit": "The submit/approval workflow is a hosted "
|
|
1644
|
+
"(Pro/Team) feature. In free, approve locally with "
|
|
1645
|
+
"`localmask review <scan>` or `localmask approve-all "
|
|
1646
|
+
"<scan>`.",
|
|
1647
|
+
}[args.command]
|
|
1648
|
+
print(f" {YELLOW}'{args.command}' needs the hosted service "
|
|
1649
|
+
f"(Pro/Team).{RESET}\n {_hint}")
|
|
1650
|
+
return
|
|
1651
|
+
|
|
1652
|
+
# ── connect ─────────────────────────────────────────────────────────────
|
|
1653
|
+
if args.command == "init":
|
|
1654
|
+
cmd_init(args)
|
|
1655
|
+
|
|
1656
|
+
elif args.command == "connect":
|
|
1657
|
+
url = args.url.rstrip("/")
|
|
1658
|
+
print(f" {DIM}Connecting to {url}...{RESET}", flush=True)
|
|
1659
|
+
client = ServiceClient(url)
|
|
1660
|
+
resp = client.health()
|
|
1661
|
+
if resp.get("status") == "ok":
|
|
1662
|
+
_save_config({"service_url": url})
|
|
1663
|
+
print(f" {GREEN}✓ Connected to LocalMask service{RESET}")
|
|
1664
|
+
print(f" {DIM}Saved to {CONFIG_FILE}{RESET}\n")
|
|
1665
|
+
else:
|
|
1666
|
+
print(f" {RED}✗ Service responded but health check failed{RESET}\n")
|
|
1667
|
+
|
|
1668
|
+
# ── store-token ─────────────────────────────────────────────────────────
|
|
1669
|
+
elif args.command == "store-token":
|
|
1670
|
+
client = _get_client()
|
|
1671
|
+
print(f" {DIM}Storing credential on service...{RESET}", flush=True)
|
|
1672
|
+
result = client.store_credential(args.token, args.label)
|
|
1673
|
+
cred_id = result["credential_id"]
|
|
1674
|
+
expires = result.get("expires_in", "1 hour")
|
|
1675
|
+
|
|
1676
|
+
# Save credential_id locally for convenience
|
|
1677
|
+
cfg = _load_config()
|
|
1678
|
+
cfg["credential_id"] = cred_id
|
|
1679
|
+
_save_config(cfg)
|
|
1680
|
+
|
|
1681
|
+
print(f" {GREEN}✓ Token stored securely{RESET}")
|
|
1682
|
+
print(f" {DIM}Credential ID:{RESET} {CYAN}{cred_id}{RESET}")
|
|
1683
|
+
print(f" {DIM}Expires in:{RESET} {expires}")
|
|
1684
|
+
print(f" {DIM}Saved to:{RESET} {CONFIG_FILE}")
|
|
1685
|
+
print(f"\n {DIM}Use with scan:{RESET}")
|
|
1686
|
+
print(f" localmask scan <repo-url> -c {cred_id}")
|
|
1687
|
+
print(f" localmask scan <repo-url> {DIM}(auto-uses saved credential){RESET}\n")
|
|
1688
|
+
|
|
1689
|
+
# ── scan ────────────────────────────────────────────────────────────────
|
|
1690
|
+
elif args.command == "scan":
|
|
1691
|
+
# Local, in-process scan (no server needed) — the default for the free
|
|
1692
|
+
# edition. Used whenever not connected to a service, or the target is a
|
|
1693
|
+
# local directory.
|
|
1694
|
+
if not _is_connected() or os.path.isdir(os.path.expanduser(args.repo_url)):
|
|
1695
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Scanning on this machine "
|
|
1696
|
+
f"(nothing leaves your computer)...", flush=True)
|
|
1697
|
+
eng = _local_engine()
|
|
1698
|
+
# Private remote? Resolve a locally-stored credential into a token.
|
|
1699
|
+
token = ""
|
|
1700
|
+
if args.credential_id:
|
|
1701
|
+
from localmask.vault_store import get_local_credential
|
|
1702
|
+
token = get_local_credential(args.credential_id) or ""
|
|
1703
|
+
if not token:
|
|
1704
|
+
print(f" {RED}✗ Unknown credential id "
|
|
1705
|
+
f"'{args.credential_id}'.{RESET} Run "
|
|
1706
|
+
f"`localmask store-token` first.")
|
|
1707
|
+
return
|
|
1708
|
+
try:
|
|
1709
|
+
result = eng.scan_repo(
|
|
1710
|
+
source=os.path.expanduser(args.repo_url),
|
|
1711
|
+
sensitivity=args.sensitivity, org=args.org, token=token)
|
|
1712
|
+
except Exception as e:
|
|
1713
|
+
print(f" {RED}✗ Scan failed:{RESET} {str(e)[:300]}")
|
|
1714
|
+
if "clone failed" in str(e).lower() or "authentication" in str(e).lower():
|
|
1715
|
+
print(f" {DIM}For a private repo, add a token: "
|
|
1716
|
+
f"localmask store-token → scan <url> -c <cred_id>.{RESET}")
|
|
1717
|
+
return
|
|
1718
|
+
scan_id = result["scan_id"]
|
|
1719
|
+
stats = result.get("summary_stats", {})
|
|
1720
|
+
print(f" {GREEN}✓ Scan complete{RESET}\n")
|
|
1721
|
+
print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
|
|
1722
|
+
print(f" {DIM}Files:{RESET} {stats.get('total_files', 0)}")
|
|
1723
|
+
print(f" {DIM}Detections:{RESET} {RED}{stats.get('total_detections', 0)}{RESET}")
|
|
1724
|
+
by_type = stats.get("by_type", {})
|
|
1725
|
+
if by_type:
|
|
1726
|
+
print(f"\n {BOLD}By type:{RESET}")
|
|
1727
|
+
for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
|
|
1728
|
+
print(f" {t:28s} {RED}{count}{RESET}")
|
|
1729
|
+
print(f"\n {DIM}Next:{RESET} localmask status · "
|
|
1730
|
+
f"localmask publish {scan_id} --target <git-url>")
|
|
1731
|
+
return
|
|
1732
|
+
|
|
1733
|
+
client = _get_client()
|
|
1734
|
+
|
|
1735
|
+
# Resolve credential_id: explicit flag > saved config
|
|
1736
|
+
cred_id = args.credential_id
|
|
1737
|
+
if not cred_id:
|
|
1738
|
+
cred_id = _load_config().get("credential_id", "")
|
|
1739
|
+
|
|
1740
|
+
print(f"\n {MAGENTA}[SERVICE]{RESET} Scanning via cloud service...", flush=True)
|
|
1741
|
+
if cred_id:
|
|
1742
|
+
print(f" {DIM}Using credential:{RESET} {cred_id}")
|
|
1743
|
+
|
|
1744
|
+
result = client.scan(
|
|
1745
|
+
repo_url=args.repo_url, credential_id=cred_id,
|
|
1746
|
+
org=args.org, sensitivity=args.sensitivity,
|
|
1747
|
+
)
|
|
1748
|
+
scan_id = result["scan_id"]
|
|
1749
|
+
stats = result.get("summary_stats", {})
|
|
1750
|
+
|
|
1751
|
+
print(f" {GREEN}✓ Scan complete{RESET}\n")
|
|
1752
|
+
print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
|
|
1753
|
+
print(f" {DIM}Files:{RESET} {stats.get('total_files', 0)}")
|
|
1754
|
+
print(f" {DIM}Detections:{RESET} {RED}{stats.get('total_detections', 0)}{RESET}")
|
|
1755
|
+
print(f" {DIM}Status:{RESET} draft")
|
|
1756
|
+
|
|
1757
|
+
by_type = stats.get("by_type", {})
|
|
1758
|
+
if by_type:
|
|
1759
|
+
print(f"\n {BOLD}By Type:{RESET}")
|
|
1760
|
+
for t, count in sorted(by_type.items(), key=lambda x: -x[1]):
|
|
1761
|
+
bar = "█" * min(count * 2, 30)
|
|
1762
|
+
print(f" {t:<30} {count:>3} {YELLOW}{bar}{RESET}")
|
|
1763
|
+
|
|
1764
|
+
print(f"\n {DIM}Next steps:{RESET}")
|
|
1765
|
+
print(f" localmask review {scan_id}")
|
|
1766
|
+
print(f" localmask approve-all {scan_id}")
|
|
1767
|
+
print(f" localmask submit {scan_id}\n")
|
|
1768
|
+
|
|
1769
|
+
# ── status ──────────────────────────────────────────────────────────────
|
|
1770
|
+
elif args.command == "status":
|
|
1771
|
+
if not _is_connected():
|
|
1772
|
+
eng = _local_engine()
|
|
1773
|
+
if args.scan_id:
|
|
1774
|
+
print_scan_detail(eng.get_scan(args.scan_id))
|
|
1775
|
+
else:
|
|
1776
|
+
scans = eng.list_scans(args.org)
|
|
1777
|
+
if not scans:
|
|
1778
|
+
print(f" {DIM}No local scans yet. Run: localmask scan <path>{RESET}")
|
|
1779
|
+
for s in scans:
|
|
1780
|
+
print(f" {CYAN}{s['scan_id']}{RESET} {s.get('repo_url','')} "
|
|
1781
|
+
f"{RED}{s.get('detection_count', 0)}{RESET} detections")
|
|
1782
|
+
return
|
|
1783
|
+
client = _get_client()
|
|
1784
|
+
if args.scan_id:
|
|
1785
|
+
scan = client.get_scan(args.scan_id)
|
|
1786
|
+
print_scan_detail(scan)
|
|
1787
|
+
else:
|
|
1788
|
+
data = client.list_repos(args.org)
|
|
1789
|
+
print_scan_list(data.get("repos", []))
|
|
1790
|
+
|
|
1791
|
+
# ── review ──────────────────────────────────────────────────────────────
|
|
1792
|
+
elif args.command == "review":
|
|
1793
|
+
scan_id = args.scan_id
|
|
1794
|
+
if not _is_connected():
|
|
1795
|
+
eng = _local_engine()
|
|
1796
|
+
|
|
1797
|
+
def _load_dets():
|
|
1798
|
+
data = eng.get_detections(scan_id)
|
|
1799
|
+
out = []
|
|
1800
|
+
for d in data.get("detections", []):
|
|
1801
|
+
det = Detection(
|
|
1802
|
+
det_id=d["det_id"], token=d["token"], det_type=d["type"],
|
|
1803
|
+
line=d.get("line", 0), confidence=d.get("confidence", 0.9),
|
|
1804
|
+
file=d.get("file", ""),
|
|
1805
|
+
context_lines=d.get("context_lines", []))
|
|
1806
|
+
if d.get("decision") == "approved":
|
|
1807
|
+
det.decision = True
|
|
1808
|
+
elif d.get("decision") == "rejected":
|
|
1809
|
+
det.decision = False
|
|
1810
|
+
out.append(det)
|
|
1811
|
+
return data.get("repo_url", scan_id), out
|
|
1812
|
+
|
|
1813
|
+
repo_url, dets = _load_dets()
|
|
1814
|
+
print(f" {GREEN}✓ {len(dets)} detections loaded (local){RESET}\n")
|
|
1815
|
+
|
|
1816
|
+
def save_local(detections):
|
|
1817
|
+
decisions = {det.id: ("approved" if det.decision else "rejected")
|
|
1818
|
+
for det in detections if det.decision is not None}
|
|
1819
|
+
if decisions:
|
|
1820
|
+
eng.review_detections(scan_id, decisions)
|
|
1821
|
+
print(f" {GREEN}✓ Saved {len(decisions)} decisions locally{RESET}")
|
|
1822
|
+
# If every detection has now been decided, mark the scan approved
|
|
1823
|
+
# so it can be published under the 'review' policy.
|
|
1824
|
+
from server_core import _get_or_load_scan, _persist_scan
|
|
1825
|
+
sc = _get_or_load_scan(scan_id)
|
|
1826
|
+
if sc:
|
|
1827
|
+
pend = _pending_count(sc)
|
|
1828
|
+
if pend == 0 and sc.get("detections"):
|
|
1829
|
+
sc["status"] = "approved"
|
|
1830
|
+
sc["reviewed_by"] = "developer"
|
|
1831
|
+
_persist_scan(scan_id)
|
|
1832
|
+
print(f" {GREEN}✓ All reviewed — scan approved for "
|
|
1833
|
+
f"publish.{RESET}")
|
|
1834
|
+
elif pend:
|
|
1835
|
+
print(f" {YELLOW}{pend} still pending{RESET} — approve "
|
|
1836
|
+
f"them (or `localmask approve-all {scan_id}`) "
|
|
1837
|
+
f"before publishing.")
|
|
1838
|
+
|
|
1839
|
+
def teach_local(value, subtype):
|
|
1840
|
+
# Persist to the repo lexicon, then re-scan in place so the
|
|
1841
|
+
# newly-masked value appears. Returns (refreshed_dets, hits,
|
|
1842
|
+
# added) — hits = occurrences in the code, added = new detections.
|
|
1843
|
+
from server_core import _get_or_load_scan
|
|
1844
|
+
from localmask.vault_store import get_vault_store, repo_id_for
|
|
1845
|
+
sc = _get_or_load_scan(scan_id)
|
|
1846
|
+
src = sc.get("repo_url", "") if sc else ""
|
|
1847
|
+
hits = _count_occurrences(src, value)
|
|
1848
|
+
get_vault_store(repo_id_for(src)).set_lexicon(
|
|
1849
|
+
value, action="mask", subtype=subtype)
|
|
1850
|
+
added = 0
|
|
1851
|
+
if hits:
|
|
1852
|
+
added = eng.sync_repo(
|
|
1853
|
+
scan_id, auto_republish=False).get("new_detections", 0)
|
|
1854
|
+
_, fresh = _load_dets()
|
|
1855
|
+
return fresh, hits, added
|
|
1856
|
+
|
|
1857
|
+
HierarchicalReviewer(dets, repo_url, save_callback=save_local,
|
|
1858
|
+
mode="LOCAL", teach_callback=teach_local).run()
|
|
1859
|
+
return
|
|
1860
|
+
|
|
1861
|
+
client = _get_client()
|
|
1862
|
+
print(f"\n {MAGENTA}[SERVICE]{RESET} Fetching detections for {scan_id}...", flush=True)
|
|
1863
|
+
data = client.get_detections(scan_id)
|
|
1864
|
+
repo_url = data.get("repo_url", scan_id)
|
|
1865
|
+
|
|
1866
|
+
dets = []
|
|
1867
|
+
for d in data["detections"]:
|
|
1868
|
+
det = Detection(
|
|
1869
|
+
det_id=d["det_id"], token=d["token"],
|
|
1870
|
+
det_type=d["type"], line=d["line"],
|
|
1871
|
+
confidence=d["confidence"], file=d["file"],
|
|
1872
|
+
context_lines=d.get("context_lines", []),
|
|
1873
|
+
)
|
|
1874
|
+
if d.get("decision") == "approved":
|
|
1875
|
+
det.decision = True
|
|
1876
|
+
elif d.get("decision") == "rejected":
|
|
1877
|
+
det.decision = False
|
|
1878
|
+
dets.append(det)
|
|
1879
|
+
|
|
1880
|
+
print(f" {GREEN}✓ {len(dets)} detections loaded{RESET}\n")
|
|
1881
|
+
|
|
1882
|
+
def save_to_service(detections):
|
|
1883
|
+
decisions = {}
|
|
1884
|
+
for det in detections:
|
|
1885
|
+
if det.decision is not None:
|
|
1886
|
+
decisions[det.id] = "approved" if det.decision else "rejected"
|
|
1887
|
+
if decisions:
|
|
1888
|
+
result = client.post_review(scan_id, decisions)
|
|
1889
|
+
print(f" {GREEN}✓ Synced {result.get('updated', 0)} decisions to service{RESET}")
|
|
1890
|
+
|
|
1891
|
+
reviewer = HierarchicalReviewer(
|
|
1892
|
+
dets, repo_url, save_callback=save_to_service
|
|
1893
|
+
)
|
|
1894
|
+
reviewer.run()
|
|
1895
|
+
|
|
1896
|
+
# ── review-local (file-based, no HTTP server needed) ─────────────────
|
|
1897
|
+
elif args.command == "review-local":
|
|
1898
|
+
scan_id = args.scan_id
|
|
1899
|
+
review_dir = os.path.expanduser("~/.localmask/reviews")
|
|
1900
|
+
review_file = os.path.join(review_dir, f"{scan_id}.json")
|
|
1901
|
+
|
|
1902
|
+
if not os.path.exists(review_file):
|
|
1903
|
+
print(f" {RED}✗ Review file not found: {review_file}{RESET}")
|
|
1904
|
+
print(f" {DIM}Make sure open_review_ui was called in the MCP server first.{RESET}")
|
|
1905
|
+
sys.exit(1)
|
|
1906
|
+
|
|
1907
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Loading detections from file...", flush=True)
|
|
1908
|
+
with open(review_file) as f:
|
|
1909
|
+
data = json.load(f)
|
|
1910
|
+
|
|
1911
|
+
repo_url = data.get("repo_url", scan_id)
|
|
1912
|
+
dets = []
|
|
1913
|
+
for d in data["detections"]:
|
|
1914
|
+
det = Detection(
|
|
1915
|
+
det_id=d["det_id"], token=d["token"],
|
|
1916
|
+
det_type=d["type"], line=d["line"],
|
|
1917
|
+
confidence=d["confidence"], file=d["file"],
|
|
1918
|
+
context_lines=d.get("context_lines", []),
|
|
1919
|
+
)
|
|
1920
|
+
if d.get("decision") == "approved":
|
|
1921
|
+
det.decision = True
|
|
1922
|
+
elif d.get("decision") == "rejected":
|
|
1923
|
+
det.decision = False
|
|
1924
|
+
dets.append(det)
|
|
1925
|
+
|
|
1926
|
+
print(f" {GREEN}✓ {len(dets)} detections loaded{RESET}\n")
|
|
1927
|
+
|
|
1928
|
+
def save_to_file(detections):
|
|
1929
|
+
"""Write decisions back to the review file for MCP sync_review to pick up."""
|
|
1930
|
+
for det in detections:
|
|
1931
|
+
for d in data["detections"]:
|
|
1932
|
+
if d["det_id"] == det.id:
|
|
1933
|
+
if det.decision is True:
|
|
1934
|
+
d["decision"] = "approved"
|
|
1935
|
+
elif det.decision is False:
|
|
1936
|
+
d["decision"] = "rejected"
|
|
1937
|
+
break
|
|
1938
|
+
with open(review_file, "w") as f:
|
|
1939
|
+
json.dump(data, f, indent=2)
|
|
1940
|
+
reviewed = sum(1 for det in detections if det.decision is not None)
|
|
1941
|
+
print(f" {GREEN}✓ Saved {reviewed} decisions to {review_file}{RESET}")
|
|
1942
|
+
|
|
1943
|
+
reviewer = HierarchicalReviewer(
|
|
1944
|
+
dets, repo_url, save_callback=save_to_file
|
|
1945
|
+
)
|
|
1946
|
+
reviewer.run()
|
|
1947
|
+
|
|
1948
|
+
# ── submit ──────────────────────────────────────────────────────────────
|
|
1949
|
+
elif args.command == "submit":
|
|
1950
|
+
client = _get_client()
|
|
1951
|
+
print(f" {DIM}Submitting {args.scan_id} for security review...{RESET}", flush=True)
|
|
1952
|
+
result = client.submit(args.scan_id)
|
|
1953
|
+
print(f" {GREEN}✓ Submitted!{RESET}")
|
|
1954
|
+
print(f" {DIM}Status:{RESET} {YELLOW}submitted{RESET} — waiting for security team approval")
|
|
1955
|
+
print(f"\n {DIM}The security manager can now review this in the dashboard.{RESET}\n")
|
|
1956
|
+
|
|
1957
|
+
# ── approve-all ─────────────────────────────────────────────────────────
|
|
1958
|
+
elif args.command == "approve-all":
|
|
1959
|
+
client = _get_client()
|
|
1960
|
+
print(f" {DIM}Fetching detections...{RESET}", flush=True)
|
|
1961
|
+
data = client.get_detections(args.scan_id)
|
|
1962
|
+
dets = data["detections"]
|
|
1963
|
+
|
|
1964
|
+
# Approve all pending
|
|
1965
|
+
decisions = {}
|
|
1966
|
+
for d in dets:
|
|
1967
|
+
if d["decision"] == "pending":
|
|
1968
|
+
decisions[d["det_id"]] = "approved"
|
|
1969
|
+
|
|
1970
|
+
if decisions:
|
|
1971
|
+
result = client.post_review(args.scan_id, decisions)
|
|
1972
|
+
print(f" {GREEN}✓ Approved {result.get('updated', 0)} detections{RESET}")
|
|
1973
|
+
else:
|
|
1974
|
+
print(f" {DIM}All detections already reviewed{RESET}")
|
|
1975
|
+
|
|
1976
|
+
# Submit for security approval
|
|
1977
|
+
print(f" {DIM}Submitting for security review...{RESET}", flush=True)
|
|
1978
|
+
result = client.submit(args.scan_id)
|
|
1979
|
+
print(f" {GREEN}✓ Submitted!{RESET}")
|
|
1980
|
+
print(f" {DIM}Status:{RESET} {YELLOW}submitted{RESET} — waiting for security team")
|
|
1981
|
+
print(f"\n {DIM}Next: security manager reviews in the dashboard{RESET}\n")
|
|
1982
|
+
|
|
1983
|
+
# ── set-key ─────────────────────────────────────────────────────────
|
|
1984
|
+
elif args.command == "set-key":
|
|
1985
|
+
client = _get_client()
|
|
1986
|
+
print(f" {DIM}Setting {args.provider} API key...{RESET}", flush=True)
|
|
1987
|
+
client.set_key(args.provider, args.key)
|
|
1988
|
+
print(f" {GREEN}✓ {args.provider} key saved on server{RESET}")
|
|
1989
|
+
|
|
1990
|
+
# Show which keys are configured
|
|
1991
|
+
keys = client.get_keys()
|
|
1992
|
+
configured = [k for k, v in keys.items() if v]
|
|
1993
|
+
if configured:
|
|
1994
|
+
print(f" {DIM}Configured:{RESET} {', '.join(configured)}")
|
|
1995
|
+
print(f"\n {DIM}Now you can use AI chat:{RESET}")
|
|
1996
|
+
print(f" localmask ask <scan_id> --provider {args.provider}\n")
|
|
1997
|
+
|
|
1998
|
+
# ── publish ─────────────────────────────────────────────────────────
|
|
1999
|
+
elif args.command == "publish":
|
|
2000
|
+
# Local, in-process publish (free edition, no server): scan → masked
|
|
2001
|
+
# git mirror the AI can read.
|
|
2002
|
+
if not _is_connected():
|
|
2003
|
+
eng = _local_engine()
|
|
2004
|
+
print(f"\n {CYAN}[LOCAL]{RESET} Publishing masked copy to "
|
|
2005
|
+
f"{args.target_url}...", flush=True)
|
|
2006
|
+
# The CLI runs per-process, so the in-memory session (masked file
|
|
2007
|
+
# contents) from an earlier `scan` is gone. Rebuild it from the
|
|
2008
|
+
# source repo, preserving token mappings, before pushing.
|
|
2009
|
+
from server_core import SESSIONS, _get_or_load_scan
|
|
2010
|
+
_sc = _get_or_load_scan(args.scan_id)
|
|
2011
|
+
if _sc and _sc.get("session_key") not in SESSIONS:
|
|
2012
|
+
eng.sync_repo(args.scan_id, auto_republish=False)
|
|
2013
|
+
_sc = _get_or_load_scan(args.scan_id)
|
|
2014
|
+
# ── Approval gate ────────────────────────────────────────────────
|
|
2015
|
+
# Under the default 'review' policy, the masked mirror only goes out
|
|
2016
|
+
# after the detections have been reviewed & approved. 'auto' skips
|
|
2017
|
+
# the gate (and auto-approves). --force overrides for a one-off.
|
|
2018
|
+
policy = _publish_policy()
|
|
2019
|
+
if _sc is not None and policy == "auto":
|
|
2020
|
+
_approve_all_local(args.scan_id)
|
|
2021
|
+
_sc = _get_or_load_scan(args.scan_id)
|
|
2022
|
+
elif _sc is not None and not getattr(args, "force", False) \
|
|
2023
|
+
and not _is_approved(_sc):
|
|
2024
|
+
pend = _pending_count(_sc)
|
|
2025
|
+
print(f" {YELLOW}⚠ Not published — this scan isn't approved "
|
|
2026
|
+
f"yet.{RESET}")
|
|
2027
|
+
print(f" {DIM}{pend} detection(s) still need review.{RESET} "
|
|
2028
|
+
f"Approve, then publish:")
|
|
2029
|
+
print(f" {CYAN}localmask review {args.scan_id}{RESET} "
|
|
2030
|
+
f"{DIM}# review each, or…{RESET}")
|
|
2031
|
+
print(f" {CYAN}localmask approve-all {args.scan_id}{RESET} "
|
|
2032
|
+
f"{DIM}# approve everything{RESET}")
|
|
2033
|
+
print(f" {DIM}Prefer no gate? {CYAN}localmask config "
|
|
2034
|
+
f"publish-policy auto{RESET}{DIM}, or one-off "
|
|
2035
|
+
f"{CYAN}--force{RESET}{DIM}.{RESET}")
|
|
2036
|
+
return
|
|
2037
|
+
# Remember the target so `sync` re-publishes on future changes.
|
|
2038
|
+
# Persist it — the CLI is per-process, so this must survive to disk.
|
|
2039
|
+
if _sc is not None:
|
|
2040
|
+
from server_core import _persist_scan
|
|
2041
|
+
_sc["publish_target"] = args.target_url
|
|
2042
|
+
if args.credential_id:
|
|
2043
|
+
_sc["credential_id"] = args.credential_id
|
|
2044
|
+
_persist_scan(args.scan_id)
|
|
2045
|
+
# Resolve a locally-stored credential id (from `store-token`) into a
|
|
2046
|
+
# token, so the token never has to be passed on the command line.
|
|
2047
|
+
token = args.token
|
|
2048
|
+
if not token and args.credential_id:
|
|
2049
|
+
from localmask.vault_store import get_local_credential
|
|
2050
|
+
token = get_local_credential(args.credential_id) or ""
|
|
2051
|
+
if not token:
|
|
2052
|
+
print(f" {RED}✗ Unknown credential id "
|
|
2053
|
+
f"'{args.credential_id}'.{RESET} Run "
|
|
2054
|
+
f"`localmask store-token` to add one.")
|
|
2055
|
+
return
|
|
2056
|
+
# Auto-create the masked repo if it doesn't exist — ask first
|
|
2057
|
+
# (unless --yes or the 'auto' policy). Then publish_scan creates it.
|
|
2058
|
+
create_if_missing = False
|
|
2059
|
+
from localmask.gitops import remote_repo_exists
|
|
2060
|
+
exists = remote_repo_exists(args.target_url, token)
|
|
2061
|
+
if exists is False:
|
|
2062
|
+
vis = "public" if args.public else "private"
|
|
2063
|
+
if args.yes or _publish_policy() == "auto":
|
|
2064
|
+
create_if_missing = True
|
|
2065
|
+
print(f" {DIM}Masked repo not found — creating a {vis} "
|
|
2066
|
+
f"one…{RESET}")
|
|
2067
|
+
else:
|
|
2068
|
+
try:
|
|
2069
|
+
ans = input(f" {YELLOW}Masked repo doesn't exist.{RESET} "
|
|
2070
|
+
f"Create it ({vis}) now? [Y/n]: ").strip().lower()
|
|
2071
|
+
except (EOFError, KeyboardInterrupt):
|
|
2072
|
+
ans = "n"
|
|
2073
|
+
if ans in ("", "y", "yes"):
|
|
2074
|
+
create_if_missing = True
|
|
2075
|
+
else:
|
|
2076
|
+
print(f" {DIM}Aborted — create it yourself, then "
|
|
2077
|
+
f"re-run publish.{RESET}")
|
|
2078
|
+
return
|
|
2079
|
+
try:
|
|
2080
|
+
result = eng.publish_scan(
|
|
2081
|
+
args.scan_id, args.target_url,
|
|
2082
|
+
token=token, credential_id="", username=args.username,
|
|
2083
|
+
create_if_missing=create_if_missing,
|
|
2084
|
+
private=not args.public)
|
|
2085
|
+
except Exception as e:
|
|
2086
|
+
_publish_error_help(str(e), args.target_url, bool(token))
|
|
2087
|
+
return
|
|
2088
|
+
if create_if_missing:
|
|
2089
|
+
print(f" {GREEN}✓ Created the masked repo{RESET}")
|
|
2090
|
+
print(f" {GREEN}✓ Published masked repo{RESET}")
|
|
2091
|
+
print(f" {DIM}Pushed to:{RESET} {result.get('pushed_to', args.target_url)}")
|
|
2092
|
+
print(f" {DIM}Files:{RESET} {result.get('files', '?')}")
|
|
2093
|
+
_print_grant_guide(args.target_url, args.scan_id)
|
|
2094
|
+
return
|
|
2095
|
+
|
|
2096
|
+
client = _get_client()
|
|
2097
|
+
|
|
2098
|
+
# Resolve credential_id from saved config if not provided
|
|
2099
|
+
cred_id = args.credential_id
|
|
2100
|
+
token = args.token
|
|
2101
|
+
if not cred_id and not token:
|
|
2102
|
+
cred_id = _load_config().get("credential_id", "")
|
|
2103
|
+
|
|
2104
|
+
# Verify scan status first
|
|
2105
|
+
scan = client.get_scan(args.scan_id)
|
|
2106
|
+
status = scan["status"]
|
|
2107
|
+
if status not in ("approved", "published"):
|
|
2108
|
+
print(f" {YELLOW}⚠ Scan status is '{status}' — typically only approved scans are published.{RESET}")
|
|
2109
|
+
try:
|
|
2110
|
+
confirm = input(f" {BOLD}Continue anyway? [y/N]: {RESET}").strip().lower()
|
|
2111
|
+
except (EOFError, KeyboardInterrupt):
|
|
2112
|
+
confirm = "n"
|
|
2113
|
+
if confirm != "y":
|
|
2114
|
+
print(f" {DIM}Aborted{RESET}\n")
|
|
2115
|
+
sys.exit(0)
|
|
2116
|
+
|
|
2117
|
+
print(f" {DIM}Publishing masked repo to {args.target_url}...{RESET}", flush=True)
|
|
2118
|
+
result = client.publish(
|
|
2119
|
+
args.scan_id, args.target_url,
|
|
2120
|
+
token=token, credential_id=cred_id,
|
|
2121
|
+
username=args.username,
|
|
2122
|
+
)
|
|
2123
|
+
print(f" {GREEN}✓ Published!{RESET}")
|
|
2124
|
+
print(f" {DIM}Pushed to:{RESET} {result.get('pushed_to', args.target_url)}")
|
|
2125
|
+
print(f" {DIM}Files:{RESET} {result.get('files', '?')}")
|
|
2126
|
+
_print_grant_guide(args.target_url, args.scan_id)
|
|
2127
|
+
|
|
2128
|
+
# ── teach (add a missed secret / ignore a false positive) ────────────
|
|
2129
|
+
elif args.command == "teach":
|
|
2130
|
+
scan_id = args.scan_id
|
|
2131
|
+
value = args.value
|
|
2132
|
+
action = "allow" if args.allow else "mask"
|
|
2133
|
+
if not _is_connected():
|
|
2134
|
+
from server_core import _get_or_load_scan
|
|
2135
|
+
from localmask.vault_store import get_vault_store, repo_id_for
|
|
2136
|
+
sc = _get_or_load_scan(scan_id)
|
|
2137
|
+
if not sc:
|
|
2138
|
+
print(f" {RED}✗ Scan not found: {scan_id}{RESET}")
|
|
2139
|
+
return
|
|
2140
|
+
src = sc.get("repo_url", "")
|
|
2141
|
+
# 0) Is the value actually present in the scanned code?
|
|
2142
|
+
hits = _count_occurrences(src, value)
|
|
2143
|
+
# 1) Persist to the repo's lexicon so it applies on every scan/sync.
|
|
2144
|
+
store = get_vault_store(repo_id_for(src))
|
|
2145
|
+
store.set_lexicon(value, action=action, subtype=args.subtype)
|
|
2146
|
+
verb = "ignore" if action == "allow" else "mask"
|
|
2147
|
+
print(f" {GREEN}✓ Taught: will always {verb} this value{RESET} "
|
|
2148
|
+
f"{DIM}(repo lexicon){RESET}")
|
|
2149
|
+
if hits == 0:
|
|
2150
|
+
print(f" {YELLOW}⚠ Not found in the scanned code{RESET} — "
|
|
2151
|
+
f"double-check the value (whitespace/quotes?). Saved to the "
|
|
2152
|
+
f"lexicon; it'll apply if it appears in a future scan.")
|
|
2153
|
+
return
|
|
2154
|
+
# 2) Re-scan in place so the change is visible now (tokens stay stable).
|
|
2155
|
+
eng = _local_engine()
|
|
2156
|
+
result = eng.sync_repo(scan_id, auto_republish=False)
|
|
2157
|
+
total = result.get("total_detections", 0)
|
|
2158
|
+
added = result.get("new_detections", 0)
|
|
2159
|
+
occ = f"{hits} occurrence" + ("s" if hits != 1 else "")
|
|
2160
|
+
if action == "mask" and added == 0:
|
|
2161
|
+
# Present in the text but the re-scan didn't add a detection.
|
|
2162
|
+
print(f" {YELLOW}⚠ Found {occ} in the source, but no new "
|
|
2163
|
+
f"detection was added.{RESET}")
|
|
2164
|
+
print(f" {DIM}It may sit inside an already-masked secret, or be "
|
|
2165
|
+
f"in a file LocalMask skips. If you just upgraded, reinstall "
|
|
2166
|
+
f"to refresh (see below).{RESET}")
|
|
2167
|
+
return
|
|
2168
|
+
print(f" {GREEN}✓ Found {occ}{RESET} — "
|
|
2169
|
+
f"{added} added to the review "
|
|
2170
|
+
f"({total} detections now, {CYAN}{scan_id}{RESET})")
|
|
2171
|
+
if action == "mask":
|
|
2172
|
+
print(f" {DIM}The value is now masked as a {args.subtype} "
|
|
2173
|
+
f"token; review/publish as usual.{RESET}")
|
|
2174
|
+
return
|
|
2175
|
+
client = _get_client()
|
|
2176
|
+
client._req("POST", f"/api/repos/{scan_id}/teach",
|
|
2177
|
+
{"value": value, "action": action, "subtype": args.subtype})
|
|
2178
|
+
print(f" {GREEN}✓ Taught (service).{RESET}")
|
|
2179
|
+
|
|
2180
|
+
# ── sync ────────────────────────────────────────────────────────────
|
|
2181
|
+
elif args.command == "sync":
|
|
2182
|
+
scan_id = args.scan_id
|
|
2183
|
+
print(f"\n {MAGENTA}[SYNC]{RESET} Re-scanning {scan_id}...", flush=True)
|
|
2184
|
+
print(f" {DIM}Pulling latest code, preserving token mappings...{RESET}")
|
|
2185
|
+
|
|
2186
|
+
if not _is_connected():
|
|
2187
|
+
eng = _local_engine()
|
|
2188
|
+
_tok = ""
|
|
2189
|
+
if args.credential_id:
|
|
2190
|
+
from localmask.vault_store import get_local_credential
|
|
2191
|
+
_tok = get_local_credential(args.credential_id) or ""
|
|
2192
|
+
result = eng.sync_repo(scan_id, token=_tok, auto_republish=False)
|
|
2193
|
+
# Re-push the masked mirror if this scan has a publish target — but
|
|
2194
|
+
# respect the approval gate. New/undecided detections since the last
|
|
2195
|
+
# approval hold the mirror until reviewed (unless policy is 'auto').
|
|
2196
|
+
from server_core import _get_or_load_scan
|
|
2197
|
+
_sc = _get_or_load_scan(scan_id)
|
|
2198
|
+
_target = _sc.get("publish_target", "") if _sc else ""
|
|
2199
|
+
policy = _publish_policy()
|
|
2200
|
+
_new_cnt = result.get("new_detections", 0)
|
|
2201
|
+
def _do_republish():
|
|
2202
|
+
try:
|
|
2203
|
+
return eng.publish_scan(
|
|
2204
|
+
scan_id, _target,
|
|
2205
|
+
credential_id=_sc.get("credential_id", ""))
|
|
2206
|
+
except Exception as e:
|
|
2207
|
+
return {"error": str(e)[:120]}
|
|
2208
|
+
if _target and not result.get("re_published"):
|
|
2209
|
+
if policy == "auto":
|
|
2210
|
+
_approve_all_local(scan_id)
|
|
2211
|
+
_sc = _get_or_load_scan(scan_id)
|
|
2212
|
+
result["re_published"] = _do_republish()
|
|
2213
|
+
elif _is_approved(_sc) and _new_cnt == 0:
|
|
2214
|
+
result["re_published"] = _do_republish()
|
|
2215
|
+
else:
|
|
2216
|
+
result["held_for_review"] = True
|
|
2217
|
+
else:
|
|
2218
|
+
client = _get_client()
|
|
2219
|
+
cred_id = args.credential_id or _load_config().get("credential_id", "")
|
|
2220
|
+
body = {"credential_id": cred_id} if cred_id else {}
|
|
2221
|
+
result = client._req("POST", f"/api/repos/{scan_id}/sync", body)
|
|
2222
|
+
|
|
2223
|
+
if result.get("error"):
|
|
2224
|
+
print(f" {RED}✗ {result['error']}{RESET}\n")
|
|
2225
|
+
else:
|
|
2226
|
+
total = result.get("total_detections", 0)
|
|
2227
|
+
new = result.get("new_detections", 0)
|
|
2228
|
+
removed = result.get("removed_detections", 0)
|
|
2229
|
+
carried = result.get("carried_decisions", 0)
|
|
2230
|
+
pending = result.get("pending_review", 0)
|
|
2231
|
+
|
|
2232
|
+
print(f" {GREEN}✓ Sync complete{RESET}\n")
|
|
2233
|
+
print(f" {BOLD}Scan ID:{RESET} {CYAN}{scan_id}{RESET}")
|
|
2234
|
+
print(f" {DIM}Total:{RESET} {total} detections")
|
|
2235
|
+
# Newly-detected secrets since the last scan — shown prominently.
|
|
2236
|
+
if new > 0:
|
|
2237
|
+
print(f" {BG_YELLOW}{BOLD} NEW {RESET} "
|
|
2238
|
+
f"{BOLD}{YELLOW}{new} new secret(s) detected since last "
|
|
2239
|
+
f"sync{RESET}")
|
|
2240
|
+
else:
|
|
2241
|
+
print(f" {DIM}New:{RESET} 0 new detections")
|
|
2242
|
+
if removed > 0:
|
|
2243
|
+
print(f" {DIM}Removed:{RESET} {removed} (no longer in code)")
|
|
2244
|
+
print(f" {DIM}Carried:{RESET} {carried} previous decisions preserved")
|
|
2245
|
+
if pending > 0:
|
|
2246
|
+
print(f" {YELLOW}Pending:{RESET} {pending} need review")
|
|
2247
|
+
|
|
2248
|
+
if result.get("re_published"):
|
|
2249
|
+
pub = result["re_published"]
|
|
2250
|
+
if pub.get("ok"):
|
|
2251
|
+
print(f"\n {GREEN}✓ Masked repo auto-republished to {pub.get('pushed_to', '?')}{RESET}")
|
|
2252
|
+
else:
|
|
2253
|
+
print(f"\n {YELLOW}⚠ Auto-republish failed:{RESET} "
|
|
2254
|
+
f"{pub.get('error', 'unknown')}")
|
|
2255
|
+
elif result.get("held_for_review"):
|
|
2256
|
+
print(f"\n {YELLOW}⚠ Masked mirror NOT updated — "
|
|
2257
|
+
f"held for review.{RESET}")
|
|
2258
|
+
print(f" {DIM}{new} new + {pending} pending detection(s) must be "
|
|
2259
|
+
f"approved before the mirror is republished.{RESET}")
|
|
2260
|
+
print(f" {CYAN}localmask review {scan_id}{RESET} or "
|
|
2261
|
+
f"{CYAN}localmask approve-all {scan_id}{RESET} then "
|
|
2262
|
+
f"{CYAN}localmask publish {scan_id} <url>{RESET}")
|
|
2263
|
+
print(f" {DIM}(auto-publish on every change: "
|
|
2264
|
+
f"{CYAN}localmask config publish-policy auto{RESET}{DIM}){RESET}")
|
|
2265
|
+
|
|
2266
|
+
if new > 0 and not result.get("held_for_review"):
|
|
2267
|
+
print(f"\n {DIM}Next: review new detections:{RESET}")
|
|
2268
|
+
print(f" localmask review {scan_id}")
|
|
2269
|
+
print()
|
|
2270
|
+
|
|
2271
|
+
# ── hook ────────────────────────────────────────────────────────────
|
|
2272
|
+
elif args.command == "hook":
|
|
2273
|
+
import stat as stat_mod
|
|
2274
|
+
repo_path = os.path.abspath(args.repo)
|
|
2275
|
+
scan_id = args.scan_id
|
|
2276
|
+
hook_type = args.type
|
|
2277
|
+
|
|
2278
|
+
hooks_dir = os.path.join(repo_path, ".git", "hooks")
|
|
2279
|
+
if not os.path.isdir(hooks_dir):
|
|
2280
|
+
print(f" {RED}✗ Not a git repo: {repo_path}{RESET}\n")
|
|
2281
|
+
sys.exit(1)
|
|
2282
|
+
|
|
2283
|
+
hook_path = os.path.join(hooks_dir, hook_type)
|
|
2284
|
+
localmask_bin = os.path.expanduser("~/.localmask/localmask")
|
|
2285
|
+
|
|
2286
|
+
hook_script = f"""#!/bin/bash
|
|
2287
|
+
# LocalMask Pro — auto-sync masked repo on {hook_type}
|
|
2288
|
+
# Scan ID: {scan_id}
|
|
2289
|
+
|
|
2290
|
+
echo "🔐 LocalMask: syncing masked repo..."
|
|
2291
|
+
if command -v localmask &>/dev/null; then
|
|
2292
|
+
localmask sync {scan_id} 2>&1 | tail -5
|
|
2293
|
+
elif [ -f "{localmask_bin}" ]; then
|
|
2294
|
+
"{localmask_bin}" sync {scan_id} 2>&1 | tail -5
|
|
2295
|
+
else
|
|
2296
|
+
echo "⚠ LocalMask not found — skipping sync"
|
|
2297
|
+
fi
|
|
2298
|
+
"""
|
|
2299
|
+
|
|
2300
|
+
if os.path.exists(hook_path):
|
|
2301
|
+
with open(hook_path) as f:
|
|
2302
|
+
existing = f.read()
|
|
2303
|
+
if "LocalMask" in existing:
|
|
2304
|
+
print(f" {GREEN}✓ Hook already installed in {hook_type}{RESET}\n")
|
|
2305
|
+
sys.exit(0)
|
|
2306
|
+
with open(hook_path, "a") as f:
|
|
2307
|
+
f.write("\n" + hook_script)
|
|
2308
|
+
else:
|
|
2309
|
+
with open(hook_path, "w") as f:
|
|
2310
|
+
f.write(hook_script)
|
|
2311
|
+
|
|
2312
|
+
st = os.stat(hook_path)
|
|
2313
|
+
os.chmod(hook_path, st.st_mode | stat_mod.S_IEXEC | stat_mod.S_IXGRP | stat_mod.S_IXOTH)
|
|
2314
|
+
|
|
2315
|
+
print(f"\n {GREEN}✓ Git {hook_type} hook installed!{RESET}")
|
|
2316
|
+
print(f" {DIM}Repo:{RESET} {repo_path}")
|
|
2317
|
+
print(f" {DIM}Scan ID:{RESET} {scan_id}")
|
|
2318
|
+
print(f" {DIM}Hook:{RESET} {hook_path}")
|
|
2319
|
+
print(f"\n Every {hook_type.replace('-', ' ')} will auto-sync the masked repo.")
|
|
2320
|
+
print(f" {DIM}Token mappings are preserved — only new secrets need review.{RESET}\n")
|
|
2321
|
+
|
|
2322
|
+
# ── activate ────────────────────────────────────────────────────────
|
|
2323
|
+
elif args.command == "activate":
|
|
2324
|
+
# License activation is local — no server needed
|
|
2325
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
2326
|
+
try:
|
|
2327
|
+
from licensing import LicenseManager
|
|
2328
|
+
mgr = LicenseManager()
|
|
2329
|
+
result = mgr.activate(args.license_key)
|
|
2330
|
+
if result.get("ok"):
|
|
2331
|
+
print(f" {GREEN}✓ License activated!{RESET}")
|
|
2332
|
+
print(f" {DIM}Tier:{RESET} {BOLD}{result['tier_name']}{RESET}")
|
|
2333
|
+
print(f" {DIM}Activated:{RESET} {result['activated_at'][:19]}")
|
|
2334
|
+
print(f"\n {DIM}You now have full access to LocalMask Pro.{RESET}\n")
|
|
2335
|
+
else:
|
|
2336
|
+
print(f" {RED}✗ Activation failed: {result.get('error', 'unknown')}{RESET}\n")
|
|
2337
|
+
except ImportError:
|
|
2338
|
+
print(f" {RED}✗ licensing.py not found. Ensure you're in the LocalMask directory.{RESET}\n")
|
|
2339
|
+
|
|
2340
|
+
# ── license ─────────────────────────────────────────────────────────
|
|
2341
|
+
elif args.command == "license":
|
|
2342
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
2343
|
+
try:
|
|
2344
|
+
from licensing import LicenseManager
|
|
2345
|
+
mgr = LicenseManager()
|
|
2346
|
+
status = mgr.get_status()
|
|
2347
|
+
print(f"\n{BOLD}{'═' * 50}{RESET}")
|
|
2348
|
+
print(f"{BOLD} LocalMask Pro — License{RESET}")
|
|
2349
|
+
print(f"{BOLD}{'═' * 50}{RESET}")
|
|
2350
|
+
tier_color = GREEN if status["tier"] != "free" else DIM
|
|
2351
|
+
print(f" {DIM}Tier:{RESET} {tier_color}{BOLD}{status['tier_name']}{RESET}")
|
|
2352
|
+
print(f" {DIM}License:{RESET} {status['license_key']}")
|
|
2353
|
+
if status.get("activated_at"):
|
|
2354
|
+
print(f" {DIM}Activated:{RESET} {status['activated_at'][:19]}")
|
|
2355
|
+
print(f" {DIM}Custom rules:{RESET} {'Yes' if status['custom_rules'] else 'No'}")
|
|
2356
|
+
print(f"\n {BOLD}Usage Today:{RESET}")
|
|
2357
|
+
for action, info in status.get("usage_today", {}).items():
|
|
2358
|
+
limit_str = str(info['limit']) if info['limit'] != 'unlimited' else '∞'
|
|
2359
|
+
used = info['used']
|
|
2360
|
+
bar_len = 20
|
|
2361
|
+
if info['limit'] != 'unlimited' and info['limit'] > 0:
|
|
2362
|
+
filled = min(bar_len, int(used / info['limit'] * bar_len))
|
|
2363
|
+
bar = f"{YELLOW}{'█' * filled}{'░' * (bar_len - filled)}{RESET}"
|
|
2364
|
+
else:
|
|
2365
|
+
bar = f"{GREEN}∞{RESET}"
|
|
2366
|
+
print(f" {action:<8} {used:>3}/{limit_str:<10} {bar}")
|
|
2367
|
+
print(f"\n{'═' * 50}\n")
|
|
2368
|
+
except ImportError:
|
|
2369
|
+
print(f" {RED}✗ licensing.py not found.{RESET}\n")
|
|
2370
|
+
|
|
2371
|
+
# ── ask ──────────────────────────────────────────────────────────────
|
|
2372
|
+
elif args.command == "ask":
|
|
2373
|
+
client = _get_client()
|
|
2374
|
+
scan_id = args.scan_id
|
|
2375
|
+
|
|
2376
|
+
# Verify scan exists
|
|
2377
|
+
scan = client.get_scan(scan_id)
|
|
2378
|
+
src_label = "published git repo" if args.source == "git" else "platform memory"
|
|
2379
|
+
print(f"\n {MAGENTA}[AI]{RESET} Scan: {CYAN}{scan_id}{RESET} "
|
|
2380
|
+
f"Status: {BOLD}{scan['status']}{RESET} "
|
|
2381
|
+
f"Repo: {scan['repo_url']}")
|
|
2382
|
+
print(f" {DIM}Source: {src_label} — AI only sees masked content.{RESET}\n")
|
|
2383
|
+
|
|
2384
|
+
if args.question:
|
|
2385
|
+
# Single question mode
|
|
2386
|
+
print(f" {DIM}Asking...{RESET}", flush=True)
|
|
2387
|
+
r = client.ask(scan_id, args.question, args.provider, args.model,
|
|
2388
|
+
args.source, args.git_url)
|
|
2389
|
+
if r.get("error"):
|
|
2390
|
+
print(f" {RED}Error: {r['error']}{RESET}")
|
|
2391
|
+
else:
|
|
2392
|
+
print(f" {DIM}Provider:{RESET} {r.get('provider', '?')} "
|
|
2393
|
+
f"{DIM}Turn:{RESET} {r.get('turns', 1)}")
|
|
2394
|
+
if r.get("masked_question") != args.question:
|
|
2395
|
+
print(f" {DIM}Sent masked:{RESET} {r['masked_question']}")
|
|
2396
|
+
print(f"\n{r.get('answer', '')}\n")
|
|
2397
|
+
else:
|
|
2398
|
+
# Interactive mode
|
|
2399
|
+
print(f" {DIM}Interactive mode — type your questions. "
|
|
2400
|
+
f"Commands: /reset /quit{RESET}\n")
|
|
2401
|
+
turn = 0
|
|
2402
|
+
while True:
|
|
2403
|
+
try:
|
|
2404
|
+
q = input(f" {BOLD}You → {RESET}").strip()
|
|
2405
|
+
except (EOFError, KeyboardInterrupt):
|
|
2406
|
+
print(f"\n {DIM}Bye{RESET}\n")
|
|
2407
|
+
break
|
|
2408
|
+
|
|
2409
|
+
if not q:
|
|
2410
|
+
continue
|
|
2411
|
+
if q.lower() in ("/quit", "/exit", "/q"):
|
|
2412
|
+
print(f" {DIM}Bye{RESET}\n")
|
|
2413
|
+
break
|
|
2414
|
+
if q.lower() == "/reset":
|
|
2415
|
+
client.ask_reset(scan_id)
|
|
2416
|
+
turn = 0
|
|
2417
|
+
print(f" {GREEN}✓ Chat reset{RESET}\n")
|
|
2418
|
+
continue
|
|
2419
|
+
|
|
2420
|
+
print(f" {DIM}Thinking...{RESET}", flush=True)
|
|
2421
|
+
r = client.ask(scan_id, q, args.provider, args.model,
|
|
2422
|
+
args.source, args.git_url)
|
|
2423
|
+
if r.get("error"):
|
|
2424
|
+
print(f" {RED}Error: {r['error']}{RESET}\n")
|
|
2425
|
+
continue
|
|
2426
|
+
|
|
2427
|
+
turn = r.get("turns", turn + 1)
|
|
2428
|
+
answer = r.get("answer", "")
|
|
2429
|
+
print(f"\n {CYAN}AI ({r.get('provider','?')} · turn {turn}):{RESET}")
|
|
2430
|
+
# Indent the answer
|
|
2431
|
+
for line in answer.split("\n"):
|
|
2432
|
+
print(f" {line}")
|
|
2433
|
+
print()
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
if __name__ == "__main__":
|
|
2437
|
+
main()
|