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:
Generated
+13
-10
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
for (const line of doc.lines) {
|
||||
if (line.kind === 'assignment') values.set(line.key, line.value);
|
||||
}
|
||||
|
||||
const key = parseKey(withoutExport.slice(0, eq));
|
||||
if (!key) {
|
||||
errors.push({ line: lineNumber, message: 'Invalid key name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValue = withoutExport.slice(eq + 1);
|
||||
const value = parseValue(rawValue, lineNumber, errors);
|
||||
values.set(key, value);
|
||||
}
|
||||
|
||||
return { values, errors };
|
||||
return { values, errors: doc.issues };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+41
-11
@@ -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,17 +71,24 @@ 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 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);
|
||||
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600 });
|
||||
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);
|
||||
@@ -69,4 +100,3 @@ export async function resolvePepper(options: PepperOptions = {}): Promise<Pepper
|
||||
|
||||
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user