commit 399374684390945670cb73f4ba4bc9976591c3b5 Author: David Ibia Date: Mon Jan 12 10:30:49 2026 +0100 Initial envsitter CLI and safe env matching diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..756ce99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Node / Bun +node_modules/ +dist/ + +# Local peppers / secrets (never commit) +.env +.env.* +.envsitter/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fefe2f7 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c6f6afb --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..cf1d024 --- /dev/null +++ b/scripts/run-tests.mjs @@ -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; + }); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8e82932 --- /dev/null +++ b/src/cli.ts @@ -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 { + 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(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 } { + const [cmd = 'help', ...rest] = argv; + const flags: Record = {}; + 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 [--filter-regex ]', + ' fingerprint --file --key ', + ' match --file (--key | --keys | --all-keys) (--candidate | --candidate-stdin)', + ' match-by-key --file (--candidates-json | --candidates-stdin)', + ' scan --file [--keys-regex ] [--detect jwt,url,base64]', + '', + 'Pepper options:', + ' --pepper-file 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): { 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 { + 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; + try { + candidates = JSON.parse(parsed) as Record; + } 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; + }); diff --git a/src/dotenv/parse.ts b/src/dotenv/parse.ts new file mode 100644 index 0000000..4c933a2 --- /dev/null +++ b/src/dotenv/parse.ts @@ -0,0 +1,121 @@ +export type DotenvParseError = { + line: number; + message: string; +}; + +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 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); + } + + return { values, errors }; +} diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..aa1a933 --- /dev/null +++ b/src/encoding.ts @@ -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')); +} diff --git a/src/envsitter.ts b/src/envsitter.ts new file mode 100644 index 0000000..c68916b --- /dev/null +++ b/src/envsitter.ts @@ -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; +}; + +type Source = { + load(): Promise; +}; + +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 { + 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 { + 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 { + 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 { + 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 { + const keys = await this.listKeys(); + return this.matchCandidateBulk(keys, candidate, options); + } + + async matchCandidatesByKey(candidatesByKey: Record, options: MatchOptions = {}): Promise { + 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 { + 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; + } +} diff --git a/src/fingerprint.ts b/src/fingerprint.ts new file mode 100644 index 0000000..5cb5215 --- /dev/null +++ b/src/fingerprint.ts @@ -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()) }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9262e06 --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/pepper.ts b/src/pepper.ts new file mode 100644 index 0000000..3f04c30 --- /dev/null +++ b/src/pepper.ts @@ -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 { + 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 }; + } +} diff --git a/src/sources/dotenvFile.ts b/src/sources/dotenvFile.ts new file mode 100644 index 0000000..c726a9e --- /dev/null +++ b/src/sources/dotenvFile.ts @@ -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; +}; + +export class DotenvFileSource { + readonly filePath: string; + readonly options: DotenvFileSourceOptions; + + constructor(filePath: string, options: DotenvFileSourceOptions = {}) { + this.filePath = filePath; + this.options = options; + } + + async load(): Promise { + 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 }; + } +} diff --git a/src/sources/externalCommand.ts b/src/sources/externalCommand.ts new file mode 100644 index 0000000..a9d548b --- /dev/null +++ b/src/sources/externalCommand.ts @@ -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; +}; + +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 { + 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 }; + } +} diff --git a/src/test/dotenv-parse.test.ts b/src/test/dotenv-parse.test.ts new file mode 100644 index 0000000..a1bae61 --- /dev/null +++ b/src/test/dotenv-parse.test.ts @@ -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'); +}); diff --git a/src/test/envsitter.test.ts b/src/test/envsitter.test.ts new file mode 100644 index 0000000..fc71fcc --- /dev/null +++ b/src/test/envsitter.test.ts @@ -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 { + 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'] } + ]); +}); diff --git a/src/test/pepper.test.ts b/src/test/pepper.test.ts new file mode 100644 index 0000000..4e95cc6 --- /dev/null +++ b/src/test/pepper.test.ts @@ -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); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4d13a8 --- /dev/null +++ b/tsconfig.json @@ -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"] +}