@ao_zorin/zocket 1.0.0 → 1.2.0
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.
- package/README.md +22 -22
- package/dist/zocket.js +2313 -0
- package/docs/AI_AUTODEPLOY.md +9 -13
- package/docs/GIT_NPM_RELEASE.md +44 -0
- package/docs/INSTALL.md +43 -158
- package/docs/SOURCES.md +29 -0
- package/package.json +49 -37
- package/scripts/install-zocket.ps1 +27 -69
- package/scripts/install-zocket.sh +144 -93
- package/bin/zocket-setup.cjs +0 -12
- package/bin/zocket.cjs +0 -174
- package/pyproject.toml +0 -29
- package/scripts/ai-autodeploy.py +0 -127
- package/zocket/__init__.py +0 -2
- package/zocket/__main__.py +0 -5
- package/zocket/audit.py +0 -76
- package/zocket/auth.py +0 -34
- package/zocket/autostart.py +0 -281
- package/zocket/backup.py +0 -33
- package/zocket/cli.py +0 -655
- package/zocket/config_store.py +0 -68
- package/zocket/crypto.py +0 -158
- package/zocket/harden.py +0 -136
- package/zocket/i18n.py +0 -216
- package/zocket/mcp_server.py +0 -249
- package/zocket/paths.py +0 -50
- package/zocket/runner.py +0 -108
- package/zocket/templates/index.html +0 -1062
- package/zocket/templates/login.html +0 -244
- package/zocket/vault.py +0 -331
- package/zocket/web.py +0 -490
package/dist/zocket.js
ADDED
|
@@ -0,0 +1,2313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { mkdirSync as mkdirSync5 } from "fs";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { serve } from "@hono/node-server";
|
|
9
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
|
|
13
|
+
// src/vault.ts
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
|
+
import { lock } from "proper-lockfile";
|
|
17
|
+
|
|
18
|
+
// src/crypto.ts
|
|
19
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
|
20
|
+
var VERSION = 1;
|
|
21
|
+
var ALGORITHM = "aes-256-gcm";
|
|
22
|
+
var IV_BYTES = 12;
|
|
23
|
+
var TAG_BYTES = 16;
|
|
24
|
+
function encrypt(plaintext, key) {
|
|
25
|
+
const iv = randomBytes(IV_BYTES);
|
|
26
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
27
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
28
|
+
const tag = cipher.getAuthTag();
|
|
29
|
+
const version = Buffer.allocUnsafe(4);
|
|
30
|
+
version.writeUInt32BE(VERSION, 0);
|
|
31
|
+
return Buffer.concat([version, iv, tag, ciphertext]);
|
|
32
|
+
}
|
|
33
|
+
function decrypt(data, key) {
|
|
34
|
+
const version = data.readUInt32BE(0);
|
|
35
|
+
if (version !== VERSION) throw new Error(`Unsupported vault version: ${version}`);
|
|
36
|
+
const iv = data.subarray(4, 4 + IV_BYTES);
|
|
37
|
+
const tag = data.subarray(4 + IV_BYTES, 4 + IV_BYTES + TAG_BYTES);
|
|
38
|
+
const ciphertext = data.subarray(4 + IV_BYTES + TAG_BYTES);
|
|
39
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
40
|
+
decipher.setAuthTag(tag);
|
|
41
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/vault.ts
|
|
45
|
+
var PROJECT_RE = /^[a-zA-Z0-9._-]+$/;
|
|
46
|
+
var SECRET_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
47
|
+
var VaultService = class {
|
|
48
|
+
constructor(vaultPath2, lockFile, key) {
|
|
49
|
+
this.vaultPath = vaultPath2;
|
|
50
|
+
this.lockFile = lockFile;
|
|
51
|
+
this.key = key;
|
|
52
|
+
}
|
|
53
|
+
load() {
|
|
54
|
+
if (!existsSync(this.vaultPath)) return { version: 1, projects: {} };
|
|
55
|
+
const raw = readFileSync(this.vaultPath);
|
|
56
|
+
return JSON.parse(decrypt(raw, this.key).toString("utf8"));
|
|
57
|
+
}
|
|
58
|
+
save(data) {
|
|
59
|
+
mkdirSync(dirname(this.vaultPath), { recursive: true });
|
|
60
|
+
writeFileSync(this.vaultPath, encrypt(Buffer.from(JSON.stringify(data)), this.key));
|
|
61
|
+
}
|
|
62
|
+
ensureExists() {
|
|
63
|
+
if (!existsSync(this.vaultPath)) {
|
|
64
|
+
this.save({ version: 1, projects: {} });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async withLock(fn) {
|
|
68
|
+
mkdirSync(dirname(this.lockFile), { recursive: true });
|
|
69
|
+
if (!existsSync(this.lockFile)) writeFileSync(this.lockFile, "");
|
|
70
|
+
const release = await lock(this.lockFile, { retries: { retries: 5, minTimeout: 50 } });
|
|
71
|
+
try {
|
|
72
|
+
const data = this.load();
|
|
73
|
+
const result = fn(data);
|
|
74
|
+
this.save(data);
|
|
75
|
+
return result;
|
|
76
|
+
} finally {
|
|
77
|
+
await release();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async createProject(name, description) {
|
|
81
|
+
if (!PROJECT_RE.test(name)) throw new Error(`Invalid project name: ${name}`);
|
|
82
|
+
await this.withLock((data) => {
|
|
83
|
+
if (data.projects[name]) throw new Error(`Project already exists: ${name}`);
|
|
84
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
85
|
+
data.projects[name] = { description, created_at: now, updated_at: now, secrets: {} };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async deleteProject(name) {
|
|
89
|
+
await this.withLock((data) => {
|
|
90
|
+
if (!data.projects[name]) throw new Error(`Project not found: ${name}`);
|
|
91
|
+
delete data.projects[name];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async listProjects() {
|
|
95
|
+
const data = this.load();
|
|
96
|
+
return Object.entries(data.projects).map(([name, p]) => ({
|
|
97
|
+
name,
|
|
98
|
+
description: p.description,
|
|
99
|
+
created_at: p.created_at,
|
|
100
|
+
folder_path: p.folder_path,
|
|
101
|
+
secret_count: Object.keys(p.secrets).length,
|
|
102
|
+
allowed_domains: p.allowed_domains
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
async setAllowedDomains(project, domains) {
|
|
106
|
+
await this.withLock((data) => {
|
|
107
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
108
|
+
data.projects[project].allowed_domains = domains;
|
|
109
|
+
data.projects[project].updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async getAllowedDomains(project) {
|
|
113
|
+
const data = this.load();
|
|
114
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
115
|
+
return data.projects[project].allowed_domains ?? null;
|
|
116
|
+
}
|
|
117
|
+
async setSecret(project, key, value, description) {
|
|
118
|
+
if (!SECRET_RE.test(key)) throw new Error(`Invalid secret key: ${key}`);
|
|
119
|
+
await this.withLock((data) => {
|
|
120
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
121
|
+
data.projects[project].secrets[key] = { value, description, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
122
|
+
data.projects[project].updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async deleteSecret(project, key) {
|
|
126
|
+
await this.withLock((data) => {
|
|
127
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
128
|
+
delete data.projects[project].secrets[key];
|
|
129
|
+
data.projects[project].updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async listKeys(project) {
|
|
133
|
+
const data = this.load();
|
|
134
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
135
|
+
return Object.keys(data.projects[project].secrets);
|
|
136
|
+
}
|
|
137
|
+
async listSecrets(project) {
|
|
138
|
+
const data = this.load();
|
|
139
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
140
|
+
return Object.entries(data.projects[project].secrets).map(([key, s]) => ({
|
|
141
|
+
key,
|
|
142
|
+
description: s.description,
|
|
143
|
+
updated_at: s.updated_at
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
async getEnv(project) {
|
|
147
|
+
const data = this.load();
|
|
148
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
149
|
+
return Object.fromEntries(Object.entries(data.projects[project].secrets).map(([k, v]) => [k, v.value]));
|
|
150
|
+
}
|
|
151
|
+
async setFolder(project, folderPath) {
|
|
152
|
+
await this.withLock((data) => {
|
|
153
|
+
if (!data.projects[project]) throw new Error(`Project not found: ${project}`);
|
|
154
|
+
data.projects[project].folder_path = folderPath;
|
|
155
|
+
data.projects[project].updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async findByPath(path) {
|
|
159
|
+
const data = this.load();
|
|
160
|
+
let best = null;
|
|
161
|
+
let bestLen = 0;
|
|
162
|
+
for (const [name, proj] of Object.entries(data.projects)) {
|
|
163
|
+
if (proj.folder_path && path.startsWith(proj.folder_path) && proj.folder_path.length > bestLen) {
|
|
164
|
+
best = name;
|
|
165
|
+
bestLen = proj.folder_path.length;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return best;
|
|
169
|
+
}
|
|
170
|
+
async getSecretValue(project, key) {
|
|
171
|
+
const data = this.load();
|
|
172
|
+
const secret = data.projects[project]?.secrets[key];
|
|
173
|
+
if (!secret) throw new Error(`Secret not found: ${project}/${key}`);
|
|
174
|
+
return secret.value;
|
|
175
|
+
}
|
|
176
|
+
async reEncrypt(newKey) {
|
|
177
|
+
mkdirSync(dirname(this.lockFile), { recursive: true });
|
|
178
|
+
if (!existsSync(this.lockFile)) writeFileSync(this.lockFile, "");
|
|
179
|
+
const release = await lock(this.lockFile, { retries: { retries: 5, minTimeout: 50 } });
|
|
180
|
+
try {
|
|
181
|
+
const data = this.load();
|
|
182
|
+
this.key = newKey;
|
|
183
|
+
this.save(data);
|
|
184
|
+
} finally {
|
|
185
|
+
await release();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// src/config.ts
|
|
191
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
192
|
+
import { dirname as dirname2 } from "path";
|
|
193
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
194
|
+
var DEFAULTS = {
|
|
195
|
+
language: "en",
|
|
196
|
+
key_storage: "file",
|
|
197
|
+
web_auth_enabled: false,
|
|
198
|
+
web_password_hash: "",
|
|
199
|
+
web_password_salt: "",
|
|
200
|
+
theme: "standard",
|
|
201
|
+
theme_variant: "light",
|
|
202
|
+
session_secret: "",
|
|
203
|
+
folder_picker_roots: ["/home", "/srv", "/opt", "/var/www", "/var/lib"],
|
|
204
|
+
exec_allow_list: null,
|
|
205
|
+
exec_max_output: 500,
|
|
206
|
+
exec_allow_substitution: true,
|
|
207
|
+
exec_allow_full_output: false,
|
|
208
|
+
exec_redact_secrets: true,
|
|
209
|
+
security_mode: "enforce",
|
|
210
|
+
security_block_threshold: "high",
|
|
211
|
+
mcp_loading: "eager"
|
|
212
|
+
};
|
|
213
|
+
var ConfigStore = class {
|
|
214
|
+
constructor(path) {
|
|
215
|
+
this.path = path;
|
|
216
|
+
}
|
|
217
|
+
load() {
|
|
218
|
+
try {
|
|
219
|
+
const raw = JSON.parse(readFileSync2(this.path, "utf8"));
|
|
220
|
+
return { ...DEFAULTS, ...raw };
|
|
221
|
+
} catch {
|
|
222
|
+
return { ...DEFAULTS };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
save(cfg) {
|
|
226
|
+
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
227
|
+
writeFileSync2(this.path, JSON.stringify(cfg, null, 2));
|
|
228
|
+
}
|
|
229
|
+
set(key, value) {
|
|
230
|
+
const cfg = this.load();
|
|
231
|
+
cfg[key] = value;
|
|
232
|
+
this.save(cfg);
|
|
233
|
+
}
|
|
234
|
+
ensureExists() {
|
|
235
|
+
const cfg = this.load();
|
|
236
|
+
if (!cfg.session_secret) {
|
|
237
|
+
cfg.session_secret = randomBytes2(32).toString("hex");
|
|
238
|
+
this.save(cfg);
|
|
239
|
+
}
|
|
240
|
+
return cfg;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// src/audit.ts
|
|
245
|
+
import { appendFileSync, readFileSync as readFileSync3, existsSync as existsSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
246
|
+
import { dirname as dirname3 } from "path";
|
|
247
|
+
var AuditLogger = class {
|
|
248
|
+
constructor(path) {
|
|
249
|
+
this.path = path;
|
|
250
|
+
}
|
|
251
|
+
log(action, actor, details, status) {
|
|
252
|
+
mkdirSync3(dirname3(this.path), { recursive: true });
|
|
253
|
+
const entry = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), action, actor, details, status };
|
|
254
|
+
appendFileSync(this.path, JSON.stringify(entry) + "\n");
|
|
255
|
+
}
|
|
256
|
+
tail(n) {
|
|
257
|
+
if (!existsSync2(this.path)) return [];
|
|
258
|
+
const lines = readFileSync3(this.path, "utf8").trim().split("\n").filter(Boolean);
|
|
259
|
+
return lines.slice(-n).map((l) => JSON.parse(l));
|
|
260
|
+
}
|
|
261
|
+
failedLogins(withinMinutes) {
|
|
262
|
+
if (!existsSync2(this.path)) return 0;
|
|
263
|
+
const since = new Date(Date.now() - withinMinutes * 6e4);
|
|
264
|
+
const lines = readFileSync3(this.path, "utf8").trim().split("\n").filter(Boolean);
|
|
265
|
+
return lines.map((l) => JSON.parse(l)).filter((e) => e.action === "login" && e.status === "fail" && new Date(e.timestamp) >= since).length;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// src/mcp.ts
|
|
270
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
271
|
+
import { z } from "zod";
|
|
272
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
273
|
+
|
|
274
|
+
// src/runner.ts
|
|
275
|
+
import { spawnSync } from "child_process";
|
|
276
|
+
import { writeFileSync as writeFileSync3, unlinkSync } from "fs";
|
|
277
|
+
import { tmpdir } from "os";
|
|
278
|
+
import { join } from "path";
|
|
279
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
280
|
+
function substituteEnv(args, env) {
|
|
281
|
+
return args.map(
|
|
282
|
+
(arg) => arg.replace(/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, (_, a, b) => env[a ?? b] ?? "")
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
function trimResult(stdout, stderr, maxChars) {
|
|
286
|
+
const truncated = stdout.length > maxChars || stderr.length > maxChars;
|
|
287
|
+
const result = {
|
|
288
|
+
exit_code: 0,
|
|
289
|
+
stdout: stdout.slice(0, maxChars),
|
|
290
|
+
truncated
|
|
291
|
+
};
|
|
292
|
+
if (stderr.trim()) result.stderr = stderr.slice(0, maxChars);
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
function runCommand(command, env, policy, maxChars = 500) {
|
|
296
|
+
const [bin, ...rawArgs] = command;
|
|
297
|
+
if (!bin) throw new Error("Command is empty");
|
|
298
|
+
if (policy.allow_list !== null && !policy.allow_list.includes(bin)) {
|
|
299
|
+
throw new Error(`Command is not allowed: ${bin}`);
|
|
300
|
+
}
|
|
301
|
+
const args = policy.allow_substitution ? substituteEnv(rawArgs, env) : rawArgs;
|
|
302
|
+
const proc = spawnSync(bin, args, {
|
|
303
|
+
env: { ...process.env, ...env },
|
|
304
|
+
encoding: "utf8",
|
|
305
|
+
maxBuffer: 10 * 1024 * 1024
|
|
306
|
+
});
|
|
307
|
+
const r = trimResult(proc.stdout ?? "", proc.stderr ?? "", maxChars);
|
|
308
|
+
r.exit_code = proc.status ?? 1;
|
|
309
|
+
return r;
|
|
310
|
+
}
|
|
311
|
+
function runScript(_lang, code, env, maxChars = 500) {
|
|
312
|
+
const ext = ".mjs";
|
|
313
|
+
const tmpFile = join(tmpdir(), `zkt-${randomBytes3(8).toString("hex")}${ext}`);
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync3(tmpFile, code, "utf8");
|
|
316
|
+
const bin = "node";
|
|
317
|
+
const proc = spawnSync(bin, [tmpFile], {
|
|
318
|
+
env: { ...process.env, ...env },
|
|
319
|
+
encoding: "utf8",
|
|
320
|
+
maxBuffer: 10 * 1024 * 1024
|
|
321
|
+
});
|
|
322
|
+
const r = trimResult(proc.stdout ?? "", proc.stderr ?? "", maxChars);
|
|
323
|
+
r.exit_code = proc.status ?? 1;
|
|
324
|
+
return r;
|
|
325
|
+
} finally {
|
|
326
|
+
try {
|
|
327
|
+
unlinkSync(tmpFile);
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/env-file.ts
|
|
334
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync3 } from "fs";
|
|
335
|
+
function listEnvKeys(filePath) {
|
|
336
|
+
if (!existsSync3(filePath)) return [];
|
|
337
|
+
return readFileSync4(filePath, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => l.split("=")[0].trim()).filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
function setEnvKey(filePath, key, value) {
|
|
340
|
+
const existing = existsSync3(filePath) ? readFileSync4(filePath, "utf8") : "";
|
|
341
|
+
const lines = existing ? existing.split("\n") : [];
|
|
342
|
+
const re = new RegExp(`^\\s*${key}\\s*=.*$`);
|
|
343
|
+
const idx = lines.findIndex((l) => re.test(l));
|
|
344
|
+
const entry = `${key}=${value}`;
|
|
345
|
+
if (idx !== -1) {
|
|
346
|
+
lines[idx] = entry;
|
|
347
|
+
} else {
|
|
348
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
|
|
349
|
+
lines.push(entry);
|
|
350
|
+
}
|
|
351
|
+
writeFileSync4(filePath, lines.join("\n"));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/api-registry.ts
|
|
355
|
+
var API_REGISTRY = [
|
|
356
|
+
// ── Stock / Media ──────────────────────────────────────────────────────────
|
|
357
|
+
{ name: "Pexels", keywords: ["pexels"], domains: ["api.pexels.com"] },
|
|
358
|
+
{ name: "Unsplash", keywords: ["unsplash"], domains: ["api.unsplash.com", "unsplash.com"] },
|
|
359
|
+
{ name: "Pixabay", keywords: ["pixabay"], domains: ["pixabay.com"] },
|
|
360
|
+
{ name: "Shutterstock", keywords: ["shutterstock"], domains: ["api.shutterstock.com"] },
|
|
361
|
+
{ name: "Getty Images", keywords: ["getty"], domains: ["api.gettyimages.com"] },
|
|
362
|
+
{ name: "Cloudinary", keywords: ["cloudinary"], domains: ["api.cloudinary.com", "res.cloudinary.com"] },
|
|
363
|
+
// ── AI / LLM ───────────────────────────────────────────────────────────────
|
|
364
|
+
{ name: "OpenAI", keywords: ["openai", "gpt", "chatgpt"], domains: ["api.openai.com"] },
|
|
365
|
+
{ name: "Anthropic", keywords: ["anthropic", "claude"], domains: ["api.anthropic.com"] },
|
|
366
|
+
{ name: "Gemini/Google", keywords: ["gemini", "bard"], domains: ["generativelanguage.googleapis.com"] },
|
|
367
|
+
{ name: "Mistral", keywords: ["mistral"], domains: ["api.mistral.ai"] },
|
|
368
|
+
{ name: "Cohere", keywords: ["cohere"], domains: ["api.cohere.ai", "api.cohere.com"] },
|
|
369
|
+
{ name: "Hugging Face", keywords: ["huggingface", "hf_"], domains: ["huggingface.co", "api-inference.huggingface.co"] },
|
|
370
|
+
{ name: "Together AI", keywords: ["together"], domains: ["api.together.xyz"] },
|
|
371
|
+
{ name: "Groq", keywords: ["groq"], domains: ["api.groq.com"] },
|
|
372
|
+
{ name: "Replicate", keywords: ["replicate"], domains: ["api.replicate.com"] },
|
|
373
|
+
{ name: "ElevenLabs", keywords: ["elevenlabs", "eleven_labs"], domains: ["api.elevenlabs.io"] },
|
|
374
|
+
{ name: "Stability AI", keywords: ["stability", "stablediffusion"], domains: ["api.stability.ai"] },
|
|
375
|
+
// ── Payment ────────────────────────────────────────────────────────────────
|
|
376
|
+
{ name: "Stripe", keywords: ["stripe"], domains: ["api.stripe.com", "checkout.stripe.com", "connect.stripe.com"] },
|
|
377
|
+
{ name: "PayPal", keywords: ["paypal"], domains: ["api.paypal.com", "api.sandbox.paypal.com"] },
|
|
378
|
+
{ name: "Braintree", keywords: ["braintree"], domains: ["api.braintreegateway.com", "sandbox.braintreegateway.com"] },
|
|
379
|
+
{ name: "Adyen", keywords: ["adyen"], domains: ["checkout-test.adyen.com", "checkout-live.adyenpayments.com"] },
|
|
380
|
+
{ name: "Square", keywords: ["square"], domains: ["connect.squareupsandbox.com", "connect.squareup.com"] },
|
|
381
|
+
{ name: "Paddle", keywords: ["paddle"], domains: ["api.paddle.com", "sandbox-api.paddle.com"] },
|
|
382
|
+
{ name: "LemonSqueezy", keywords: ["lemonsqueezy", "lemon_squeezy"], domains: ["api.lemonsqueezy.com"] },
|
|
383
|
+
{ name: "Coinbase", keywords: ["coinbase"], domains: ["api.coinbase.com", "api.commerce.coinbase.com"] },
|
|
384
|
+
// ── Cloud / Infrastructure ─────────────────────────────────────────────────
|
|
385
|
+
{ name: "AWS", keywords: ["aws", "amazon", "amazonaws"], domains: ["amazonaws.com", "s3.amazonaws.com", "ec2.amazonaws.com", "sts.amazonaws.com"] },
|
|
386
|
+
{ name: "Google Cloud", keywords: ["gcp", "google_cloud", "gcloud"], domains: ["googleapis.com", "storage.googleapis.com"] },
|
|
387
|
+
{ name: "Azure", keywords: ["azure"], domains: ["azure.com", "management.azure.com", "login.microsoftonline.com"] },
|
|
388
|
+
{ name: "Cloudflare", keywords: ["cloudflare", "cf_"], domains: ["api.cloudflare.com"] },
|
|
389
|
+
{ name: "DigitalOcean", keywords: ["digitalocean", "do_"], domains: ["api.digitalocean.com"] },
|
|
390
|
+
{ name: "Vercel", keywords: ["vercel"], domains: ["api.vercel.com"] },
|
|
391
|
+
{ name: "Netlify", keywords: ["netlify"], domains: ["api.netlify.com"] },
|
|
392
|
+
{ name: "Railway", keywords: ["railway"], domains: ["backboard.railway.app"] },
|
|
393
|
+
{ name: "Fly.io", keywords: ["fly", "flyio"], domains: ["api.machines.dev", "fly.io"] },
|
|
394
|
+
// ── Communication ──────────────────────────────────────────────────────────
|
|
395
|
+
{ name: "Twilio", keywords: ["twilio"], domains: ["api.twilio.com", "verify.twilio.com"] },
|
|
396
|
+
{ name: "SendGrid", keywords: ["sendgrid"], domains: ["api.sendgrid.com"] },
|
|
397
|
+
{ name: "Mailgun", keywords: ["mailgun"], domains: ["api.mailgun.net", "api.eu.mailgun.net"] },
|
|
398
|
+
{ name: "Postmark", keywords: ["postmark"], domains: ["api.postmarkapp.com"] },
|
|
399
|
+
{ name: "Resend", keywords: ["resend"], domains: ["api.resend.com"] },
|
|
400
|
+
{ name: "Slack", keywords: ["slack"], domains: ["slack.com", "hooks.slack.com"] },
|
|
401
|
+
{ name: "Discord", keywords: ["discord"], domains: ["discord.com", "discordapp.com"] },
|
|
402
|
+
{ name: "Telegram", keywords: ["telegram", "tg_bot"], domains: ["api.telegram.org"] },
|
|
403
|
+
{ name: "WhatsApp", keywords: ["whatsapp"], domains: ["graph.facebook.com"] },
|
|
404
|
+
{ name: "Vonage/Nexmo", keywords: ["vonage", "nexmo"], domains: ["rest.nexmo.com", "api.nexmo.com"] },
|
|
405
|
+
// ── Auth / Identity ────────────────────────────────────────────────────────
|
|
406
|
+
{ name: "Auth0", keywords: ["auth0"], domains: ["auth0.com"] },
|
|
407
|
+
{ name: "Clerk", keywords: ["clerk"], domains: ["api.clerk.dev", "clerk.com"] },
|
|
408
|
+
{ name: "Supabase", keywords: ["supabase"], domains: ["supabase.co", "supabase.io"] },
|
|
409
|
+
{ name: "Firebase", keywords: ["firebase", "firestore"], domains: ["firebase.googleapis.com", "firebaseio.com", "identitytoolkit.googleapis.com"] },
|
|
410
|
+
{ name: "Okta", keywords: ["okta"], domains: ["okta.com", "okta-emea.com"] },
|
|
411
|
+
// ── Dev Tools ──────────────────────────────────────────────────────────────
|
|
412
|
+
{ name: "GitHub", keywords: ["github", "gh_"], domains: ["api.github.com", "github.com"] },
|
|
413
|
+
{ name: "GitLab", keywords: ["gitlab"], domains: ["gitlab.com"] },
|
|
414
|
+
{ name: "Bitbucket", keywords: ["bitbucket"], domains: ["api.bitbucket.org"] },
|
|
415
|
+
{ name: "Linear", keywords: ["linear"], domains: ["api.linear.app"] },
|
|
416
|
+
{ name: "Jira/Atlassian", keywords: ["jira", "atlassian", "confluence"], domains: ["atlassian.net", "atlassian.com"] },
|
|
417
|
+
{ name: "Sentry", keywords: ["sentry"], domains: ["sentry.io"] },
|
|
418
|
+
{ name: "Datadog", keywords: ["datadog", "dd_"], domains: ["api.datadoghq.com", "api.datadoghq.eu"] },
|
|
419
|
+
{ name: "PagerDuty", keywords: ["pagerduty"], domains: ["api.pagerduty.com"] },
|
|
420
|
+
// ── Data / Analytics ──────────────────────────────────────────────────────
|
|
421
|
+
{ name: "Airtable", keywords: ["airtable"], domains: ["api.airtable.com"] },
|
|
422
|
+
{ name: "Notion", keywords: ["notion"], domains: ["api.notion.com"] },
|
|
423
|
+
{ name: "Supabase", keywords: ["supabase"], domains: ["supabase.co"] },
|
|
424
|
+
{ name: "PlanetScale", keywords: ["planetscale"], domains: ["api.planetscale.com"] },
|
|
425
|
+
{ name: "MongoDB Atlas", keywords: ["mongodb", "atlas"], domains: ["cloud.mongodb.com", "data.mongodb-api.com"] },
|
|
426
|
+
{ name: "Pinecone", keywords: ["pinecone"], domains: ["api.pinecone.io"] },
|
|
427
|
+
{ name: "Algolia", keywords: ["algolia"], domains: ["algolia.net", "algolianet.com"] },
|
|
428
|
+
{ name: "Elastic", keywords: ["elastic", "elasticsearch"], domains: ["cloud.elastic.co"] },
|
|
429
|
+
{ name: "Mixpanel", keywords: ["mixpanel"], domains: ["api.mixpanel.com"] },
|
|
430
|
+
{ name: "Amplitude", keywords: ["amplitude"], domains: ["api.amplitude.com", "api2.amplitude.com"] },
|
|
431
|
+
// ── Maps / Location ────────────────────────────────────────────────────────
|
|
432
|
+
{ name: "Google Maps", keywords: ["googlemaps", "google_maps", "gmaps"], domains: ["maps.googleapis.com"] },
|
|
433
|
+
{ name: "Mapbox", keywords: ["mapbox"], domains: ["api.mapbox.com"] },
|
|
434
|
+
{ name: "HERE Maps", keywords: ["here_maps", "heremaps"], domains: ["geocoder.ls.hereapi.com", "router.hereapi.com"] },
|
|
435
|
+
{ name: "OpenWeather", keywords: ["openweather", "owm"], domains: ["api.openweathermap.org"] },
|
|
436
|
+
// ── E-commerce ────────────────────────────────────────────────────────────
|
|
437
|
+
{ name: "Shopify", keywords: ["shopify"], domains: ["myshopify.com", "admin.shopify.com"] },
|
|
438
|
+
{ name: "WooCommerce", keywords: ["woocommerce", "woo"], domains: ["woocommerce.com"] },
|
|
439
|
+
{ name: "Amazon MWS/SP", keywords: ["amazon_seller", "mws"], domains: ["sellingpartnerapi-na.amazon.com", "mws.amazonservices.com"] }
|
|
440
|
+
];
|
|
441
|
+
function extractHints(projectName, keyNames) {
|
|
442
|
+
return [projectName, ...keyNames].join(" ").toLowerCase().split(/[\s_\-./]+/).filter((s) => s.length >= 3);
|
|
443
|
+
}
|
|
444
|
+
function checkDomainMatch(hints, destinationDomain) {
|
|
445
|
+
const dest = destinationDomain.toLowerCase();
|
|
446
|
+
for (const entry of API_REGISTRY) {
|
|
447
|
+
const matched = entry.keywords.some((kw) => hints.some((h) => h.includes(kw) || kw.includes(h)));
|
|
448
|
+
if (!matched) continue;
|
|
449
|
+
const ok2 = entry.domains.some((d) => dest === d || dest.endsWith("." + d) || d.endsWith("." + dest));
|
|
450
|
+
return { matched: entry, expected: entry.domains, actual: dest, ok: ok2 };
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/security.ts
|
|
456
|
+
var W = { CRITICAL: 20, HIGH: 10, MEDIUM: 5, LOW: 2 };
|
|
457
|
+
var RISK_THRESHOLDS = [
|
|
458
|
+
[20, "critical"],
|
|
459
|
+
[10, "high"],
|
|
460
|
+
[5, "medium"],
|
|
461
|
+
[1, "low"],
|
|
462
|
+
[0, "none"]
|
|
463
|
+
];
|
|
464
|
+
var BLOCK_SCORE = {
|
|
465
|
+
none: Infinity,
|
|
466
|
+
low: 1,
|
|
467
|
+
medium: 5,
|
|
468
|
+
high: 10,
|
|
469
|
+
critical: 20
|
|
470
|
+
};
|
|
471
|
+
function scoreToRisk(score) {
|
|
472
|
+
for (const [threshold, level] of RISK_THRESHOLDS) {
|
|
473
|
+
if (score >= threshold) return level;
|
|
474
|
+
}
|
|
475
|
+
return "none";
|
|
476
|
+
}
|
|
477
|
+
function extractDomains(text) {
|
|
478
|
+
const matches = [...text.matchAll(/https?:\/\/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g)];
|
|
479
|
+
return [...new Set(matches.map((m) => m[1].toLowerCase()))];
|
|
480
|
+
}
|
|
481
|
+
function domainAllowed(domain, allowedDomains) {
|
|
482
|
+
return allowedDomains.some((allowed) => {
|
|
483
|
+
const a = allowed.toLowerCase();
|
|
484
|
+
return domain === a || domain.endsWith("." + a);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
var NETWORK_RE = /\b(curl|wget|nc\b|ncat|socat|netcat|fetch|axios|requests?\.|urllib|http\.client|httpx|aiohttp|XMLHttpRequest|require\(['"]https?['"]\)|require\(["']node:https?["']\))\b/;
|
|
488
|
+
function envRefsIn(text) {
|
|
489
|
+
const matches = [...text.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)].map((m) => m[1]);
|
|
490
|
+
return [...new Set(matches)];
|
|
491
|
+
}
|
|
492
|
+
function buildCtx(text) {
|
|
493
|
+
return { text, envRefs: envRefsIn(text), hasNetwork: NETWORK_RE.test(text) };
|
|
494
|
+
}
|
|
495
|
+
var RULES = [
|
|
496
|
+
// ── Critical ────────────────────────────────────────────────────────────
|
|
497
|
+
{
|
|
498
|
+
id: "ENV_DUMP_PIPE_NETWORK",
|
|
499
|
+
description: "printenv or env piped directly to a network tool (classic exfiltration)",
|
|
500
|
+
severity: "critical",
|
|
501
|
+
weight: W.CRITICAL,
|
|
502
|
+
supersedes: ["BARE_ENV_DUMP", "SINGLE_ENV_NETWORK"],
|
|
503
|
+
test: (c) => /\b(printenv|env)\b[^|]*\|[^|]*(curl|wget|nc\b|ncat|socat|netcat)\b/.test(c.text)
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: "PROC_ENVIRON_READ",
|
|
507
|
+
description: "Reading another process environment via /proc/<pid>/environ",
|
|
508
|
+
severity: "critical",
|
|
509
|
+
weight: W.CRITICAL,
|
|
510
|
+
test: (c) => /\/proc\/[0-9*]+\/environ/.test(c.text)
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: "LOOP_ENV_EXFIL",
|
|
514
|
+
description: "Loop iterating over environment variables combined with network call",
|
|
515
|
+
severity: "critical",
|
|
516
|
+
weight: W.CRITICAL,
|
|
517
|
+
supersedes: ["MULTIPLE_SECRETS_ARGS", "SINGLE_ENV_NETWORK"],
|
|
518
|
+
test: (c) => /\bfor\b[\s\S]{0,120}(printenv|os\.environ|process\.env)[\s\S]{0,300}(curl|wget|fetch|requests?\.|urllib|http\.client)\b/.test(c.text) || /for\s+\w+\s+in\s+(os\.environ|os\.environ\.items\(\)|os\.environ\.keys\(\))[\s\S]{0,300}(requests?|urllib|http\.client|curl|wget)\b/.test(c.text)
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
id: "MULTI_SECRET_IN_URL",
|
|
522
|
+
description: "Two or more distinct secrets referenced in a single outbound URL or query string",
|
|
523
|
+
severity: "critical",
|
|
524
|
+
weight: W.CRITICAL,
|
|
525
|
+
supersedes: ["MULTIPLE_SECRETS_ARGS", "SINGLE_ENV_NETWORK"],
|
|
526
|
+
test: (c) => {
|
|
527
|
+
if (c.envRefs.length < 2 || !c.hasNetwork) return false;
|
|
528
|
+
if (/https?:\/\/[^\s]*\$\{?[A-Z_][A-Z0-9_]*\}?[^\s]*\$\{?[A-Z_][A-Z0-9_]*\}?/.test(c.text)) return true;
|
|
529
|
+
if (/[?&]\w+=\$\{?[A-Z_][A-Z0-9_]*\}?(&\w+=\$\{?[A-Z_][A-Z0-9_]*\}?)+/.test(c.text)) return true;
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
// ── High ────────────────────────────────────────────────────────────────
|
|
534
|
+
{
|
|
535
|
+
id: "ENCODE_PIPE_NETWORK",
|
|
536
|
+
description: "Base64/hex encoding of data combined with network transmission (obfuscated exfiltration)",
|
|
537
|
+
severity: "high",
|
|
538
|
+
weight: W.HIGH,
|
|
539
|
+
supersedes: ["SINGLE_ENV_NETWORK"],
|
|
540
|
+
test: (c) => /(base64|xxd\b|od\b|hexdump|btoa\(|\.toString\(['"]base64['"]\)|b64encode|binascii\.hexlify)[\s\S]{0,400}(curl|wget|nc\b|fetch|requests?\.|urllib)/.test(c.text)
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: "SENSITIVE_FILE_EXFIL",
|
|
544
|
+
description: "Reading sensitive system files (/etc/shadow, ~/.ssh/) combined with network call",
|
|
545
|
+
severity: "high",
|
|
546
|
+
weight: W.HIGH,
|
|
547
|
+
test: (c) => /(\/etc\/shadow|\/etc\/passwd|\/root\/\.ssh|~\/\.ssh\/|\/home\/[^/\s]+\/\.ssh\/)[\s\S]{0,400}(curl|wget|nc\b|fetch|requests?\.|urllib|http\.client)/.test(c.text) || /(curl|wget|nc\b|fetch|requests?\.|urllib)[\s\S]{0,400}(\/etc\/shadow|\/etc\/passwd|\/root\/\.ssh|~\/\.ssh\/|\/home\/[^/\s]+\/\.ssh\/)/.test(c.text)
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: "ENV_DUMP_SUBSHELL",
|
|
551
|
+
description: "printenv/env used inside a subshell substitution",
|
|
552
|
+
severity: "high",
|
|
553
|
+
weight: W.HIGH,
|
|
554
|
+
supersedes: ["BARE_ENV_DUMP"],
|
|
555
|
+
test: (c) => /\$\(\s*(printenv|env)\s*\)/.test(c.text)
|
|
556
|
+
},
|
|
557
|
+
// ── Medium ───────────────────────────────────────────────────────────────
|
|
558
|
+
{
|
|
559
|
+
id: "BARE_ENV_DUMP",
|
|
560
|
+
description: "Bare printenv/env call with no output destination \u2014 dumps all environment variables to stdout",
|
|
561
|
+
severity: "medium",
|
|
562
|
+
weight: W.MEDIUM,
|
|
563
|
+
test: (c) => /^\s*(printenv|env)\s*$/.test(c.text.trim())
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
id: "MULTIPLE_SECRETS_ARGS",
|
|
567
|
+
description: "Two or more distinct secrets referenced alongside a network operation",
|
|
568
|
+
severity: "medium",
|
|
569
|
+
weight: W.MEDIUM,
|
|
570
|
+
test: (c) => c.envRefs.length >= 2 && c.hasNetwork
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
id: "SCRIPT_ENUMERATE_ALL_ENV",
|
|
574
|
+
description: "Script bulk-reads all environment keys (Object.keys/entries or os.environ.items) with network present",
|
|
575
|
+
severity: "medium",
|
|
576
|
+
weight: W.MEDIUM,
|
|
577
|
+
test: (c) => c.hasNetwork && /(Object\.(keys|entries|values)\(process\.env\)|for\s*\(\s*(const\s+)?\[?\w+\]?\s+(?:in|of)\s+(?:Object\.\w+\()?process\.env|dict\(os\.environ\)|os\.environ\.items\(\)|os\.environ\.keys\(\))/.test(c.text)
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "REDIRECT_ENV_TO_FILE",
|
|
581
|
+
description: "Environment dump redirected to a file (data persistence risk)",
|
|
582
|
+
severity: "medium",
|
|
583
|
+
weight: W.MEDIUM,
|
|
584
|
+
test: (c) => /\b(printenv|env)\b[^|]*>/.test(c.text)
|
|
585
|
+
},
|
|
586
|
+
// ── Low ──────────────────────────────────────────────────────────────────
|
|
587
|
+
{
|
|
588
|
+
id: "SINGLE_ENV_NETWORK",
|
|
589
|
+
description: "A single secret is referenced alongside a network call (may be legitimate API auth)",
|
|
590
|
+
severity: "low",
|
|
591
|
+
weight: W.LOW,
|
|
592
|
+
test: (c) => c.envRefs.length >= 1 && c.hasNetwork
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
id: "PROC_SELF_ENVIRON",
|
|
596
|
+
description: "Reading /proc/self/environ (own process environment)",
|
|
597
|
+
severity: "low",
|
|
598
|
+
weight: W.LOW,
|
|
599
|
+
test: (c) => /\/proc\/self\/environ/.test(c.text)
|
|
600
|
+
},
|
|
601
|
+
// This rule is only registered when allowed_domains is configured — see analyze()
|
|
602
|
+
{
|
|
603
|
+
id: "UNKNOWN_DOMAIN",
|
|
604
|
+
description: "Secret sent to a domain not in exec_allowed_domains allowlist",
|
|
605
|
+
severity: "high",
|
|
606
|
+
weight: W.HIGH,
|
|
607
|
+
test: (_) => false
|
|
608
|
+
// replaced dynamically in analyze()
|
|
609
|
+
},
|
|
610
|
+
// This rule is only active when hints are configured — see analyze()
|
|
611
|
+
{
|
|
612
|
+
id: "SUSPICIOUS_DOMAIN",
|
|
613
|
+
description: "Secret sent to a domain that does not match the known API for this project (semantic mismatch)",
|
|
614
|
+
severity: "medium",
|
|
615
|
+
weight: W.MEDIUM,
|
|
616
|
+
supersedes: ["SINGLE_ENV_NETWORK"],
|
|
617
|
+
test: (_) => false
|
|
618
|
+
// replaced dynamically in analyze()
|
|
619
|
+
}
|
|
620
|
+
];
|
|
621
|
+
var ALLOWANCES = [
|
|
622
|
+
{
|
|
623
|
+
name: "AUTH_HEADER_SINGLE_KEY",
|
|
624
|
+
score_delta: -3,
|
|
625
|
+
// curl -H "Authorization: Bearer $API_KEY" https://api.example.com
|
|
626
|
+
test: (c) => c.envRefs.length === 1 && /-H\s+['"]?Authorization:\s*(Bearer|Basic|Token)\s+\$\{?[A-Z_][A-Z0-9_]*\}?['"]?/.test(c.text)
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: "STATIC_HTTPS_TARGET",
|
|
630
|
+
score_delta: -1,
|
|
631
|
+
// URL target is a static domain, not $VAR-based host
|
|
632
|
+
test: (c) => /https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(c.text) && !/https?:\/\/\$\{?[A-Z_]/.test(c.text)
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
name: "SINGLE_ENV_VAR_ONLY",
|
|
636
|
+
score_delta: -1,
|
|
637
|
+
test: (c) => c.envRefs.length === 1
|
|
638
|
+
}
|
|
639
|
+
];
|
|
640
|
+
var SecurityAnalyzer = class {
|
|
641
|
+
constructor(cfg) {
|
|
642
|
+
this.cfg = cfg;
|
|
643
|
+
}
|
|
644
|
+
analyzeCommand(command) {
|
|
645
|
+
if (this.cfg.mode === "off") return allow();
|
|
646
|
+
const isBashC = command.length >= 3 && /^(ba?sh|sh|dash|zsh|ksh)$/.test(command[0]) && command[1] === "-c";
|
|
647
|
+
const text = isBashC ? command[2] : command.join(" ");
|
|
648
|
+
return this.analyze(buildCtx(text));
|
|
649
|
+
}
|
|
650
|
+
analyzeScript(_lang, code) {
|
|
651
|
+
if (this.cfg.mode === "off") return allow();
|
|
652
|
+
const ctx = buildCtx(code);
|
|
653
|
+
return this.analyze(ctx);
|
|
654
|
+
}
|
|
655
|
+
analyze(ctx) {
|
|
656
|
+
const fired = /* @__PURE__ */ new Set();
|
|
657
|
+
const findings = [];
|
|
658
|
+
const allowedDomains = this.cfg.allowed_domains;
|
|
659
|
+
const unknownDomainRule = RULES.find((r) => r.id === "UNKNOWN_DOMAIN");
|
|
660
|
+
if (allowedDomains && allowedDomains.length > 0) {
|
|
661
|
+
unknownDomainRule.test = (c) => {
|
|
662
|
+
if (!c.hasNetwork || c.envRefs.length === 0) return false;
|
|
663
|
+
const domains = extractDomains(c.text);
|
|
664
|
+
if (domains.length === 0) return false;
|
|
665
|
+
return domains.some((d) => !domainAllowed(d, allowedDomains));
|
|
666
|
+
};
|
|
667
|
+
} else {
|
|
668
|
+
unknownDomainRule.test = () => false;
|
|
669
|
+
}
|
|
670
|
+
const hints = this.cfg.hints ?? null;
|
|
671
|
+
const suspiciousDomainRule = RULES.find((r) => r.id === "SUSPICIOUS_DOMAIN");
|
|
672
|
+
if (hints && hints.length > 0) {
|
|
673
|
+
suspiciousDomainRule.test = (c) => {
|
|
674
|
+
if (!c.hasNetwork || c.envRefs.length === 0) return false;
|
|
675
|
+
const domains = extractDomains(c.text);
|
|
676
|
+
if (domains.length === 0) return false;
|
|
677
|
+
return domains.some((d) => {
|
|
678
|
+
const result = checkDomainMatch(hints, d);
|
|
679
|
+
return result !== null && !result.ok;
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
} else {
|
|
683
|
+
suspiciousDomainRule.test = () => false;
|
|
684
|
+
}
|
|
685
|
+
const sorted = [...RULES].sort((a, b) => b.weight - a.weight);
|
|
686
|
+
for (const rule of sorted) {
|
|
687
|
+
if (fired.has(rule.id)) continue;
|
|
688
|
+
if (rule.test(ctx)) {
|
|
689
|
+
findings.push({ pattern: rule.id, description: rule.description, severity: rule.severity });
|
|
690
|
+
fired.add(rule.id);
|
|
691
|
+
rule.supersedes?.forEach((id) => fired.add(id));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
let score = findings.reduce((s, f) => {
|
|
695
|
+
const rule = RULES.find((r) => r.id === f.pattern);
|
|
696
|
+
return s + rule.weight;
|
|
697
|
+
}, 0);
|
|
698
|
+
const hasSuspiciousDomain = findings.some((f) => f.pattern === "SUSPICIOUS_DOMAIN");
|
|
699
|
+
if (score < W.HIGH && !hasSuspiciousDomain) {
|
|
700
|
+
for (const allowance of ALLOWANCES) {
|
|
701
|
+
if (allowance.test(ctx)) score += allowance.score_delta;
|
|
702
|
+
}
|
|
703
|
+
score = Math.max(0, score);
|
|
704
|
+
}
|
|
705
|
+
const risk = scoreToRisk(score);
|
|
706
|
+
const blocked = this.cfg.mode === "enforce" && score >= BLOCK_SCORE[this.cfg.block_threshold];
|
|
707
|
+
const allowed = !blocked;
|
|
708
|
+
return {
|
|
709
|
+
allowed,
|
|
710
|
+
risk,
|
|
711
|
+
score,
|
|
712
|
+
findings,
|
|
713
|
+
reason: blocked ? `[${risk.toUpperCase()}] ${findings.map((f) => f.pattern).join(", ")}` : void 0
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
function createAnalyzer(cfg, allowedDomains = null, hints = null) {
|
|
718
|
+
return new SecurityAnalyzer({
|
|
719
|
+
mode: cfg.security_mode,
|
|
720
|
+
block_threshold: cfg.security_block_threshold,
|
|
721
|
+
allowed_domains: allowedDomains,
|
|
722
|
+
hints
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
function allow() {
|
|
726
|
+
return { allowed: true, risk: "none", score: 0, findings: [] };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/mcp.ts
|
|
730
|
+
var SYSTEM_INSTRUCTIONS = `
|
|
731
|
+
Zocket MCP \u2014 encrypted local vault + safe command runner.
|
|
732
|
+
|
|
733
|
+
Rules:
|
|
734
|
+
- Secret VALUES are never returned by any tool. Use run_with_project_env or run_script to consume them.
|
|
735
|
+
- Filesystem is NOT shared between tool calls. Do not save intermediate data to /tmp.
|
|
736
|
+
- Prefer run_script for multi-step data processing instead of many sequential run_with_project_env calls.
|
|
737
|
+
- Use max_chars: 200 for status-only checks; only request more when you actually need the full output.
|
|
738
|
+
- Use output_filter (jq expression) to extract only the field you need from JSON responses.
|
|
739
|
+
- $VAR and \${VAR} placeholders in command args are substituted server-side with project secrets.
|
|
740
|
+
- In lazy mode: call list_tools_summary to see available tools, then activate_tool(name) before first use.
|
|
741
|
+
`.trim();
|
|
742
|
+
function applyJq(json, expr) {
|
|
743
|
+
const proc = spawnSync2("jq", ["-r", expr], {
|
|
744
|
+
input: json,
|
|
745
|
+
encoding: "utf8",
|
|
746
|
+
timeout: 3e3
|
|
747
|
+
});
|
|
748
|
+
if (proc.status === 0 && proc.stdout) return proc.stdout.trimEnd();
|
|
749
|
+
return json;
|
|
750
|
+
}
|
|
751
|
+
function ok(data) {
|
|
752
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
753
|
+
}
|
|
754
|
+
function err(message) {
|
|
755
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
|
|
756
|
+
}
|
|
757
|
+
function buildCatalog(services) {
|
|
758
|
+
const { vault, config, audit } = services;
|
|
759
|
+
return [
|
|
760
|
+
{
|
|
761
|
+
name: "list_projects",
|
|
762
|
+
summary: "List all projects (name, description, secret_count, folder_path). No secret values.",
|
|
763
|
+
register: (server) => {
|
|
764
|
+
server.tool(
|
|
765
|
+
"list_projects",
|
|
766
|
+
"List all projects. Returns name, description, secret_count, folder_path. No secret values.",
|
|
767
|
+
async () => {
|
|
768
|
+
try {
|
|
769
|
+
return ok({ projects: await vault.listProjects() });
|
|
770
|
+
} catch (e) {
|
|
771
|
+
return err(String(e));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
name: "list_project_keys",
|
|
779
|
+
summary: "List secret key names for a project. Values are never returned.",
|
|
780
|
+
register: (server) => {
|
|
781
|
+
server.tool(
|
|
782
|
+
"list_project_keys",
|
|
783
|
+
"List secret key names for a project. Values are never returned.",
|
|
784
|
+
{ project: z.string().describe("Project name") },
|
|
785
|
+
async ({ project }) => {
|
|
786
|
+
try {
|
|
787
|
+
return ok({ project, keys: await vault.listKeys(project) });
|
|
788
|
+
} catch (e) {
|
|
789
|
+
return err(String(e));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
name: "get_exec_policy",
|
|
797
|
+
summary: "Get current command execution policy (allowed commands, output limits).",
|
|
798
|
+
register: (server) => {
|
|
799
|
+
server.tool(
|
|
800
|
+
"get_exec_policy",
|
|
801
|
+
"Get the current command execution policy (allowed commands, output limits).",
|
|
802
|
+
async () => {
|
|
803
|
+
const cfg = config.load();
|
|
804
|
+
return ok({
|
|
805
|
+
allow_list: cfg.exec_allow_list,
|
|
806
|
+
max_output: cfg.exec_max_output,
|
|
807
|
+
allow_substitution: cfg.exec_allow_substitution
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
name: "run_with_project_env",
|
|
815
|
+
summary: "Run a command with project secrets injected as $VAR env placeholders.",
|
|
816
|
+
register: (server) => {
|
|
817
|
+
server.tool(
|
|
818
|
+
"run_with_project_env",
|
|
819
|
+
[
|
|
820
|
+
"Run a command with project secrets injected as environment variables.",
|
|
821
|
+
"Use $VAR or ${VAR} placeholders in args \u2014 substituted server-side.",
|
|
822
|
+
"Tip: use output_filter (jq expression) to extract only the field you need.",
|
|
823
|
+
"Tip: use max_chars: 200 for status-only checks."
|
|
824
|
+
].join(" "),
|
|
825
|
+
{
|
|
826
|
+
project: z.string().describe("Project name"),
|
|
827
|
+
command: z.array(z.string()).min(1).describe('Command and args, e.g. ["curl", "-H", "Authorization: $API_KEY", "https://..."]'),
|
|
828
|
+
max_chars: z.number().int().min(1).max(32e3).optional().describe("Max output chars (default ~500)"),
|
|
829
|
+
output_filter: z.string().optional().describe('jq expression to filter JSON stdout, e.g. ".items[0].url"'),
|
|
830
|
+
confirm: z.boolean().optional().describe("Set to true to confirm execution of a medium-risk command after reviewing the warning")
|
|
831
|
+
},
|
|
832
|
+
async ({ project, command, max_chars, output_filter, confirm }) => {
|
|
833
|
+
try {
|
|
834
|
+
const cfg = config.load();
|
|
835
|
+
const allowedDomains = await vault.getAllowedDomains(project);
|
|
836
|
+
const keyNames = await vault.listKeys(project);
|
|
837
|
+
const hints = extractHints(project, keyNames);
|
|
838
|
+
const sec = createAnalyzer(cfg, allowedDomains, hints).analyzeCommand(command);
|
|
839
|
+
audit.log("security_check", "mcp", { project, command: command[0], risk: sec.risk, findings: sec.findings.map((f) => f.pattern) }, sec.allowed ? "ok" : "blocked");
|
|
840
|
+
if (!sec.allowed) return err(`Security check blocked command: ${sec.reason}`);
|
|
841
|
+
if (sec.risk === "medium" && !confirm) {
|
|
842
|
+
return ok({
|
|
843
|
+
requires_confirmation: true,
|
|
844
|
+
risk: sec.risk,
|
|
845
|
+
warning: `Command flagged as ${sec.risk.toUpperCase()} risk. Review findings and re-call with confirm: true to proceed.`,
|
|
846
|
+
findings: sec.findings.map((f) => ({ pattern: f.pattern, description: f.description }))
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const policy = {
|
|
850
|
+
allow_list: cfg.exec_allow_list,
|
|
851
|
+
max_output: cfg.exec_max_output,
|
|
852
|
+
allow_substitution: cfg.exec_allow_substitution
|
|
853
|
+
};
|
|
854
|
+
const env = await vault.getEnv(project);
|
|
855
|
+
const result = runCommand(command, env, policy, max_chars ?? cfg.exec_max_output);
|
|
856
|
+
if (output_filter && result.stdout) result.stdout = applyJq(result.stdout, output_filter);
|
|
857
|
+
audit.log("run_command", "mcp", { project, command: command[0] }, result.exit_code === 0 ? "ok" : "fail");
|
|
858
|
+
const res = { exit_code: result.exit_code, stdout: result.stdout };
|
|
859
|
+
if (result.stderr) res.stderr = result.stderr;
|
|
860
|
+
if (result.truncated) res.truncated = true;
|
|
861
|
+
return ok(res);
|
|
862
|
+
} catch (e) {
|
|
863
|
+
audit.log("run_command", "mcp", { project, command: command[0] }, "denied");
|
|
864
|
+
return err(String(e));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
name: "run_script",
|
|
872
|
+
summary: "Run an inline node script with project secrets injected as env vars. Prefer over multiple run_with_project_env calls.",
|
|
873
|
+
register: (server) => {
|
|
874
|
+
server.tool(
|
|
875
|
+
"run_script",
|
|
876
|
+
[
|
|
877
|
+
"Run an inline node script with project secrets available as environment variables.",
|
|
878
|
+
"Use this instead of multiple run_with_project_env calls \u2014 write the full logic in one script.",
|
|
879
|
+
"Filesystem is NOT shared between calls. Secret values never appear in this conversation."
|
|
880
|
+
].join(" "),
|
|
881
|
+
{
|
|
882
|
+
project: z.string().describe("Project name"),
|
|
883
|
+
lang: z.enum(["node"]).describe("Script language"),
|
|
884
|
+
code: z.string().min(1).describe("Full script source code"),
|
|
885
|
+
max_chars: z.number().int().min(1).max(32e3).optional().describe("Max output chars (default ~500)"),
|
|
886
|
+
confirm: z.boolean().optional().describe("Set to true to confirm execution of a medium-risk script after reviewing the warning")
|
|
887
|
+
},
|
|
888
|
+
async ({ project, lang, code, max_chars, confirm }) => {
|
|
889
|
+
try {
|
|
890
|
+
const cfg = config.load();
|
|
891
|
+
const allowedDomains = await vault.getAllowedDomains(project);
|
|
892
|
+
const keyNames = await vault.listKeys(project);
|
|
893
|
+
const hints = extractHints(project, keyNames);
|
|
894
|
+
const sec = createAnalyzer(cfg, allowedDomains, hints).analyzeScript(lang, code);
|
|
895
|
+
audit.log("security_check", "mcp", { project, lang, risk: sec.risk, findings: sec.findings.map((f) => f.pattern) }, sec.allowed ? "ok" : "blocked");
|
|
896
|
+
if (!sec.allowed) return err(`Security check blocked script: ${sec.reason}`);
|
|
897
|
+
if (sec.risk === "medium" && !confirm) {
|
|
898
|
+
return ok({
|
|
899
|
+
requires_confirmation: true,
|
|
900
|
+
risk: sec.risk,
|
|
901
|
+
warning: `Script flagged as ${sec.risk.toUpperCase()} risk. Review findings and re-call with confirm: true to proceed.`,
|
|
902
|
+
findings: sec.findings.map((f) => ({ pattern: f.pattern, description: f.description }))
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
const env = await vault.getEnv(project);
|
|
906
|
+
const result = runScript(lang, code, env, max_chars ?? cfg.exec_max_output);
|
|
907
|
+
audit.log("run_script", "mcp", { project, lang }, result.exit_code === 0 ? "ok" : "fail");
|
|
908
|
+
const res = { exit_code: result.exit_code, stdout: result.stdout };
|
|
909
|
+
if (result.stderr) res.stderr = result.stderr;
|
|
910
|
+
if (result.truncated) res.truncated = true;
|
|
911
|
+
return ok(res);
|
|
912
|
+
} catch (e) {
|
|
913
|
+
audit.log("run_script", "mcp", { project }, "denied");
|
|
914
|
+
return err(String(e));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
name: "env_keys",
|
|
922
|
+
summary: "List key names in a .env file. Values are never returned.",
|
|
923
|
+
register: (server) => {
|
|
924
|
+
server.tool(
|
|
925
|
+
"env_keys",
|
|
926
|
+
"List key names in a .env file. Values are never returned.",
|
|
927
|
+
{ path: z.string().describe("Absolute path to the .env file") },
|
|
928
|
+
({ path }) => {
|
|
929
|
+
try {
|
|
930
|
+
return ok({ path, keys: listEnvKeys(path) });
|
|
931
|
+
} catch (e) {
|
|
932
|
+
return err(String(e));
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
name: "env_set",
|
|
940
|
+
summary: "Insert or update a key=value pair in a .env file.",
|
|
941
|
+
register: (server) => {
|
|
942
|
+
server.tool(
|
|
943
|
+
"env_set",
|
|
944
|
+
"Insert or update a key=value pair in a .env file. Creates the file if it does not exist.",
|
|
945
|
+
{
|
|
946
|
+
path: z.string().describe("Absolute path to the .env file"),
|
|
947
|
+
key: z.string().describe("Variable name, e.g. API_KEY"),
|
|
948
|
+
value: z.string().describe("Value to set")
|
|
949
|
+
},
|
|
950
|
+
({ path, key, value }) => {
|
|
951
|
+
try {
|
|
952
|
+
setEnvKey(path, key, value);
|
|
953
|
+
audit.log("env_set", "mcp", { path, key }, "ok");
|
|
954
|
+
return ok({ path, key, updated: true });
|
|
955
|
+
} catch (e) {
|
|
956
|
+
return err(String(e));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
name: "set_project_folder",
|
|
964
|
+
summary: "Set (or clear) the local folder path associated with a project.",
|
|
965
|
+
register: (server) => {
|
|
966
|
+
server.tool(
|
|
967
|
+
"set_project_folder",
|
|
968
|
+
"Associate a local folder path with a project. Pass null to remove the association.",
|
|
969
|
+
{
|
|
970
|
+
project: z.string().describe("Project name"),
|
|
971
|
+
folder_path: z.string().nullable().describe("Absolute folder path, or null to clear")
|
|
972
|
+
},
|
|
973
|
+
async ({ project, folder_path }) => {
|
|
974
|
+
try {
|
|
975
|
+
await vault.setFolder(project, folder_path ?? void 0);
|
|
976
|
+
audit.log("set_project_folder", "mcp", { project, folder_path }, "ok");
|
|
977
|
+
return ok({ project, folder_path });
|
|
978
|
+
} catch (e) {
|
|
979
|
+
return err(String(e));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
name: "set_project_domains",
|
|
987
|
+
summary: "Set allowed outbound domains for a project. Requests to other domains will be blocked.",
|
|
988
|
+
register: (server) => {
|
|
989
|
+
server.tool(
|
|
990
|
+
"set_project_domains",
|
|
991
|
+
[
|
|
992
|
+
"Set the list of domains this project's secrets are allowed to be sent to.",
|
|
993
|
+
'Pass null to remove restrictions. Example: ["api.stripe.com", "zorin.pw"]',
|
|
994
|
+
"After setting, run_with_project_env and run_script will block requests to any other domain."
|
|
995
|
+
].join(" "),
|
|
996
|
+
{
|
|
997
|
+
project: z.string().describe("Project name"),
|
|
998
|
+
domains: z.array(z.string()).nullable().describe("Domain list (no protocol), or null to remove restriction")
|
|
999
|
+
},
|
|
1000
|
+
async ({ project, domains }) => {
|
|
1001
|
+
try {
|
|
1002
|
+
await vault.setAllowedDomains(project, domains);
|
|
1003
|
+
audit.log("set_project_domains", "mcp", { project, domains }, "ok");
|
|
1004
|
+
return ok({ project, allowed_domains: domains });
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
return err(String(e));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
name: "get_settings",
|
|
1014
|
+
summary: "Show current Zocket settings: security mode, loading mode, execution policy.",
|
|
1015
|
+
register: (server) => {
|
|
1016
|
+
server.tool(
|
|
1017
|
+
"get_settings",
|
|
1018
|
+
"Show current Zocket settings: security mode, loading mode, execution policy.",
|
|
1019
|
+
async () => {
|
|
1020
|
+
const cfg = config.load();
|
|
1021
|
+
return ok({
|
|
1022
|
+
security_mode: cfg.security_mode,
|
|
1023
|
+
security_block_threshold: cfg.security_block_threshold,
|
|
1024
|
+
mcp_loading: cfg.mcp_loading,
|
|
1025
|
+
exec_allow_list: cfg.exec_allow_list,
|
|
1026
|
+
exec_max_output: cfg.exec_max_output,
|
|
1027
|
+
exec_allow_substitution: cfg.exec_allow_substitution
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
name: "configure",
|
|
1035
|
+
summary: "Update Zocket settings: security mode (off/audit/enforce), loading mode (eager/lazy), block threshold.",
|
|
1036
|
+
register: (server) => {
|
|
1037
|
+
server.tool(
|
|
1038
|
+
"configure",
|
|
1039
|
+
[
|
|
1040
|
+
"Update Zocket settings. All parameters are optional \u2014 only provided fields are changed.",
|
|
1041
|
+
"security_mode: off (disabled), audit (log only), enforce (block threats).",
|
|
1042
|
+
"mcp_loading: eager (all tools at connect), lazy (on-demand via activate_tool). Takes effect on next server restart.",
|
|
1043
|
+
"security_block_threshold: minimum risk level that triggers a block (low/medium/high/critical)."
|
|
1044
|
+
].join(" "),
|
|
1045
|
+
{
|
|
1046
|
+
security_mode: z.enum(["off", "audit", "enforce"]).optional().describe("Security analysis mode"),
|
|
1047
|
+
security_block_threshold: z.enum(["low", "medium", "high", "critical"]).optional().describe("Block commands at this risk level and above"),
|
|
1048
|
+
mcp_loading: z.enum(["eager", "lazy"]).optional().describe("Tool registration strategy (takes effect on restart)")
|
|
1049
|
+
},
|
|
1050
|
+
async ({ security_mode, security_block_threshold, mcp_loading }) => {
|
|
1051
|
+
try {
|
|
1052
|
+
const cfg = config.load();
|
|
1053
|
+
if (security_mode !== void 0) cfg.security_mode = security_mode;
|
|
1054
|
+
if (security_block_threshold !== void 0) cfg.security_block_threshold = security_block_threshold;
|
|
1055
|
+
if (mcp_loading !== void 0) cfg.mcp_loading = mcp_loading;
|
|
1056
|
+
config.save(cfg);
|
|
1057
|
+
audit.log("configure", "mcp", { security_mode, security_block_threshold, mcp_loading }, "ok");
|
|
1058
|
+
return ok({
|
|
1059
|
+
security_mode: cfg.security_mode,
|
|
1060
|
+
security_block_threshold: cfg.security_block_threshold,
|
|
1061
|
+
mcp_loading: cfg.mcp_loading,
|
|
1062
|
+
note: mcp_loading !== void 0 ? "mcp_loading takes effect on next server restart" : void 0
|
|
1063
|
+
});
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
return err(String(e));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
];
|
|
1072
|
+
}
|
|
1073
|
+
function createMcpServer(services, options = {}) {
|
|
1074
|
+
const { loading = services.config.load().mcp_loading } = options;
|
|
1075
|
+
const server = new McpServer(
|
|
1076
|
+
{ name: "zocket", version: "1.0.0" },
|
|
1077
|
+
{ instructions: SYSTEM_INSTRUCTIONS }
|
|
1078
|
+
);
|
|
1079
|
+
const catalog = buildCatalog(services);
|
|
1080
|
+
if (loading === "eager") {
|
|
1081
|
+
for (const entry of catalog) entry.register(server, services);
|
|
1082
|
+
return server;
|
|
1083
|
+
}
|
|
1084
|
+
const activated = /* @__PURE__ */ new Set();
|
|
1085
|
+
server.tool(
|
|
1086
|
+
"list_tools_summary",
|
|
1087
|
+
"List available tools with short descriptions. Call activate_tool(name) to unlock one before use.",
|
|
1088
|
+
{
|
|
1089
|
+
query: z.string().optional().describe("Optional filter \u2014 returns tools whose name or summary contains this string")
|
|
1090
|
+
},
|
|
1091
|
+
({ query }) => {
|
|
1092
|
+
const q = query?.toLowerCase();
|
|
1093
|
+
const tools = catalog.filter((e) => !q || e.name.includes(q) || e.summary.toLowerCase().includes(q)).map((e) => ({ name: e.name, summary: e.summary, active: activated.has(e.name) }));
|
|
1094
|
+
return ok({ tools });
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
server.tool(
|
|
1098
|
+
"activate_tool",
|
|
1099
|
+
"Register a tool by name so it becomes available. Call list_tools_summary first to see options.",
|
|
1100
|
+
{ name: z.string().describe("Tool name to activate") },
|
|
1101
|
+
async ({ name }) => {
|
|
1102
|
+
const entry = catalog.find((e) => e.name === name);
|
|
1103
|
+
if (!entry) return err(`Unknown tool: ${name}. Call list_tools_summary to see available tools.`);
|
|
1104
|
+
if (activated.has(name)) return ok({ name, status: "already_active" });
|
|
1105
|
+
entry.register(server, services);
|
|
1106
|
+
activated.add(name);
|
|
1107
|
+
try {
|
|
1108
|
+
await server.server.notification({ method: "notifications/tools/list_changed" });
|
|
1109
|
+
} catch {
|
|
1110
|
+
}
|
|
1111
|
+
return ok({ name, status: "activated" });
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
1114
|
+
return server;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// src/web.ts
|
|
1118
|
+
import { Hono } from "hono";
|
|
1119
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
1120
|
+
import { createHmac, randomBytes as randomBytes5 } from "crypto";
|
|
1121
|
+
import { readdirSync, statSync, existsSync as existsSync4 } from "fs";
|
|
1122
|
+
import { join as join2, resolve as resolvePath, dirname as dirname4 } from "path";
|
|
1123
|
+
|
|
1124
|
+
// src/auth.ts
|
|
1125
|
+
import { randomBytes as randomBytes4, pbkdf2Sync, timingSafeEqual } from "crypto";
|
|
1126
|
+
var ITERATIONS = 6e5;
|
|
1127
|
+
var KEY_LEN = 32;
|
|
1128
|
+
var DIGEST = "sha256";
|
|
1129
|
+
function hashPassword(password) {
|
|
1130
|
+
const salt = randomBytes4(16).toString("hex");
|
|
1131
|
+
const hash = pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST).toString("hex");
|
|
1132
|
+
return { hash, salt };
|
|
1133
|
+
}
|
|
1134
|
+
function verifyPassword(password, hash, salt) {
|
|
1135
|
+
const derived = pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST);
|
|
1136
|
+
const expected = Buffer.from(hash, "hex");
|
|
1137
|
+
if (derived.length !== expected.length) return false;
|
|
1138
|
+
return timingSafeEqual(derived, expected);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/i18n.ts
|
|
1142
|
+
var messages = {
|
|
1143
|
+
en: {
|
|
1144
|
+
"app.tagline": "Local encrypted vault for MCP/CLI workflows.",
|
|
1145
|
+
"ui.projects": "Projects",
|
|
1146
|
+
"ui.name": "Name",
|
|
1147
|
+
"ui.keys_count": "Keys",
|
|
1148
|
+
"ui.new_project": "New project",
|
|
1149
|
+
"ui.optional_desc": "Description (optional)",
|
|
1150
|
+
"ui.optional_folder": "Project folder path (optional)",
|
|
1151
|
+
"ui.project_folder": "Project folder",
|
|
1152
|
+
"ui.not_set": "Not set",
|
|
1153
|
+
"ui.choose_folder": "Choose folder",
|
|
1154
|
+
"ui.save_folder": "Save folder",
|
|
1155
|
+
"ui.clear_folder": "Clear folder",
|
|
1156
|
+
"ui.folder_picker": "Folder picker",
|
|
1157
|
+
"ui.current_path": "Current path",
|
|
1158
|
+
"ui.parent_folder": "Up",
|
|
1159
|
+
"ui.roots": "Roots",
|
|
1160
|
+
"ui.select_folder": "Select this folder",
|
|
1161
|
+
"ui.close": "Close",
|
|
1162
|
+
"ui.loading": "Loading...",
|
|
1163
|
+
"ui.no_subfolders": "No subfolders",
|
|
1164
|
+
"ui.folder_picker_failed": "Failed to load folders",
|
|
1165
|
+
"ui.create": "Create",
|
|
1166
|
+
"ui.real_values_visible": "Real values are visible.",
|
|
1167
|
+
"ui.hide_values": "Hide values",
|
|
1168
|
+
"ui.masked_values_visible": "Masked values are visible.",
|
|
1169
|
+
"ui.show_values": "Show values",
|
|
1170
|
+
"ui.delete_project": "Delete project",
|
|
1171
|
+
"ui.secrets": "Secrets",
|
|
1172
|
+
"ui.description": "Description",
|
|
1173
|
+
"ui.updated_at": "Updated",
|
|
1174
|
+
"ui.value": "Value",
|
|
1175
|
+
"ui.delete": "Delete",
|
|
1176
|
+
"ui.edit_secret": "Edit secret",
|
|
1177
|
+
"ui.add_or_update_secret": "Add or update secret",
|
|
1178
|
+
"ui.secret_preset": "Secret preset",
|
|
1179
|
+
"ui.choose_preset": "Choose preset",
|
|
1180
|
+
"ui.friendly_name": "Friendly name (optional)",
|
|
1181
|
+
"ui.key": "Key",
|
|
1182
|
+
"ui.folder_search": "Search folders",
|
|
1183
|
+
"ui.no_matching_folders": "No matching folders",
|
|
1184
|
+
"ui.save": "Save",
|
|
1185
|
+
"ui.no_projects": "No projects yet",
|
|
1186
|
+
"ui.create_left": "Create a project in the left panel.",
|
|
1187
|
+
"ui.lang": "Language",
|
|
1188
|
+
"ui.lang_en": "English",
|
|
1189
|
+
"ui.lang_ru": "Russian",
|
|
1190
|
+
"ui.theme": "Theme",
|
|
1191
|
+
"ui.theme_standard": "Standard",
|
|
1192
|
+
"ui.theme_zorin": "Zorin Pretty",
|
|
1193
|
+
"ui.variant_light": "Light view",
|
|
1194
|
+
"ui.variant_dark": "Dark glow",
|
|
1195
|
+
"ui.sign_in": "Sign in",
|
|
1196
|
+
"ui.password": "Password",
|
|
1197
|
+
"ui.password_repeat": "Repeat password",
|
|
1198
|
+
"ui.login": "Login",
|
|
1199
|
+
"ui.logout": "Logout",
|
|
1200
|
+
"ui.invalid_login": "Invalid password",
|
|
1201
|
+
"ui.auth_required": "Authentication is required",
|
|
1202
|
+
"ui.first_time_set_password": "Set admin password first via CLI: zocket auth set-password",
|
|
1203
|
+
"ui.first_setup_title": "First launch setup",
|
|
1204
|
+
"ui.first_setup_subtitle": "Choose how to protect your local panel.",
|
|
1205
|
+
"ui.set_password": "Set your password",
|
|
1206
|
+
"ui.save_and_enter": "Save and open panel",
|
|
1207
|
+
"ui.generate_password": "Generate strong password",
|
|
1208
|
+
"ui.generate_password_hint": "A secure random password will be generated and shown once.",
|
|
1209
|
+
"ui.generate_and_enter": "Generate and open panel",
|
|
1210
|
+
"ui.continue_without_password": "Continue without password",
|
|
1211
|
+
"ui.insecure_warning": "This is less secure. Anyone with local access may open the panel.",
|
|
1212
|
+
"ui.i_understand_risk": "I understand the risk",
|
|
1213
|
+
"ui.continue_anyway": "Continue without password",
|
|
1214
|
+
"ui.insecure_confirm_dialog": "Continue without password? This is less secure.",
|
|
1215
|
+
"ui.confirm_insecure_required": "Please confirm that you understand the security risk.",
|
|
1216
|
+
"ui.invalid_setup_option": "Invalid setup option.",
|
|
1217
|
+
"ui.password_required": "Password is required.",
|
|
1218
|
+
"ui.passwords_do_not_match": "Passwords do not match.",
|
|
1219
|
+
"ui.generated_password_notice": "Generated admin password (shown once):",
|
|
1220
|
+
"ui.generated_password_save_now": "Save it now. You can change it later via CLI.",
|
|
1221
|
+
"msg.key_file": "Key file: {path}",
|
|
1222
|
+
"msg.vault_file": "Vault file: {path}",
|
|
1223
|
+
"msg.init_complete": "Initialization complete.",
|
|
1224
|
+
"msg.project_created": "Project created: {name}",
|
|
1225
|
+
"msg.project_deleted": "Project deleted: {name}",
|
|
1226
|
+
"msg.project_folder_set": "Project folder saved: {name}",
|
|
1227
|
+
"msg.project_folder_cleared": "Project folder cleared: {name}",
|
|
1228
|
+
"msg.secret_saved": "Secret {key} saved for project {project}",
|
|
1229
|
+
"msg.secret_deleted": "Secret {key} deleted from project {project}",
|
|
1230
|
+
"msg.password_set": "Web admin password was updated.",
|
|
1231
|
+
"msg.language_set": "Language set to {lang}.",
|
|
1232
|
+
"err.usage_use": "Usage: zocket use <project> -- <command> [args...]",
|
|
1233
|
+
"err.need_login": "Error: password login required."
|
|
1234
|
+
},
|
|
1235
|
+
ru: {
|
|
1236
|
+
"app.tagline": "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u0435 \u0448\u0438\u0444\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0435 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435 \u0434\u043B\u044F MCP/CLI.",
|
|
1237
|
+
"ui.projects": "\u041F\u0440\u043E\u0435\u043A\u0442\u044B",
|
|
1238
|
+
"ui.name": "\u0418\u043C\u044F",
|
|
1239
|
+
"ui.keys_count": "\u041A\u043B\u044E\u0447\u0435\u0439",
|
|
1240
|
+
"ui.new_project": "\u041D\u043E\u0432\u044B\u0439 \u043F\u0440\u043E\u0435\u043A\u0442",
|
|
1241
|
+
"ui.optional_desc": "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)",
|
|
1242
|
+
"ui.optional_folder": "\u041F\u0430\u043F\u043A\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)",
|
|
1243
|
+
"ui.project_folder": "\u041F\u0430\u043F\u043A\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430",
|
|
1244
|
+
"ui.not_set": "\u041D\u0435 \u0437\u0430\u0434\u0430\u043D\u043E",
|
|
1245
|
+
"ui.choose_folder": "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
|
1246
|
+
"ui.save_folder": "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
|
1247
|
+
"ui.clear_folder": "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
|
1248
|
+
"ui.folder_picker": "\u0412\u044B\u0431\u043E\u0440 \u043F\u0430\u043F\u043A\u0438",
|
|
1249
|
+
"ui.current_path": "\u0422\u0435\u043A\u0443\u0449\u0438\u0439 \u043F\u0443\u0442\u044C",
|
|
1250
|
+
"ui.parent_folder": "\u0412\u0432\u0435\u0440\u0445",
|
|
1251
|
+
"ui.roots": "\u041A\u043E\u0440\u043D\u0438",
|
|
1252
|
+
"ui.select_folder": "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u044D\u0442\u0443 \u043F\u0430\u043F\u043A\u0443",
|
|
1253
|
+
"ui.close": "\u0417\u0430\u043A\u0440\u044B\u0442\u044C",
|
|
1254
|
+
"ui.loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...",
|
|
1255
|
+
"ui.no_subfolders": "\u041F\u043E\u0434\u043F\u0430\u043F\u043E\u043A \u043D\u0435\u0442",
|
|
1256
|
+
"ui.folder_picker_failed": "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u043F\u0430\u043F\u043A\u0438",
|
|
1257
|
+
"ui.create": "\u0421\u043E\u0437\u0434\u0430\u0442\u044C",
|
|
1258
|
+
"ui.real_values_visible": "\u041F\u043E\u043A\u0430\u0437\u0430\u043D\u044B \u0440\u0435\u0430\u043B\u044C\u043D\u044B\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F.",
|
|
1259
|
+
"ui.hide_values": "\u0421\u043A\u0440\u044B\u0442\u044C \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F",
|
|
1260
|
+
"ui.masked_values_visible": "\u041F\u043E\u043A\u0430\u0437\u0430\u043D\u044B \u0437\u0430\u043C\u0430\u0441\u043A\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F.",
|
|
1261
|
+
"ui.show_values": "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F",
|
|
1262
|
+
"ui.delete_project": "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u0440\u043E\u0435\u043A\u0442",
|
|
1263
|
+
"ui.secrets": "\u0421\u0435\u043A\u0440\u0435\u0442\u044B",
|
|
1264
|
+
"ui.description": "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435",
|
|
1265
|
+
"ui.updated_at": "\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D",
|
|
1266
|
+
"ui.value": "\u0417\u043D\u0430\u0447\u0435\u043D\u0438\u0435",
|
|
1267
|
+
"ui.delete": "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
|
1268
|
+
"ui.edit_secret": "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
|
|
1269
|
+
"ui.add_or_update_secret": "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u0438\u043B\u0438 \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0441\u0435\u043A\u0440\u0435\u0442",
|
|
1270
|
+
"ui.secret_preset": "\u041F\u0440\u0435\u0441\u0435\u0442 \u0441\u0435\u043A\u0440\u0435\u0442\u0430",
|
|
1271
|
+
"ui.choose_preset": "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0440\u0435\u0441\u0435\u0442",
|
|
1272
|
+
"ui.friendly_name": "\u0427\u0438\u0442\u0430\u0431\u0435\u043B\u044C\u043D\u043E\u0435 \u0438\u043C\u044F (\u043E\u043F\u0446\u0438\u043E\u043D\u0430\u043B\u044C\u043D\u043E)",
|
|
1273
|
+
"ui.key": "\u041A\u043B\u044E\u0447",
|
|
1274
|
+
"ui.folder_search": "\u041F\u043E\u0438\u0441\u043A \u043F\u0430\u043F\u043E\u043A",
|
|
1275
|
+
"ui.no_matching_folders": "\u0421\u043E\u0432\u043F\u0430\u0434\u0435\u043D\u0438\u0439 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E",
|
|
1276
|
+
"ui.save": "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C",
|
|
1277
|
+
"ui.no_projects": "\u041F\u0440\u043E\u0435\u043A\u0442\u043E\u0432 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442",
|
|
1278
|
+
"ui.create_left": "\u0421\u043E\u0437\u0434\u0430\u0439\u0442\u0435 \u043F\u0440\u043E\u0435\u043A\u0442 \u0441\u043B\u0435\u0432\u0430.",
|
|
1279
|
+
"ui.lang": "\u042F\u0437\u044B\u043A",
|
|
1280
|
+
"ui.lang_en": "\u0410\u043D\u0433\u043B\u0438\u0439\u0441\u043A\u0438\u0439",
|
|
1281
|
+
"ui.lang_ru": "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
|
|
1282
|
+
"ui.theme": "\u0422\u0435\u043C\u0430",
|
|
1283
|
+
"ui.theme_standard": "\u0421\u0442\u0430\u043D\u0434\u0430\u0440\u0442\u043D\u0430\u044F",
|
|
1284
|
+
"ui.theme_zorin": "Zorin Pretty",
|
|
1285
|
+
"ui.variant_light": "\u0421\u0432\u0435\u0442\u043B\u0430\u044F",
|
|
1286
|
+
"ui.variant_dark": "\u0422\u0451\u043C\u043D\u0430\u044F",
|
|
1287
|
+
"ui.sign_in": "\u0412\u0445\u043E\u0434",
|
|
1288
|
+
"ui.password": "\u041F\u0430\u0440\u043E\u043B\u044C",
|
|
1289
|
+
"ui.password_repeat": "\u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C",
|
|
1290
|
+
"ui.login": "\u0412\u043E\u0439\u0442\u0438",
|
|
1291
|
+
"ui.logout": "\u0412\u044B\u0439\u0442\u0438",
|
|
1292
|
+
"ui.invalid_login": "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u043F\u0430\u0440\u043E\u043B\u044C",
|
|
1293
|
+
"ui.auth_required": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044F \u0430\u0443\u0442\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0446\u0438\u044F",
|
|
1294
|
+
"ui.first_time_set_password": "\u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u0437\u0430\u0434\u0430\u0439\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C \u0447\u0435\u0440\u0435\u0437 CLI: zocket auth set-password",
|
|
1295
|
+
"ui.first_setup_title": "\u041F\u0435\u0440\u0432\u0438\u0447\u043D\u0430\u044F \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0430",
|
|
1296
|
+
"ui.first_setup_subtitle": "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u043E\u0441\u043E\u0431 \u0437\u0430\u0449\u0438\u0442\u044B \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u0439 \u043F\u0430\u043D\u0435\u043B\u0438.",
|
|
1297
|
+
"ui.set_password": "\u0417\u0430\u0434\u0430\u0442\u044C \u0441\u0432\u043E\u0439 \u043F\u0430\u0440\u043E\u043B\u044C",
|
|
1298
|
+
"ui.save_and_enter": "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C \u0438 \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u043F\u0430\u043D\u0435\u043B\u044C",
|
|
1299
|
+
"ui.generate_password": "\u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043D\u0430\u0434\u0451\u0436\u043D\u044B\u0439 \u043F\u0430\u0440\u043E\u043B\u044C",
|
|
1300
|
+
"ui.generate_password_hint": "\u0411\u0443\u0434\u0435\u0442 \u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D \u0441\u043B\u0443\u0447\u0430\u0439\u043D\u044B\u0439 \u043F\u0430\u0440\u043E\u043B\u044C \u0438 \u043F\u043E\u043A\u0430\u0437\u0430\u043D \u043E\u0434\u0438\u043D \u0440\u0430\u0437.",
|
|
1301
|
+
"ui.generate_and_enter": "\u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0438 \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u043F\u0430\u043D\u0435\u043B\u044C",
|
|
1302
|
+
"ui.continue_without_password": "\u041F\u0440\u043E\u0434\u043E\u043B\u0436\u0438\u0442\u044C \u0431\u0435\u0437 \u043F\u0430\u0440\u043E\u043B\u044F",
|
|
1303
|
+
"ui.insecure_warning": "\u042D\u0442\u043E \u043C\u0435\u043D\u0435\u0435 \u0431\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u043E. \u041B\u044E\u0431\u043E\u0439 \u0441 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u043C \u0434\u043E\u0441\u0442\u0443\u043F\u043E\u043C \u0441\u043C\u043E\u0436\u0435\u0442 \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u043F\u0430\u043D\u0435\u043B\u044C.",
|
|
1304
|
+
"ui.i_understand_risk": "\u042F \u043F\u043E\u043D\u0438\u043C\u0430\u044E \u0440\u0438\u0441\u043A",
|
|
1305
|
+
"ui.continue_anyway": "\u041F\u0440\u043E\u0434\u043E\u043B\u0436\u0438\u0442\u044C \u0431\u0435\u0437 \u043F\u0430\u0440\u043E\u043B\u044F",
|
|
1306
|
+
"ui.insecure_confirm_dialog": "\u041F\u0440\u043E\u0434\u043E\u043B\u0436\u0438\u0442\u044C \u0431\u0435\u0437 \u043F\u0430\u0440\u043E\u043B\u044F? \u042D\u0442\u043E \u043C\u0435\u043D\u0435\u0435 \u0431\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u043E.",
|
|
1307
|
+
"ui.confirm_insecure_required": "\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435, \u0447\u0442\u043E \u0432\u044B \u043F\u043E\u043D\u0438\u043C\u0430\u0435\u0442\u0435 \u0440\u0438\u0441\u043A \u0431\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u043E\u0441\u0442\u0438.",
|
|
1308
|
+
"ui.invalid_setup_option": "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u0432\u0430\u0440\u0438\u0430\u043D\u0442 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438.",
|
|
1309
|
+
"ui.password_required": "\u041D\u0443\u0436\u043D\u043E \u0432\u0432\u0435\u0441\u0442\u0438 \u043F\u0430\u0440\u043E\u043B\u044C.",
|
|
1310
|
+
"ui.passwords_do_not_match": "\u041F\u0430\u0440\u043E\u043B\u0438 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u044E\u0442.",
|
|
1311
|
+
"ui.generated_password_notice": "\u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u043F\u0430\u0440\u043E\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430 (\u043F\u043E\u043A\u0430\u0437\u0430\u043D \u043E\u0434\u0438\u043D \u0440\u0430\u0437):",
|
|
1312
|
+
"ui.generated_password_save_now": "\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u0435 \u0435\u0433\u043E \u0441\u0435\u0439\u0447\u0430\u0441. \u041F\u043E\u0437\u0436\u0435 \u043C\u043E\u0436\u043D\u043E \u0441\u043C\u0435\u043D\u0438\u0442\u044C \u0447\u0435\u0440\u0435\u0437 CLI.",
|
|
1313
|
+
"msg.key_file": "\u0424\u0430\u0439\u043B \u043A\u043B\u044E\u0447\u0430: {path}",
|
|
1314
|
+
"msg.vault_file": "\u0424\u0430\u0439\u043B vault: {path}",
|
|
1315
|
+
"msg.init_complete": "\u0418\u043D\u0438\u0446\u0438\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u044F \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430.",
|
|
1316
|
+
"msg.project_created": "\u041F\u0440\u043E\u0435\u043A\u0442 \u0441\u043E\u0437\u0434\u0430\u043D: {name}",
|
|
1317
|
+
"msg.project_deleted": "\u041F\u0440\u043E\u0435\u043A\u0442 \u0443\u0434\u0430\u043B\u0451\u043D: {name}",
|
|
1318
|
+
"msg.project_folder_set": "\u041F\u0430\u043F\u043A\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430: {name}",
|
|
1319
|
+
"msg.project_folder_cleared": "\u041F\u0430\u043F\u043A\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u043E\u0447\u0438\u0449\u0435\u043D\u0430: {name}",
|
|
1320
|
+
"msg.secret_saved": "\u0421\u0435\u043A\u0440\u0435\u0442 {key} \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D \u0434\u043B\u044F \u043F\u0440\u043E\u0435\u043A\u0442\u0430 {project}",
|
|
1321
|
+
"msg.secret_deleted": "\u0421\u0435\u043A\u0440\u0435\u0442 {key} \u0443\u0434\u0430\u043B\u0451\u043D \u0438\u0437 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 {project}",
|
|
1322
|
+
"msg.password_set": "\u041F\u0430\u0440\u043E\u043B\u044C \u0432\u0435\u0431-\u0430\u0434\u043C\u0438\u043D\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D.",
|
|
1323
|
+
"msg.language_set": "\u042F\u0437\u044B\u043A \u043F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0435\u043D \u043D\u0430 {lang}.",
|
|
1324
|
+
"err.usage_use": "\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435: zocket use <project> -- <command> [args...]",
|
|
1325
|
+
"err.need_login": "\u041E\u0448\u0438\u0431\u043A\u0430: \u043D\u0443\u0436\u0435\u043D \u043F\u0430\u0440\u043E\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434."
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
function t(key, lang = "en", vars = {}) {
|
|
1329
|
+
const msg = messages[lang]?.[key] ?? messages["en"]?.[key] ?? key;
|
|
1330
|
+
return msg.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
|
1331
|
+
}
|
|
1332
|
+
function normalizeLang(lang) {
|
|
1333
|
+
return lang?.toLowerCase().startsWith("ru") ? "ru" : "en";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// src/web.ts
|
|
1337
|
+
var COOKIE = "zs";
|
|
1338
|
+
function signSession(data, secret) {
|
|
1339
|
+
const payload = Buffer.from(JSON.stringify(data)).toString("base64url");
|
|
1340
|
+
const sig = createHmac("sha256", secret).update(payload).digest("hex").slice(0, 20);
|
|
1341
|
+
return `${payload}.${sig}`;
|
|
1342
|
+
}
|
|
1343
|
+
function parseSession(value, secret) {
|
|
1344
|
+
const dot = value.lastIndexOf(".");
|
|
1345
|
+
if (dot === -1) return null;
|
|
1346
|
+
const payload = value.slice(0, dot);
|
|
1347
|
+
const sig = value.slice(dot + 1);
|
|
1348
|
+
const expected = createHmac("sha256", secret).update(payload).digest("hex").slice(0, 20);
|
|
1349
|
+
if (sig !== expected) return null;
|
|
1350
|
+
try {
|
|
1351
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
1352
|
+
} catch {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function safeResolve(p) {
|
|
1357
|
+
return resolvePath(p.replace(/^~/, process.env.HOME ?? ""));
|
|
1358
|
+
}
|
|
1359
|
+
function isSubPath(child, parent) {
|
|
1360
|
+
return child === parent || child.startsWith(parent + "/");
|
|
1361
|
+
}
|
|
1362
|
+
var CSS_COMMON = `
|
|
1363
|
+
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap");
|
|
1364
|
+
:root{--p:#0088cc;--pd:#006699;--bg:#f4faff;--bga:#eaf4fb;--card:rgba(255,255,255,.92);--text:#1b2b37;--muted:#607889;--bdr:rgba(0,136,204,.23);--danger:#be123c;--ok:#0f766e}
|
|
1365
|
+
*{box-sizing:border-box}
|
|
1366
|
+
body{margin:0;font-family:"Manrope","Segoe UI",sans-serif;color:var(--text)}
|
|
1367
|
+
input,button,textarea{width:100%;border-radius:11px;padding:9px 10px;font:inherit;margin-bottom:8px}
|
|
1368
|
+
input,textarea{border:1px solid var(--bdr);background:rgba(255,255,255,.95)}
|
|
1369
|
+
input:focus,textarea:focus{outline:2px solid rgba(0,136,204,.28);border-color:var(--p)}
|
|
1370
|
+
button{border:0;cursor:pointer;color:#fff;font-weight:800;background:linear-gradient(95deg,var(--p),#00a7ff);box-shadow:0 10px 22px rgba(0,136,204,.23)}
|
|
1371
|
+
button:hover{transform:translateY(-1px)}
|
|
1372
|
+
.danger{background:linear-gradient(95deg,var(--danger),#e11d48)!important;box-shadow:0 10px 22px rgba(190,18,60,.25)!important}
|
|
1373
|
+
.mono{font-family:"JetBrains Mono","Fira Code",monospace;font-size:13px;word-break:break-word}
|
|
1374
|
+
.inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
1375
|
+
.inline form{margin:0}
|
|
1376
|
+
.btn-sm{width:auto;margin-bottom:0;padding:7px 11px}
|
|
1377
|
+
.error{margin-bottom:12px;padding:10px 12px;border:1px solid #fecdd3;background:#fff1f2;color:#9f1239;border-radius:12px}
|
|
1378
|
+
.notice{margin-bottom:12px;padding:10px 12px;border:1px solid #99f6e4;background:#f0fdfa;color:#115e59;border-radius:12px}
|
|
1379
|
+
`;
|
|
1380
|
+
function pageShell(lang, title, body) {
|
|
1381
|
+
return `<!doctype html>
|
|
1382
|
+
<html lang="${lang}"><head>
|
|
1383
|
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
1384
|
+
<title>${title}</title>
|
|
1385
|
+
<style>${CSS_COMMON}
|
|
1386
|
+
body{background:radial-gradient(40vw 40vw at 6% 0%,rgba(0,136,204,.20),transparent 55%),radial-gradient(45vw 45vw at 95% 100%,rgba(0,136,204,.17),transparent 60%),linear-gradient(140deg,var(--bg) 0%,var(--bga) 100%);min-height:100vh;padding:16px}
|
|
1387
|
+
.app{max-width:1320px;margin:0 auto}
|
|
1388
|
+
.top{border:1px solid var(--bdr);border-radius:20px;background:var(--card);box-shadow:0 18px 55px rgba(0,63,94,.17);backdrop-filter:blur(8px);padding:16px;display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:14px}
|
|
1389
|
+
.brand{font-size:clamp(22px,3vw,32px);font-weight:800;letter-spacing:-.03em}
|
|
1390
|
+
.brand span{background:linear-gradient(95deg,var(--p),#00a7ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
1391
|
+
.meta{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
|
1392
|
+
.meta a{text-decoration:none;color:var(--pd);font-weight:700}
|
|
1393
|
+
.layout{display:grid;grid-template-columns:360px 1fr;gap:14px}
|
|
1394
|
+
.card{border:1px solid var(--bdr);border-radius:20px;background:var(--card);box-shadow:0 16px 42px rgba(0,70,110,.14);backdrop-filter:blur(8px);padding:14px}
|
|
1395
|
+
h2,h3{margin:0 0 10px;letter-spacing:-.015em}
|
|
1396
|
+
p{margin:0 0 10px;color:var(--muted)}
|
|
1397
|
+
table{width:100%;border-collapse:collapse}
|
|
1398
|
+
th,td{text-align:left;border-bottom:1px solid rgba(0,136,204,.15);padding:8px 7px;vertical-align:top}
|
|
1399
|
+
th{font-size:13px;color:var(--muted)}
|
|
1400
|
+
.folder-field{display:flex;gap:8px;align-items:center}
|
|
1401
|
+
.folder-field input{margin-bottom:0}
|
|
1402
|
+
.modal[hidden]{display:none}
|
|
1403
|
+
.modal{position:fixed;inset:0;background:rgba(17,31,41,.62);display:flex;align-items:center;justify-content:center;padding:16px}
|
|
1404
|
+
.modal-card{width:min(820px,96vw);max-height:90vh;overflow:auto;border:1px solid var(--bdr);border-radius:18px;background:#fff;padding:14px}
|
|
1405
|
+
.modal-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
1406
|
+
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--bdr);border-radius:999px;padding:6px 10px;background:rgba(255,255,255,.8);color:var(--muted);font-size:13px}
|
|
1407
|
+
.folder-list{max-height:280px;overflow:auto;border:1px solid var(--bdr);border-radius:10px;padding:8px}
|
|
1408
|
+
.folder-list button{width:100%;text-align:left;margin-bottom:6px}
|
|
1409
|
+
.folder-list button:last-child{margin-bottom:0}
|
|
1410
|
+
hr{border:none;border-top:1px solid var(--bdr);margin:12px 0}
|
|
1411
|
+
@media(max-width:1040px){.layout{grid-template-columns:1fr}}
|
|
1412
|
+
@media(max-width:700px){.top{flex-direction:column;align-items:flex-start}}
|
|
1413
|
+
</style></head>
|
|
1414
|
+
<body><div class="app">${body}</div></body></html>`;
|
|
1415
|
+
}
|
|
1416
|
+
function loginPage(lang, error, missingPassword) {
|
|
1417
|
+
const body = `
|
|
1418
|
+
<style>
|
|
1419
|
+
body{display:grid;place-items:center;min-height:100vh;padding:20px}
|
|
1420
|
+
.shell{width:min(680px,100%);border-radius:22px;border:1px solid var(--bdr);background:linear-gradient(180deg,rgba(255,255,255,.96),rgba(255,255,255,.93));box-shadow:0 24px 70px rgba(0,62,95,.20);backdrop-filter:blur(7px);padding:22px}
|
|
1421
|
+
.head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:14px}
|
|
1422
|
+
.logo{font-size:24px;font-weight:800;letter-spacing:-.02em}
|
|
1423
|
+
.logo span{background:linear-gradient(95deg,var(--p),#00a9ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
1424
|
+
.lang a{color:var(--pd);text-decoration:none;font-weight:700;margin-left:10px}
|
|
1425
|
+
h1{margin:4px 0 6px;font-size:clamp(22px,4.6vw,32px);line-height:1.1;letter-spacing:-.02em}
|
|
1426
|
+
h2{margin:0 0 8px;font-size:17px;letter-spacing:-.01em}
|
|
1427
|
+
.section{border:1px solid var(--bdr);border-radius:14px;background:rgba(255,255,255,.82);padding:12px;margin-bottom:10px}
|
|
1428
|
+
.section.warn{border-color:rgba(190,18,60,.30);background:#fff7f9}
|
|
1429
|
+
.checkline{display:flex;gap:8px;align-items:flex-start;margin-bottom:8px;color:#374151}
|
|
1430
|
+
.checkline input{width:auto;margin:4px 0 0;padding:0}
|
|
1431
|
+
</style>
|
|
1432
|
+
<div class="shell">
|
|
1433
|
+
<div class="head">
|
|
1434
|
+
<div class="logo">zocket <span>pretty</span></div>
|
|
1435
|
+
<div class="lang"><a href="/login?lang=en">EN</a><a href="/login?lang=ru">RU</a></div>
|
|
1436
|
+
</div>
|
|
1437
|
+
${error ? `<div class="error">${error}</div>` : ""}
|
|
1438
|
+
${missingPassword ? `
|
|
1439
|
+
<h1>${t("ui.first_setup_title", lang)}</h1>
|
|
1440
|
+
<p>${t("ui.first_setup_subtitle", lang)}</p>
|
|
1441
|
+
<div class="section">
|
|
1442
|
+
<h2>${t("ui.set_password", lang)}</h2>
|
|
1443
|
+
<form method="post" action="/setup/first-run">
|
|
1444
|
+
<input type="hidden" name="mode" value="set_password"/>
|
|
1445
|
+
<input type="password" name="password" placeholder="${t("ui.password", lang)}" required/>
|
|
1446
|
+
<input type="password" name="password_repeat" placeholder="${t("ui.password_repeat", lang)}" required/>
|
|
1447
|
+
<button type="submit">${t("ui.save_and_enter", lang)}</button>
|
|
1448
|
+
</form>
|
|
1449
|
+
</div>
|
|
1450
|
+
<div class="section">
|
|
1451
|
+
<h2>${t("ui.generate_password", lang)}</h2>
|
|
1452
|
+
<p>${t("ui.generate_password_hint", lang)}</p>
|
|
1453
|
+
<form method="post" action="/setup/first-run">
|
|
1454
|
+
<input type="hidden" name="mode" value="generate_password"/>
|
|
1455
|
+
<button type="submit">${t("ui.generate_and_enter", lang)}</button>
|
|
1456
|
+
</form>
|
|
1457
|
+
</div>
|
|
1458
|
+
<div class="section warn">
|
|
1459
|
+
<h2>${t("ui.continue_without_password", lang)}</h2>
|
|
1460
|
+
<p>${t("ui.insecure_warning", lang)}</p>
|
|
1461
|
+
<form method="post" action="/setup/first-run" onsubmit="return confirm('${t("ui.insecure_confirm_dialog", lang)}')">
|
|
1462
|
+
<input type="hidden" name="mode" value="no_password"/>
|
|
1463
|
+
<label class="checkline">
|
|
1464
|
+
<input type="checkbox" name="confirm_no_password" value="1" required/>
|
|
1465
|
+
<span>${t("ui.i_understand_risk", lang)}</span>
|
|
1466
|
+
</label>
|
|
1467
|
+
<button class="danger" type="submit">${t("ui.continue_anyway", lang)}</button>
|
|
1468
|
+
</form>
|
|
1469
|
+
</div>
|
|
1470
|
+
` : `
|
|
1471
|
+
<h1>${t("ui.sign_in", lang)}</h1>
|
|
1472
|
+
<form method="post" action="/login">
|
|
1473
|
+
<input type="password" name="password" placeholder="${t("ui.password", lang)}" required/>
|
|
1474
|
+
<button type="submit">${t("ui.login", lang)}</button>
|
|
1475
|
+
</form>
|
|
1476
|
+
`}
|
|
1477
|
+
</div>`;
|
|
1478
|
+
return `<!doctype html><html lang="${lang}"><head>
|
|
1479
|
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
1480
|
+
<title>zocket</title><style>${CSS_COMMON}</style></head><body>${body}</body></html>`;
|
|
1481
|
+
}
|
|
1482
|
+
function mainPage(opts) {
|
|
1483
|
+
const { lang, projects, selected, secrets, showValues, secretValues, error, notice } = opts;
|
|
1484
|
+
const selInfo = projects.find((p) => p.name === selected);
|
|
1485
|
+
const sidebar = `
|
|
1486
|
+
<aside class="card">
|
|
1487
|
+
<h2>${t("ui.projects", lang)}</h2>
|
|
1488
|
+
<table>
|
|
1489
|
+
<thead><tr><th>${t("ui.name", lang)}</th><th>${t("ui.keys_count", lang)}</th></tr></thead>
|
|
1490
|
+
<tbody>
|
|
1491
|
+
${projects.map((p) => `<tr><td><a href="/?project=${enc(p.name)}">${esc(p.name)}</a></td><td>${p.secret_count}</td></tr>`).join("")}
|
|
1492
|
+
</tbody>
|
|
1493
|
+
</table>
|
|
1494
|
+
<hr/>
|
|
1495
|
+
<h3>${t("ui.new_project", lang)}</h3>
|
|
1496
|
+
<form method="post" action="/projects/create">
|
|
1497
|
+
<input name="name" placeholder="project-name" required/>
|
|
1498
|
+
<input name="description" placeholder="${t("ui.optional_desc", lang)}"/>
|
|
1499
|
+
<div class="folder-field">
|
|
1500
|
+
<input id="cf" name="folder_path" placeholder="${t("ui.optional_folder", lang)}" readonly/>
|
|
1501
|
+
<button class="btn-sm" type="button" onclick="openPicker('cf')">${t("ui.choose_folder", lang)}</button>
|
|
1502
|
+
</div>
|
|
1503
|
+
<button type="submit">${t("ui.create", lang)}</button>
|
|
1504
|
+
</form>
|
|
1505
|
+
</aside>`;
|
|
1506
|
+
let main = `<main class="card">`;
|
|
1507
|
+
if (!selected) {
|
|
1508
|
+
main += `<h2>${t("ui.no_projects", lang)}</h2><p>${t("ui.create_left", lang)}</p>`;
|
|
1509
|
+
} else {
|
|
1510
|
+
const domains = selInfo?.allowed_domains;
|
|
1511
|
+
const domainsStr = domains ? domains.join(", ") : "";
|
|
1512
|
+
main += `
|
|
1513
|
+
<h2>${esc(selected)}</h2>
|
|
1514
|
+
<p>${t("ui.project_folder", lang)}: ${selInfo?.folder_path ? `<span class="mono">${esc(selInfo.folder_path)}</span>` : t("ui.not_set", lang)}</p>
|
|
1515
|
+
|
|
1516
|
+
<form method="post" action="/projects/${enc(selected)}/folder">
|
|
1517
|
+
<div class="folder-field">
|
|
1518
|
+
<input id="ef" name="folder_path" placeholder="${t("ui.optional_folder", lang)}" value="${esc(selInfo?.folder_path ?? "")}" readonly/>
|
|
1519
|
+
<button class="btn-sm" type="button" onclick="openPicker('ef')">${t("ui.choose_folder", lang)}</button>
|
|
1520
|
+
</div>
|
|
1521
|
+
<button type="submit">${t("ui.save_folder", lang)}</button>
|
|
1522
|
+
</form>
|
|
1523
|
+
${selInfo?.folder_path ? `
|
|
1524
|
+
<form method="post" action="/projects/${enc(selected)}/folder" onsubmit="return confirm('${t("ui.clear_folder", lang)}?')">
|
|
1525
|
+
<input type="hidden" name="clear" value="1"/>
|
|
1526
|
+
<button class="danger btn-sm" type="submit">${t("ui.clear_folder", lang)}</button>
|
|
1527
|
+
</form>` : ""}
|
|
1528
|
+
|
|
1529
|
+
<p>Domains: ${domains ? `<span class="mono">${esc(domainsStr)}</span>` : t("ui.not_set", lang)}</p>
|
|
1530
|
+
<form method="post" action="/projects/${enc(selected)}/domains">
|
|
1531
|
+
<input name="domains" placeholder="api.example.com, api2.example.com (empty to remove)" value="${esc(domainsStr)}"/>
|
|
1532
|
+
<button type="submit" class="btn-sm">Save domains</button>
|
|
1533
|
+
</form>
|
|
1534
|
+
|
|
1535
|
+
<p>
|
|
1536
|
+
${showValues ? `${t("ui.real_values_visible", lang)} <a href="/?project=${enc(selected)}">${t("ui.hide_values", lang)}</a>` : `${t("ui.masked_values_visible", lang)} <a href="/?project=${enc(selected)}&show_values=1">${t("ui.show_values", lang)}</a>`}
|
|
1537
|
+
</p>
|
|
1538
|
+
<div class="inline">
|
|
1539
|
+
<form method="post" action="/projects/${enc(selected)}/delete" onsubmit="return confirm('${t("ui.delete_project", lang)}?')">
|
|
1540
|
+
<button class="danger btn-sm" type="submit">${t("ui.delete_project", lang)}</button>
|
|
1541
|
+
</form>
|
|
1542
|
+
</div>
|
|
1543
|
+
|
|
1544
|
+
<h3>${t("ui.secrets", lang)}</h3>
|
|
1545
|
+
<table>
|
|
1546
|
+
<thead><tr><th>KEY</th><th>${t("ui.value", lang)}</th><th>${t("ui.description", lang)}</th><th>${t("ui.updated_at", lang)}</th><th></th></tr></thead>
|
|
1547
|
+
<tbody>
|
|
1548
|
+
${secrets.map((s) => `<tr>
|
|
1549
|
+
<td class="mono">${esc(s.key)}</td>
|
|
1550
|
+
<td class="mono">${showValues ? esc(secretValues[s.key] ?? "") : "***"}</td>
|
|
1551
|
+
<td>${esc(s.description)}</td>
|
|
1552
|
+
<td>${esc(s.updated_at.slice(0, 16).replace("T", " "))}</td>
|
|
1553
|
+
<td>
|
|
1554
|
+
<form method="post" action="/projects/${enc(selected)}/secrets/${enc(s.key)}/delete" onsubmit="return confirm('Delete ${esc(s.key)}?')">
|
|
1555
|
+
<button class="danger btn-sm" type="submit">${t("ui.delete", lang)}</button>
|
|
1556
|
+
</form>
|
|
1557
|
+
</td>
|
|
1558
|
+
</tr>`).join("")}
|
|
1559
|
+
</tbody>
|
|
1560
|
+
</table>
|
|
1561
|
+
|
|
1562
|
+
<h3>${t("ui.add_or_update_secret", lang)}</h3>
|
|
1563
|
+
<form method="post" action="/projects/${enc(selected)}/secrets/upsert">
|
|
1564
|
+
<input name="key" placeholder="API_KEY" required/>
|
|
1565
|
+
<input name="value" placeholder="${t("ui.value", lang)}" required/>
|
|
1566
|
+
<input name="description" placeholder="${t("ui.optional_desc", lang)}"/>
|
|
1567
|
+
<button type="submit">${t("ui.save", lang)}</button>
|
|
1568
|
+
</form>`;
|
|
1569
|
+
}
|
|
1570
|
+
main += `</main>`;
|
|
1571
|
+
const folderScript = `
|
|
1572
|
+
<script>
|
|
1573
|
+
const ps={targetId:null,current:null,parent:null};
|
|
1574
|
+
const pm=document.getElementById('fp-modal');
|
|
1575
|
+
const pc=document.getElementById('fp-cur');
|
|
1576
|
+
const pl=document.getElementById('fp-list');
|
|
1577
|
+
const pe=document.getElementById('fp-err');
|
|
1578
|
+
const pu=document.getElementById('fp-up');
|
|
1579
|
+
const psel=document.getElementById('fp-sel');
|
|
1580
|
+
function _setErr(m){if(!m){pe.style.display='none';pe.textContent='';return}pe.style.display='block';pe.textContent=m}
|
|
1581
|
+
function _renderBtns(rows){pl.innerHTML='';if(!rows||!rows.length){pl.innerHTML='<p>${t("ui.no_subfolders", lang)}</p>';return}for(const r of rows){const b=document.createElement('button');b.type='button';b.textContent=r.name;b.addEventListener('click',()=>_load(r.path));pl.appendChild(b)}}
|
|
1582
|
+
async function _load(path){_setErr('');pl.innerHTML='<p>${t("ui.loading", lang)}</p>';const q=path?'?path='+encodeURIComponent(path):'';const r=await fetch('/api/folders'+q,{credentials:'same-origin'});const d=await r.json();if(!r.ok||!d.ok){_setErr(d&&d.error?d.error:'${t("ui.folder_picker_failed", lang)}');pl.innerHTML='';return}ps.current=d.current;ps.parent=d.parent;pc.textContent=d.current||'${t("ui.roots", lang)}';pu.disabled=!d.parent;psel.disabled=!d.current;_renderBtns(d.directories||[])}
|
|
1583
|
+
function openPicker(id){ps.targetId=id;pm.hidden=false;const inp=document.getElementById(id);_load(inp&&inp.value?inp.value:'')}
|
|
1584
|
+
function closePicker(){pm.hidden=true;ps.targetId=null;ps.current=null;ps.parent=null}
|
|
1585
|
+
function pickerUp(){if(ps.parent)_load(ps.parent)}
|
|
1586
|
+
function pickerRoots(){_load('')}
|
|
1587
|
+
function pickerSelect(){if(!ps.targetId||!ps.current)return;const inp=document.getElementById(ps.targetId);if(inp)inp.value=ps.current;closePicker()}
|
|
1588
|
+
</script>`;
|
|
1589
|
+
const topBar = `
|
|
1590
|
+
<div class="top">
|
|
1591
|
+
<div>
|
|
1592
|
+
<div class="brand">zocket <span>pretty</span></div>
|
|
1593
|
+
<p style="margin:0;color:var(--muted)">${t("app.tagline", lang)}</p>
|
|
1594
|
+
</div>
|
|
1595
|
+
<div class="meta">
|
|
1596
|
+
<span>${t("ui.lang", lang)}:</span>
|
|
1597
|
+
<a href="/?lang=en${selected ? `&project=${enc(selected)}` : ""}">EN</a>
|
|
1598
|
+
<a href="/?lang=ru${selected ? `&project=${enc(selected)}` : ""}">RU</a>
|
|
1599
|
+
<form method="post" action="/logout"><button class="btn-sm" type="submit">${t("ui.logout", lang)}</button></form>
|
|
1600
|
+
</div>
|
|
1601
|
+
</div>`;
|
|
1602
|
+
const folderModal = `
|
|
1603
|
+
<div id="fp-modal" class="modal" hidden>
|
|
1604
|
+
<div class="modal-card">
|
|
1605
|
+
<div class="modal-head">
|
|
1606
|
+
<h3>${t("ui.folder_picker", lang)}</h3>
|
|
1607
|
+
<button class="btn-sm danger" type="button" onclick="closePicker()">${t("ui.close", lang)}</button>
|
|
1608
|
+
</div>
|
|
1609
|
+
<p>${t("ui.current_path", lang)}: <span class="mono" id="fp-cur">-</span></p>
|
|
1610
|
+
<div class="inline">
|
|
1611
|
+
<button class="btn-sm" type="button" id="fp-up" onclick="pickerUp()">${t("ui.parent_folder", lang)}</button>
|
|
1612
|
+
<button class="btn-sm" type="button" onclick="pickerRoots()">${t("ui.roots", lang)}</button>
|
|
1613
|
+
<button class="btn-sm" type="button" id="fp-sel" onclick="pickerSelect()">${t("ui.select_folder", lang)}</button>
|
|
1614
|
+
</div>
|
|
1615
|
+
<div id="fp-err" class="error" style="display:none"></div>
|
|
1616
|
+
<div id="fp-list" class="folder-list"></div>
|
|
1617
|
+
</div>
|
|
1618
|
+
</div>`;
|
|
1619
|
+
const content = `
|
|
1620
|
+
${topBar}
|
|
1621
|
+
${error ? `<div class="error">${esc(error)}</div>` : ""}
|
|
1622
|
+
${notice ? `<div class="notice">${esc(notice)}</div>` : ""}
|
|
1623
|
+
<div class="layout">${sidebar}${main}</div>
|
|
1624
|
+
${folderModal}
|
|
1625
|
+
${folderScript}`;
|
|
1626
|
+
return pageShell(lang, "zocket", content);
|
|
1627
|
+
}
|
|
1628
|
+
function esc(s) {
|
|
1629
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1630
|
+
}
|
|
1631
|
+
function enc(s) {
|
|
1632
|
+
return encodeURIComponent(s);
|
|
1633
|
+
}
|
|
1634
|
+
function createWebApp(services) {
|
|
1635
|
+
const { vault, config, audit } = services;
|
|
1636
|
+
const app = new Hono();
|
|
1637
|
+
function getLang(cookieLang, queryLang) {
|
|
1638
|
+
const raw = queryLang ?? cookieLang ?? config.load().language;
|
|
1639
|
+
return normalizeLang(raw);
|
|
1640
|
+
}
|
|
1641
|
+
function isAuthenticated(sessionCookie, secret) {
|
|
1642
|
+
if (!config.load().web_auth_enabled) return true;
|
|
1643
|
+
if (!sessionCookie) return false;
|
|
1644
|
+
const sess = parseSession(sessionCookie, secret);
|
|
1645
|
+
return sess?.auth === true;
|
|
1646
|
+
}
|
|
1647
|
+
function hasPassword() {
|
|
1648
|
+
const cfg = config.load();
|
|
1649
|
+
return !!(cfg.web_password_hash && cfg.web_password_salt);
|
|
1650
|
+
}
|
|
1651
|
+
function requireAuth(next) {
|
|
1652
|
+
return async (c) => {
|
|
1653
|
+
const cfg = config.load();
|
|
1654
|
+
const lang = getLang(getCookie(c, "lang"), c.req.query("lang"));
|
|
1655
|
+
if (c.req.query("lang")) setCookie(c, "lang", lang, { path: "/", sameSite: "Lax" });
|
|
1656
|
+
const sess = getCookie(c, COOKIE);
|
|
1657
|
+
if (!isAuthenticated(sess, cfg.session_secret)) {
|
|
1658
|
+
const dest = c.req.path;
|
|
1659
|
+
return c.redirect(`/login${dest !== "/" ? `?next=${enc(dest)}` : ""}`);
|
|
1660
|
+
}
|
|
1661
|
+
return next(cfg);
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
app.get("/login", (c) => {
|
|
1665
|
+
const cfg = config.load();
|
|
1666
|
+
const lang = getLang(getCookie(c, "lang"), c.req.query("lang"));
|
|
1667
|
+
if (c.req.query("lang")) setCookie(c, "lang", lang, { path: "/", sameSite: "Lax" });
|
|
1668
|
+
if (!cfg.web_auth_enabled) return c.redirect("/");
|
|
1669
|
+
const sess = getCookie(c, COOKIE);
|
|
1670
|
+
if (isAuthenticated(sess, cfg.session_secret)) return c.redirect("/");
|
|
1671
|
+
return c.html(loginPage(lang, c.req.query("error"), !hasPassword()));
|
|
1672
|
+
});
|
|
1673
|
+
app.post("/login", async (c) => {
|
|
1674
|
+
const cfg = config.load();
|
|
1675
|
+
const lang = getLang(getCookie(c, "lang"), void 0);
|
|
1676
|
+
const body = await c.req.parseBody();
|
|
1677
|
+
const password = String(body.password ?? "");
|
|
1678
|
+
const ok2 = verifyPassword(password, String(cfg.web_password_hash), String(cfg.web_password_salt));
|
|
1679
|
+
if (!ok2) {
|
|
1680
|
+
audit.log("web.login", "web", { remote: c.req.header("x-forwarded-for") ?? "local" }, "fail");
|
|
1681
|
+
return c.redirect(`/login?error=${enc(t("ui.invalid_login", lang))}`);
|
|
1682
|
+
}
|
|
1683
|
+
setCookie(c, COOKIE, signSession({ auth: true }, cfg.session_secret), { path: "/", httpOnly: true, sameSite: "Lax" });
|
|
1684
|
+
audit.log("web.login", "web", {}, "ok");
|
|
1685
|
+
const next = c.req.query("next") ?? "/";
|
|
1686
|
+
return c.redirect(next);
|
|
1687
|
+
});
|
|
1688
|
+
app.post("/setup/first-run", async (c) => {
|
|
1689
|
+
const cfg = config.load();
|
|
1690
|
+
const lang = getLang(getCookie(c, "lang"), void 0);
|
|
1691
|
+
if (hasPassword()) return c.redirect("/login");
|
|
1692
|
+
const body = await c.req.parseBody();
|
|
1693
|
+
const mode = String(body.mode ?? "");
|
|
1694
|
+
if (mode === "set_password") {
|
|
1695
|
+
const pw = String(body.password ?? ""), pw2 = String(body.password_repeat ?? "");
|
|
1696
|
+
if (!pw) return c.redirect(`/login?error=${enc(t("ui.password_required", lang))}`);
|
|
1697
|
+
if (pw !== pw2) return c.redirect(`/login?error=${enc(t("ui.passwords_do_not_match", lang))}`);
|
|
1698
|
+
const { salt, hash } = hashPassword(pw);
|
|
1699
|
+
cfg.web_password_salt = salt;
|
|
1700
|
+
cfg.web_password_hash = hash;
|
|
1701
|
+
cfg.web_auth_enabled = true;
|
|
1702
|
+
config.save(cfg);
|
|
1703
|
+
setCookie(c, COOKIE, signSession({ auth: true }, cfg.session_secret), { path: "/", httpOnly: true, sameSite: "Lax" });
|
|
1704
|
+
audit.log("web.setup", "web", { mode: "set_password" }, "ok");
|
|
1705
|
+
return c.redirect("/");
|
|
1706
|
+
}
|
|
1707
|
+
if (mode === "generate_password") {
|
|
1708
|
+
const generated = randomBytes5(18).toString("base64url");
|
|
1709
|
+
const { salt, hash } = hashPassword(generated);
|
|
1710
|
+
cfg.web_password_salt = salt;
|
|
1711
|
+
cfg.web_password_hash = hash;
|
|
1712
|
+
cfg.web_auth_enabled = true;
|
|
1713
|
+
config.save(cfg);
|
|
1714
|
+
setCookie(c, COOKIE, signSession({ auth: true }, cfg.session_secret), { path: "/", httpOnly: true, sameSite: "Lax" });
|
|
1715
|
+
setCookie(c, "genpw", generated, { path: "/", httpOnly: true, sameSite: "Lax", maxAge: 60 });
|
|
1716
|
+
audit.log("web.setup", "web", { mode: "generate_password" }, "ok");
|
|
1717
|
+
return c.redirect("/");
|
|
1718
|
+
}
|
|
1719
|
+
if (mode === "no_password") {
|
|
1720
|
+
if (String(body.confirm_no_password) !== "1")
|
|
1721
|
+
return c.redirect(`/login?error=${enc(t("ui.confirm_insecure_required", lang))}`);
|
|
1722
|
+
cfg.web_auth_enabled = false;
|
|
1723
|
+
cfg.web_password_hash = "";
|
|
1724
|
+
cfg.web_password_salt = "";
|
|
1725
|
+
config.save(cfg);
|
|
1726
|
+
setCookie(c, COOKIE, signSession({ auth: true }, cfg.session_secret), { path: "/", httpOnly: true, sameSite: "Lax" });
|
|
1727
|
+
audit.log("web.setup", "web", { mode: "no_password" }, "ok");
|
|
1728
|
+
return c.redirect("/");
|
|
1729
|
+
}
|
|
1730
|
+
return c.redirect(`/login?error=${enc(t("ui.invalid_setup_option", lang))}`);
|
|
1731
|
+
});
|
|
1732
|
+
app.post("/logout", (c) => {
|
|
1733
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
1734
|
+
return c.redirect("/login");
|
|
1735
|
+
});
|
|
1736
|
+
app.get("/api/folders", (c) => {
|
|
1737
|
+
const cfg = config.load();
|
|
1738
|
+
const sess = getCookie(c, COOKIE);
|
|
1739
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.json({ ok: false, error: "Unauthorized" }, 401);
|
|
1740
|
+
const roots = (cfg.folder_picker_roots ?? ["/home", "/srv", "/opt", "/var/www"]).filter((r) => {
|
|
1741
|
+
try {
|
|
1742
|
+
return existsSync4(r) && statSync(r).isDirectory();
|
|
1743
|
+
} catch {
|
|
1744
|
+
return false;
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
if (!roots.length) return c.json({ ok: false, error: "No folder picker roots configured" }, 500);
|
|
1748
|
+
const requested = c.req.query("path")?.trim() ?? "";
|
|
1749
|
+
if (!requested) {
|
|
1750
|
+
const rows = roots.map((r) => ({ name: r, path: r }));
|
|
1751
|
+
return c.json({ ok: true, current: null, parent: null, roots: rows, directories: rows });
|
|
1752
|
+
}
|
|
1753
|
+
const current = safeResolve(requested);
|
|
1754
|
+
if (!roots.some((r) => isSubPath(current, r)))
|
|
1755
|
+
return c.json({ ok: false, error: "Folder is outside allowed roots." }, 403);
|
|
1756
|
+
if (!existsSync4(current) || !statSync(current).isDirectory())
|
|
1757
|
+
return c.json({ ok: false, error: "Folder not found." }, 404);
|
|
1758
|
+
let dirs = [];
|
|
1759
|
+
try {
|
|
1760
|
+
dirs = readdirSync(current, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join2(current, e.name) })).filter((d) => roots.some((r) => isSubPath(d.path, r))).sort((a, b) => a.name.localeCompare(b.name));
|
|
1761
|
+
} catch {
|
|
1762
|
+
dirs = [];
|
|
1763
|
+
}
|
|
1764
|
+
const parent_ = dirname4(current);
|
|
1765
|
+
const parent = parent_ !== current && roots.some((r) => isSubPath(parent_, r)) ? parent_ : null;
|
|
1766
|
+
return c.json({ ok: true, current, parent, roots: roots.map((r) => ({ name: r, path: r })), directories: dirs });
|
|
1767
|
+
});
|
|
1768
|
+
app.get("/", async (c) => {
|
|
1769
|
+
const cfg = config.load();
|
|
1770
|
+
const lang = getLang(getCookie(c, "lang"), c.req.query("lang"));
|
|
1771
|
+
if (c.req.query("lang")) setCookie(c, "lang", lang, { path: "/", sameSite: "Lax" });
|
|
1772
|
+
const sess = getCookie(c, COOKIE);
|
|
1773
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1774
|
+
const projects = await vault.listProjects();
|
|
1775
|
+
const reqProject = c.req.query("project");
|
|
1776
|
+
const selected = reqProject ?? (projects[0]?.name ?? null);
|
|
1777
|
+
const showValues = c.req.query("show_values") === "1";
|
|
1778
|
+
const notice_raw = getCookie(c, "genpw");
|
|
1779
|
+
if (notice_raw) deleteCookie(c, "genpw", { path: "/" });
|
|
1780
|
+
let secrets = [];
|
|
1781
|
+
let secretValues = {};
|
|
1782
|
+
if (selected) {
|
|
1783
|
+
try {
|
|
1784
|
+
secrets = await vault.listSecrets(selected);
|
|
1785
|
+
if (showValues) secretValues = await vault.getEnv(selected);
|
|
1786
|
+
} catch {
|
|
1787
|
+
secrets = [];
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return c.html(mainPage({
|
|
1791
|
+
lang,
|
|
1792
|
+
projects,
|
|
1793
|
+
selected,
|
|
1794
|
+
secrets,
|
|
1795
|
+
showValues,
|
|
1796
|
+
secretValues,
|
|
1797
|
+
error: c.req.query("error"),
|
|
1798
|
+
notice: notice_raw ? `${t("ui.generated_password_notice", lang)}
|
|
1799
|
+
${notice_raw}
|
|
1800
|
+
${t("ui.generated_password_save_now", lang)}` : void 0
|
|
1801
|
+
}));
|
|
1802
|
+
});
|
|
1803
|
+
app.post("/projects/create", async (c) => {
|
|
1804
|
+
const cfg = config.load();
|
|
1805
|
+
const lang = getLang(getCookie(c, "lang"), void 0);
|
|
1806
|
+
const sess = getCookie(c, COOKIE);
|
|
1807
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1808
|
+
const body = await c.req.parseBody();
|
|
1809
|
+
const name = String(body.name ?? "").trim();
|
|
1810
|
+
const description = String(body.description ?? "");
|
|
1811
|
+
const folder_path = String(body.folder_path ?? "").trim() || void 0;
|
|
1812
|
+
try {
|
|
1813
|
+
await vault.createProject(name, description);
|
|
1814
|
+
if (folder_path) await vault.setFolder(name, folder_path);
|
|
1815
|
+
audit.log("web.project.create", "web", { name, folder_path }, "ok");
|
|
1816
|
+
} catch (e) {
|
|
1817
|
+
audit.log("web.project.create", "web", { name }, "fail");
|
|
1818
|
+
return c.redirect(`/?error=${enc(String(e))}`);
|
|
1819
|
+
}
|
|
1820
|
+
return c.redirect(`/?project=${enc(name)}`);
|
|
1821
|
+
});
|
|
1822
|
+
app.post("/projects/:project/delete", async (c) => {
|
|
1823
|
+
const cfg = config.load();
|
|
1824
|
+
const sess = getCookie(c, COOKIE);
|
|
1825
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1826
|
+
const project = c.req.param("project");
|
|
1827
|
+
try {
|
|
1828
|
+
await vault.deleteProject(project);
|
|
1829
|
+
audit.log("web.project.delete", "web", { project }, "ok");
|
|
1830
|
+
} catch (e) {
|
|
1831
|
+
return c.redirect(`/?error=${enc(String(e))}`);
|
|
1832
|
+
}
|
|
1833
|
+
return c.redirect("/");
|
|
1834
|
+
});
|
|
1835
|
+
app.post("/projects/:project/folder", async (c) => {
|
|
1836
|
+
const cfg = config.load();
|
|
1837
|
+
const sess = getCookie(c, COOKIE);
|
|
1838
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1839
|
+
const project = c.req.param("project");
|
|
1840
|
+
const body = await c.req.parseBody();
|
|
1841
|
+
const clear = body.clear === "1";
|
|
1842
|
+
const folder_path = clear ? void 0 : String(body.folder_path ?? "").trim() || void 0;
|
|
1843
|
+
try {
|
|
1844
|
+
await vault.setFolder(project, folder_path);
|
|
1845
|
+
audit.log("web.project.folder", "web", { project, folder_path }, "ok");
|
|
1846
|
+
} catch (e) {
|
|
1847
|
+
return c.redirect(`/?project=${enc(project)}&error=${enc(String(e))}`);
|
|
1848
|
+
}
|
|
1849
|
+
return c.redirect(`/?project=${enc(project)}`);
|
|
1850
|
+
});
|
|
1851
|
+
app.post("/projects/:project/domains", async (c) => {
|
|
1852
|
+
const cfg = config.load();
|
|
1853
|
+
const sess = getCookie(c, COOKIE);
|
|
1854
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1855
|
+
const project = c.req.param("project");
|
|
1856
|
+
const body = await c.req.parseBody();
|
|
1857
|
+
const raw = String(body.domains ?? "").trim();
|
|
1858
|
+
const domains = raw ? raw.split(/[\s,]+/).map((d) => d.trim()).filter(Boolean) : null;
|
|
1859
|
+
try {
|
|
1860
|
+
await vault.setAllowedDomains(project, domains);
|
|
1861
|
+
audit.log("web.project.domains", "web", { project, domains }, "ok");
|
|
1862
|
+
} catch (e) {
|
|
1863
|
+
return c.redirect(`/?project=${enc(project)}&error=${enc(String(e))}`);
|
|
1864
|
+
}
|
|
1865
|
+
return c.redirect(`/?project=${enc(project)}`);
|
|
1866
|
+
});
|
|
1867
|
+
app.post("/projects/:project/secrets/upsert", async (c) => {
|
|
1868
|
+
const cfg = config.load();
|
|
1869
|
+
const sess = getCookie(c, COOKIE);
|
|
1870
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1871
|
+
const project = c.req.param("project");
|
|
1872
|
+
const body = await c.req.parseBody();
|
|
1873
|
+
const key = String(body.key ?? "").trim();
|
|
1874
|
+
const value = String(body.value ?? "");
|
|
1875
|
+
const description = String(body.description ?? "");
|
|
1876
|
+
try {
|
|
1877
|
+
await vault.setSecret(project, key, value, description);
|
|
1878
|
+
audit.log("web.secret.upsert", "web", { project, key }, "ok");
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
audit.log("web.secret.upsert", "web", { project, key }, "fail");
|
|
1881
|
+
return c.redirect(`/?project=${enc(project)}&error=${enc(String(e))}`);
|
|
1882
|
+
}
|
|
1883
|
+
return c.redirect(`/?project=${enc(project)}`);
|
|
1884
|
+
});
|
|
1885
|
+
app.post("/projects/:project/secrets/:key/delete", async (c) => {
|
|
1886
|
+
const cfg = config.load();
|
|
1887
|
+
const sess = getCookie(c, COOKIE);
|
|
1888
|
+
if (!isAuthenticated(sess, cfg.session_secret)) return c.redirect("/login");
|
|
1889
|
+
const project = c.req.param("project");
|
|
1890
|
+
const key = c.req.param("key");
|
|
1891
|
+
try {
|
|
1892
|
+
await vault.deleteSecret(project, key);
|
|
1893
|
+
audit.log("web.secret.delete", "web", { project, key }, "ok");
|
|
1894
|
+
} catch (e) {
|
|
1895
|
+
return c.redirect(`/?project=${enc(project)}&error=${enc(String(e))}`);
|
|
1896
|
+
}
|
|
1897
|
+
return c.redirect(`/?project=${enc(project)}`);
|
|
1898
|
+
});
|
|
1899
|
+
return app;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/paths.ts
|
|
1903
|
+
import { homedir } from "os";
|
|
1904
|
+
import { join as join3 } from "path";
|
|
1905
|
+
function zocketHome() {
|
|
1906
|
+
return process.env.ZOCKET_HOME ?? join3(homedir(), ".zocket");
|
|
1907
|
+
}
|
|
1908
|
+
function vaultPath(home = zocketHome()) {
|
|
1909
|
+
return join3(home, "vault.enc");
|
|
1910
|
+
}
|
|
1911
|
+
function keyPath(home = zocketHome()) {
|
|
1912
|
+
return join3(home, "master.key");
|
|
1913
|
+
}
|
|
1914
|
+
function configPath(home = zocketHome()) {
|
|
1915
|
+
return join3(home, "config.json");
|
|
1916
|
+
}
|
|
1917
|
+
function auditPath(home = zocketHome()) {
|
|
1918
|
+
return join3(home, "audit.log");
|
|
1919
|
+
}
|
|
1920
|
+
function lockPath(home = zocketHome()) {
|
|
1921
|
+
return join3(home, "vault.lock");
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// src/keys.ts
|
|
1925
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
|
|
1926
|
+
import { dirname as dirname5 } from "path";
|
|
1927
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
1928
|
+
function loadOrCreateKey(keyFile) {
|
|
1929
|
+
if (existsSync5(keyFile)) {
|
|
1930
|
+
const raw = readFileSync5(keyFile, "utf8").trim();
|
|
1931
|
+
return Buffer.from(raw, "hex");
|
|
1932
|
+
}
|
|
1933
|
+
mkdirSync4(dirname5(keyFile), { recursive: true });
|
|
1934
|
+
const key = randomBytes6(32);
|
|
1935
|
+
writeFileSync5(keyFile, key.toString("hex"), { mode: 384 });
|
|
1936
|
+
return key;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// src/tui.ts
|
|
1940
|
+
import readline from "readline/promises";
|
|
1941
|
+
import { stdin as input, stdout as output } from "process";
|
|
1942
|
+
async function promptMenu(rl, title, options) {
|
|
1943
|
+
output.write(`
|
|
1944
|
+
${title}
|
|
1945
|
+
`);
|
|
1946
|
+
options.forEach((opt, i) => output.write(` ${i + 1}) ${opt}
|
|
1947
|
+
`));
|
|
1948
|
+
const answer = await rl.question("> ");
|
|
1949
|
+
const idx = Number(answer.trim()) - 1;
|
|
1950
|
+
return Number.isFinite(idx) && idx >= 0 && idx < options.length ? idx : -1;
|
|
1951
|
+
}
|
|
1952
|
+
async function promptInput(rl, label, fallback = "") {
|
|
1953
|
+
const ans = await rl.question(`${label}${fallback ? ` (${fallback})` : ""}: `);
|
|
1954
|
+
return ans.trim() || fallback;
|
|
1955
|
+
}
|
|
1956
|
+
async function promptConfirm(rl, label) {
|
|
1957
|
+
const ans = await rl.question(`${label} [y/N]: `);
|
|
1958
|
+
return ans.trim().toLowerCase() === "y";
|
|
1959
|
+
}
|
|
1960
|
+
async function pickProject(rl, vault) {
|
|
1961
|
+
const rows = await vault.listProjects();
|
|
1962
|
+
if (!rows.length) {
|
|
1963
|
+
output.write("No projects\n");
|
|
1964
|
+
return null;
|
|
1965
|
+
}
|
|
1966
|
+
const idx = await promptMenu(rl, "Select project", rows.map((r) => `${r.name} (${r.secret_count})`));
|
|
1967
|
+
if (idx < 0) return null;
|
|
1968
|
+
return rows[idx].name;
|
|
1969
|
+
}
|
|
1970
|
+
async function showProjects(vault) {
|
|
1971
|
+
const rows = await vault.listProjects();
|
|
1972
|
+
if (!rows.length) {
|
|
1973
|
+
output.write("No projects\n");
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
rows.forEach((r) => {
|
|
1977
|
+
output.write(`${r.name} ${r.secret_count} ${r.folder_path ?? ""}
|
|
1978
|
+
`);
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
async function showSecrets(vault, project, showValues) {
|
|
1982
|
+
const rows = await vault.listSecrets(project);
|
|
1983
|
+
if (!rows.length) {
|
|
1984
|
+
output.write("No secrets\n");
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
for (const r of rows) {
|
|
1988
|
+
if (showValues) {
|
|
1989
|
+
const v = await vault.getSecretValue(project, r.key);
|
|
1990
|
+
output.write(`${r.key} ${v} ${r.description ?? ""}
|
|
1991
|
+
`);
|
|
1992
|
+
} else {
|
|
1993
|
+
output.write(`${r.key} ${r.description ?? ""}
|
|
1994
|
+
`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
async function runTui(services) {
|
|
1999
|
+
const rl = readline.createInterface({ input, output });
|
|
2000
|
+
const { vault } = services;
|
|
2001
|
+
try {
|
|
2002
|
+
while (true) {
|
|
2003
|
+
const main = await promptMenu(rl, "Zocket TUI", [
|
|
2004
|
+
"Projects",
|
|
2005
|
+
"Secrets",
|
|
2006
|
+
"Exit"
|
|
2007
|
+
]);
|
|
2008
|
+
if (main === 2) break;
|
|
2009
|
+
if (main === 0) {
|
|
2010
|
+
const idx = await promptMenu(rl, "Projects", [
|
|
2011
|
+
"List",
|
|
2012
|
+
"Create",
|
|
2013
|
+
"Delete",
|
|
2014
|
+
"Set folder path",
|
|
2015
|
+
"Set allowed domains",
|
|
2016
|
+
"Back"
|
|
2017
|
+
]);
|
|
2018
|
+
if (idx === 5) continue;
|
|
2019
|
+
if (idx === 0) await showProjects(vault);
|
|
2020
|
+
if (idx === 1) {
|
|
2021
|
+
const name = await promptInput(rl, "Project name");
|
|
2022
|
+
const desc = await promptInput(rl, "Description", "");
|
|
2023
|
+
const folder = await promptInput(rl, "Folder path", "");
|
|
2024
|
+
await vault.createProject(name, desc);
|
|
2025
|
+
if (folder) await vault.setFolder(name, folder);
|
|
2026
|
+
output.write("Project created\n");
|
|
2027
|
+
}
|
|
2028
|
+
if (idx === 2) {
|
|
2029
|
+
const name = await pickProject(rl, vault);
|
|
2030
|
+
if (!name) continue;
|
|
2031
|
+
if (await promptConfirm(rl, `Delete ${name}?`)) {
|
|
2032
|
+
await vault.deleteProject(name);
|
|
2033
|
+
output.write("Project deleted\n");
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
if (idx === 3) {
|
|
2037
|
+
const name = await pickProject(rl, vault);
|
|
2038
|
+
if (!name) continue;
|
|
2039
|
+
const path = await promptInput(rl, "Folder path (empty to clear)", "");
|
|
2040
|
+
await vault.setFolder(name, path || void 0);
|
|
2041
|
+
output.write("Folder updated\n");
|
|
2042
|
+
}
|
|
2043
|
+
if (idx === 4) {
|
|
2044
|
+
const name = await pickProject(rl, vault);
|
|
2045
|
+
if (!name) continue;
|
|
2046
|
+
const domains = await promptInput(rl, "Allowed domains (comma-separated, empty to clear)", "");
|
|
2047
|
+
const value = domains ? domains.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
2048
|
+
await vault.setAllowedDomains(name, value);
|
|
2049
|
+
output.write("Allowed domains updated\n");
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
if (main === 1) {
|
|
2053
|
+
const project = await pickProject(rl, vault);
|
|
2054
|
+
if (!project) continue;
|
|
2055
|
+
const idx = await promptMenu(rl, `Secrets for ${project}`, [
|
|
2056
|
+
"List (no values)",
|
|
2057
|
+
"List (with values)",
|
|
2058
|
+
"Add or update",
|
|
2059
|
+
"Get value",
|
|
2060
|
+
"Delete",
|
|
2061
|
+
"Back"
|
|
2062
|
+
]);
|
|
2063
|
+
if (idx === 5) continue;
|
|
2064
|
+
if (idx === 0) await showSecrets(vault, project, false);
|
|
2065
|
+
if (idx === 1) await showSecrets(vault, project, true);
|
|
2066
|
+
if (idx === 2) {
|
|
2067
|
+
const key = await promptInput(rl, "Key (UPPERCASE)", "");
|
|
2068
|
+
const value = await promptInput(rl, "Value", "");
|
|
2069
|
+
const desc = await promptInput(rl, "Description", "");
|
|
2070
|
+
await vault.setSecret(project, key, value, desc);
|
|
2071
|
+
output.write("Secret saved\n");
|
|
2072
|
+
}
|
|
2073
|
+
if (idx === 3) {
|
|
2074
|
+
const key = await promptInput(rl, "Key", "");
|
|
2075
|
+
const value = await vault.getSecretValue(project, key);
|
|
2076
|
+
output.write(`${value}
|
|
2077
|
+
`);
|
|
2078
|
+
}
|
|
2079
|
+
if (idx === 4) {
|
|
2080
|
+
const key = await promptInput(rl, "Key", "");
|
|
2081
|
+
if (await promptConfirm(rl, `Delete ${key}?`)) {
|
|
2082
|
+
await vault.deleteSecret(project, key);
|
|
2083
|
+
output.write("Secret deleted\n");
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} finally {
|
|
2089
|
+
rl.close();
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// src/cli.ts
|
|
2094
|
+
function createServices() {
|
|
2095
|
+
const home = zocketHome();
|
|
2096
|
+
mkdirSync5(home, { recursive: true });
|
|
2097
|
+
const key = loadOrCreateKey(keyPath());
|
|
2098
|
+
const vault = new VaultService(vaultPath(), lockPath(), key);
|
|
2099
|
+
vault.ensureExists();
|
|
2100
|
+
const config = new ConfigStore(configPath());
|
|
2101
|
+
const audit = new AuditLogger(auditPath());
|
|
2102
|
+
config.ensureExists();
|
|
2103
|
+
return { vault, config, audit };
|
|
2104
|
+
}
|
|
2105
|
+
async function cmdStart(opts) {
|
|
2106
|
+
const { vault, config, audit } = createServices();
|
|
2107
|
+
const cfg = config.ensureExists();
|
|
2108
|
+
const mode = opts.mode === "admin" ? "admin" : "metadata";
|
|
2109
|
+
const services = { vault, config, audit, mode };
|
|
2110
|
+
const webApp = createWebApp({ vault, config, audit });
|
|
2111
|
+
serve({ fetch: webApp.fetch, hostname: opts.host, port: opts.webPort }, () => {
|
|
2112
|
+
console.log(`[zocket] web http://${opts.host}:${opts.webPort}`);
|
|
2113
|
+
});
|
|
2114
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2115
|
+
const mcpHttp = createServer((req, res) => {
|
|
2116
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
2117
|
+
if (req.method === "GET" && url.pathname === "/sse") {
|
|
2118
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
2119
|
+
sessions.set(transport.sessionId, transport);
|
|
2120
|
+
res.on("close", () => sessions.delete(transport.sessionId));
|
|
2121
|
+
const mcpServer = createMcpServer(services, { loading: cfg.mcp_loading });
|
|
2122
|
+
mcpServer.connect(transport).catch((e) => {
|
|
2123
|
+
console.error("[zocket] MCP connect error:", e);
|
|
2124
|
+
});
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
if (req.method === "POST" && url.pathname === "/messages") {
|
|
2128
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
2129
|
+
const transport = sessions.get(sessionId);
|
|
2130
|
+
if (!transport) {
|
|
2131
|
+
res.writeHead(404).end("Session not found");
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
transport.handlePostMessage(req, res).catch((e) => {
|
|
2135
|
+
console.error("[zocket] MCP message error:", e);
|
|
2136
|
+
});
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
res.writeHead(404).end("Not found");
|
|
2140
|
+
});
|
|
2141
|
+
mcpHttp.listen(opts.mcpPort, opts.host, () => {
|
|
2142
|
+
console.log(`[zocket] mcp http://${opts.host}:${opts.mcpPort}/sse (mode: ${mode}, loading: ${cfg.mcp_loading})`);
|
|
2143
|
+
});
|
|
2144
|
+
const streamableSessions = /* @__PURE__ */ new Map();
|
|
2145
|
+
const streamableHttp = createServer(async (req, res) => {
|
|
2146
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
2147
|
+
if (url.pathname !== "/mcp") {
|
|
2148
|
+
res.writeHead(404).end("Not found");
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2152
|
+
let parsedBody = void 0;
|
|
2153
|
+
if (method === "POST") {
|
|
2154
|
+
let raw = "";
|
|
2155
|
+
for await (const chunk of req) {
|
|
2156
|
+
raw += chunk;
|
|
2157
|
+
}
|
|
2158
|
+
if (raw.trim().length > 0) {
|
|
2159
|
+
try {
|
|
2160
|
+
parsedBody = JSON.parse(raw);
|
|
2161
|
+
} catch {
|
|
2162
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2163
|
+
res.end(JSON.stringify({
|
|
2164
|
+
jsonrpc: "2.0",
|
|
2165
|
+
error: { code: -32700, message: "Parse error" },
|
|
2166
|
+
id: null
|
|
2167
|
+
}));
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
const header = req.headers["mcp-session-id"];
|
|
2173
|
+
const sessionId = Array.isArray(header) ? header[0] : header;
|
|
2174
|
+
let transport;
|
|
2175
|
+
if (sessionId && streamableSessions.has(sessionId)) {
|
|
2176
|
+
transport = streamableSessions.get(sessionId);
|
|
2177
|
+
} else if (!sessionId && method === "POST" && parsedBody && isInitializeRequest(parsedBody)) {
|
|
2178
|
+
transport = new StreamableHTTPServerTransport({
|
|
2179
|
+
sessionIdGenerator: () => randomUUID(),
|
|
2180
|
+
onsessioninitialized: (sid) => {
|
|
2181
|
+
if (transport) streamableSessions.set(sid, transport);
|
|
2182
|
+
}
|
|
2183
|
+
});
|
|
2184
|
+
transport.onclose = () => {
|
|
2185
|
+
const sid = transport?.sessionId;
|
|
2186
|
+
if (sid && streamableSessions.has(sid)) streamableSessions.delete(sid);
|
|
2187
|
+
};
|
|
2188
|
+
const mcpServer = createMcpServer(services, { loading: cfg.mcp_loading });
|
|
2189
|
+
mcpServer.connect(transport).catch((e) => {
|
|
2190
|
+
console.error("[zocket] MCP streamable connect error:", e);
|
|
2191
|
+
});
|
|
2192
|
+
} else {
|
|
2193
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2194
|
+
res.end(JSON.stringify({
|
|
2195
|
+
jsonrpc: "2.0",
|
|
2196
|
+
error: { code: -32e3, message: "Bad Request: No valid session ID provided" },
|
|
2197
|
+
id: null
|
|
2198
|
+
}));
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
try {
|
|
2202
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
2203
|
+
} catch (e) {
|
|
2204
|
+
console.error("[zocket] MCP streamable request error:", e);
|
|
2205
|
+
if (!res.headersSent) {
|
|
2206
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2207
|
+
res.end(JSON.stringify({
|
|
2208
|
+
jsonrpc: "2.0",
|
|
2209
|
+
error: { code: -32603, message: "Internal server error" },
|
|
2210
|
+
id: null
|
|
2211
|
+
}));
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
streamableHttp.listen(opts.mcpStreamPort, opts.host, () => {
|
|
2216
|
+
console.log(`[zocket] mcp http://${opts.host}:${opts.mcpStreamPort}/mcp (streamable-http)`);
|
|
2217
|
+
console.log(`[zocket] ready \u2014 vault: ${vaultPath()}`);
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
function buildCli() {
|
|
2221
|
+
const program = new Command("zocket").description("Local encrypted vault + MCP server for AI agent workflows").version("1.0.0");
|
|
2222
|
+
program.command("init").description("Initialize vault and config").action(async () => {
|
|
2223
|
+
createServices();
|
|
2224
|
+
console.log(`[zocket] initialized \u2014 vault: ${vaultPath()}`);
|
|
2225
|
+
});
|
|
2226
|
+
program.command("start").description("Start web panel + MCP SSE + MCP Streamable HTTP servers").option("--host <host>", "Bind host", "127.0.0.1").option("--web-port <port>", "Web panel port", "18001").option("--mcp-port <port>", "MCP SSE port", "18002").option("--mcp-stream-port <port>", "MCP Streamable HTTP port", "18003").option("--mode <mode>", "MCP mode (metadata|admin)", "admin").action(async (opts) => {
|
|
2227
|
+
await cmdStart({
|
|
2228
|
+
host: opts.host,
|
|
2229
|
+
webPort: parseInt(opts.webPort, 10),
|
|
2230
|
+
mcpPort: parseInt(opts.mcpPort, 10),
|
|
2231
|
+
mcpStreamPort: parseInt(opts.mcpStreamPort, 10),
|
|
2232
|
+
mode: opts.mode
|
|
2233
|
+
});
|
|
2234
|
+
});
|
|
2235
|
+
const projects = program.command("projects").description("Manage projects");
|
|
2236
|
+
projects.command("list").action(async () => {
|
|
2237
|
+
const { vault } = createServices();
|
|
2238
|
+
const rows = await vault.listProjects();
|
|
2239
|
+
if (!rows.length) {
|
|
2240
|
+
console.log("No projects");
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
for (const r of rows) {
|
|
2244
|
+
console.log(`${r.name} ${r.secret_count} ${r.folder_path ?? ""}`);
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
projects.command("create <name>").option("--description <text>", "Description", "").option("--folder <path>", "Folder path", "").action(async (name, opts) => {
|
|
2248
|
+
const { vault } = createServices();
|
|
2249
|
+
await vault.createProject(name, opts.description ?? "");
|
|
2250
|
+
if (opts.folder) await vault.setFolder(name, opts.folder);
|
|
2251
|
+
console.log("Project created:", name);
|
|
2252
|
+
});
|
|
2253
|
+
projects.command("delete <name>").action(async (name) => {
|
|
2254
|
+
const { vault } = createServices();
|
|
2255
|
+
await vault.deleteProject(name);
|
|
2256
|
+
console.log("Project deleted:", name);
|
|
2257
|
+
});
|
|
2258
|
+
projects.command("set-folder <name> [path]").description('Set or clear folder path (use "-" to clear)').action(async (name, path) => {
|
|
2259
|
+
const { vault } = createServices();
|
|
2260
|
+
const value = path && path !== "-" ? path : void 0;
|
|
2261
|
+
await vault.setFolder(name, value);
|
|
2262
|
+
console.log("Folder updated:", name);
|
|
2263
|
+
});
|
|
2264
|
+
projects.command("set-domains <name> [domains]").description('Set or clear allowed domains (comma-separated, use "-" to clear)').action(async (name, domains) => {
|
|
2265
|
+
const { vault } = createServices();
|
|
2266
|
+
const value = domains && domains !== "-" ? domains.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
2267
|
+
await vault.setAllowedDomains(name, value);
|
|
2268
|
+
console.log("Domains updated:", name);
|
|
2269
|
+
});
|
|
2270
|
+
const secrets = program.command("secrets").description("Manage secrets");
|
|
2271
|
+
secrets.command("list <project>").option("--show-values", "Include secret values", false).action(async (project, opts) => {
|
|
2272
|
+
const { vault } = createServices();
|
|
2273
|
+
const rows = await vault.listSecrets(project);
|
|
2274
|
+
if (!rows.length) {
|
|
2275
|
+
console.log("No secrets");
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
for (const r of rows) {
|
|
2279
|
+
if (opts.showValues) {
|
|
2280
|
+
const v = await vault.getSecretValue(project, r.key);
|
|
2281
|
+
console.log(`${r.key} ${v} ${r.description ?? ""}`);
|
|
2282
|
+
} else {
|
|
2283
|
+
console.log(`${r.key} ${r.description ?? ""}`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
secrets.command("get <project> <key>").action(async (project, key) => {
|
|
2288
|
+
const { vault } = createServices();
|
|
2289
|
+
const v = await vault.getSecretValue(project, key);
|
|
2290
|
+
console.log(v);
|
|
2291
|
+
});
|
|
2292
|
+
secrets.command("set <project> <key> <value>").option("--description <text>", "Description", "").action(async (project, key, value, opts) => {
|
|
2293
|
+
const { vault } = createServices();
|
|
2294
|
+
await vault.setSecret(project, key, value, opts.description ?? "");
|
|
2295
|
+
console.log("Secret saved:", key);
|
|
2296
|
+
});
|
|
2297
|
+
secrets.command("delete <project> <key>").action(async (project, key) => {
|
|
2298
|
+
const { vault } = createServices();
|
|
2299
|
+
await vault.deleteSecret(project, key);
|
|
2300
|
+
console.log("Secret deleted:", key);
|
|
2301
|
+
});
|
|
2302
|
+
program.command("tui").description("Interactive terminal UI for full management").action(async () => {
|
|
2303
|
+
const { vault, config, audit } = createServices();
|
|
2304
|
+
await runTui({ vault, config, audit });
|
|
2305
|
+
});
|
|
2306
|
+
return program;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// src/index.ts
|
|
2310
|
+
buildCli().parseAsync(process.argv).catch((e) => {
|
|
2311
|
+
console.error(e);
|
|
2312
|
+
process.exit(1);
|
|
2313
|
+
});
|