keyshroud 0.5.1 → 0.5.3
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/CHANGELOG.md +49 -0
- package/README.md +61 -20
- package/dist/cli.js +177 -17
- package/dist/core.d.ts +5 -0
- package/dist/core.js +172 -12
- package/dist/hooks.js +2 -2
- package/dist/mcp.js +48 -11
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +16 -0
- package/dist/refs.js +92 -0
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +7 -3
- package/src/cli.ts +127 -13
- package/src/core.ts +133 -12
- package/src/hooks.ts +2 -2
- package/src/mcp.ts +37 -11
- package/src/redact.ts +1 -1
- package/src/refs.ts +66 -0
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmod, copyFile, mkdir, lstat, readdir, readFile, readlink, symlink, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, copyFile, mkdir, lstat, readdir, readFile, readlink, rename, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { constants, homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
7
8
|
import process from "node:process";
|
|
8
9
|
import { BRAND } from "./brand.ts";
|
|
9
10
|
import * as core from "./core.ts";
|
|
10
11
|
import { runClaudeHook } from "./hooks.ts";
|
|
11
12
|
import { serveMcp } from "./mcp.ts";
|
|
12
13
|
import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream, type RedactIndex } from "./redact.ts";
|
|
13
|
-
import { ENV_NAME_RE, parseRef } from "./refs.ts";
|
|
14
|
+
import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
|
|
14
15
|
|
|
15
16
|
const usage = `Usage: ${BRAND.bin} <command> [options]
|
|
16
17
|
|
|
@@ -22,8 +23,11 @@ Secrets
|
|
|
22
23
|
redact Copy stdin to stdout with vault secrets masked
|
|
23
24
|
scan [paths...] Find vault secret values in files (exit 1 on leaks)
|
|
24
25
|
env [--shell] List wired names (or print shell exports)
|
|
25
|
-
add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin]
|
|
26
|
-
|
|
26
|
+
add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
|
|
27
|
+
import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
|
|
28
|
+
Move secrets from a .env into the vault and write .env.refs
|
|
29
|
+
(dry run unless --yes; prints names only)
|
|
30
|
+
rotate --item ITEM [--stdin|--dialog]
|
|
27
31
|
wire --env NAME --item ITEM [--target pi|omp|both] [--force]
|
|
28
32
|
unwire --env NAME [--target pi|omp|both]
|
|
29
33
|
|
|
@@ -53,6 +57,21 @@ function targetList(value: string | undefined): string[] {
|
|
|
53
57
|
if (value === "pi") return [targetPaths.pi];
|
|
54
58
|
fail("--target must be omp, pi, or both", 2);
|
|
55
59
|
}
|
|
60
|
+
// --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
|
|
61
|
+
// a native dialog, so the agent can start the command and the user only pastes.
|
|
62
|
+
async function readSecret(args: string[], prompt: string, itemName: string, envName?: string): Promise<string> {
|
|
63
|
+
if (args.includes("--stdin")) return core.readSecretFromInput({ prompt, stdin: true });
|
|
64
|
+
if (process.stdin.isTTY && !args.includes("--dialog")) return core.readSecretFromInput({ prompt, stdin: false });
|
|
65
|
+
const value = await core.readSecretFromDialog({ itemName, envName });
|
|
66
|
+
if (value) return value;
|
|
67
|
+
if (value === "") throw new Error("Empty secret");
|
|
68
|
+
fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
|
|
69
|
+
}
|
|
70
|
+
function checkNames(envName: string, itemName: string): void {
|
|
71
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
72
|
+
const problem = itemNameProblem(itemName);
|
|
73
|
+
if (problem) fail(`Vault item name ${problem}`, 2);
|
|
74
|
+
}
|
|
56
75
|
function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
57
76
|
function failureCode(error: unknown): number {
|
|
58
77
|
const text = error instanceof Error ? error.message : String(error);
|
|
@@ -128,9 +147,13 @@ async function inject(args: string[]): Promise<void> {
|
|
|
128
147
|
await core.audit("inject", refs, output ?? "stdout");
|
|
129
148
|
const rendered = template.replace(pattern, (_match, raw: string) => values.get(raw) ?? "");
|
|
130
149
|
if (!output) { process.stdout.write(rendered); return; }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await
|
|
150
|
+
// lstat, and a private temp file renamed over the target, so a planted
|
|
151
|
+
// symlink (even a dangling one) is replaced instead of followed.
|
|
152
|
+
const exists = await lstat(output).then(() => true, () => false);
|
|
153
|
+
if (exists && !args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5);
|
|
154
|
+
const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
|
|
155
|
+
await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
|
|
156
|
+
await rename(tmp, output);
|
|
134
157
|
console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
|
|
135
158
|
}
|
|
136
159
|
|
|
@@ -171,6 +194,90 @@ async function scan(paths: string[]): Promise<void> {
|
|
|
171
194
|
if (leaks) process.exitCode = 1;
|
|
172
195
|
}
|
|
173
196
|
|
|
197
|
+
interface ImportRow { entry: DotenvEntry; kind: "new" | "reuse" | "conflict" | "literal" | "ref"; item?: string; note?: string }
|
|
198
|
+
|
|
199
|
+
async function importCommand(args: string[]): Promise<void> {
|
|
200
|
+
requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
|
|
201
|
+
const from = valueAfter(args, "--from") ?? ".env";
|
|
202
|
+
const yes = args.includes("--yes"); const replace = args.includes("--replace");
|
|
203
|
+
if (replace && (from === "-" || valueAfter(args, "--out"))) fail("--replace rewrites the --from file; it cannot be combined with stdin or --out", 2);
|
|
204
|
+
if (from === "-" && process.stdin.isTTY) fail("--from - needs the .env piped on stdin", 2);
|
|
205
|
+
const baseDir = from === "-" ? process.cwd() : dirname(resolve(from));
|
|
206
|
+
const out = replace ? resolve(from) : resolve(valueAfter(args, "--out") ?? join(baseDir, ".env.refs"));
|
|
207
|
+
const prefix = valueAfter(args, "--prefix") ?? basename(baseDir);
|
|
208
|
+
if (!/^[A-Za-z0-9._-]+$/.test(prefix)) fail("--prefix may contain only letters, digits, '.', '_' and '-' (set it with --prefix)", 2);
|
|
209
|
+
const list = (flag: string) => (valueAfter(args, flag) ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
210
|
+
const only = list("--only"); const skip = list("--skip");
|
|
211
|
+
|
|
212
|
+
let text: string;
|
|
213
|
+
try { text = from === "-" ? await readStdin() : await readFile(from, "utf8"); } catch { fail(`Cannot read ${from}`, 2); }
|
|
214
|
+
const { entries, errors } = parseDotenvStrict(text);
|
|
215
|
+
if (errors.length) {
|
|
216
|
+
for (const error of errors) console.error(`${from}:${error.line} ${error.reason}`);
|
|
217
|
+
fail(`${errors.length} line(s) cannot be imported safely; fix them or move them out of the file`, 2);
|
|
218
|
+
}
|
|
219
|
+
for (const name of [...only, ...skip]) if (!entries.some((entry) => entry.name === name)) fail(`--only/--skip name not in ${from}: ${name}`, 2);
|
|
220
|
+
|
|
221
|
+
await core.ensureSession();
|
|
222
|
+
const existing = await core.findItems(prefix);
|
|
223
|
+
const rows: ImportRow[] = [];
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (isRef(entry.value)) { rows.push({ entry, kind: "ref" }); continue; }
|
|
226
|
+
const secret = (only.length ? only.includes(entry.name) : looksSecret(entry.name, entry.value)) && !skip.includes(entry.name);
|
|
227
|
+
if (!secret) { rows.push({ entry, kind: "literal" }); continue; }
|
|
228
|
+
const item = `${prefix} ${entry.name}`;
|
|
229
|
+
const matches = existing.filter((candidate) => candidate.name.toLowerCase() === item.toLowerCase());
|
|
230
|
+
if (matches.length > 1) { rows.push({ entry, item, kind: "conflict", note: "more than one vault item has this name" }); continue; }
|
|
231
|
+
if (matches.length === 0) { rows.push({ entry, item, kind: "new" }); continue; }
|
|
232
|
+
const current = await core.resolveRef({ item: matches[0].id, field: "password" });
|
|
233
|
+
rows.push(current === entry.value
|
|
234
|
+
? { entry, item, kind: "reuse", note: "same value already in the vault" }
|
|
235
|
+
: { entry, item, kind: "conflict", note: "vault item exists with a different value" });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const width = Math.max(4, ...rows.map((row) => row.entry.name.length));
|
|
239
|
+
console.log(`import plan for ${from} (item prefix "${prefix}")`);
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
const label = row.kind === "literal" ? "literal kept" : row.kind === "ref" ? "reference kept"
|
|
242
|
+
: row.kind === "conflict" ? `secret conflict: ${row.note} ("${row.item}")`
|
|
243
|
+
: `secret ${row.kind.padEnd(8)} -> bw://${row.item}${row.note ? ` (${row.note})` : ""}`;
|
|
244
|
+
const short = row.item && row.entry.value.length < 6 ? " [under 6 characters: stored, but too short to be masked]" : "";
|
|
245
|
+
console.log(` ${row.entry.name.padEnd(width)} ${label}${short}`);
|
|
246
|
+
}
|
|
247
|
+
const count = (kind: ImportRow["kind"]) => rows.filter((row) => row.kind === kind).length;
|
|
248
|
+
const toStore = rows.filter((row) => row.kind === "new" || row.kind === "reuse");
|
|
249
|
+
console.log(`${toStore.length + count("conflict")} secret(s): ${count("new")} new, ${count("reuse")} already in the vault, ${count("conflict")} conflict(s); ${count("literal")} literal(s), ${count("ref")} reference(s)`);
|
|
250
|
+
if (count("conflict")) fail("Resolve the conflicts (choose another --prefix, or rename the vault items) before importing", 5);
|
|
251
|
+
if (!toStore.length) { console.log("Nothing to import."); return; }
|
|
252
|
+
const outExists = await stat(out).then(() => true, () => false);
|
|
253
|
+
if (outExists && !replace && !args.includes("--force")) fail(`${out} already exists; use --force to overwrite it`, 5);
|
|
254
|
+
if (!yes) { console.log(`Dry run: nothing written. Re-run with --yes to create ${count("new")} item(s) and write ${out}`); return; }
|
|
255
|
+
|
|
256
|
+
const created: string[] = [];
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
if (row.kind !== "new") continue;
|
|
259
|
+
try { await core.createSecretItem({ itemName: row.item!, secret: row.entry.value, envName: row.entry.name, notes: `Imported by ${BRAND.bin} from ${basename(from === "-" ? "stdin" : from)}` }); }
|
|
260
|
+
catch { if (created.length) console.error(`Created before the failure: ${created.join(", ")}`); fail(`Failed to create the vault item for ${row.entry.name}; nothing was written to ${out}`, 3); }
|
|
261
|
+
created.push(row.entry.name);
|
|
262
|
+
}
|
|
263
|
+
for (const row of toStore) {
|
|
264
|
+
let value: string | null = null;
|
|
265
|
+
try { value = await core.resolveRef({ item: row.item!, field: "password" }); } catch {}
|
|
266
|
+
if (value !== row.entry.value) fail(`Verification failed for ${row.entry.name}: the vault does not return the imported value; nothing was written to ${out}`, 4);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
270
|
+
const lines = text.split(/\r?\n/);
|
|
271
|
+
for (const row of toStore) lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
|
|
272
|
+
const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
|
|
273
|
+
const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
|
|
274
|
+
await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
|
|
275
|
+
await rename(tmp, out);
|
|
276
|
+
await core.audit("import", toStore.map((row) => row.entry.name), "cli");
|
|
277
|
+
console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
|
|
278
|
+
if (!replace && from !== "-") console.log(`${from} still holds the plaintext values. Check with \`${BRAND.bin} run --env-file ${relative(process.cwd(), out) || out} -- <cmd>\`, then delete it or re-run with --replace.`);
|
|
279
|
+
}
|
|
280
|
+
|
|
174
281
|
async function main(): Promise<void> {
|
|
175
282
|
const args = process.argv.slice(2); const command = args.shift();
|
|
176
283
|
if (!command || command === "--help" || command === "-h" || command === "help") { console.log(usage); if (!command) process.exitCode = 2; return; }
|
|
@@ -226,31 +333,38 @@ async function main(): Promise<void> {
|
|
|
226
333
|
await core.ensureSession(); process.stdout.write(await core.getSecret(item)); return;
|
|
227
334
|
}
|
|
228
335
|
if (command === "add") {
|
|
229
|
-
requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin"]);
|
|
336
|
+
requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
|
|
230
337
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
|
|
231
338
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
339
|
+
checkNames(envName, itemName);
|
|
232
340
|
const reference = core.referenceFor(itemName);
|
|
233
341
|
const targets = targetList(valueAfter(args, "--target"));
|
|
234
342
|
await core.ensureSession();
|
|
235
|
-
|
|
343
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) fail(`Vault item already exists: ${itemName}`, 5);
|
|
344
|
+
const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
|
|
236
345
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
346
|
+
await core.audit("add", [envName], "cli");
|
|
237
347
|
let conflict = false;
|
|
238
348
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
|
|
239
349
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
350
|
+
await core.refreshRedactIndex();
|
|
240
351
|
console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
|
|
241
352
|
if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
|
|
242
353
|
return;
|
|
243
354
|
}
|
|
355
|
+
if (command === "import") { await importCommand(args); return; }
|
|
244
356
|
if (command === "rotate") {
|
|
245
|
-
requireKnownFlags(args, ["--item"], ["--stdin"]);
|
|
357
|
+
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
246
358
|
const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
|
|
247
|
-
await core.ensureSession(); const secret = await
|
|
248
|
-
await core.rotateSecretItem({ itemName, secret });
|
|
359
|
+
await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", itemName);
|
|
360
|
+
await core.rotateSecretItem({ itemName, secret }); await core.audit("rotate", [itemName], "cli"); await core.refreshRedactIndex();
|
|
361
|
+
console.log(`rotated item=${itemName}`); return;
|
|
249
362
|
}
|
|
250
363
|
if (command === "wire") {
|
|
251
364
|
requireKnownFlags(args, ["--env", "--item", "--target"], ["--force"]);
|
|
252
365
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item");
|
|
253
366
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
367
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
254
368
|
const reference = core.referenceFor(itemName);
|
|
255
369
|
const targets = targetList(valueAfter(args, "--target"));
|
|
256
370
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference, { overwrite: args.includes("--force") }); if (!result.ok) fail(result.message, 5); }
|
package/src/core.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join, dirname } from "node:path";
|
|
7
8
|
import process from "node:process";
|
|
8
|
-
import * as readline from "node:readline";
|
|
9
9
|
import { BRAND } from "./brand.ts";
|
|
10
10
|
import { buildRedactIndex, type RedactIndex } from "./redact.ts";
|
|
11
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
11
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
12
12
|
|
|
13
13
|
const P = BRAND.envPrefix;
|
|
14
14
|
function setting(name: string): string | undefined {
|
|
@@ -72,7 +72,8 @@ export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "
|
|
|
72
72
|
export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
73
73
|
const env: Record<string, string> = {};
|
|
74
74
|
for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) env[name] = value;
|
|
75
|
-
|
|
75
|
+
for (const [name, value] of Object.entries(extra)) if (!isLoaderEnvName(name)) env[name] = value;
|
|
76
|
+
return env;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export async function bw(args: string[], opts: { input?: string; session?: string } = {}): Promise<string> {
|
|
@@ -252,8 +253,8 @@ export function ensureSession(): Promise<string> {
|
|
|
252
253
|
|
|
253
254
|
async function writePrivateFile(path: string, content: string): Promise<void> {
|
|
254
255
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
255
|
-
const tmp = `${path}.tmp.${
|
|
256
|
-
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
256
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
|
|
257
|
+
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
257
258
|
await chmod(tmp, 0o600);
|
|
258
259
|
await rename(tmp, path);
|
|
259
260
|
await chmod(path, 0o600);
|
|
@@ -456,6 +457,7 @@ export async function resolveEntries(entries: Array<{ name: string; raw: string
|
|
|
456
457
|
export async function envFileEntries(path: string): Promise<Array<{ name: string; raw: string }>> {
|
|
457
458
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
458
459
|
if (value.trim().startsWith("!")) throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
|
|
460
|
+
if (isLoaderEnvName(name)) throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
|
|
459
461
|
return { name, raw: value };
|
|
460
462
|
});
|
|
461
463
|
}
|
|
@@ -505,6 +507,12 @@ export async function writeRedactIndex(values: Record<string, string>): Promise<
|
|
|
505
507
|
return index;
|
|
506
508
|
}
|
|
507
509
|
|
|
510
|
+
// Re-index after the wired set changes so hooks mask a new value right away.
|
|
511
|
+
export async function refreshRedactIndex(): Promise<void> {
|
|
512
|
+
const env = await resolveEntries(await authEnvEntries());
|
|
513
|
+
await writeRedactIndex(indexValues(env.secrets));
|
|
514
|
+
}
|
|
515
|
+
|
|
508
516
|
export async function readRedactIndex(): Promise<RedactIndex | null> {
|
|
509
517
|
try {
|
|
510
518
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8")) as RedactIndex;
|
|
@@ -532,7 +540,16 @@ export async function getSecret(itemName: string): Promise<string> {
|
|
|
532
540
|
try { return await bw(["get", "password", itemName]); } catch (error) { throw mapItemError(error, itemName); }
|
|
533
541
|
}
|
|
534
542
|
|
|
543
|
+
// An agent can start add/rotate, so a dialog could be used to phish the master
|
|
544
|
+
// password into an item it can then read; refuse it outright.
|
|
545
|
+
async function refuseMasterPassword(secret: string): Promise<void> {
|
|
546
|
+
if (secret && secret === await readMasterPassword()) throw new Error("Refusing to store the vault master password as a secret");
|
|
547
|
+
}
|
|
548
|
+
|
|
535
549
|
export async function createSecretItem(opts: { itemName: string; secret: string; envName?: string; notes?: string }): Promise<{ id: string }> {
|
|
550
|
+
const problem = itemNameProblem(opts.itemName);
|
|
551
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
552
|
+
await refuseMasterPassword(opts.secret);
|
|
536
553
|
const existing = await findItems(opts.itemName);
|
|
537
554
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase())) throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
538
555
|
const item: Record<string, unknown> = {
|
|
@@ -549,6 +566,7 @@ export async function createSecretItem(opts: { itemName: string; secret: string;
|
|
|
549
566
|
}
|
|
550
567
|
|
|
551
568
|
export async function rotateSecretItem(opts: { itemName: string; secret: string }): Promise<{ id: string }> {
|
|
569
|
+
await refuseMasterPassword(opts.secret);
|
|
552
570
|
let parsed: Record<string, unknown>;
|
|
553
571
|
try { parsed = JSON.parse(await bw(["get", "item", opts.itemName])) as Record<string, unknown>; } catch (error) { throw mapItemError(error, opts.itemName); }
|
|
554
572
|
const login = parsed.login && typeof parsed.login === "object" ? parsed.login as Record<string, unknown> : {};
|
|
@@ -566,19 +584,122 @@ export async function deleteItem(id: string, permanent = false): Promise<void> {
|
|
|
566
584
|
}
|
|
567
585
|
|
|
568
586
|
export async function readSecretFromInput(opts: { prompt: string; stdin: boolean }): Promise<string> {
|
|
569
|
-
if (opts.stdin
|
|
587
|
+
if (opts.stdin) {
|
|
570
588
|
let input = "";
|
|
571
589
|
for await (const chunk of process.stdin) input += String(chunk);
|
|
572
590
|
input = input.replace(/\r?\n$/, "");
|
|
573
591
|
if (!input) throw new Error("Empty secret");
|
|
574
592
|
return input;
|
|
575
593
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
process.stderr.write(opts.prompt);
|
|
579
|
-
const value = await new Promise<string>((resolve) => rl.question("", resolve));
|
|
580
|
-
rl.close();
|
|
581
|
-
process.stderr.write("\n");
|
|
594
|
+
if (!process.stdin.isTTY) throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
595
|
+
const value = await readMasked(opts.prompt);
|
|
582
596
|
if (!value) throw new Error("Empty secret");
|
|
583
597
|
return value;
|
|
584
598
|
}
|
|
599
|
+
|
|
600
|
+
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
601
|
+
// An agent can open it, so the wording is fixed, says who asked, and warns
|
|
602
|
+
// against the master password; names are validated and passed as data (argv),
|
|
603
|
+
// never as script. One dialog at a time, with a pause after a cancel. Resolves
|
|
604
|
+
// null when no dialog can be shown (SSH, headless Linux, missing tools).
|
|
605
|
+
const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
|
|
606
|
+
const DIALOG_COOLDOWN_MS = 30_000;
|
|
607
|
+
const DIALOG_TIMEOUT_MS = 300_000;
|
|
608
|
+
|
|
609
|
+
export async function readSecretFromDialog(opts: { itemName: string; envName?: string }): Promise<string | null> {
|
|
610
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
|
|
611
|
+
const problem = itemNameProblem(opts.itemName);
|
|
612
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
613
|
+
if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName)) throw new Error("Variable names must be UPPER_SNAKE_CASE");
|
|
614
|
+
const text = [
|
|
615
|
+
opts.envName ? "A program on this computer, usually an AI agent, asked to store a NEW secret." : "A program on this computer, usually an AI agent, asked to REPLACE the value of a secret.",
|
|
616
|
+
"",
|
|
617
|
+
...(opts.envName ? [`Variable: ${opts.envName}`] : []),
|
|
618
|
+
`Vault item: ${opts.itemName}`,
|
|
619
|
+
"",
|
|
620
|
+
"This is not a login prompt. Never enter your vault master password here.",
|
|
621
|
+
].join("\n");
|
|
622
|
+
const candidates: Array<[string, string[]]> = process.platform === "darwin"
|
|
623
|
+
? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
|
|
624
|
+
'text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel" with icon caution)',
|
|
625
|
+
"-e", "end run", "--", text]]]
|
|
626
|
+
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
627
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
628
|
+
: [];
|
|
629
|
+
if (!candidates.length) return null;
|
|
630
|
+
return withDialogLock(async () => {
|
|
631
|
+
for (const [command, args] of candidates) {
|
|
632
|
+
const result = await new Promise<{ code: number | string | null; stdout: string; stderr: string }>((resolve) => {
|
|
633
|
+
execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
|
|
634
|
+
});
|
|
635
|
+
if (result.code === "ENOENT") continue;
|
|
636
|
+
if (result.code === 0) return result.stdout.replace(/\r?\n$/, "");
|
|
637
|
+
if (command !== "osascript" || /-128/.test(result.stderr)) throw new Error("Cancelled");
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function withDialogLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
645
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
646
|
+
let held = "";
|
|
647
|
+
try { held = await readFile(DIALOG_LOCK, "utf8"); } catch {}
|
|
648
|
+
const [kind, at] = held.trim().split(":");
|
|
649
|
+
const age = Date.now() - Number(at);
|
|
650
|
+
const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
|
|
651
|
+
if (!live) await rm(DIALOG_LOCK, { force: true });
|
|
652
|
+
try { await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 }); }
|
|
653
|
+
catch { throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open"); }
|
|
654
|
+
try {
|
|
655
|
+
const value = await fn();
|
|
656
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
657
|
+
return value;
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error instanceof Error && error.message === "Cancelled") await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
|
|
660
|
+
else await rm(DIALOG_LOCK, { force: true });
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
666
|
+
// the user sees typing and pastes land without the value ever reaching the screen.
|
|
667
|
+
function readMasked(prompt: string): Promise<string> {
|
|
668
|
+
const stdin = process.stdin;
|
|
669
|
+
const chars: string[] = [];
|
|
670
|
+
const draw = () => {
|
|
671
|
+
const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
|
|
672
|
+
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
673
|
+
};
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
// After Enter, keep reading briefly: the rest of a multi-line paste must be
|
|
676
|
+
// consumed here (or it would reach the parent shell) and is then refused.
|
|
677
|
+
let ending: ReturnType<typeof setTimeout> | undefined;
|
|
678
|
+
let multiline = false;
|
|
679
|
+
const settle = () => {
|
|
680
|
+
clearTimeout(ending);
|
|
681
|
+
ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
|
|
682
|
+
};
|
|
683
|
+
const finish = (error?: Error) => {
|
|
684
|
+
clearTimeout(ending);
|
|
685
|
+
stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
|
|
686
|
+
process.stderr.write("\n");
|
|
687
|
+
if (error) reject(error); else resolve(chars.join(""));
|
|
688
|
+
};
|
|
689
|
+
const onData = (chunk: string) => {
|
|
690
|
+
if (ending) { if (/[^\r\n]/.test(chunk)) multiline = true; return settle(); }
|
|
691
|
+
const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
|
|
692
|
+
for (const [index, ch] of clean.entries()) {
|
|
693
|
+
if (ch === "\r" || ch === "\n") { if (/[^\r\n]/.test(clean.slice(index + 1).join(""))) multiline = true; return settle(); }
|
|
694
|
+
if (ch === "\x03") return finish(new Error("Cancelled"));
|
|
695
|
+
if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
|
|
696
|
+
if (ch === "\x7f" || ch === "\b") chars.pop();
|
|
697
|
+
else if (ch === "\x15") chars.length = 0;
|
|
698
|
+
else if (ch >= " ") chars.push(ch);
|
|
699
|
+
}
|
|
700
|
+
draw();
|
|
701
|
+
};
|
|
702
|
+
stdin.setRawMode(true); stdin.setEncoding("utf8"); stdin.resume(); stdin.on("data", onData);
|
|
703
|
+
draw();
|
|
704
|
+
});
|
|
705
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -22,8 +22,8 @@ async function sessionStart(): Promise<string> {
|
|
|
22
22
|
await core.writeRedactIndex(core.indexValues(env.secrets));
|
|
23
23
|
const names = Object.keys(env.values);
|
|
24
24
|
context = names.length
|
|
25
|
-
? `${BRAND.name}: vault secrets are available by name only: ${names.join(", ")}. Run every command that uses one through \`${BRAND.bin} run -- <command>\` (or the secrets_run MCP tool): its output is masked as <redacted:NAME> even when the command fails. Never print a secret. Never ask the user to paste a secret into chat
|
|
26
|
-
: `${BRAND.name}: no secrets are wired yet.
|
|
25
|
+
? `${BRAND.name}: vault secrets are available by name only: ${names.join(", ")}. Run every command that uses one through \`${BRAND.bin} run -- <command>\` (or the secrets_run MCP tool): its output is masked as <redacted:NAME> even when the command fails. Never print a secret. Never ask the user to paste a secret into chat. To store a new one, run \`${BRAND.bin} add --env NAME --item "Item"\` (or call secrets_request): it opens a dialog on the user's desktop for the value.`
|
|
26
|
+
: `${BRAND.name}: no secrets are wired yet. To store one, run \`${BRAND.bin} add --env NAME --item "Item"\` (or call secrets_request): it opens a dialog on the user's desktop for the value.`;
|
|
27
27
|
} catch (error) {
|
|
28
28
|
context = `${BRAND.name}: the vault could not be unlocked (${error instanceof Error ? error.message : String(error)}), so tool-output redaction may be incomplete this session. Do not print or inspect secrets; ask the user to run \`${BRAND.bin} unlock\`.`;
|
|
29
29
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -7,7 +7,7 @@ import process from "node:process";
|
|
|
7
7
|
import { BRAND } from "./brand.ts";
|
|
8
8
|
import * as core from "./core.ts";
|
|
9
9
|
import { createRedactor } from "./redact.ts";
|
|
10
|
-
import { ENV_NAME_RE } from "./refs.ts";
|
|
10
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem } from "./refs.ts";
|
|
11
11
|
|
|
12
12
|
const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
13
13
|
const OUTPUT_LIMIT = 256 * 1024;
|
|
@@ -35,7 +35,7 @@ const TOOLS = [
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
name: "secrets_request",
|
|
38
|
-
description: "Ask the human to store a new secret.
|
|
38
|
+
description: "Ask the human to store a new secret. Opens a native hidden-input dialog on the user's desktop and stores the value in the vault; where no dialog can be shown, returns a terminal command for the user to run instead. The value never passes through the conversation.",
|
|
39
39
|
inputSchema: {
|
|
40
40
|
type: "object",
|
|
41
41
|
required: ["env_name", "item_name"],
|
|
@@ -85,13 +85,13 @@ async function runTool(args: Json): Promise<Json> {
|
|
|
85
85
|
detached: true,
|
|
86
86
|
});
|
|
87
87
|
const chunks = { stdout: [] as Buffer[], stderr: [] as Buffer[] };
|
|
88
|
-
// Collect generously, mask, then truncate
|
|
89
|
-
// before
|
|
90
|
-
|
|
88
|
+
// Collect generously per stream, mask, then truncate. If collection stopped
|
|
89
|
+
// early, the tail may end inside a secret, so it is dropped before masking.
|
|
90
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
91
91
|
for (const stream of ["stdout", "stderr"] as const) {
|
|
92
92
|
child[stream].on("data", (chunk: Buffer) => {
|
|
93
|
-
if (
|
|
94
|
-
|
|
93
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) return;
|
|
94
|
+
sizes[stream] += chunk.length;
|
|
95
95
|
chunks[stream].push(chunk);
|
|
96
96
|
});
|
|
97
97
|
}
|
|
@@ -101,9 +101,11 @@ async function runTool(args: Json): Promise<Json> {
|
|
|
101
101
|
child.on("error", (error) => done(`spawn error: ${error.message}`));
|
|
102
102
|
});
|
|
103
103
|
clearTimeout(timer);
|
|
104
|
-
let truncated =
|
|
104
|
+
let truncated = false;
|
|
105
105
|
const out = (stream: "stdout" | "stderr") => {
|
|
106
|
-
|
|
106
|
+
let raw = Buffer.concat(chunks[stream]).toString("utf8");
|
|
107
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) { truncated = true; raw = raw.slice(0, redactor.safeCut(raw)); }
|
|
108
|
+
const masked = redactor.redact(raw);
|
|
107
109
|
if (masked.length <= OUTPUT_LIMIT / 2) return masked;
|
|
108
110
|
truncated = true;
|
|
109
111
|
return masked.slice(0, OUTPUT_LIMIT / 2);
|
|
@@ -132,8 +134,32 @@ async function callTool(name: string, args: Json): Promise<Json> {
|
|
|
132
134
|
const envName = String(args.env_name ?? "");
|
|
133
135
|
const itemName = String(args.item_name ?? "");
|
|
134
136
|
if (!ENV_NAME_RE.test(envName) || !itemName) return text("env_name must be UPPER_SNAKE_CASE and item_name is required", true);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
if (isLoaderEnvName(envName)) return text(`${envName} changes what programs load or run; it cannot be wired to a secret`, true);
|
|
138
|
+
const problem = itemNameProblem(itemName);
|
|
139
|
+
if (problem) return text(`item_name ${problem}`, true);
|
|
140
|
+
const notes = typeof args.purpose === "string" && args.purpose ? args.purpose.slice(0, 500) : undefined;
|
|
141
|
+
try {
|
|
142
|
+
const reference = core.referenceFor(itemName);
|
|
143
|
+
await core.ensureSession();
|
|
144
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) return text(`Vault item already exists: ${itemName}`, true);
|
|
145
|
+
const secret = await core.readSecretFromDialog({ itemName, envName });
|
|
146
|
+
if (secret === "") return text("The user saved an empty value; nothing was stored.", true);
|
|
147
|
+
if (secret !== null) {
|
|
148
|
+
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
149
|
+
await core.audit("request", [envName], "mcp");
|
|
150
|
+
const conflicts: string[] = [];
|
|
151
|
+
for (const path of Object.values(core.getAuthTargets())) if (!(await core.writeAuthEntry(path, envName, reference)).ok) conflicts.push(path);
|
|
152
|
+
await core.refreshRedactIndex();
|
|
153
|
+
if (conflicts.length) return text(`created item=${itemName}, but ${envName} is already wired to another secret in ${conflicts.join(", ")} and was left unchanged. Ask the user before running \`${BRAND.bin} wire --env ${envName} --item ${shellQuote(itemName)} --force\`.`, true);
|
|
154
|
+
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
155
|
+
return text(`The user entered the value in a ${BRAND.name} dialog. created item=${itemName} env=${envName} verified=${verified ? "yes" : "no"}. Use secrets_run with $${envName}.`);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
|
|
160
|
+
}
|
|
161
|
+
const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
|
|
162
|
+
return text(`No dialog can be shown here. Ask the user to run this in their own terminal (the value is entered in a masked prompt; it must never be pasted into the chat):\n\n ${BRAND.bin} add --env ${envName} --item ${shellQuote(itemName)}${purpose}\n\nAfter it reports verified=yes, use secrets_run with $${envName}.`);
|
|
137
163
|
}
|
|
138
164
|
if (name === "vault_status") {
|
|
139
165
|
try { await core.ensureSession(); } catch {}
|
package/src/redact.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function createRedactor(values: Record<string, string>): Redactor {
|
|
|
81
81
|
if (text.length - cut <= MAX_HOLD) return cut;
|
|
82
82
|
// Bounded fallback: cut near the end, moving forward past any complete
|
|
83
83
|
// secret that straddles it, so whole matches are still replaced.
|
|
84
|
-
cut = text.length - maxLength;
|
|
84
|
+
cut = Math.max(0, text.length - maxLength);
|
|
85
85
|
while (cut < text.length && straddling(cut)) cut++;
|
|
86
86
|
return cut;
|
|
87
87
|
},
|
package/src/refs.ts
CHANGED
|
@@ -55,6 +55,20 @@ export function legacyRefFor(itemName: string): string {
|
|
|
55
55
|
|
|
56
56
|
export const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
57
57
|
|
|
58
|
+
// Variables that change what a child process loads or runs. Env files can come
|
|
59
|
+
// from a cloned repo and agents can wire names, so neither may set these.
|
|
60
|
+
const LOADER_ENV_RE = /^(?:PATH|HOME|ZDOTDIR|XDG_CONFIG_HOME|IFS|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|PS4|PROMPT_COMMAND|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PERL5OPT|PERL5LIB|PERLLIB|RUBYOPT|RUBYLIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|EDITOR|VISUAL|PAGER|LESSOPEN|LESSCLOSE|BROWSER|SSH_ASKPASS|LD_\w*|DYLD_\w*|NPM_CONFIG_\w*|GIT_(?:SSH|SSH_COMMAND|EXEC_PATH|ASKPASS|EDITOR|PAGER|EXTERNAL_DIFF|PROXY_COMMAND|DIR|WORK_TREE|TEMPLATE_DIR|CONFIG\w*))$/i;
|
|
61
|
+
export function isLoaderEnvName(name: string): boolean { return LOADER_ENV_RE.test(name); }
|
|
62
|
+
|
|
63
|
+
// Item names appear in native dialogs and lookups: one line of printable text,
|
|
64
|
+
// no padding (lookups trim, so a padded name would shadow an existing item).
|
|
65
|
+
export function itemNameProblem(name: string): string | null {
|
|
66
|
+
if (!name || name !== name.trim()) return "must not be empty or start or end with spaces";
|
|
67
|
+
if (name.length > 120) return "must be at most 120 characters";
|
|
68
|
+
if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(name)) return "must be one line of printable text";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
// Parses dotenv-style files whose values may be references:
|
|
59
73
|
// GH_TOKEN=bw://GitHub CLI token
|
|
60
74
|
// export SENTRY_DSN="bw://Sentry/fields/dsn"
|
|
@@ -74,3 +88,55 @@ export function parseEnvFile(text: string): Array<{ name: string; value: string
|
|
|
74
88
|
});
|
|
75
89
|
return out;
|
|
76
90
|
}
|
|
91
|
+
|
|
92
|
+
export interface DotenvEntry { line: number; name: string; value: string; exported: boolean }
|
|
93
|
+
|
|
94
|
+
// Stricter than parseEnvFile, for `import`: a misread value would be stored in
|
|
95
|
+
// the vault, so anything ambiguous is reported by line number (never by value)
|
|
96
|
+
// instead of guessed. Values must also read back unchanged through parseEnvFile.
|
|
97
|
+
export function parseDotenvStrict(text: string): { entries: DotenvEntry[]; errors: Array<{ line: number; reason: string }> } {
|
|
98
|
+
const entries: DotenvEntry[] = [];
|
|
99
|
+
const errors: Array<{ line: number; reason: string }> = [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
text.split(/\r?\n/).forEach((raw, index) => {
|
|
102
|
+
const line = index + 1;
|
|
103
|
+
const trimmed = raw.trim();
|
|
104
|
+
if (!trimmed || trimmed.startsWith("#")) return;
|
|
105
|
+
const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
|
|
106
|
+
if (!match) { errors.push({ line, reason: "not a NAME=value line" }); return; }
|
|
107
|
+
const rest = match[3].trim();
|
|
108
|
+
let value: string;
|
|
109
|
+
if (rest.startsWith("'") || rest.startsWith('"')) {
|
|
110
|
+
const end = rest.indexOf(rest[0], 1);
|
|
111
|
+
if (end < 0) { errors.push({ line, reason: "unterminated quote (multiline values are not supported)" }); return; }
|
|
112
|
+
const after = rest.slice(end + 1).trim();
|
|
113
|
+
if (after && !/^#/.test(after)) { errors.push({ line, reason: "text after the closing quote" }); return; }
|
|
114
|
+
if (after && end + 1 === rest.indexOf("#", end)) { errors.push({ line, reason: "comment must be separated from the closing quote by a space" }); return; }
|
|
115
|
+
value = rest.slice(1, end);
|
|
116
|
+
if (rest[0] === '"' && /[\\$]/.test(value)) { errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" }); return; }
|
|
117
|
+
} else {
|
|
118
|
+
value = rest.replace(/\s+#.*$/, "");
|
|
119
|
+
if (value.includes("${")) { errors.push({ line, reason: "${} expansion" }); return; }
|
|
120
|
+
}
|
|
121
|
+
if (value.startsWith("!")) { errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" }); return; }
|
|
122
|
+
if (seen.has(match[2])) { errors.push({ line, reason: `duplicate key ${match[2]}` }); return; }
|
|
123
|
+
seen.add(match[2]);
|
|
124
|
+
entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
|
|
125
|
+
});
|
|
126
|
+
return { entries, errors };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|(?:^|_)PASS(?:$|_)|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|(?:^|_)CREDS?(?:$|_)|AUTH|JWT|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
130
|
+
const SECRET_VALUE_RE = /^(?:gh[pousr]_|github_pat_|glpat-|xox[abprs]-|sk-|sk_(?:live|test)_|rk_(?:live|test)_|whsec_|AKIA|ASIA|AIza|npm_|hf_|ya29\.|eyJ)/;
|
|
131
|
+
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
|
|
132
|
+
|
|
133
|
+
// Local heuristic for `import`: never sends anything anywhere. A known secret
|
|
134
|
+
// format wins over the name; otherwise names with a PUBLIC segment
|
|
135
|
+
// (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
|
|
136
|
+
export function looksSecret(name: string, value: string): boolean {
|
|
137
|
+
if (!value || isRef(value)) return false;
|
|
138
|
+
if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value)) return true;
|
|
139
|
+
if (/(?:^|_)PUBLIC(?:$|_)/i.test(name)) return false;
|
|
140
|
+
if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value)) return true;
|
|
141
|
+
return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
|
|
142
|
+
}
|