Initial envsitter CLI and safe env matching

This commit is contained in:
David Ibia
2026-01-12 10:30:49 +01:00
commit 3993746843
17 changed files with 1001 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Node / Bun
node_modules/
dist/
# Local peppers / secrets (never commit)
.env
.env.*
.envsitter/

51
package-lock.json generated Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "envsitter",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "envsitter",
"version": "0.1.0",
"license": "MIT",
"bin": {
"envsitter": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.3"
}
},
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "envsitter",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Safely inspect and match .env secrets without exposing values",
"license": "MIT",
"bin": {
"envsitter": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node scripts/run-tests.mjs"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.3"
}
}

21
scripts/run-tests.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
const testDir = join(process.cwd(), 'dist', 'test');
const entries = await readdir(testDir, { withFileTypes: true });
const testFiles = entries
.filter((e) => e.isFile() && e.name.endsWith('.test.js'))
.map((e) => join(testDir, e.name))
.sort((a, b) => a.localeCompare(b));
if (testFiles.length === 0) {
process.stderr.write('envsitter: no test files found in dist/test\n');
process.exitCode = 1;
} else {
const child = spawn(process.execPath, ['--test', ...testFiles], { stdio: 'inherit' });
child.on('exit', (code) => {
process.exitCode = code ?? 1;
});
}

227
src/cli.ts Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
import { EnvSitter } from './envsitter.js';
type PepperCliOptions = {
pepperFile?: string;
};
type CommonCliOptions = {
file?: string;
pepper?: PepperCliOptions;
json?: boolean;
};
function parseRegex(input: string): RegExp {
const trimmed = input.trim();
if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) {
const lastSlash = trimmed.lastIndexOf('/');
const body = trimmed.slice(1, lastSlash);
const flags = trimmed.slice(lastSlash + 1);
return new RegExp(body, flags);
}
return new RegExp(trimmed);
}
function parseList(input: string): string[] {
return input
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function readStdinText(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
function requireValue<T>(value: T | undefined, message: string): T {
if (value === undefined) throw new Error(message);
return value;
}
function parseArgs(argv: string[]): { cmd: string; args: string[]; flags: Record<string, string | boolean> } {
const [cmd = 'help', ...rest] = argv;
const flags: Record<string, string | boolean> = {};
const args: string[] = [];
for (let i = 0; i < rest.length; i++) {
const token = rest[i];
if (!token) continue;
if (token.startsWith('--')) {
const [name, inlineValue] = token.slice(2).split('=', 2);
if (!name) continue;
if (inlineValue !== undefined) {
flags[name] = inlineValue;
continue;
}
const next = rest[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
continue;
}
flags[name] = true;
continue;
}
args.push(token);
}
return { cmd, args, flags };
}
function jsonOut(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function printHelp(): void {
process.stdout.write(
[
'envsitter: safely inspect and match .env secrets without exposing values',
'',
'Commands:',
' keys --file <path> [--filter-regex <re>]',
' fingerprint --file <path> --key <KEY>',
' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) (--candidate <value> | --candidate-stdin)',
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
'',
'Pepper options:',
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
'',
'Notes:',
' Candidate values passed via argv may end up in shell history. Prefer --candidate-stdin.',
''
].join('\n')
);
}
function getPepperOptions(flags: Record<string, string | boolean>): { pepperFilePath?: string } | undefined {
const pepperFile = flags['pepper-file'];
if (typeof pepperFile === 'string' && pepperFile.length > 0) {
return { pepperFilePath: pepperFile };
}
return undefined;
}
function pepperMatchOptions(pepperFilePath: string | undefined): { pepper?: { pepperFilePath: string } } {
if (pepperFilePath) return { pepper: { pepperFilePath } };
return {};
}
async function run(): Promise<number> {
const { cmd, flags } = parseArgs(process.argv.slice(2));
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
printHelp();
return 0;
}
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const pepper = getPepperOptions(flags);
const envsitter = EnvSitter.fromDotenvFile(file);
if (cmd === 'keys') {
const filterRegexRaw = typeof flags['filter-regex'] === 'string' ? flags['filter-regex'] : undefined;
const filter = filterRegexRaw ? parseRegex(filterRegexRaw) : undefined;
const keys = await envsitter.listKeys(filter ? { filter } : {});
if (flags['json'] === true) jsonOut({ keys });
else process.stdout.write(`${keys.join('\n')}\n`);
return 0;
}
if (cmd === 'fingerprint') {
const key = requireValue(typeof flags['key'] === 'string' ? flags['key'] : undefined, '--key is required');
const fp = await envsitter.fingerprintKey(key, pepperMatchOptions(pepper?.pepperFilePath));
jsonOut(fp);
return 0;
}
if (cmd === 'match') {
const candidateArg = typeof flags['candidate'] === 'string' ? flags['candidate'] : undefined;
const candidate = flags['candidate-stdin'] === true ? (await readStdinText()).trimEnd() : candidateArg;
const candidateValue = requireValue(candidate, 'Provide --candidate or --candidate-stdin');
const pepperOptions = pepperMatchOptions(pepper?.pepperFilePath);
const key = typeof flags['key'] === 'string' ? flags['key'] : undefined;
const keysCsv = typeof flags['keys'] === 'string' ? flags['keys'] : undefined;
const allKeys = flags['all-keys'] === true;
if (key) {
const match = await envsitter.matchCandidate(key, candidateValue, pepperOptions);
if (flags['json'] === true) jsonOut({ key, match });
return match ? 0 : 1;
}
if (keysCsv) {
const keys = parseList(keysCsv);
const results = await envsitter.matchCandidateBulk(keys, candidateValue, pepperOptions);
if (flags['json'] === true) jsonOut({ matches: results });
return results.some((r) => r.match) ? 0 : 1;
}
if (allKeys) {
const results = await envsitter.matchCandidateAll(candidateValue, pepperOptions);
if (flags['json'] === true) jsonOut({ matches: results });
return results.some((r) => r.match) ? 0 : 1;
}
throw new Error('Provide --key, --keys, or --all-keys');
}
if (cmd === 'match-by-key') {
const candidatesJson = typeof flags['candidates-json'] === 'string' ? flags['candidates-json'] : undefined;
const candidatesStdin = flags['candidates-stdin'] === true ? (await readStdinText()).trim() : undefined;
const raw = candidatesJson ?? candidatesStdin;
const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
let candidates: Record<string, string>;
try {
candidates = JSON.parse(parsed) as Record<string, string>;
} catch {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
}
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
jsonOut({ matches });
return matches.some((m) => m.match) ? 0 : 1;
}
if (cmd === 'scan') {
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined;
const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined;
const keysFilter = keysRegexRaw ? parseRegex(keysRegexRaw) : undefined;
const detect = detectRaw ? (parseList(detectRaw) as Array<'jwt' | 'url' | 'base64'>) : undefined;
const findings = await envsitter.scan({
...(keysFilter ? { keysFilter } : {}),
...(detect ? { detect } : {})
});
jsonOut({ findings });
return 0;
}
printHelp();
return 2;
}
run()
.then((code) => {
process.exitCode = code;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 2;
});

121
src/dotenv/parse.ts Normal file
View File

@@ -0,0 +1,121 @@
export type DotenvParseError = {
line: number;
message: string;
};
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 values = new Map<string, string>();
const errors: DotenvParseError[] = [];
const lines = contents.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = lines[i] ?? '';
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
const eq = withoutExport.indexOf('=');
if (eq === -1) {
errors.push({ line: lineNumber, message: 'Missing = in assignment' });
continue;
}
const key = parseKey(withoutExport.slice(0, eq));
if (!key) {
errors.push({ line: lineNumber, message: 'Invalid key name' });
continue;
}
const rawValue = withoutExport.slice(eq + 1);
const value = parseValue(rawValue, lineNumber, errors);
values.set(key, value);
}
return { values, errors };
}

8
src/encoding.ts Normal file
View File

@@ -0,0 +1,8 @@
export function base64UrlEncode(bytes: Uint8Array): string {
const base64 = Buffer.from(bytes).toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64DecodeToBytes(base64: string): Uint8Array {
return new Uint8Array(Buffer.from(base64, 'base64'));
}

205
src/envsitter.ts Normal file
View File

@@ -0,0 +1,205 @@
import { timingSafeEqual } from 'node:crypto';
import { base64UrlEncode } from './encoding.js';
import { fingerprintValueHmacSha256 } from './fingerprint.js';
import { resolvePepper, type PepperOptions } from './pepper.js';
import { DotenvFileSource } from './sources/dotenvFile.js';
import { ExternalCommandSource } from './sources/externalCommand.js';
type Snapshot = {
values: ReadonlyMap<string, string>;
};
type Source = {
load(): Promise<Snapshot>;
};
export type EnvSitterFingerprint = {
key: string;
algorithm: 'hmac-sha256';
fingerprint: string;
length: number;
pepperSource: 'env' | 'file';
pepperFilePath?: string;
};
export type EnvSitterKeyMatch = {
key: string;
match: boolean;
};
export type Detection = 'jwt' | 'url' | 'base64';
export type ScanFinding = {
key: string;
detections: Detection[];
};
export type ScanOptions = {
keysFilter?: RegExp;
detect?: readonly Detection[];
};
export type ListKeysOptions = {
filter?: RegExp;
};
export type MatchOptions = {
pepper?: PepperOptions;
};
export class EnvSitter {
private readonly source: Source;
private constructor(source: Source) {
this.source = source;
}
static fromDotenvFile(filePath: string): EnvSitter {
return new EnvSitter(new DotenvFileSource(filePath));
}
static fromExternalCommand(command: string, args: readonly string[] = []): EnvSitter {
return new EnvSitter(new ExternalCommandSource(command, args));
}
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));
}
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
const snapshot = await this.source.load();
const value = snapshot.values.get(key);
if (value === undefined) throw new Error(`Key not found: ${key}`);
const pepper = await resolvePepper(options.pepper);
const fp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
return {
key,
algorithm: fp.algorithm,
fingerprint: base64UrlEncode(fp.digestBytes),
length: value.length,
pepperSource: pepper.source,
...(pepper.pepperFilePath ? { pepperFilePath: pepper.pepperFilePath } : {})
};
}
async matchCandidate(key: string, candidate: string, options: MatchOptions = {}): Promise<boolean> {
const snapshot = await this.source.load();
const value = snapshot.values.get(key);
if (value === undefined) return false;
const pepper = await resolvePepper(options.pepper);
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes);
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
const a = Buffer.from(candidateFp.digestBytes);
const b = Buffer.from(valueFp.digestBytes);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
const snapshot = await this.source.load();
const pepper = await resolvePepper(options.pepper);
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes);
const candidateBuf = Buffer.from(candidateFp.digestBytes);
const results: EnvSitterKeyMatch[] = [];
for (const key of keys) {
const value = snapshot.values.get(key);
if (value === undefined) {
results.push({ key, match: false });
continue;
}
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
const valueBuf = Buffer.from(valueFp.digestBytes);
const match = valueBuf.length === candidateBuf.length && timingSafeEqual(valueBuf, candidateBuf);
results.push({ key, match });
}
return results;
}
async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
const keys = await this.listKeys();
return this.matchCandidateBulk(keys, candidate, options);
}
async matchCandidatesByKey(candidatesByKey: Record<string, string>, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
const snapshot = await this.source.load();
const pepper = await resolvePepper(options.pepper);
const results: EnvSitterKeyMatch[] = [];
for (const [key, candidate] of Object.entries(candidatesByKey)) {
const value = snapshot.values.get(key);
if (value === undefined) {
results.push({ key, match: false });
continue;
}
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes);
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
const a = Buffer.from(candidateFp.digestBytes);
const b = Buffer.from(valueFp.digestBytes);
const match = a.length === b.length && timingSafeEqual(a, b);
results.push({ key, match });
}
return results;
}
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;
const detections: Detection[] = [];
for (const kind of detectionsToRun) {
if (kind === 'jwt' && looksLikeJwt(value)) detections.push('jwt');
else if (kind === 'url' && looksLikeUrl(value)) detections.push('url');
else if (kind === 'base64' && looksLikeBase64(value)) detections.push('base64');
}
if (detections.length > 0) findings.push({ key, detections });
}
return findings;
}
}
function looksLikeJwt(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 3) return false;
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 0);
}
function looksLikeUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function looksLikeBase64(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (!/^[A-Za-z0-9+/=]+$/.test(trimmed)) return false;
if (trimmed.length % 4 !== 0) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
return decoded.length > 0;
} catch {
return false;
}
}

14
src/fingerprint.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createHmac } from 'node:crypto';
export type FingerprintAlgorithm = 'hmac-sha256';
export type FingerprintResult = {
algorithm: FingerprintAlgorithm;
digestBytes: Uint8Array;
};
export function fingerprintValueHmacSha256(value: string, pepperBytes: Uint8Array): FingerprintResult {
const hmac = createHmac('sha256', Buffer.from(pepperBytes));
hmac.update(value, 'utf8');
return { algorithm: 'hmac-sha256', digestBytes: new Uint8Array(hmac.digest()) };
}

12
src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
EnvSitter,
type Detection,
type EnvSitterFingerprint,
type EnvSitterKeyMatch,
type ListKeysOptions,
type MatchOptions,
type ScanFinding,
type ScanOptions
} from './envsitter.js';
export { type PepperOptions, resolvePepper } from './pepper.js';

72
src/pepper.ts Normal file
View File

@@ -0,0 +1,72 @@
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export type PepperOptions = {
envVarNames?: string[];
pepperFilePath?: string;
createIfMissing?: boolean;
};
export type PepperResult = {
pepperBytes: Uint8Array;
source: 'env' | 'file';
pepperFilePath?: string;
};
function defaultPepperFilePath(): string {
return join(process.cwd(), '.envsitter', 'pepper');
}
function getPepperFromEnv(envVarNames: string[]): string | undefined {
for (const name of envVarNames) {
const value = process.env[name];
if (value && value.length > 0) return value;
}
return undefined;
}
function parsePepperFileContentToBytes(content: string): Uint8Array {
const trimmed = content.trim();
if (!trimmed) throw new Error('Pepper file is empty');
const decoded = Buffer.from(trimmed, 'base64');
if (decoded.length < 16) throw new Error('Pepper file content is too short');
return new Uint8Array(decoded);
}
export async function resolvePepper(options: PepperOptions = {}): Promise<PepperResult> {
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
const pepperFromEnv = getPepperFromEnv(envVarNames);
if (pepperFromEnv !== undefined) {
return {
pepperBytes: new TextEncoder().encode(pepperFromEnv),
source: 'env'
};
}
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
const createIfMissing = options.createIfMissing ?? true;
try {
const content = await readFile(pepperFilePath, 'utf8');
return { pepperBytes: parsePepperFileContentToBytes(content), source: 'file', pepperFilePath };
} catch (error) {
if (!createIfMissing) throw error;
const dir = dirname(pepperFilePath);
await mkdir(dir, { recursive: true });
const pepper = randomBytes(32);
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600 });
try {
await chmod(pepperFilePath, 0o600);
} catch {
if (process.env.ENVSITTER_DEBUG === '1') {
console.error(`envsitter: could not set permissions on ${pepperFilePath}`);
}
}
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
}
}

32
src/sources/dotenvFile.ts Normal file
View File

@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises';
import { parseDotenv } from '../dotenv/parse.js';
export type DotenvFileSourceOptions = {
allowErrors?: boolean;
};
type Snapshot = {
values: ReadonlyMap<string, string>;
};
export class DotenvFileSource {
readonly filePath: string;
readonly options: DotenvFileSourceOptions;
constructor(filePath: string, options: DotenvFileSourceOptions = {}) {
this.filePath = filePath;
this.options = options;
}
async load(): Promise<Snapshot> {
const contents = await readFile(this.filePath, 'utf8');
const parsed = parseDotenv(contents);
if (parsed.errors.length > 0 && !this.options.allowErrors) {
const message = parsed.errors.map((e) => `L${e.line}: ${e.message}`).join(', ');
throw new Error(`Invalid dotenv file: ${message}`);
}
return { values: parsed.values };
}
}

View File

@@ -0,0 +1,45 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { parseDotenv } from '../dotenv/parse.js';
const execFileAsync = promisify(execFile);
type Snapshot = {
values: ReadonlyMap<string, string>;
};
export type ExternalCommandSourceOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
allowErrors?: boolean;
};
export class ExternalCommandSource {
readonly command: string;
readonly args: readonly string[];
readonly options: ExternalCommandSourceOptions;
constructor(command: string, args: readonly string[] = [], options: ExternalCommandSourceOptions = {}) {
this.command = command;
this.args = args;
this.options = options;
}
async load(): Promise<Snapshot> {
const { stdout } = await execFileAsync(this.command, [...this.args], {
cwd: this.options.cwd,
env: this.options.env,
timeout: this.options.timeoutMs
});
const parsed = parseDotenv(stdout);
if (parsed.errors.length > 0 && !this.options.allowErrors) {
const message = parsed.errors.map((e) => `L${e.line}: ${e.message}`).join(', ');
throw new Error(`Invalid dotenv content from external command: ${message}`);
}
return { values: parsed.values };
}
}

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseDotenv } from '../dotenv/parse.js';
test('parseDotenv parses basic assignments and ignores comments', () => {
const input = [
'# comment',
'FOO=bar',
'export BAZ=qux',
'EMPTY=',
'TRAILING=ok # inline',
''
].join('\n');
const parsed = parseDotenv(input);
assert.equal(parsed.errors.length, 0);
assert.equal(parsed.values.get('FOO'), 'bar');
assert.equal(parsed.values.get('BAZ'), 'qux');
assert.equal(parsed.values.get('EMPTY'), '');
assert.equal(parsed.values.get('TRAILING'), 'ok');
});
test('parseDotenv supports quoted values', () => {
const input = [
"SINGLE='a b c'",
'DOUBLE="a\\n\\t\\r\\\\b"'
].join('\n');
const parsed = parseDotenv(input);
assert.equal(parsed.errors.length, 0);
assert.equal(parsed.values.get('SINGLE'), 'a b c');
assert.equal(parsed.values.get('DOUBLE'), 'a\n\t\r\\b');
});
test('parseDotenv reports invalid keys', () => {
const input = 'NOT-OK=value\nOK=value2';
const parsed = parseDotenv(input);
assert.ok(parsed.errors.length >= 1);
assert.equal(parsed.values.get('OK'), 'value2');
});

View File

@@ -0,0 +1,65 @@
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 '../envsitter.js';
async function makeTempDotenv(contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const filePath = join(dir, '.env');
await writeFile(filePath, contents, 'utf8');
return filePath;
}
test('EnvSitter lists keys and fingerprints without returning values', async () => {
const filePath = await makeTempDotenv('A=1\nB=two\n');
const es = EnvSitter.fromDotenvFile(filePath);
const keys = await es.listKeys();
assert.deepEqual(keys, ['A', 'B']);
const fp = await es.fingerprintKey('B');
assert.equal(fp.key, 'B');
assert.equal(fp.algorithm, 'hmac-sha256');
assert.equal(fp.length, 3);
assert.ok(fp.fingerprint.length > 10);
});
test('EnvSitter matches a candidate for a key (outside-in)', async () => {
const filePath = await makeTempDotenv('OPENAI_API_KEY=sk-test-123\n');
const es = EnvSitter.fromDotenvFile(filePath);
assert.equal(await es.matchCandidate('OPENAI_API_KEY', 'sk-test-123'), true);
assert.equal(await es.matchCandidate('OPENAI_API_KEY', 'nope'), false);
assert.equal(await es.matchCandidate('MISSING', 'sk-test-123'), false);
});
test('EnvSitter bulk matching works across keys and by-key candidates', async () => {
const filePath = await makeTempDotenv('K1=V1\nK2=V2\n');
const es = EnvSitter.fromDotenvFile(filePath);
const bulk = await es.matchCandidateBulk(['K1', 'K2'], 'V2');
assert.deepEqual(bulk, [
{ key: 'K1', match: false },
{ key: 'K2', match: true }
]);
const byKey = await es.matchCandidatesByKey({ K1: 'V1', K2: 'nope' });
assert.deepEqual(byKey, [
{ key: 'K1', match: true },
{ key: 'K2', match: false }
]);
});
test('EnvSitter scan detects JWT-like and URL values without exposing them', async () => {
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.sgn';
const filePath = await makeTempDotenv(`JWT=${jwt}\nURL=https://example.com\nNOISE=hello\n`);
const es = EnvSitter.fromDotenvFile(filePath);
const findings = await es.scan({ detect: ['jwt', 'url', 'base64'] });
assert.deepEqual(findings, [
{ key: 'JWT', detections: ['jwt'] },
{ key: 'URL', detections: ['url'] }
]);
});

33
src/test/pepper.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolvePepper } from '../pepper.js';
test('resolvePepper reads from env when set', async () => {
const prev = process.env.ENVSITTER_PEPPER;
process.env.ENVSITTER_PEPPER = 'unit-test-pepper';
try {
const pepper = await resolvePepper({ createIfMissing: false });
assert.equal(pepper.source, 'env');
assert.equal(new TextDecoder().decode(pepper.pepperBytes), 'unit-test-pepper');
} finally {
if (prev === undefined) delete process.env.ENVSITTER_PEPPER;
else process.env.ENVSITTER_PEPPER = prev;
}
});
test('resolvePepper creates a pepper file when missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const pepperPath = join(dir, 'pepper');
const pepper = await resolvePepper({ pepperFilePath: pepperPath });
assert.equal(pepper.source, 'file');
assert.equal(pepper.pepperFilePath, pepperPath);
assert.ok(pepper.pepperBytes.length >= 16);
const persisted = (await readFile(pepperPath, 'utf8')).trim();
assert.ok(persisted.length > 0);
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"]
}