Prepare 0.1.0: one dotenv grammar, path policy, redactor, and safer writes

- parseDotenv, validate, and every edit share the round-trip document parser
- Path policy helpers (isEnvFile, isSensitiveEnvFile, isEnvSitterPepperFile) used by envsitter-guard
- CLI file commands run through the library file operations
- EnvSitter#redactor() and the redact command replace secret-looking values in text
- Writes keep file permissions and go through symlinks; new files are 0600
- An existing pepper is never replaced; a new .envsitter/ gets a .gitignore
- set/add/copy into empty files write clean output
- Regexes with g/y flags no longer skip keys
- TypeScript 6, Node 22+ engines, prepack build, dist/test excluded from the package
This commit is contained in:
David Ibia
2026-10-20 15:10:13 +01:00
parent 33b4a72f2c
commit af471c9821
23 changed files with 790 additions and 261 deletions
+13 -10
View File
@@ -1,26 +1,29 @@
{ {
"name": "envsitter", "name": "envsitter",
"version": "0.0.2", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "envsitter", "name": "envsitter",
"version": "0.0.2", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"envsitter": "dist/cli.js" "envsitter": "dist/cli.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.20.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"typescript": "^5.7.3" "typescript": "^6.0.3"
},
"engines": {
"node": ">=22"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.5", "version": "22.20.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
"integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -44,9 +47,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
+10 -4
View File
@@ -1,9 +1,10 @@
{ {
"name": "envsitter", "name": "envsitter",
"version": "0.0.4", "version": "0.1.0",
"private": false, "private": false,
"type": "module", "type": "module",
"description": "Safely inspect and match .env secrets without exposing values", "description": "Safely inspect and match .env secrets without exposing values",
"homepage": "https://boxpositron.github.io/envsitter/",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/boxpositron/envsitter.git" "url": "git+https://github.com/boxpositron/envsitter.git"
@@ -23,6 +24,9 @@
"config" "config"
], ],
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=22"
},
"bin": { "bin": {
"envsitter": "dist/cli.js" "envsitter": "dist/cli.js"
}, },
@@ -33,17 +37,19 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"!dist/test"
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node scripts/run-tests.mjs", "test": "npm run build && node scripts/run-tests.mjs",
"prepack": "npm run build",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.20.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"typescript": "^5.7.3" "typescript": "^6.0.3"
} }
} }
+67 -66
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js'; import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
import { addDotenvKey, annotateDotenvKey, copyDotenvKeys, deleteDotenvKeys, formatDotenv, setDotenvKey, unsetDotenvKey, validateDotenv } from './dotenv/edit.js'; import {
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js'; addEnvFileKey,
import { isExampleEnvFile } from './dotenv/utils.js'; annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile
} from './file-ops.js';
import { isExampleEnvFile } from './paths.js';
function parseRegex(input: string): RegExp { function parseRegex(input: string): RegExp {
@@ -37,6 +44,19 @@ function requireValue<T>(value: T | undefined, message: string): T {
return value; return value;
} }
function toCandidatesByKey(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
}
const candidates: Record<string, string> = {};
for (const [key, candidate] of Object.entries(value)) {
if (typeof candidate !== 'string') throw new Error(`Candidate for ${key} must be a string`);
candidates[key] = candidate;
}
return candidates;
}
function parseMatcher(op: string, candidate: string | undefined): EnvSitterMatcher { function parseMatcher(op: string, candidate: string | undefined): EnvSitterMatcher {
if (op === 'exists') return { op: 'exists' }; if (op === 'exists') return { op: 'exists' };
if (op === 'is_empty') return { op: 'is_empty' }; if (op === 'is_empty') return { op: 'is_empty' };
@@ -122,6 +142,7 @@ function printHelp(): void {
' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]', ' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]',
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)', ' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]', ' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
' redact --file <path> [--all] [--min-length <n>] Reads text on stdin, writes it with secret values replaced',
' validate --file <path>', ' validate --file <path>',
' copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]', ' copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]',
' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]', ' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
@@ -169,8 +190,7 @@ async function run(): Promise<number> {
if (cmd === 'validate') { if (cmd === 'validate') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required'); const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const contents = await readFile(file, 'utf8'); const result = await validateEnvFile(file);
const result = validateDotenv(contents);
if (json) jsonOut(result); if (json) jsonOut(result);
else { else {
@@ -197,37 +217,21 @@ async function run(): Promise<number> {
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined; const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined; const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
const sourceContents = await readFile(from, 'utf8'); const result = await copyEnvFileKeys({
const targetContents = await readTextFileOrEmpty(to); from,
to,
const result = copyDotenvKeys({
sourceContents,
targetContents,
...(keysRaw ? { keys: parseList(keysRaw) } : {}), ...(keysRaw ? { keys: parseList(keysRaw) } : {}),
...(includeRaw ? { include: parseRegex(includeRaw) } : {}), ...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}), ...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
...(renameRaw ? { rename: renameRaw } : {}), ...(renameRaw ? { rename: renameRaw } : {}),
onConflict onConflict,
write: flags['write'] === true
}); });
const conflicts = result.plan.filter((p) => p.action === 'conflict'); const conflicts = result.plan.filter((p) => p.action === 'conflict');
const willWrite = flags['write'] === true;
if (willWrite && conflicts.length === 0 && result.hasChanges) {
await writeTextFileAtomic(to, result.output);
}
if (json) { if (json) {
jsonOut({ jsonOut(result);
from,
to,
onConflict,
willWrite,
wrote: willWrite && conflicts.length === 0 && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
});
} else { } else {
for (const p of result.plan) { for (const p of result.plan) {
const fromAt = p.fromLine ? ` L${p.fromLine}` : ''; const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
@@ -248,14 +252,10 @@ async function run(): Promise<number> {
const mode = modeRaw === 'global' ? 'global' : 'sections'; const mode = modeRaw === 'global' ? 'global' : 'sections';
const sort = sortRaw === 'none' ? 'none' : 'alpha'; const sort = sortRaw === 'none' ? 'none' : 'alpha';
const contents = await readFile(file, 'utf8'); const result = await formatEnvFile({ file, mode, sort, write: flags['write'] === true });
const result = formatDotenv({ contents, mode, sort });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, mode, sort, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues }); jsonOut(result);
} else { } else {
process.stdout.write(result.hasChanges ? 'CHANGED\n' : 'NO_CHANGES\n'); process.stdout.write(result.hasChanges ? 'CHANGED\n' : 'NO_CHANGES\n');
} }
@@ -270,14 +270,10 @@ async function run(): Promise<number> {
const lineRaw = typeof flags['line'] === 'string' ? flags['line'] : undefined; const lineRaw = typeof flags['line'] === 'string' ? flags['line'] : undefined;
const line = lineRaw ? Number(lineRaw) : undefined; const line = lineRaw ? Number(lineRaw) : undefined;
const contents = await readFile(file, 'utf8'); const result = await annotateEnvFile({ file, key, comment, ...(line ? { line } : {}), write: flags['write'] === true });
const result = annotateDotenvKey({ contents, key, comment, ...(line ? { line } : {}) });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan }); jsonOut(result);
} else { } else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}\n`); process.stdout.write(`${result.plan.action}: ${result.plan.key}\n`);
} }
@@ -295,14 +291,10 @@ async function run(): Promise<number> {
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined; const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? ''; const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file); const result = await addEnvFileKey({ file, key, value, write: flags['write'] === true });
const result = addDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan }); jsonOut(result);
} else { } else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`); process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
} }
@@ -320,14 +312,10 @@ async function run(): Promise<number> {
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined; const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? ''; const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file); const result = await setEnvFileKey({ file, key, value, write: flags['write'] === true });
const result = setDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan }); jsonOut(result);
} else { } else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`); process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
} }
@@ -341,14 +329,10 @@ async function run(): Promise<number> {
const noExampleWarning = flags['no-example-warning'] === true; const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning); warnIfExampleFile(file, noExampleWarning);
const contents = await readFile(file, 'utf8'); const result = await unsetEnvFileKey({ file, key, write: flags['write'] === true });
const result = unsetDotenvKey({ contents, key });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan }); jsonOut(result);
} else { } else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`); process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
} }
@@ -367,14 +351,10 @@ async function run(): Promise<number> {
const keys = keyArg ? [keyArg] : keysArg ? parseList(keysArg) : undefined; const keys = keyArg ? [keyArg] : keysArg ? parseList(keysArg) : undefined;
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys'); if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
const contents = await readFile(file, 'utf8'); const result = await deleteEnvFileKeys({ file, keys, write: flags['write'] === true });
const result = deleteDotenvKeys({ contents, keys });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) { if (json) {
jsonOut({ file, keys, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan }); jsonOut(result);
} else { } else {
for (const p of result.plan) { for (const p of result.plan) {
process.stdout.write(`${p.action}: ${p.key}${p.line ? ` L${p.line}` : ''}\n`); process.stdout.write(`${p.action}: ${p.key}${p.line ? ` L${p.line}` : ''}\n`);
@@ -451,18 +431,39 @@ async function run(): Promise<number> {
const raw = candidatesJson ?? candidatesStdin; const raw = candidatesJson ?? candidatesStdin;
const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin'); const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
let candidates: Record<string, string>; let parsedJson: unknown;
try { try {
candidates = JSON.parse(parsed) as Record<string, string>; parsedJson = JSON.parse(parsed);
} catch { } catch {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}'); throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
} }
const candidates = toCandidatesByKey(parsedJson);
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath)); const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
jsonOut({ matches }); jsonOut({ matches });
return matches.some((m) => m.match) ? 0 : 1; return matches.some((m) => m.match) ? 0 : 1;
} }
if (cmd === 'redact') {
const minLengthRaw = typeof flags['min-length'] === 'string' ? Number(flags['min-length']) : undefined;
if (minLengthRaw !== undefined && (!Number.isInteger(minLengthRaw) || minLengthRaw < 1)) {
throw new Error('--min-length must be a positive integer');
}
const redactor = await envsitter.redactor({
...(minLengthRaw !== undefined ? { minLength: minLengthRaw } : {}),
...(flags['all'] === true ? { all: true } : {})
});
const result = redactor.redact(await readStdinText());
if (json) jsonOut(result);
else {
process.stdout.write(result.text);
for (const r of result.redactions) process.stderr.write(`envsitter: redacted ${r.count} occurrence(s) of ${r.key}\n`);
}
return 0;
}
if (cmd === 'scan') { if (cmd === 'scan') {
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined; const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined;
const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined; const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined;
+3
View File
@@ -160,6 +160,8 @@ function parseAssignmentLine(raw: string, line: number, issues: DotenvIssue[]):
export function parseDotenvDocument(contents: string): DotenvDocument { export function parseDotenvDocument(contents: string): DotenvDocument {
const issues: DotenvIssue[] = []; const issues: DotenvIssue[] = [];
if (contents.length === 0) return { lines: [], issues, endsWithNewline: true };
const endsWithNewline = contents.endsWith('\n'); const endsWithNewline = contents.endsWith('\n');
const split = contents.split(/\r?\n/); const split = contents.split(/\r?\n/);
@@ -190,6 +192,7 @@ export function parseDotenvDocument(contents: string): DotenvDocument {
} }
export function stringifyDotenvDocument(doc: DotenvDocument): string { export function stringifyDotenvDocument(doc: DotenvDocument): string {
if (doc.lines.length === 0) return '';
const out = doc.lines.map((l) => l.raw).join('\n'); const out = doc.lines.map((l) => l.raw).join('\n');
return doc.endsWithNewline ? `${out}\n` : out; return doc.endsWithNewline ? `${out}\n` : out;
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js'; import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
import { regexTest } from '../regex.js';
import { buildAssignmentLine } from './utils.js'; import { buildAssignmentLine } from './utils.js';
export type DotenvWriteMode = 'dry-run' | 'write'; export type DotenvWriteMode = 'dry-run' | 'write';
@@ -91,8 +92,8 @@ export function copyDotenvKeys(options: {
let hasChanges = false; let hasChanges = false;
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) { for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
if (options.include && !options.include.test(fromKey)) continue; if (options.include && !regexTest(options.include, fromKey)) continue;
if (options.exclude && options.exclude.test(fromKey)) continue; if (options.exclude && regexTest(options.exclude, fromKey)) continue;
const toKey = renameMap.get(fromKey) ?? fromKey; const toKey = renameMap.get(fromKey) ?? fromKey;
+33 -6
View File
@@ -1,23 +1,50 @@
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
const newFileMode = 0o600;
function isNotFound(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT';
}
export async function readTextFileOrEmpty(filePath: string): Promise<string> { export async function readTextFileOrEmpty(filePath: string): Promise<string> {
try { try {
return await readFile(filePath, 'utf8'); return await readFile(filePath, 'utf8');
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT') return ''; if (isNotFound(error)) return '';
throw error; throw error;
} }
} }
async function resolveWriteTarget(filePath: string): Promise<string> {
try {
return await realpath(filePath);
} catch (error: unknown) {
if (isNotFound(error)) return filePath;
throw error;
}
}
async function existingFileMode(filePath: string): Promise<number | undefined> {
try {
return (await stat(filePath)).mode & 0o7777;
} catch (error: unknown) {
if (isNotFound(error)) return undefined;
throw error;
}
}
// Writes through symlinks to the real file and keeps its permission bits; new files are owner-only.
export async function writeTextFileAtomic(filePath: string, contents: string): Promise<void> { export async function writeTextFileAtomic(filePath: string, contents: string): Promise<void> {
const dir = dirname(filePath); const target = await resolveWriteTarget(filePath);
const tmp = await mkdtemp(join(dir, '.envsitter-tmp-')); const mode = (await existingFileMode(target)) ?? newFileMode;
const tmp = await mkdtemp(join(dirname(target), '.envsitter-tmp-'));
const tmpFile = join(tmp, 'file'); const tmpFile = join(tmp, 'file');
try { try {
await writeFile(tmpFile, contents, 'utf8'); await writeFile(tmpFile, contents, { encoding: 'utf8', mode });
await rename(tmpFile, filePath); await chmod(tmpFile, mode);
await rename(tmpFile, target);
} finally { } finally {
await rm(tmp, { recursive: true, force: true }); await rm(tmp, { recursive: true, force: true });
} }
+7 -109
View File
@@ -1,121 +1,19 @@
export type DotenvParseError = { import { parseDotenvDocument, type DotenvIssue } from './document.js';
line: number;
message: string; export type DotenvParseError = DotenvIssue;
};
export type DotenvParseResult = { export type DotenvParseResult = {
values: Map<string, string>; values: Map<string, string>;
errors: DotenvParseError[]; errors: DotenvParseError[];
}; };
function isValidKeyChar(char: string): boolean {
return /[A-Za-z0-9_]/.test(char);
}
function parseKey(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
for (let i = 0; i < trimmed.length; i++) {
if (!isValidKeyChar(trimmed[i] ?? '')) return undefined;
}
return trimmed;
}
function stripInlineComment(unquotedValue: string): string {
for (let i = 0; i < unquotedValue.length; i++) {
const c = unquotedValue[i];
if (c === '#') {
const prev = i > 0 ? (unquotedValue[i - 1] ?? '') : '';
if (prev === '' || /\s/.test(prev)) {
return unquotedValue.slice(0, i);
}
}
}
return unquotedValue;
}
function unescapeDoubleQuoted(value: string): string {
let out = '';
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c !== '\\') {
out += c;
continue;
}
const next = value[i + 1];
if (next === undefined) {
out += '\\';
continue;
}
i++;
if (next === 'n') out += '\n';
else if (next === 'r') out += '\r';
else if (next === 't') out += '\t';
else out += next;
}
return out;
}
function parseValue(raw: string, line: number, errors: DotenvParseError[]): string {
const trimmed = raw.trimStart();
if (!trimmed) return '';
const first = trimmed[0];
if (first === "'") {
const end = trimmed.indexOf("'", 1);
if (end === -1) {
errors.push({ line, message: 'Unterminated single-quoted value' });
return trimmed.slice(1);
}
return trimmed.slice(1, end);
}
if (first === '"') {
let end = 1;
for (; end < trimmed.length; end++) {
const c = trimmed[end];
if (c === '"' && trimmed[end - 1] !== '\\') break;
}
if (end >= trimmed.length || trimmed[end] !== '"') {
errors.push({ line, message: 'Unterminated double-quoted value' });
return unescapeDoubleQuoted(trimmed.slice(1));
}
return unescapeDoubleQuoted(trimmed.slice(1, end));
}
return stripInlineComment(trimmed).trimEnd();
}
export function parseDotenv(contents: string): DotenvParseResult { export function parseDotenv(contents: string): DotenvParseResult {
const doc = parseDotenvDocument(contents);
const values = new Map<string, string>(); const values = new Map<string, string>();
const errors: DotenvParseError[] = [];
const lines = contents.split(/\r?\n/); for (const line of doc.lines) {
for (let i = 0; i < lines.length; i++) { if (line.kind === 'assignment') values.set(line.key, line.value);
const lineNumber = i + 1;
const line = lines[i] ?? '';
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
const eq = withoutExport.indexOf('=');
if (eq === -1) {
errors.push({ line: lineNumber, message: 'Missing = in assignment' });
continue;
}
const key = parseKey(withoutExport.slice(0, eq));
if (!key) {
errors.push({ line: lineNumber, message: 'Invalid key name' });
continue;
}
const rawValue = withoutExport.slice(eq + 1);
const value = parseValue(rawValue, lineNumber, errors);
values.set(key, value);
} }
return { values, errors }; return { values, errors: doc.issues };
} }
-6
View File
@@ -1,9 +1,3 @@
const EXAMPLE_FILE_PATTERN = /\.env\.(example|sample|template|dist|defaults?)$/i;
export function isExampleEnvFile(filePath: string): boolean {
return EXAMPLE_FILE_PATTERN.test(filePath);
}
export function quoteValue(value: string): string { export function quoteValue(value: string): string {
if (value === '') return ''; if (value === '') return '';
+13 -4
View File
@@ -2,6 +2,8 @@ import { timingSafeEqual } from 'node:crypto';
import { base64UrlEncode } from './encoding.js'; import { base64UrlEncode } from './encoding.js';
import { fingerprintValueHmacSha256 } from './fingerprint.js'; import { fingerprintValueHmacSha256 } from './fingerprint.js';
import { resolvePepper, type PepperOptions } from './pepper.js'; import { resolvePepper, type PepperOptions } from './pepper.js';
import { createRedactor, type EnvSitterRedactor, type RedactOptions } from './redact.js';
import { regexTest } from './regex.js';
import { DotenvFileSource } from './sources/dotenvFile.js'; import { DotenvFileSource } from './sources/dotenvFile.js';
import { ExternalCommandSource } from './sources/externalCommand.js'; import { ExternalCommandSource } from './sources/externalCommand.js';
@@ -76,8 +78,9 @@ export class EnvSitter {
async listKeys(options: ListKeysOptions = {}): Promise<string[]> { async listKeys(options: ListKeysOptions = {}): Promise<string[]> {
const snapshot = await this.source.load(); const snapshot = await this.source.load();
const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b)); const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b));
if (!options.filter) return keys; const filter = options.filter;
return keys.filter((k) => options.filter?.test(k)); if (!filter) return keys;
return keys.filter((k) => regexTest(filter, k));
} }
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> { async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
@@ -209,13 +212,19 @@ export class EnvSitter {
return results; return results;
} }
/** Builds a redactor that replaces secret-looking values in arbitrary text; the values never leave it. */
async redactor(options: RedactOptions = {}): Promise<EnvSitterRedactor> {
const snapshot = await this.source.load();
return createRedactor(snapshot.values, options);
}
async scan(options: ScanOptions = {}): Promise<ScanFinding[]> { async scan(options: ScanOptions = {}): Promise<ScanFinding[]> {
const snapshot = await this.source.load(); const snapshot = await this.source.load();
const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64']; const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64'];
const findings: ScanFinding[] = []; const findings: ScanFinding[] = [];
for (const [key, value] of snapshot.values.entries()) { for (const [key, value] of snapshot.values.entries()) {
if (options.keysFilter && !options.keysFilter.test(key)) continue; if (options.keysFilter && !regexTest(options.keysFilter, key)) continue;
const detections: Detection[] = []; const detections: Detection[] = [];
for (const kind of detectionsToRun) { for (const kind of detectionsToRun) {
@@ -236,7 +245,7 @@ function matchValue(value: string, matcher: Exclude<EnvSitterMatcher, { op: 'exi
if (matcher.op === 'partial_match_prefix') return value.startsWith(matcher.prefix); if (matcher.op === 'partial_match_prefix') return value.startsWith(matcher.prefix);
if (matcher.op === 'partial_match_suffix') return value.endsWith(matcher.suffix); if (matcher.op === 'partial_match_suffix') return value.endsWith(matcher.suffix);
if (matcher.op === 'partial_match_regex') return matcher.regex.test(value); if (matcher.op === 'partial_match_regex') return regexTest(matcher.regex, value);
if (matcher.op === 'is_number') return isNumberLike(value); if (matcher.op === 'is_number') return isNumberLike(value);
if (matcher.op === 'is_boolean') return isBooleanLike(value); if (matcher.op === 'is_boolean') return isBooleanLike(value);
+7 -25
View File
@@ -9,36 +9,18 @@ import {
setDotenvKey, setDotenvKey,
unsetDotenvKey, unsetDotenvKey,
validateDotenv, validateDotenv,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyPlanItem,
type FormatMode,
type FormatSort,
type KeyMutationAction, type KeyMutationAction,
type KeyMutationPlanItem type KeyMutationPlanItem
} from './dotenv/edit.js'; } from './dotenv/edit.js';
import type { DotenvIssue } from './dotenv/document.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js'; import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
export type DotenvIssue = { export type { AnnotatePlan, CopyConflictPolicy, CopyPlanItem, DotenvIssue, FormatMode, FormatSort };
line: number;
column: number;
message: string;
};
export type CopyConflictPolicy = 'error' | 'skip' | 'overwrite';
export type CopyPlanItem = {
fromKey: string;
toKey: string;
action: 'copy' | 'skip' | 'overwrite' | 'missing_source' | 'conflict';
fromLine?: number;
toLine?: number;
};
export type FormatMode = 'sections' | 'global';
export type FormatSort = 'alpha' | 'none';
export type AnnotatePlan = {
key: string;
action: 'inserted' | 'updated' | 'not_found' | 'ambiguous';
keyLines?: number[];
line?: number;
};
export type CopyEnvFilesResult = { export type CopyEnvFilesResult = {
from: string; from: string;
+9 -1
View File
@@ -12,6 +12,8 @@ export {
export { type PepperOptions, resolvePepper } from './pepper.js'; export { type PepperOptions, resolvePepper } from './pepper.js';
export { type EnvSitterRedactor, type RedactionCount, type RedactOptions, type RedactResult } from './redact.js';
export { export {
addEnvFileKey, addEnvFileKey,
annotateEnvFile, annotateEnvFile,
@@ -23,9 +25,15 @@ export {
validateEnvFile, validateEnvFile,
type AddEnvFileKeyResult, type AddEnvFileKeyResult,
type AnnotateEnvFileResult, type AnnotateEnvFileResult,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyEnvFilesResult, type CopyEnvFilesResult,
type CopyPlanItem,
type DeleteEnvFileKeysResult, type DeleteEnvFileKeysResult,
type DotenvIssue,
type FormatEnvFileResult, type FormatEnvFileResult,
type FormatMode,
type FormatSort,
type KeyMutationAction, type KeyMutationAction,
type KeyMutationPlanItem, type KeyMutationPlanItem,
type SetEnvFileKeyResult, type SetEnvFileKeyResult,
@@ -33,4 +41,4 @@ export {
type ValidateEnvFileResult type ValidateEnvFileResult
} from './file-ops.js'; } from './file-ops.js';
export { isExampleEnvFile } from './dotenv/utils.js'; export { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from './paths.js';
+65
View File
@@ -0,0 +1,65 @@
const EXAMPLE_FILE_PATTERN = /\.env\.(example|sample|template|dist|defaults?)$/i;
const PEPPER_FILE_PATTERN = /(^|\/)\.envsitter\/pepper$/;
// `<name>.env.<ext>` with one of these extensions is source code (e.g. `app.env.ts`), not a dotenv file.
const SOURCE_EXTENSIONS = new Set([
'js',
'cjs',
'mjs',
'jsx',
'ts',
'cts',
'mts',
'tsx',
'md',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'php',
'cs',
'lua',
'dart',
'ex',
'exs'
]);
function normalizePath(filePath: string): string {
return filePath.trim().replace(/\\/g, '/').replace(/\/+$/, '');
}
function baseName(filePath: string): string {
const normalized = normalizePath(filePath);
return normalized.slice(normalized.lastIndexOf('/') + 1);
}
/**
* True for dotenv-style file names: `.env`, `.env.local`, `api.env`, `config.env.local`, `.env.example`.
* Matches on the file name only, so it works for relative, absolute, and Windows paths.
*/
export function isEnvFile(filePath: string): boolean {
const parts = baseName(filePath).toLowerCase().split('.');
const envIndex = parts.indexOf('env', 1);
if (envIndex === -1) return false;
const last = parts[parts.length - 1] ?? '';
return !(parts.length - 1 > envIndex && SOURCE_EXTENSIONS.has(last));
}
/** True for committed template files (`.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.default(s)`). */
export function isExampleEnvFile(filePath: string): boolean {
return EXAMPLE_FILE_PATTERN.test(normalizePath(filePath));
}
/** True for dotenv files that may hold real values: every env file except example/template files. */
export function isSensitiveEnvFile(filePath: string): boolean {
return isEnvFile(filePath) && !isExampleEnvFile(filePath);
}
/** True for the default pepper location (`.envsitter/pepper`). Custom `--pepper-file` paths are not detectable. */
export function isEnvSitterPepperFile(filePath: string): boolean {
return PEPPER_FILE_PATTERN.test(normalizePath(filePath));
}
+55 -25
View File
@@ -1,5 +1,5 @@
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path'; import { basename, dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
export type PepperOptions = { export type PepperOptions = {
@@ -26,14 +26,38 @@ function getPepperFromEnv(envVarNames: string[]): string | undefined {
return undefined; return undefined;
} }
function parsePepperFileContentToBytes(content: string): Uint8Array { function errorCode(error: unknown): string | undefined {
return error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : undefined;
}
function parsePepperFileContentToBytes(content: string, pepperFilePath: string): Uint8Array {
const recovery = 'Restore it, or delete it to generate a new pepper (fingerprints made with the old pepper will stop matching).';
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) throw new Error('Pepper file is empty'); if (!trimmed) throw new Error(`Pepper file ${pepperFilePath} is empty. ${recovery}`);
const decoded = Buffer.from(trimmed, 'base64'); const decoded = Buffer.from(trimmed, 'base64');
if (decoded.length < 16) throw new Error('Pepper file content is too short'); if (decoded.length < 16) throw new Error(`Pepper file ${pepperFilePath} is too short. ${recovery}`);
return new Uint8Array(decoded); return new Uint8Array(decoded);
} }
async function readPepperFile(pepperFilePath: string): Promise<string | undefined> {
try {
return await readFile(pepperFilePath, 'utf8');
} catch (error: unknown) {
if (errorCode(error) === 'ENOENT') return undefined;
throw error;
}
}
// Keeps a project's .envsitter/ out of git even when the project's own .gitignore does not list it.
async function ignorePepperDirectory(dir: string): Promise<void> {
try {
await writeFile(join(dir, '.gitignore'), '*\n', { encoding: 'utf8', flag: 'wx' });
} catch (error: unknown) {
if (errorCode(error) === 'EEXIST') return;
throw error;
}
}
export async function resolvePepper(options: PepperOptions = {}): Promise<PepperResult> { export async function resolvePepper(options: PepperOptions = {}): Promise<PepperResult> {
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER']; const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
const pepperFromEnv = getPepperFromEnv(envVarNames); const pepperFromEnv = getPepperFromEnv(envVarNames);
@@ -47,26 +71,32 @@ export async function resolvePepper(options: PepperOptions = {}): Promise<Pepper
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath(); const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
const createIfMissing = options.createIfMissing ?? true; const createIfMissing = options.createIfMissing ?? true;
try { const existing = await readPepperFile(pepperFilePath);
const content = await readFile(pepperFilePath, 'utf8'); if (existing !== undefined) {
return { pepperBytes: parsePepperFileContentToBytes(content), source: 'file', pepperFilePath }; return { pepperBytes: parsePepperFileContentToBytes(existing, pepperFilePath), source: 'file', pepperFilePath };
} catch (error) {
if (!createIfMissing) throw error;
const dir = dirname(pepperFilePath);
await mkdir(dir, { recursive: true });
const pepper = randomBytes(32);
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600 });
try {
await chmod(pepperFilePath, 0o600);
} catch {
if (process.env.ENVSITTER_DEBUG === '1') {
console.error(`envsitter: could not set permissions on ${pepperFilePath}`);
}
}
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
} }
if (!createIfMissing) throw new Error(`Pepper file ${pepperFilePath} does not exist`);
const dir = dirname(pepperFilePath);
await mkdir(dir, { recursive: true });
if (basename(dir) === '.envsitter') await ignorePepperDirectory(dir);
const pepper = randomBytes(32);
try {
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
} catch (error: unknown) {
if (errorCode(error) !== 'EEXIST') throw error;
const created = await readFile(pepperFilePath, 'utf8');
return { pepperBytes: parsePepperFileContentToBytes(created, pepperFilePath), source: 'file', pepperFilePath };
}
try {
await chmod(pepperFilePath, 0o600);
} catch {
if (process.env.ENVSITTER_DEBUG === '1') {
console.error(`envsitter: could not set permissions on ${pepperFilePath}`);
}
}
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
} }
+127
View File
@@ -0,0 +1,127 @@
export type RedactOptions = {
/** Shortest value that is redacted. Shorter values match ordinary text too often. Default 8. */
minLength?: number;
/** Redact every value at least `minLength` long, not only values that look secret. Default false. */
all?: boolean;
};
export type RedactionCount = {
key: string;
count: number;
};
export type RedactResult = {
text: string;
redactions: RedactionCount[];
};
export type EnvSitterRedactor = {
/** Keys whose values this redactor replaces (never the values themselves). */
readonly keys: readonly string[];
redact(text: string): RedactResult;
};
const defaultMinLength = 8;
const randomTokenMinLength = 16;
const secretKeyName =
/SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PRIVATE_?KEY|CREDENTIAL|API_?KEY|ACCESS_?KEY|SIGNING_?KEY|WEBHOOK|DSN|(?:^|_)(?:KEY|PASS|PWD|SALT|PEPPER)$/i;
export function redactionPlaceholder(key: string): string {
return `[envsitter: ${key} redacted]`;
}
function isPlainScalar(value: string): boolean {
const trimmed = value.trim().toLowerCase();
if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) return true;
return ['true', 'false', 'yes', 'no', 'on', 'off', 'null', 'none'].includes(trimmed);
}
function bitsPerChar(value: string): number {
const counts = new Map<string, number>();
for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
let bits = 0;
for (const count of counts.values()) {
const p = count / value.length;
bits -= p * Math.log2(p);
}
return bits;
}
// Random-looking tokens: long, letters and digits, no whitespace, and no ':' (which rules out URLs, ARNs, host:port).
function looksLikeRandomToken(value: string): boolean {
if (value.length < randomTokenMinLength || /[\s:]/.test(value)) return false;
if (!/[A-Za-z]/.test(value) || !/\d/.test(value)) return false;
return bitsPerChar(value) >= 3;
}
function urlPasswords(value: string): string[] {
if (!value.includes('://')) return [];
let password: string;
try {
password = new URL(value).password;
} catch {
return [];
}
if (!password) return [];
const decoded = decodeURIComponent(password);
return decoded === password ? [password] : [password, decoded];
}
function redactionTerms(key: string, value: string, options: RedactOptions): string[] {
const minLength = options.minLength ?? defaultMinLength;
if (value.length < minLength || isPlainScalar(value)) return [];
const passwords = urlPasswords(value);
const isPem = value.includes('-----BEGIN');
const isSecret = options.all === true || secretKeyName.test(key) || looksLikeRandomToken(value) || passwords.length > 0 || isPem;
if (!isSecret) return [];
const terms = [value, ...passwords.filter((password) => password.length >= minLength)];
if (value.includes('\n')) {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (looksLikeRandomToken(trimmed)) terms.push(trimmed);
}
}
return terms;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function createRedactor(values: ReadonlyMap<string, string>, options: RedactOptions = {}): EnvSitterRedactor {
const keyByTerm = new Map<string, string>();
for (const [key, value] of values) {
for (const term of redactionTerms(key, value, options)) {
if (!keyByTerm.has(term)) keyByTerm.set(term, key);
}
}
const terms = [...keyByTerm.keys()].sort((a, b) => b.length - a.length);
const pattern = terms.length > 0 ? new RegExp(terms.map(escapeRegExp).join('|'), 'g') : undefined;
const keys = [...new Set(keyByTerm.values())].sort((a, b) => a.localeCompare(b));
return {
keys,
redact(text: string): RedactResult {
if (!pattern || text.length === 0) return { text, redactions: [] };
const counts = new Map<string, number>();
const redacted = text.replace(pattern, (match) => {
const key = keyByTerm.get(match);
if (key === undefined) return match;
counts.set(key, (counts.get(key) ?? 0) + 1);
return redactionPlaceholder(key);
});
const redactions = [...counts.entries()]
.map(([key, count]) => ({ key, count }))
.sort((a, b) => a.key.localeCompare(b.key));
return { text: redacted, redactions };
}
};
}
+5
View File
@@ -0,0 +1,5 @@
// RegExp#test advances lastIndex for global/sticky patterns, which makes repeated calls alternate.
export function regexTest(regex: RegExp, input: string): boolean {
if (regex.global || regex.sticky) regex.lastIndex = 0;
return regex.test(input);
}
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const cliPath = fileURLToPath(new URL('../cli.js', import.meta.url));
type CliResult = { code: number; stdout: string; stderr: string };
function runCli(args: string[], input?: string): Promise<CliResult> {
return new Promise((resolve) => {
const child = execFile(process.execPath, [cliPath, ...args], { env: { ...process.env, ENVSITTER_PEPPER: 'cli-test-pepper' } }, (error, stdout, stderr) => {
const code = error && typeof error.code === 'number' ? error.code : 0;
resolve({ code, stdout, stderr });
});
if (input !== undefined) child.stdin?.end(input);
});
}
async function makeTempFile(fileName: string, contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-cli-'));
const filePath = join(dir, fileName);
await writeFile(filePath, contents, 'utf8');
return filePath;
}
test('cli validate --json reports file, ok, and issues', async () => {
const file = await makeTempFile('.env', 'GOOD=secret-value\nBAD-KEY=x\n');
const result = await runCli(['validate', '--file', file, '--json']);
assert.equal(result.code, 2);
assert.ok(!result.stdout.includes('secret-value'));
const parsed = JSON.parse(result.stdout) as { file: string; ok: boolean; issues: Array<{ line: number; column: number }> };
assert.equal(parsed.file, file);
assert.equal(parsed.ok, false);
assert.equal(parsed.issues[0]?.line, 2);
});
test('cli set is a dry run until --write and never prints the value', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const dryRun = await runCli(['set', '--file', file, '--key', 'TOKEN', '--value', 'super-secret', '--json']);
assert.equal(dryRun.code, 0);
assert.ok(!dryRun.stdout.includes('super-secret'));
const planned = JSON.parse(dryRun.stdout) as { willWrite: boolean; wrote: boolean; plan: { action: string } };
assert.equal(planned.wrote, false);
assert.equal(planned.plan.action, 'added');
assert.equal(await readFile(file, 'utf8'), 'A=1\n');
const written = await runCli(['set', '--file', file, '--key', 'TOKEN', '--value', 'super-secret', '--write', '--json']);
assert.equal(written.code, 0);
assert.equal((JSON.parse(written.stdout) as { wrote: boolean }).wrote, true);
assert.equal(await readFile(file, 'utf8'), 'A=1\nTOKEN=super-secret\n');
});
test('cli delete exits 2 when no key was found', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const result = await runCli(['delete', '--file', file, '--key', 'MISSING', '--json']);
assert.equal(result.code, 2);
const parsed = JSON.parse(result.stdout) as { keys: string[]; plan: Array<{ action: string }> };
assert.deepEqual(parsed.keys, ['MISSING']);
assert.equal(parsed.plan[0]?.action, 'not_found');
});
test('cli redact replaces secret values in stdin and reports counts', async () => {
const file = await makeTempFile('.env', 'API_TOKEN=tok_live_9f8a7b6c5d4e\nNODE_ENV=development\n');
const log = 'start development\ncalling with tok_live_9f8a7b6c5d4e\n';
const plain = await runCli(['redact', '--file', file], log);
assert.equal(plain.code, 0);
assert.equal(plain.stdout, 'start development\ncalling with [envsitter: API_TOKEN redacted]\n');
assert.match(plain.stderr, /redacted 1 occurrence\(s\) of API_TOKEN/);
const json = await runCli(['redact', '--file', file, '--json'], log);
assert.equal(json.code, 0);
assert.ok(!json.stdout.includes('tok_live_9f8a7b6c5d4e'));
assert.deepEqual((JSON.parse(json.stdout) as { redactions: unknown }).redactions, [{ key: 'API_TOKEN', count: 1 }]);
const bad = await runCli(['redact', '--file', file, '--min-length', '0'], log);
assert.equal(bad.code, 2);
assert.match(bad.stderr, /--min-length must be a positive integer/);
});
test('cli match-by-key rejects non-string candidates', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const result = await runCli(['match-by-key', '--file', file, '--candidates-json', '{"A":1}']);
assert.equal(result.code, 2);
assert.match(result.stderr, /Candidate for A must be a string/);
});
+73 -2
View File
@@ -1,8 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { chmod, lstat, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { dirname, join } from 'node:path';
import { addEnvFileKey, annotateEnvFile, copyEnvFileKeys, deleteEnvFileKeys, formatEnvFile, isExampleEnvFile, setEnvFileKey, unsetEnvFileKey, validateEnvFile } from '../index.js'; import { addEnvFileKey, annotateEnvFile, copyEnvFileKeys, deleteEnvFileKeys, formatEnvFile, isExampleEnvFile, setEnvFileKey, unsetEnvFileKey, validateEnvFile } from '../index.js';
@@ -46,6 +46,18 @@ test('copyEnvFileKeys copies selected keys and supports rename', async () => {
assert.ok(out.includes('D=keep')); assert.ok(out.includes('D=keep'));
}); });
test('copyEnvFileKeys include/exclude regexes work with the global flag', async () => {
const from = await makeTempFile('.env.prod', ['API_A=1', 'API_B=2', 'API_C=3', 'DB_URL=4'].join('\n') + '\n');
const to = await makeTempFile('.env.staging', '');
const res = await copyEnvFileKeys({ from, to, include: /^API_/g, exclude: /_C$/g });
assert.deepEqual(
res.plan.map((p) => p.fromKey),
['API_A', 'API_B']
);
});
test('annotateEnvFile inserts and updates envsitter comment', async () => { test('annotateEnvFile inserts and updates envsitter comment', async () => {
const file = await makeTempFile('.env', 'A=1\n'); const file = await makeTempFile('.env', 'A=1\n');
@@ -190,3 +202,62 @@ test('isExampleEnvFile detects example/template files', () => {
assert.equal(isExampleEnvFile('config.env.template'), true); assert.equal(isExampleEnvFile('config.env.template'), true);
assert.equal(isExampleEnvFile('/path/to/api.env.example'), true); assert.equal(isExampleEnvFile('/path/to/api.env.example'), true);
}); });
test('edits into an empty env file write one clean line', async () => {
const setFile = await makeTempFile('.env', '');
const set = await setEnvFileKey({ file: setFile, key: 'A', value: '1', write: true });
assert.equal(set.plan.line, 1);
assert.equal(await readFile(setFile, 'utf8'), 'A=1\n');
const addFile = await makeTempFile('.env', '');
await addEnvFileKey({ file: addFile, key: 'A', value: '1', write: true });
assert.equal(await readFile(addFile, 'utf8'), 'A=1\n');
const from = await makeTempFile('.env.prod', 'A=1\nB=2\n');
const to = join(dirname(from), '.env.staging');
await copyEnvFileKeys({ from, to, keys: ['A'], write: true });
assert.equal(await readFile(to, 'utf8'), 'A=1\n');
const empty = await makeTempFile('.env', '');
const formatted = await formatEnvFile({ file: empty, write: true });
assert.equal(formatted.hasChanges, false);
assert.equal(await readFile(empty, 'utf8'), '');
});
const posixOnly = { skip: process.platform === 'win32' ? 'POSIX permissions and symlinks' : false };
test('writes keep the existing file mode', posixOnly, async () => {
const file = await makeTempFile('.env', 'A=1\n');
await chmod(file, 0o600);
const res = await setEnvFileKey({ file, key: 'B', value: '2', write: true });
assert.equal(res.wrote, true);
assert.equal((await stat(file)).mode & 0o777, 0o600);
await chmod(file, 0o640);
await setEnvFileKey({ file, key: 'C', value: '3', write: true });
assert.equal((await stat(file)).mode & 0o777, 0o640);
});
test('writes create new env files readable only by the owner', posixOnly, async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
const file = join(dir, '.env.local');
const res = await setEnvFileKey({ file, key: 'A', value: '1', write: true });
assert.equal(res.wrote, true);
assert.equal(await readFile(file, 'utf8'), 'A=1\n');
assert.equal((await stat(file)).mode & 0o777, 0o600);
});
test('writes through a symlinked env file update its target and keep the link', posixOnly, async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
const target = join(dir, 'shared.env');
const link = join(dir, '.env');
await writeFile(target, 'A=1\n', 'utf8');
await symlink('shared.env', link);
const res = await setEnvFileKey({ file: link, key: 'B', value: '2', write: true });
assert.equal(res.wrote, true);
assert.equal((await lstat(link)).isSymbolicLink(), true);
assert.equal(await readFile(target, 'utf8'), 'A=1\nB=2\n');
});
+17
View File
@@ -1,5 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseDotenvDocument } from '../dotenv/document.js';
import { parseDotenv } from '../dotenv/parse.js'; import { parseDotenv } from '../dotenv/parse.js';
test('parseDotenv parses basic assignments and ignores comments', () => { test('parseDotenv parses basic assignments and ignores comments', () => {
@@ -38,3 +39,19 @@ test('parseDotenv reports invalid keys', () => {
assert.ok(parsed.errors.length >= 1); assert.ok(parsed.errors.length >= 1);
assert.equal(parsed.values.get('OK'), 'value2'); assert.equal(parsed.values.get('OK'), 'value2');
}); });
test('parseDotenv shares the document grammar, including issue columns', () => {
const input = ['export\tTABBED=1', 'DUP=first', 'DUP=second', "BAD='unterminated", ' NOEQ', ''].join('\n');
const parsed = parseDotenv(input);
assert.equal(parsed.values.get('TABBED'), '1');
assert.equal(parsed.values.get('DUP'), 'second');
assert.deepEqual(parsed.errors, parseDotenvDocument(input).issues);
assert.deepEqual(
parsed.errors.map((e) => [e.line, e.column]),
[
[4, 1],
[5, 7]
]
);
});
+19
View File
@@ -129,3 +129,22 @@ test('EnvSitter scan detects JWT-like and URL values without exposing them', asy
{ key: 'URL', detections: ['url'] } { key: 'URL', detections: ['url'] }
]); ]);
}); });
test('EnvSitter regex filters and matchers are stable with the global flag', async () => {
const filePath = await makeTempDotenv('API_A=https://a.example\nAPI_B=https://b.example\nAPI_C=https://c.example\nOTHER=https://x.example\n');
const es = EnvSitter.fromDotenvFile(filePath);
assert.deepEqual(await es.listKeys({ filter: /^API_/g }), ['API_A', 'API_B', 'API_C']);
const matches = await es.matchKeyBulk(['API_A', 'API_B', 'API_C'], { op: 'partial_match_regex', regex: /^https:/g });
assert.deepEqual(
matches.map((m) => m.match),
[true, true, true]
);
const findings = await es.scan({ keysFilter: /^API_/gy, detect: ['url'] });
assert.deepEqual(
findings.map((f) => f.key),
['API_A', 'API_B', 'API_C']
);
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from '../index.js';
test('isEnvFile recognizes dotenv file names in any directory', () => {
for (const name of ['.env', '.env.local', '.env.production.local', 'api.env', 'config.env.local', 'nested/dir/.env', 'C:\\app\\.env.keys', '.ENV']) {
assert.equal(isEnvFile(name), true, name);
}
for (const name of ['env', '.envrc', 'environment.ts', 'src/env.ts', 'app.env.ts', '.env.d.ts', 'vite-env.d.ts', 'README.md', '.envsitter/pepper']) {
assert.equal(isEnvFile(name), false, name);
}
});
test('isSensitiveEnvFile excludes example and template files', () => {
for (const name of ['.env', '.env.local', 'api.env', '.env.example.local', 'deploy/.env.production']) {
assert.equal(isSensitiveEnvFile(name), true, name);
}
for (const name of ['.env.example', '.env.sample', '.env.template', '.env.dist', '.env.default', '.env.defaults', 'api.env.example', 'src/index.ts']) {
assert.equal(isSensitiveEnvFile(name), false, name);
}
assert.equal(isExampleEnvFile('api.env.sample'), true);
});
test('isEnvSitterPepperFile matches the default pepper location only', () => {
assert.equal(isEnvSitterPepperFile('.envsitter/pepper'), true);
assert.equal(isEnvSitterPepperFile('/repo/.envsitter/pepper'), true);
assert.equal(isEnvSitterPepperFile('C:\\repo\\.envsitter\\pepper'), true);
assert.equal(isEnvSitterPepperFile('.envsitter/pepper.bak'), false);
assert.equal(isEnvSitterPepperFile('pepper'), false);
});
+55 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises'; import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { resolvePepper } from '../pepper.js'; import { resolvePepper } from '../pepper.js';
@@ -36,6 +36,10 @@ test('resolvePepper creates a pepper file when missing', async () => {
const persisted = (await readFile(pepperPath, 'utf8')).trim(); const persisted = (await readFile(pepperPath, 'utf8')).trim();
assert.ok(persisted.length > 0); assert.ok(persisted.length > 0);
const projectPepperPath = join(dir, 'project', '.envsitter', 'pepper');
await resolvePepper({ pepperFilePath: projectPepperPath });
assert.equal(await readFile(join(dir, 'project', '.envsitter', '.gitignore'), 'utf8'), '*\n');
} finally { } finally {
if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER; if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER;
else process.env.ENVSITTER_PEPPER = prevEnvSitterPepper; else process.env.ENVSITTER_PEPPER = prevEnvSitterPepper;
@@ -44,3 +48,53 @@ test('resolvePepper creates a pepper file when missing', async () => {
else process.env.ENV_SITTER_PEPPER = prevEnvSitterPepperAlt; else process.env.ENV_SITTER_PEPPER = prevEnvSitterPepperAlt;
} }
}); });
async function withoutEnvPepper(fn: () => Promise<void>): Promise<void> {
const saved = [process.env.ENVSITTER_PEPPER, process.env.ENV_SITTER_PEPPER] as const;
delete process.env.ENVSITTER_PEPPER;
delete process.env.ENV_SITTER_PEPPER;
try {
await fn();
} finally {
if (saved[0] === undefined) delete process.env.ENVSITTER_PEPPER;
else process.env.ENVSITTER_PEPPER = saved[0];
if (saved[1] === undefined) delete process.env.ENV_SITTER_PEPPER;
else process.env.ENV_SITTER_PEPPER = saved[1];
}
}
test('resolvePepper refuses to replace a malformed pepper file', async () => {
await withoutEnvPepper(async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const empty = join(dir, 'empty-pepper');
await writeFile(empty, '');
await assert.rejects(() => resolvePepper({ pepperFilePath: empty }), /is empty/);
assert.equal(await readFile(empty, 'utf8'), '');
const short = join(dir, 'short-pepper');
await writeFile(short, 'c2hvcnQ=');
await assert.rejects(() => resolvePepper({ pepperFilePath: short }), /too short/);
assert.equal(await readFile(short, 'utf8'), 'c2hvcnQ=');
await assert.rejects(() => resolvePepper({ pepperFilePath: join(dir, 'missing'), createIfMissing: false }), /does not exist/);
});
});
const canTestUnreadable = process.platform !== 'win32' && process.getuid?.() !== 0;
test('resolvePepper surfaces an unreadable pepper file instead of replacing it', { skip: canTestUnreadable ? false : 'needs POSIX permissions and a non-root user' }, async () => {
await withoutEnvPepper(async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const locked = join(dir, 'pepper');
await writeFile(locked, 'original-pepper');
await chmod(locked, 0o000);
try {
await assert.rejects(() => resolvePepper({ pepperFilePath: locked }), { code: 'EACCES' });
} finally {
await chmod(locked, 0o600);
}
assert.equal(await readFile(locked, 'utf8'), 'original-pepper');
});
});
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EnvSitter } from '../index.js';
import { createRedactor } from '../redact.js';
const envContents = [
'API_TOKEN=tok_live_9f8a7b6c5d4e',
'NODE_ENV=development',
'PORT=3000',
'DEBUG=true',
'NEXTAUTH_URL=http://localhost:3000',
'DATABASE_URL=postgres://app:s3cretPassw0rd@db:5432/app',
'SESSION_SIGNING=Zq8xV2mN4pL7rT1y'
].join('\n');
async function makeEnvFile(contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-redact-'));
const file = join(dir, '.env');
await writeFile(file, contents, 'utf8');
return file;
}
test('redactor replaces secret-looking values and leaves ordinary config alone', async () => {
const redactor = await EnvSitter.fromDotenvFile(await makeEnvFile(envContents)).redactor();
assert.deepEqual(redactor.keys, ['API_TOKEN', 'DATABASE_URL', 'SESSION_SIGNING']);
const text = [
'auth: Bearer tok_live_9f8a7b6c5d4e',
'env=development port=3000 debug=true url=http://localhost:3000',
'db: postgres://app:s3cretPassw0rd@db:5432/app (password s3cretPassw0rd)',
'signing: Zq8xV2mN4pL7rT1y'
].join('\n');
const result = redactor.redact(text);
for (const secret of ['tok_live_9f8a7b6c5d4e', 's3cretPassw0rd', 'Zq8xV2mN4pL7rT1y']) {
assert.ok(!result.text.includes(secret), `${secret} should be redacted`);
}
assert.ok(result.text.includes('Bearer [envsitter: API_TOKEN redacted]'));
assert.ok(result.text.includes('env=development port=3000 debug=true url=http://localhost:3000'));
assert.deepEqual(result.redactions, [
{ key: 'API_TOKEN', count: 1 },
{ key: 'DATABASE_URL', count: 2 },
{ key: 'SESSION_SIGNING', count: 1 }
]);
});
test('redactor options widen or narrow what is redacted', async () => {
const sitter = EnvSitter.fromDotenvFile(await makeEnvFile(envContents));
const all = await sitter.redactor({ all: true });
assert.ok(all.keys.includes('NODE_ENV'));
assert.ok(!all.keys.includes('PORT'), 'plain numbers are never redacted');
assert.equal(all.redact('mode development').text, 'mode [envsitter: NODE_ENV redacted]');
const strict = await sitter.redactor({ all: true, minLength: 12 });
assert.ok(!strict.keys.includes('NODE_ENV'));
assert.equal(strict.redact('mode development').text, 'mode development');
});
test('redactor covers each random-looking line of a multi-line key', () => {
const pemBody = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7';
const pem = ['-----BEGIN PRIVATE KEY-----', pemBody, '-----END PRIVATE KEY-----'].join('\n');
const redactor = createRedactor(new Map([['TLS_CERT_DATA', pem]]));
const result = redactor.redact(`partial dump: ${pemBody}\nheader: -----BEGIN PRIVATE KEY-----`);
assert.ok(!result.text.includes(pemBody));
assert.ok(result.text.includes('header: -----BEGIN PRIVATE KEY-----'));
assert.deepEqual(result.redactions, [{ key: 'TLS_CERT_DATA', count: 1 }]);
});
test('redactor returns text unchanged when nothing looks secret', () => {
const redactor = createRedactor(new Map([['NODE_ENV', 'production'], ['PORT', '8080']]));
assert.deepEqual(redactor.keys, []);
assert.deepEqual(redactor.redact('production on 8080'), { text: 'production on 8080', redactions: [] });
});
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"types": ["node"],
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,