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

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;
}
}