dotcrypt 0.0.0-stage → 1.0.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/src/server.js ADDED
@@ -0,0 +1,321 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { CONFIG_FILENAME, DEFAULT_ENVIRONMENTS, findProjectRoot, loadConfig } from './config.js';
9
+ import { ConfirmationRequiredError, VaultError, isWithin } from './utils.js';
10
+ import * as vault from './vault.js';
11
+
12
+ export const UI_DIST_DIR = fileURLToPath(new URL('../ui/dist/', import.meta.url));
13
+ const MAX_BODY_BYTES = 64 * 1024;
14
+
15
+ export function isUiBuilt() {
16
+ return fs.existsSync(path.join(UI_DIST_DIR, 'index.html'));
17
+ }
18
+
19
+ const MIME_TYPES = {
20
+ '.html': 'text/html; charset=utf-8',
21
+ '.js': 'text/javascript; charset=utf-8',
22
+ '.css': 'text/css; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.svg': 'image/svg+xml',
25
+ '.png': 'image/png',
26
+ '.ico': 'image/x-icon',
27
+ '.woff2': 'font/woff2',
28
+ '.woff': 'font/woff',
29
+ };
30
+
31
+ const SECURITY_HEADERS = {
32
+ 'Content-Security-Policy': [
33
+ "default-src 'self'",
34
+ "script-src 'self'",
35
+ "style-src 'self' 'unsafe-inline'",
36
+ "img-src 'self' data:",
37
+ "font-src 'self' data:",
38
+ "connect-src 'self'",
39
+ "object-src 'none'",
40
+ "base-uri 'none'",
41
+ "form-action 'none'",
42
+ "frame-ancestors 'none'",
43
+ ].join('; '),
44
+ 'X-Content-Type-Options': 'nosniff',
45
+ 'X-Frame-Options': 'DENY',
46
+ 'Referrer-Policy': 'no-referrer',
47
+ 'Cross-Origin-Opener-Policy': 'same-origin',
48
+ 'Cross-Origin-Resource-Policy': 'same-origin',
49
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
50
+ };
51
+
52
+ const ERROR_STATUS = {
53
+ NO_CONFIG: 404,
54
+ NOT_FOUND: 404,
55
+ DECRYPT_FAILED: 422,
56
+ INVALID_VAULT: 422,
57
+ WEAK_PASSWORD: 400,
58
+ INVALID_INPUT: 400,
59
+ CONFIRMATION_REQUIRED: 409,
60
+ };
61
+
62
+ class HttpError extends Error {
63
+ constructor(status, message) {
64
+ super(message);
65
+ this.status = status;
66
+ }
67
+ }
68
+
69
+ function send(res, status, body, headers = {}) {
70
+ res.writeHead(status, { ...SECURITY_HEADERS, ...headers });
71
+ res.end(body);
72
+ }
73
+
74
+ function sendJson(res, status, payload) {
75
+ send(res, status, JSON.stringify(payload), {
76
+ 'Content-Type': 'application/json; charset=utf-8',
77
+ 'Cache-Control': 'no-store',
78
+ });
79
+ }
80
+
81
+ function readJsonBody(req) {
82
+ const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
83
+ if (type !== 'application/json') return Promise.reject(new HttpError(415, 'Content-Type must be application/json.'));
84
+
85
+ return new Promise((resolve, reject) => {
86
+ const chunks = [];
87
+ let size = 0;
88
+ req.on('data', (chunk) => {
89
+ size += chunk.length;
90
+ if (size > MAX_BODY_BYTES) {
91
+ reject(new HttpError(413, 'Request body too large.'));
92
+ req.destroy();
93
+ return;
94
+ }
95
+ chunks.push(chunk);
96
+ });
97
+ req.on('end', () => {
98
+ const raw = Buffer.concat(chunks);
99
+ try {
100
+ const parsed = raw.length ? JSON.parse(raw.toString('utf8')) : {};
101
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
+ throw new Error('not an object');
103
+ }
104
+ resolve(parsed);
105
+ } catch {
106
+ reject(new HttpError(400, 'Request body must be a JSON object.'));
107
+ } finally {
108
+ raw.fill(0); // body may contain passwords
109
+ }
110
+ });
111
+ req.on('error', reject);
112
+ });
113
+ }
114
+
115
+ function errorResponse(err) {
116
+ if (err instanceof ConfirmationRequiredError) {
117
+ return [409, { code: err.code, message: err.message, reason: err.reason, diff: err.diff, hint: err.hint }];
118
+ }
119
+ if (err instanceof VaultError) {
120
+ return [ERROR_STATUS[err.code] ?? 400, { code: err.code, message: err.message, hint: err.hint }];
121
+ }
122
+ if (err instanceof HttpError) return [err.status, { code: 'HTTP_ERROR', message: err.message }];
123
+ return [500, { code: 'INTERNAL_ERROR', message: 'Unexpected server error. See the terminal for details.' }];
124
+ }
125
+
126
+ const bool = (value) => value === true;
127
+ const str = (value) => (typeof value === 'string' ? value : undefined);
128
+
129
+ /** Opens a URL in the default browser without going through a shell. */
130
+ export function openBrowser(url) {
131
+ const [cmd, args] =
132
+ process.platform === 'win32'
133
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
134
+ : process.platform === 'darwin'
135
+ ? ['open', [url]]
136
+ : ['xdg-open', [url]];
137
+ const child = execFile(cmd, args, { windowsHide: true }, () => {});
138
+ child.on('error', () => {});
139
+ child.unref?.();
140
+ }
141
+
142
+ /**
143
+ * Starts the local dashboard server bound to 127.0.0.1.
144
+ *
145
+ * Security model:
146
+ * - Loopback-only listener; Host header allow-list blocks DNS-rebinding attacks.
147
+ * - Every API call needs a random per-session bearer token (constant-time compare).
148
+ * - Mutations require JSON + same-origin, so other websites cannot forge requests.
149
+ * - Responses never contain secret values; passwords are never logged.
150
+ */
151
+ export function startUiServer({ cwd = process.cwd(), port = 0, onLog = () => {} } = {}) {
152
+ const projectDir = findProjectRoot(cwd) ?? path.resolve(cwd);
153
+ const token = crypto.randomBytes(32).toString('hex');
154
+ const tokenBytes = Buffer.from(token, 'utf8');
155
+ const allowedHosts = new Set();
156
+
157
+ const hasConfig = () => fs.existsSync(path.join(projectDir, CONFIG_FILENAME));
158
+ const context = () => {
159
+ const ctx = loadConfig(projectDir);
160
+ if (ctx.root !== projectDir) throw new VaultError(`No ${CONFIG_FILENAME} in ${projectDir}.`, { code: 'NO_CONFIG' });
161
+ return ctx;
162
+ };
163
+
164
+ const isAuthorized = (req) => {
165
+ const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization ?? '');
166
+ return Boolean(match) && crypto.timingSafeEqual(Buffer.from(match[1], 'utf8'), tokenBytes);
167
+ };
168
+
169
+ const isSameOrigin = (req) => {
170
+ const origin = req.headers.origin;
171
+ if (origin !== undefined && origin !== `http://${req.headers.host}`) return false;
172
+ const site = req.headers['sec-fetch-site'];
173
+ return site === undefined || site === 'same-origin' || site === 'none';
174
+ };
175
+
176
+ const envActions = {
177
+ encrypt: (ctx, name, body) =>
178
+ vault.encryptEnvironment(ctx, name, str(body.password), {
179
+ overwrite: bool(body.overwrite),
180
+ replaceExisting: bool(body.replaceExisting),
181
+ }),
182
+ decrypt: (ctx, name, body) => vault.decryptEnvironment(ctx, name, str(body.password), { overwrite: bool(body.overwrite) }),
183
+ diff: (ctx, name, body) => vault.diffEnvironment(ctx, name, str(body.password)),
184
+ rotate: (ctx, name, body) => vault.rotateEnvironmentKey(ctx, name, str(body.oldPassword), str(body.newPassword)),
185
+ default: (ctx, name) => {
186
+ vault.setDefaultEnvironment(ctx, name);
187
+ return { status: 'ok', environment: name };
188
+ },
189
+ };
190
+
191
+ async function handleApi(req, res, pathname) {
192
+ if (!isAuthorized(req)) throw new HttpError(401, 'Invalid or missing session token. Reopen the URL printed in the terminal.');
193
+
194
+ if (req.method === 'GET' && pathname === '/api/status') {
195
+ if (!hasConfig()) {
196
+ return {
197
+ initialized: false,
198
+ root: projectDir,
199
+ configFile: CONFIG_FILENAME,
200
+ suggestedName: path.basename(projectDir),
201
+ defaultEnvironments: DEFAULT_ENVIRONMENTS,
202
+ };
203
+ }
204
+ const ctx = context();
205
+ return {
206
+ initialized: true,
207
+ ...vault.getVaultStatus(ctx),
208
+ gitignoreMissing: vault.repairGitignore(ctx, { dryRun: true }).gitignoreAdded,
209
+ };
210
+ }
211
+
212
+ if (req.method !== 'POST') throw new HttpError(405, 'Method not allowed.');
213
+ if (!isSameOrigin(req)) throw new HttpError(403, 'Cross-origin requests are not allowed.');
214
+ const body = await readJsonBody(req);
215
+
216
+ if (pathname === '/api/init') {
217
+ const environments = Array.isArray(body.environments) ? body.environments.filter((e) => typeof e === 'string') : undefined;
218
+ const result = vault.initVault(projectDir, {
219
+ name: str(body.name),
220
+ environments,
221
+ defaultEnvironment: str(body.defaultEnvironment),
222
+ overwrite: bool(body.overwrite),
223
+ });
224
+ onLog(`init: created ${CONFIG_FILENAME}`);
225
+ return { status: 'created', gitignoreAdded: result.gitignoreAdded, warnings: result.warnings };
226
+ }
227
+ if (pathname === '/api/environments') {
228
+ const result = vault.addEnvironment(context(), {
229
+ name: str(body.name),
230
+ file: str(body.file),
231
+ encryptedFile: str(body.encryptedFile),
232
+ });
233
+ onLog(`add environment: ${body.name}`);
234
+ return { status: 'created', gitignoreAdded: result.gitignoreAdded };
235
+ }
236
+ if (pathname === '/api/gitignore') {
237
+ const result = vault.repairGitignore(context());
238
+ onLog('.gitignore repaired');
239
+ return result;
240
+ }
241
+
242
+ const match = /^\/api\/environments\/([^/]+)\/([a-z]+)$/.exec(pathname);
243
+ if (match && Object.hasOwn(envActions, match[2])) {
244
+ const name = decodeURIComponent(match[1]);
245
+ const action = match[2];
246
+ const result = envActions[action](context(), name, body);
247
+ onLog(`${action} ${name}: ${result.status ?? 'ok'}`);
248
+ return result;
249
+ }
250
+ throw new HttpError(404, 'Not found.');
251
+ }
252
+
253
+ function serveStatic(req, res, pathname) {
254
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed');
255
+ let decoded;
256
+ try {
257
+ decoded = decodeURIComponent(pathname);
258
+ } catch {
259
+ return send(res, 400, 'Bad request');
260
+ }
261
+ let file = path.resolve(UI_DIST_DIR, `.${decoded}`);
262
+ if (!isWithin(UI_DIST_DIR, file)) return send(res, 404, 'Not found');
263
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) file = path.join(UI_DIST_DIR, 'index.html');
264
+ if (!fs.existsSync(file)) {
265
+ return send(res, 503, 'Dashboard assets are not built. Run "npm run build:ui" in the dotcrypt package.', {
266
+ 'Content-Type': 'text/plain; charset=utf-8',
267
+ });
268
+ }
269
+
270
+ const type = MIME_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
271
+ const immutable = decoded.startsWith('/assets/');
272
+ send(res, 200, req.method === 'HEAD' ? undefined : fs.readFileSync(file), {
273
+ 'Content-Type': type,
274
+ 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-store',
275
+ });
276
+ }
277
+
278
+ const server = http.createServer(async (req, res) => {
279
+ try {
280
+ if (!allowedHosts.has(req.headers.host ?? '')) return send(res, 421, 'Misdirected request');
281
+ const { pathname } = new URL(req.url ?? '/', `http://${req.headers.host}`);
282
+
283
+ if (pathname === '/api' || pathname.startsWith('/api/')) {
284
+ try {
285
+ sendJson(res, 200, await handleApi(req, res, pathname));
286
+ } catch (err) {
287
+ const [status, payload] = errorResponse(err);
288
+ if (status === 500) onLog(`error: ${err.stack ?? err.message}`);
289
+ sendJson(res, status, { error: payload });
290
+ }
291
+ return undefined;
292
+ }
293
+ return serveStatic(req, res, pathname);
294
+ } catch (err) {
295
+ onLog(`error: ${err.message}`);
296
+ if (!res.headersSent) send(res, 500, 'Internal error');
297
+ return undefined;
298
+ }
299
+ });
300
+
301
+ return new Promise((resolve, reject) => {
302
+ server.once('error', reject);
303
+ server.listen(port, '127.0.0.1', () => {
304
+ const actualPort = server.address().port;
305
+ allowedHosts.add(`127.0.0.1:${actualPort}`);
306
+ allowedHosts.add(`localhost:${actualPort}`);
307
+ resolve({
308
+ server,
309
+ port: actualPort,
310
+ projectDir,
311
+ token,
312
+ url: `http://127.0.0.1:${actualPort}/?token=${token}`,
313
+ close: () =>
314
+ new Promise((done) => {
315
+ server.close(() => done());
316
+ server.closeAllConnections?.();
317
+ }),
318
+ });
319
+ });
320
+ });
321
+ }
package/src/utils.js ADDED
@@ -0,0 +1,253 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import dotenv from 'dotenv';
6
+
7
+ export const MAX_FILE_SIZE = 5 * 1024 * 1024;
8
+ export const GITIGNORE_HEADER = '# dotcrypt: never commit plaintext environment files';
9
+ export const BASE_GITIGNORE_RULES = Object.freeze(['.env', '.env.*', '!.env.*.enc']);
10
+
11
+ export class VaultError extends Error {
12
+ constructor(message, { exitCode = 1, hint, code = 'VAULT_ERROR' } = {}) {
13
+ super(message);
14
+ this.name = 'VaultError';
15
+ this.exitCode = exitCode;
16
+ this.hint = hint;
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Raised when an operation would discard data and needs explicit approval.
23
+ * `reason` is one of: "overwrite", "password-mismatch", "unreadable-vault", "config-exists".
24
+ */
25
+ export class ConfirmationRequiredError extends VaultError {
26
+ constructor(message, { reason, diff, hint } = {}) {
27
+ super(message, { code: 'CONFIRMATION_REQUIRED', hint });
28
+ this.name = 'ConfirmationRequiredError';
29
+ this.reason = reason;
30
+ this.diff = diff;
31
+ }
32
+ }
33
+
34
+ export function isWithin(parent, child) {
35
+ const rel = path.relative(parent, child);
36
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
37
+ }
38
+
39
+ /**
40
+ * Resolves a config-supplied relative path, rejecting absolute paths and traversal.
41
+ */
42
+ export function resolveInside(root, relativePath) {
43
+ if (typeof relativePath !== 'string' || relativePath.trim() === '' || relativePath.includes('\0')) {
44
+ throw new VaultError(`Invalid path: ${JSON.stringify(relativePath)}`);
45
+ }
46
+ if (path.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath)) {
47
+ throw new VaultError(`Path must be relative to the project root: ${relativePath}`);
48
+ }
49
+ const base = path.resolve(root);
50
+ const resolved = path.resolve(base, relativePath);
51
+ if (resolved === base || !isWithin(base, resolved)) {
52
+ throw new VaultError(`Path escapes the project directory: ${relativePath}`);
53
+ }
54
+ return resolved;
55
+ }
56
+
57
+ export function displayPath(root, target) {
58
+ return path.relative(root, target).split(path.sep).join('/');
59
+ }
60
+
61
+ export function fileExists(target) {
62
+ try {
63
+ fs.lstatSync(target);
64
+ return true;
65
+ } catch (err) {
66
+ if (err.code === 'ENOENT') return false;
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Guards a path we are about to read or write: it must live inside the project,
73
+ * must not be a symlink, and no symlinked parent directory may lead outside the
74
+ * project. Prevents a malicious repo from redirecting writes (e.g. to ~/.bashrc)
75
+ * or reads (e.g. of ~/.ssh/id_rsa into a committed vault file).
76
+ */
77
+ export function assertSafePath(root, target) {
78
+ const base = path.resolve(root);
79
+ const resolved = path.resolve(target);
80
+ const shown = displayPath(base, resolved);
81
+ if (resolved === base || !isWithin(base, resolved)) {
82
+ throw new VaultError(`Refusing to access ${resolved}: it is outside the project directory.`);
83
+ }
84
+
85
+ let stat = null;
86
+ try {
87
+ stat = fs.lstatSync(resolved);
88
+ } catch (err) {
89
+ if (err.code !== 'ENOENT') throw err;
90
+ }
91
+ if (stat?.isSymbolicLink()) throw new VaultError(`Refusing to use ${shown}: it is a symbolic link.`);
92
+ if (stat && !stat.isFile()) throw new VaultError(`Refusing to use ${shown}: it is not a regular file.`);
93
+
94
+ let dir = path.dirname(resolved);
95
+ while (!fs.existsSync(dir)) dir = path.dirname(dir);
96
+ if (!isWithin(fs.realpathSync.native(base), fs.realpathSync.native(dir))) {
97
+ throw new VaultError(`Refusing to use ${shown}: its directory resolves outside the project.`);
98
+ }
99
+ return resolved;
100
+ }
101
+
102
+ export function safeReadFile(root, target, { maxSize = MAX_FILE_SIZE } = {}) {
103
+ const resolved = assertSafePath(root, target);
104
+ let stat;
105
+ try {
106
+ stat = fs.statSync(resolved);
107
+ } catch (err) {
108
+ if (err.code === 'ENOENT') throw new VaultError(`File not found: ${displayPath(root, resolved)}`);
109
+ throw err;
110
+ }
111
+ if (stat.size > maxSize) {
112
+ throw new VaultError(`${displayPath(root, resolved)} is larger than the ${maxSize} byte limit.`);
113
+ }
114
+ return fs.readFileSync(resolved);
115
+ }
116
+
117
+ /**
118
+ * Atomic write: data goes to an exclusive temp file in the same directory, is
119
+ * fsync'd, then renamed over the target. A crash never leaves a half-written file.
120
+ */
121
+ export function safeWriteFile(root, target, data, { mode = 0o600 } = {}) {
122
+ const resolved = assertSafePath(root, target);
123
+ const dir = path.dirname(resolved);
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ assertSafePath(root, resolved);
126
+
127
+ // Temp name starts with the target's basename so it matches the same ignore rules.
128
+ const tmp = path.join(dir, `${path.basename(resolved)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
129
+ let fd;
130
+ try {
131
+ fd = fs.openSync(tmp, 'wx', mode);
132
+ fs.writeFileSync(fd, data);
133
+ fs.fsyncSync(fd);
134
+ fs.closeSync(fd);
135
+ fd = undefined;
136
+ fs.renameSync(tmp, resolved);
137
+ try {
138
+ fs.chmodSync(resolved, mode);
139
+ } catch {
140
+ // chmod is best-effort (no-op on some Windows filesystems).
141
+ }
142
+ } catch (err) {
143
+ if (fd !== undefined) {
144
+ try {
145
+ fs.closeSync(fd);
146
+ } catch {
147
+ // ignore
148
+ }
149
+ }
150
+ fs.rmSync(tmp, { force: true });
151
+ throw err;
152
+ }
153
+ }
154
+
155
+ export function buffersEqual(a, b) {
156
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
157
+ }
158
+
159
+ function toPosix(p) {
160
+ return p.replace(/\\/g, '/').replace(/^\.\//, '');
161
+ }
162
+
163
+ /** True when BASE_GITIGNORE_RULES already ignore this plaintext path. */
164
+ export function isCoveredByBaseRules(relPath) {
165
+ const name = path.posix.basename(toPosix(relPath));
166
+ return name === '.env' || (name.startsWith('.env.') && !name.endsWith('.enc'));
167
+ }
168
+
169
+ /**
170
+ * Appends any missing ignore rules for plaintext env files to .gitignore.
171
+ * Never removes or rewrites existing user rules.
172
+ * @returns {string[]} rules that were added.
173
+ */
174
+ export function ensureGitignore(root, plaintextFiles = [], { dryRun = false } = {}) {
175
+ const gitignorePath = path.join(root, '.gitignore');
176
+ const content = fileExists(gitignorePath) ? safeReadFile(root, gitignorePath).toString('utf8') : '';
177
+ const existing = new Set(content.split(/\r?\n/).map((line) => line.trim()));
178
+
179
+ const wanted = [
180
+ ...BASE_GITIGNORE_RULES,
181
+ ...plaintextFiles
182
+ .map(toPosix)
183
+ .filter((file) => !isCoveredByBaseRules(file))
184
+ .map((file) => `/${file}`),
185
+ ];
186
+ const missing = [...new Set(wanted)].filter((rule) => !existing.has(rule));
187
+ if (missing.length === 0 || dryRun) return missing;
188
+
189
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
190
+ let block = '';
191
+ if (content.length > 0) block += content.endsWith('\n') ? eol : eol + eol;
192
+ if (!existing.has(GITIGNORE_HEADER)) block += GITIGNORE_HEADER + eol;
193
+ block += missing.join(eol) + eol;
194
+
195
+ safeWriteFile(root, gitignorePath, content + block, { mode: 0o644 });
196
+ return missing;
197
+ }
198
+
199
+ function runGit(root, args) {
200
+ try {
201
+ execFileSync('git', args, { cwd: root, stdio: 'ignore', windowsHide: true });
202
+ return 0;
203
+ } catch (err) {
204
+ return typeof err.status === 'number' ? err.status : null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Reports whether a path is tracked or ignored by Git. `inRepo` is false when
210
+ * Git is unavailable or root is not inside a work tree.
211
+ */
212
+ export function gitStatusOf(root, relPath) {
213
+ if (runGit(root, ['rev-parse', '--is-inside-work-tree']) !== 0) return { inRepo: false };
214
+ const file = toPosix(relPath);
215
+ return {
216
+ inRepo: true,
217
+ tracked: runGit(root, ['ls-files', '--error-unmatch', '--', file]) === 0,
218
+ ignored: runGit(root, ['check-ignore', '-q', '--', file]) === 0,
219
+ };
220
+ }
221
+
222
+ export function parseEnv(content) {
223
+ return dotenv.parse(content);
224
+ }
225
+
226
+ /**
227
+ * Compares variable maps by key. Returns key names only, never values.
228
+ * "added" = only in local, "removed" = only in vault, "changed" = value differs.
229
+ */
230
+ export function diffEnv(vaultVars, localVars) {
231
+ const added = [];
232
+ const removed = [];
233
+ const changed = [];
234
+ let unchanged = 0;
235
+ for (const key of Object.keys(localVars)) {
236
+ if (!Object.hasOwn(vaultVars, key)) added.push(key);
237
+ else if (vaultVars[key] !== localVars[key]) changed.push(key);
238
+ else unchanged += 1;
239
+ }
240
+ for (const key of Object.keys(vaultVars)) {
241
+ if (!Object.hasOwn(localVars, key)) removed.push(key);
242
+ }
243
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort(), unchanged };
244
+ }
245
+
246
+ export function hasDifferences({ added, removed, changed }) {
247
+ return added.length + removed.length + changed.length > 0;
248
+ }
249
+
250
+ /** Strips control characters so decrypted key names cannot inject terminal escapes. */
251
+ export function sanitizeForTerminal(value) {
252
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
253
+ }