From af471c9821ba1b0a8a80f9c07606cc5ddc13f289 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Tue, 20 Oct 2026 15:10:13 +0100 Subject: [PATCH] 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 --- package-lock.json | 23 +++--- package.json | 14 +++- src/cli.ts | 133 ++++++++++++++++--------------- src/dotenv/document.ts | 3 + src/dotenv/edit.ts | 5 +- src/dotenv/io.ts | 39 +++++++-- src/dotenv/parse.ts | 116 ++------------------------- src/dotenv/utils.ts | 6 -- src/envsitter.ts | 17 +++- src/file-ops.ts | 32 ++------ src/index.ts | 10 ++- src/paths.ts | 65 +++++++++++++++ src/pepper.ts | 80 +++++++++++++------ src/redact.ts | 127 +++++++++++++++++++++++++++++ src/regex.ts | 5 ++ src/test/cli.test.ts | 95 ++++++++++++++++++++++ src/test/dotenv-file-ops.test.ts | 75 ++++++++++++++++- src/test/dotenv-parse.test.ts | 17 ++++ src/test/envsitter.test.ts | 19 +++++ src/test/paths.test.ts | 34 ++++++++ src/test/pepper.test.ts | 56 ++++++++++++- src/test/redact.test.ts | 79 ++++++++++++++++++ tsconfig.json | 1 + 23 files changed, 790 insertions(+), 261 deletions(-) create mode 100644 src/paths.ts create mode 100644 src/redact.ts create mode 100644 src/regex.ts create mode 100644 src/test/cli.test.ts create mode 100644 src/test/paths.test.ts create mode 100644 src/test/redact.test.ts diff --git a/package-lock.json b/package-lock.json index a2e0333..fe55e58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 9b5dc35..c6bec48 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/cli.ts b/src/cli.ts index ea7f3e8..ff1c12a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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(value: T | undefined, message: string): T { return value; } +function toCandidatesByKey(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}'); + } + + const candidates: Record = {}; + 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 (--key | --keys | --all-keys) [--op ] [--candidate | --candidate-stdin]', ' match-by-key --file (--candidates-json | --candidates-stdin)', ' scan --file [--keys-regex ] [--detect jwt,url,base64]', + ' redact --file [--all] [--min-length ] Reads text on stdin, writes it with secret values replaced', ' validate --file ', ' copy --from --to [--keys ] [--include-regex ] [--exclude-regex ] [--rename ] [--on-conflict error|skip|overwrite] [--write]', ' format --file [--mode sections|global] [--sort alpha|none] [--write]', @@ -169,8 +190,7 @@ async function run(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const raw = candidatesJson ?? candidatesStdin; const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin'); - let candidates: Record; + let parsedJson: unknown; try { - candidates = JSON.parse(parsed) as Record; + 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; diff --git a/src/dotenv/document.ts b/src/dotenv/document.ts index 2519760..f30d14a 100644 --- a/src/dotenv/document.ts +++ b/src/dotenv/document.ts @@ -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; } diff --git a/src/dotenv/edit.ts b/src/dotenv/edit.ts index cf10e6d..3a123d3 100644 --- a/src/dotenv/edit.ts +++ b/src/dotenv/edit.ts @@ -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; diff --git a/src/dotenv/io.ts b/src/dotenv/io.ts index 15f26d6..3350ab1 100644 --- a/src/dotenv/io.ts +++ b/src/dotenv/io.ts @@ -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 { 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 { + try { + return await realpath(filePath); + } catch (error: unknown) { + if (isNotFound(error)) return filePath; + throw error; + } +} + +async function existingFileMode(filePath: string): Promise { + 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 { - 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 }); } diff --git a/src/dotenv/parse.ts b/src/dotenv/parse.ts index 4c933a2..4176f42 100644 --- a/src/dotenv/parse.ts +++ b/src/dotenv/parse.ts @@ -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; 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(); - 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 }; } diff --git a/src/dotenv/utils.ts b/src/dotenv/utils.ts index f45486d..6f81683 100644 --- a/src/dotenv/utils.ts +++ b/src/dotenv/utils.ts @@ -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 ''; diff --git a/src/envsitter.ts b/src/envsitter.ts index b248f9a..a5e9607 100644 --- a/src/envsitter.ts +++ b/src/envsitter.ts @@ -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 { 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 { @@ -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 { + const snapshot = await this.source.load(); + return createRedactor(snapshot.values, options); + } + async scan(options: ScanOptions = {}): Promise { 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.env.` 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)); +} diff --git a/src/pepper.ts b/src/pepper.ts index 3f04c30..348955d 100644 --- a/src/pepper.ts +++ b/src/pepper.ts @@ -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 { + 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 { + 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 { const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER']; const pepperFromEnv = getPepperFromEnv(envVarNames); @@ -47,26 +71,32 @@ export async function resolvePepper(options: PepperOptions = {}): Promise(); + 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, options: RedactOptions = {}): EnvSitterRedactor { + const keyByTerm = new Map(); + 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(); + 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 }; + } + }; +} diff --git a/src/regex.ts b/src/regex.ts new file mode 100644 index 0000000..9e7c597 --- /dev/null +++ b/src/regex.ts @@ -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); +} diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts new file mode 100644 index 0000000..7015528 --- /dev/null +++ b/src/test/cli.test.ts @@ -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 { + 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 { + 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/); +}); diff --git a/src/test/dotenv-file-ops.test.ts b/src/test/dotenv-file-ops.test.ts index 118fdb8..015cc9b 100644 --- a/src/test/dotenv-file-ops.test.ts +++ b/src/test/dotenv-file-ops.test.ts @@ -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'); +}); diff --git a/src/test/dotenv-parse.test.ts b/src/test/dotenv-parse.test.ts index a1bae61..10c8894 100644 --- a/src/test/dotenv-parse.test.ts +++ b/src/test/dotenv-parse.test.ts @@ -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] + ] + ); +}); diff --git a/src/test/envsitter.test.ts b/src/test/envsitter.test.ts index 19e0fd2..dfde6af 100644 --- a/src/test/envsitter.test.ts +++ b/src/test/envsitter.test.ts @@ -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'] + ); +}); diff --git a/src/test/paths.test.ts b/src/test/paths.test.ts new file mode 100644 index 0000000..2ddf8b9 --- /dev/null +++ b/src/test/paths.test.ts @@ -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); +}); diff --git a/src/test/pepper.test.ts b/src/test/pepper.test.ts index ef14316..ca2d061 100644 --- a/src/test/pepper.test.ts +++ b/src/test/pepper.test.ts @@ -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): Promise { + 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'); + }); +}); diff --git a/src/test/redact.test.ts b/src/test/redact.test.ts new file mode 100644 index 0000000..b1e61da --- /dev/null +++ b/src/test/redact.test.ts @@ -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 { + 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: [] }); +}); diff --git a/tsconfig.json b/tsconfig.json index a4d13a8..eb7506a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", + "types": ["node"], "declaration": true, "declarationMap": true, "sourceMap": true,