Initial envsitter CLI and safe env matching
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
51
package-lock.json
generated
Normal 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
29
package.json
Normal 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
21
scripts/run-tests.mjs
Normal 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
227
src/cli.ts
Normal 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
121
src/dotenv/parse.ts
Normal 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
8
src/encoding.ts
Normal 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
205
src/envsitter.ts
Normal 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
14
src/fingerprint.ts
Normal 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
12
src/index.ts
Normal 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
72
src/pepper.ts
Normal 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
32
src/sources/dotenvFile.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
45
src/sources/externalCommand.ts
Normal file
45
src/sources/externalCommand.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
40
src/test/dotenv-parse.test.ts
Normal file
40
src/test/dotenv-parse.test.ts
Normal 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');
|
||||
});
|
||||
65
src/test/envsitter.test.ts
Normal file
65
src/test/envsitter.test.ts
Normal 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
33
src/test/pepper.test.ts
Normal 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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user