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",
"version": "0.0.2",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "envsitter",
"version": "0.0.2",
"version": "0.1.0",
"license": "MIT",
"bin": {
"envsitter": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/node": "^22.20.5",
"husky": "^9.1.7",
"typescript": "^5.7.3"
"typescript": "^6.0.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@types/node": {
"version": "22.19.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz",
"integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==",
"version": "22.20.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
"integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -44,9 +47,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+10 -4
View File
@@ -1,9 +1,10 @@
{
"name": "envsitter",
"version": "0.0.4",
"version": "0.1.0",
"private": false,
"type": "module",
"description": "Safely inspect and match .env secrets without exposing values",
"homepage": "https://boxpositron.github.io/envsitter/",
"repository": {
"type": "git",
"url": "git+https://github.com/boxpositron/envsitter.git"
@@ -23,6 +24,9 @@
"config"
],
"license": "MIT",
"engines": {
"node": ">=22"
},
"bin": {
"envsitter": "dist/cli.js"
},
@@ -33,17 +37,19 @@
}
},
"files": [
"dist"
"dist",
"!dist/test"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node scripts/run-tests.mjs",
"prepack": "npm run build",
"prepare": "husky"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/node": "^22.20.5",
"husky": "^9.1.7",
"typescript": "^5.7.3"
"typescript": "^6.0.3"
}
}
+67 -66
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
import { addDotenvKey, annotateDotenvKey, copyDotenvKeys, deleteDotenvKeys, formatDotenv, setDotenvKey, unsetDotenvKey, validateDotenv } from './dotenv/edit.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
import { isExampleEnvFile } from './dotenv/utils.js';
import {
addEnvFileKey,
annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile
} from './file-ops.js';
import { isExampleEnvFile } from './paths.js';
function parseRegex(input: string): RegExp {
@@ -37,6 +44,19 @@ function requireValue<T>(value: T | undefined, message: string): T {
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 {
if (op === 'exists') return { op: 'exists' };
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-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
' 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>',
' 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]',
@@ -169,8 +190,7 @@ async function run(): Promise<number> {
if (cmd === 'validate') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const contents = await readFile(file, 'utf8');
const result = validateDotenv(contents);
const result = await validateEnvFile(file);
if (json) jsonOut(result);
else {
@@ -197,37 +217,21 @@ async function run(): Promise<number> {
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
const sourceContents = await readFile(from, 'utf8');
const targetContents = await readTextFileOrEmpty(to);
const result = copyDotenvKeys({
sourceContents,
targetContents,
const result = await copyEnvFileKeys({
from,
to,
...(keysRaw ? { keys: parseList(keysRaw) } : {}),
...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
...(renameRaw ? { rename: renameRaw } : {}),
onConflict
onConflict,
write: flags['write'] === true
});
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) {
jsonOut({
from,
to,
onConflict,
willWrite,
wrote: willWrite && conflicts.length === 0 && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
});
jsonOut(result);
} else {
for (const p of result.plan) {
const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
@@ -248,14 +252,10 @@ async function run(): Promise<number> {
const mode = modeRaw === 'global' ? 'global' : 'sections';
const sort = sortRaw === 'none' ? 'none' : 'alpha';
const contents = await readFile(file, 'utf8');
const result = formatDotenv({ contents, mode, sort });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await formatEnvFile({ file, mode, sort, write: flags['write'] === true });
if (json) {
jsonOut({ file, mode, sort, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues });
jsonOut(result);
} else {
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 line = lineRaw ? Number(lineRaw) : undefined;
const contents = await readFile(file, 'utf8');
const result = annotateDotenvKey({ contents, key, comment, ...(line ? { line } : {}) });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await annotateEnvFile({ file, key, comment, ...(line ? { line } : {}), write: flags['write'] === true });
if (json) {
jsonOut({ file, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
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 value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = addDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await addEnvFileKey({ file, key, value, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
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 value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = setDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await setEnvFileKey({ file, key, value, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
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;
warnIfExampleFile(file, noExampleWarning);
const contents = await readFile(file, 'utf8');
const result = unsetDotenvKey({ contents, key });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await unsetEnvFileKey({ file, key, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
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;
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
const contents = await readFile(file, 'utf8');
const result = deleteDotenvKeys({ contents, keys });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await deleteEnvFileKeys({ file, keys, write: flags['write'] === true });
if (json) {
jsonOut({ file, keys, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
for (const p of result.plan) {
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 parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
let candidates: Record<string, string>;
let parsedJson: unknown;
try {
candidates = JSON.parse(parsed) as Record<string, string>;
parsedJson = JSON.parse(parsed);
} catch {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
}
const candidates = toCandidatesByKey(parsedJson);
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
jsonOut({ matches });
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') {
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : 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 {
const issues: DotenvIssue[] = [];
if (contents.length === 0) return { lines: [], issues, endsWithNewline: true };
const endsWithNewline = contents.endsWith('\n');
const split = contents.split(/\r?\n/);
@@ -190,6 +192,7 @@ export function parseDotenvDocument(contents: string): DotenvDocument {
}
export function stringifyDotenvDocument(doc: DotenvDocument): string {
if (doc.lines.length === 0) return '';
const out = doc.lines.map((l) => l.raw).join('\n');
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 { regexTest } from '../regex.js';
import { buildAssignmentLine } from './utils.js';
export type DotenvWriteMode = 'dry-run' | 'write';
@@ -91,8 +92,8 @@ export function copyDotenvKeys(options: {
let hasChanges = false;
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
if (options.include && !options.include.test(fromKey)) continue;
if (options.exclude && options.exclude.test(fromKey)) continue;
if (options.include && !regexTest(options.include, fromKey)) continue;
if (options.exclude && regexTest(options.exclude, fromKey)) continue;
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';
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> {
try {
return await readFile(filePath, 'utf8');
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT') return '';
if (isNotFound(error)) return '';
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> {
const dir = dirname(filePath);
const tmp = await mkdtemp(join(dir, '.envsitter-tmp-'));
const target = await resolveWriteTarget(filePath);
const mode = (await existingFileMode(target)) ?? newFileMode;
const tmp = await mkdtemp(join(dirname(target), '.envsitter-tmp-'));
const tmpFile = join(tmp, 'file');
try {
await writeFile(tmpFile, contents, 'utf8');
await rename(tmpFile, filePath);
await writeFile(tmpFile, contents, { encoding: 'utf8', mode });
await chmod(tmpFile, mode);
await rename(tmpFile, target);
} finally {
await rm(tmp, { recursive: true, force: true });
}
+7 -109
View File
@@ -1,121 +1,19 @@
export type DotenvParseError = {
line: number;
message: string;
};
import { parseDotenvDocument, type DotenvIssue } from './document.js';
export type DotenvParseError = DotenvIssue;
export type DotenvParseResult = {
values: Map<string, string>;
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 {
const doc = parseDotenvDocument(contents);
const values = new Map<string, string>();
const errors: DotenvParseError[] = [];
const lines = contents.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
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);
for (const line of doc.lines) {
if (line.kind === 'assignment') values.set(line.key, line.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 {
if (value === '') return '';
+13 -4
View File
@@ -2,6 +2,8 @@ import { timingSafeEqual } from 'node:crypto';
import { base64UrlEncode } from './encoding.js';
import { fingerprintValueHmacSha256 } from './fingerprint.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 { ExternalCommandSource } from './sources/externalCommand.js';
@@ -76,8 +78,9 @@ export class EnvSitter {
async listKeys(options: ListKeysOptions = {}): Promise<string[]> {
const snapshot = await this.source.load();
const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b));
if (!options.filter) return keys;
return keys.filter((k) => options.filter?.test(k));
const filter = options.filter;
if (!filter) return keys;
return keys.filter((k) => regexTest(filter, k));
}
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
@@ -209,13 +212,19 @@ export class EnvSitter {
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[]> {
const snapshot = await this.source.load();
const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64'];
const findings: ScanFinding[] = [];
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[] = [];
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_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_boolean') return isBooleanLike(value);
+7 -25
View File
@@ -9,36 +9,18 @@ import {
setDotenvKey,
unsetDotenvKey,
validateDotenv,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyPlanItem,
type FormatMode,
type FormatSort,
type KeyMutationAction,
type KeyMutationPlanItem
} from './dotenv/edit.js';
import type { DotenvIssue } from './dotenv/document.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
export type DotenvIssue = {
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 { AnnotatePlan, CopyConflictPolicy, CopyPlanItem, DotenvIssue, FormatMode, FormatSort };
export type CopyEnvFilesResult = {
from: string;
+9 -1
View File
@@ -12,6 +12,8 @@ export {
export { type PepperOptions, resolvePepper } from './pepper.js';
export { type EnvSitterRedactor, type RedactionCount, type RedactOptions, type RedactResult } from './redact.js';
export {
addEnvFileKey,
annotateEnvFile,
@@ -23,9 +25,15 @@ export {
validateEnvFile,
type AddEnvFileKeyResult,
type AnnotateEnvFileResult,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyEnvFilesResult,
type CopyPlanItem,
type DeleteEnvFileKeysResult,
type DotenvIssue,
type FormatEnvFileResult,
type FormatMode,
type FormatSort,
type KeyMutationAction,
type KeyMutationPlanItem,
type SetEnvFileKeyResult,
@@ -33,4 +41,4 @@ export {
type ValidateEnvFileResult
} 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 { dirname, join } from 'node:path';
import { basename, dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export type PepperOptions = {
@@ -26,14 +26,38 @@ function getPepperFromEnv(envVarNames: string[]): string | 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();
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');
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);
}
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> {
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
const pepperFromEnv = getPepperFromEnv(envVarNames);
@@ -47,26 +71,32 @@ export async function resolvePepper(options: PepperOptions = {}): Promise<Pepper
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
const createIfMissing = options.createIfMissing ?? true;
try {
const content = await readFile(pepperFilePath, 'utf8');
return { pepperBytes: parsePepperFileContentToBytes(content), 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 };
const existing = await readPepperFile(pepperFilePath);
if (existing !== undefined) {
return { pepperBytes: parsePepperFileContentToBytes(existing, pepperFilePath), 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 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 { join } from 'node:path';
import { dirname, join } from 'node:path';
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'));
});
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 () => {
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('/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 assert from 'node:assert/strict';
import { parseDotenvDocument } from '../dotenv/document.js';
import { parseDotenv } from '../dotenv/parse.js';
test('parseDotenv parses basic assignments and ignores comments', () => {
@@ -38,3 +39,19 @@ test('parseDotenv reports invalid keys', () => {
assert.ok(parsed.errors.length >= 1);
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'] }
]);
});
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 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 { join } from 'node:path';
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();
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 {
if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER;
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;
}
});
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",
"outDir": "dist",
"rootDir": "src",
"types": ["node"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,