From 4568f9d4218200ebad3bd135e538594ed8d0c0a4 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Mon, 12 Jan 2026 18:14:33 +0100 Subject: [PATCH] Add match operators for key checks --- README.md | 56 +++++++++++++++++ src/cli.ts | 57 ++++++++++++++---- src/envsitter.ts | 120 +++++++++++++++++++++++++++++++------ src/index.ts | 1 + src/test/envsitter.test.ts | 66 ++++++++++++++++++++ 5 files changed, 271 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0935328..fa3ae2e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,62 @@ Exit codes: - `1` no match - `2` error/usage +### Match operators (for humans) + +`envsitter match` supports an `--op` flag. + +- Default: `--op is_equal` +- When `--op is_equal` is used, EnvSitter hashes both the candidate and stored value with the local pepper (HMAC-SHA-256) and compares digests using constant-time equality. +- Other operators evaluate against the raw value in-process, but still only return booleans/match results (no values are returned or printed). + +Operators: + +- `exists`: key is present in the source (no candidate required) +- `is_empty`: value is exactly empty string (no candidate required) +- `is_equal`: deterministic match against a candidate value (candidate required) +- `partial_match_prefix`: `value.startsWith(candidate)` (candidate required) +- `partial_match_suffix`: `value.endsWith(candidate)` (candidate required) +- `partial_match_regex`: regex test against value (candidate required; candidate is a regex like `"/^sk-/"` or a raw regex body) +- `is_number`: value parses as a finite number (no candidate required) +- `is_boolean`: value is `true`/`false` (case-insensitive, whitespace-trimmed) (no candidate required) +- `is_string`: value is neither `is_number` nor `is_boolean` (no candidate required) + +Examples: + +```bash +# Prefix match +node -e "process.stdout.write('sk-')" \ + | envsitter match --file .env --key OPENAI_API_KEY --op partial_match_prefix --candidate-stdin + +# Regex match (regex literal syntax) +node -e "process.stdout.write('/^sk-[a-z]+-/i')" \ + | envsitter match --file .env --key OPENAI_API_KEY --op partial_match_regex --candidate-stdin + +# Exists (no candidate) +envsitter match --file .env --key OPENAI_API_KEY --op exists --json +``` + +### Output contract (for LLMs) + +General rules: + +- Never output secret values; treat all values as sensitive. +- Prefer `--candidate-stdin` over `--candidate` to avoid shell history. +- Exit codes: `0` match found, `1` no match, `2` error/usage. + +JSON outputs: + +- `keys --json` -> `{ "keys": string[] }` +- `fingerprint` -> `{ "key": string, "algorithm": "hmac-sha256", "fingerprint": string, "length": number, "pepperSource": "env"|"file", "pepperFilePath"?: string }` +- `match --json` (single key) -> + - default op (not provided): `{ "key": string, "match": boolean }` + - with `--op`: `{ "key": string, "op": string, "match": boolean }` +- `match --json` (bulk keys / all keys) -> + - default op (not provided): `{ "matches": Array<{ "key": string, "match": boolean }> }` + - with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }` +- `match-by-key --json` -> `{ "matches": Array<{ "key": string, "match": boolean }> }` +- `scan --json` -> `{ "findings": Array<{ "key": string, "detections": Array<"jwt"|"url"|"base64"> }> }` + ### Match one candidate against multiple keys ```bash diff --git a/src/cli.ts b/src/cli.ts index 8e82932..b07b488 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { EnvSitter } from './envsitter.js'; +import { EnvSitter, type EnvSitterMatcher } from './envsitter.js'; type PepperCliOptions = { pepperFile?: string; @@ -42,6 +42,35 @@ function requireValue(value: T | undefined, message: string): T { return value; } +function parseMatcher(op: string, candidate: string | undefined): EnvSitterMatcher { + if (op === 'exists') return { op: 'exists' }; + if (op === 'is_empty') return { op: 'is_empty' }; + if (op === 'is_number') return { op: 'is_number' }; + if (op === 'is_string') return { op: 'is_string' }; + if (op === 'is_boolean') return { op: 'is_boolean' }; + + if (op === 'is_equal') { + return { op: 'is_equal', candidate: requireValue(candidate, 'Provide --candidate or --candidate-stdin') }; + } + + if (op === 'partial_match_prefix') { + return { op: 'partial_match_prefix', prefix: requireValue(candidate, 'Provide --candidate or --candidate-stdin') }; + } + + if (op === 'partial_match_suffix') { + return { op: 'partial_match_suffix', suffix: requireValue(candidate, 'Provide --candidate or --candidate-stdin') }; + } + + if (op === 'partial_match_regex') { + const raw = requireValue(candidate, 'Provide --candidate or --candidate-stdin'); + return { op: 'partial_match_regex', regex: parseRegex(raw) }; + } + + throw new Error( + `Unknown --op: ${op}. Expected one of: exists,is_empty,is_equal,partial_match_regex,partial_match_prefix,partial_match_suffix,is_number,is_string,is_boolean` + ); +} + function parseArgs(argv: string[]): { cmd: string; args: string[]; flags: Record } { const [cmd = 'help', ...rest] = argv; const flags: Record = {}; @@ -89,7 +118,7 @@ function printHelp(): void { 'Commands:', ' keys --file [--filter-regex ]', ' fingerprint --file --key ', - ' match --file (--key | --keys | --all-keys) (--candidate | --candidate-stdin)', + ' match --file (--key | --keys | --all-keys) [--op ] [--candidate | --candidate-stdin]', ' match-by-key --file (--candidates-json | --candidates-stdin)', ' scan --file [--keys-regex ] [--detect jwt,url,base64]', '', @@ -97,6 +126,7 @@ function printHelp(): void { ' --pepper-file Defaults to .envsitter/pepper (auto-created)', '', 'Notes:', + ' match --op defaults to is_equal. Ops: exists,is_empty,is_equal,partial_match_regex,partial_match_prefix,partial_match_suffix,is_number,is_string,is_boolean', ' Candidate values passed via argv may end up in shell history. Prefer --candidate-stdin.', '' ].join('\n') @@ -146,32 +176,37 @@ async function run(): Promise { } 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 op = typeof flags['op'] === 'string' ? flags['op'] : 'is_equal'; + const candidateArg = typeof flags['candidate'] === 'string' ? flags['candidate'] : undefined; + const candidateStdin = flags['candidate-stdin'] === true ? (await readStdinText()).trimEnd() : undefined; + const candidate = candidateStdin ?? candidateArg; + + const matcher = parseMatcher(op, candidate); 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; + const includeOp = typeof flags['op'] === 'string'; + if (key) { - const match = await envsitter.matchCandidate(key, candidateValue, pepperOptions); - if (flags['json'] === true) jsonOut({ key, match }); + const match = await envsitter.matchKey(key, matcher, pepperOptions); + if (flags['json'] === true) jsonOut(includeOp ? { key, op: matcher.op, match } : { 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 }); + const results = await envsitter.matchKeyBulk(keys, matcher, pepperOptions); + if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { 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 }); + const results = await envsitter.matchKeyAll(matcher, pepperOptions); + if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results }); return results.some((r) => r.match) ? 0 : 1; } diff --git a/src/envsitter.ts b/src/envsitter.ts index c68916b..b248f9a 100644 --- a/src/envsitter.ts +++ b/src/envsitter.ts @@ -27,6 +27,17 @@ export type EnvSitterKeyMatch = { match: boolean; }; +export type EnvSitterMatcher = + | { op: 'exists' } + | { op: 'is_empty' } + | { op: 'is_equal'; candidate: string } + | { op: 'partial_match_regex'; regex: RegExp } + | { op: 'partial_match_prefix'; prefix: string } + | { op: 'partial_match_suffix'; suffix: string } + | { op: 'is_number' } + | { op: 'is_string' } + | { op: 'is_boolean' }; + export type Detection = 'jwt' | 'url' | 'base64'; export type ScanFinding = { @@ -87,48 +98,92 @@ export class EnvSitter { }; } - async matchCandidate(key: string, candidate: string, options: MatchOptions = {}): Promise { + async matchKey(key: string, matcher: EnvSitterMatcher, options: MatchOptions = {}): Promise { const snapshot = await this.source.load(); + + if (matcher.op === 'exists') return snapshot.values.has(key); + 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); + if (matcher.op === 'is_equal') { + const pepper = await resolvePepper(options.pepper); + const candidateFp = fingerprintValueHmacSha256(matcher.candidate, pepper.pepperBytes); + const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes); - const a = Buffer.from(candidateFp.digestBytes); - const b = Buffer.from(valueFp.digestBytes); + const a = Buffer.from(candidateFp.digestBytes); + const b = Buffer.from(valueFp.digestBytes); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } + + return matchValue(value, matcher); } - async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise { + async matchKeyBulk( + keys: readonly string[], + matcher: EnvSitterMatcher, + 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); + + if (matcher.op === 'is_equal') { + const pepper = await resolvePepper(options.pepper); + const candidateFp = fingerprintValueHmacSha256(matcher.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; + } const results: EnvSitterKeyMatch[] = []; for (const key of keys) { + if (matcher.op === 'exists') { + results.push({ key, match: snapshot.values.has(key) }); + continue; + } + 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 }); + results.push({ key, match: matchValue(value, matcher) }); } return results; } - async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise { + async matchKeyAll(matcher: EnvSitterMatcher, options: MatchOptions = {}): Promise { const keys = await this.listKeys(); - return this.matchCandidateBulk(keys, candidate, options); + return this.matchKeyBulk(keys, matcher, options); + } + + async matchCandidate(key: string, candidate: string, options: MatchOptions = {}): Promise { + return this.matchKey(key, { op: 'is_equal', candidate }, options); + } + + async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise { + return this.matchKeyBulk(keys, { op: 'is_equal', candidate }, options); + } + + async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise { + return this.matchKeyAll({ op: 'is_equal', candidate }, options); } async matchCandidatesByKey(candidatesByKey: Record, options: MatchOptions = {}): Promise { @@ -176,6 +231,35 @@ export class EnvSitter { } } +function matchValue(value: string, matcher: Exclude): boolean { + if (matcher.op === 'is_empty') return value.length === 0; + + if (matcher.op === 'partial_match_prefix') return value.startsWith(matcher.prefix); + if (matcher.op === 'partial_match_suffix') return value.endsWith(matcher.suffix); + if (matcher.op === 'partial_match_regex') return matcher.regex.test(value); + + if (matcher.op === 'is_number') return isNumberLike(value); + if (matcher.op === 'is_boolean') return isBooleanLike(value); + if (matcher.op === 'is_string') return !isNumberLike(value) && !isBooleanLike(value); + + const neverMatcher: never = matcher; + throw new Error(`Unhandled matcher: ${JSON.stringify(neverMatcher)}`); +} + +function isNumberLike(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + + if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) return false; + const n = Number(trimmed); + return Number.isFinite(n); +} + +function isBooleanLike(value: string): boolean { + const trimmed = value.trim().toLowerCase(); + return trimmed === 'true' || trimmed === 'false'; +} + function looksLikeJwt(value: string): boolean { const parts = value.split('.'); if (parts.length !== 3) return false; diff --git a/src/index.ts b/src/index.ts index 9262e06..526d9a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { type Detection, type EnvSitterFingerprint, type EnvSitterKeyMatch, + type EnvSitterMatcher, type ListKeysOptions, type MatchOptions, type ScanFinding, diff --git a/src/test/envsitter.test.ts b/src/test/envsitter.test.ts index fc71fcc..19e0fd2 100644 --- a/src/test/envsitter.test.ts +++ b/src/test/envsitter.test.ts @@ -52,6 +52,72 @@ test('EnvSitter bulk matching works across keys and by-key candidates', async () ]); }); +test('EnvSitter matchKey supports matcher operators', async () => { + const filePath = await makeTempDotenv( + [ + 'EMPTY=', + 'SPACE=" "', + 'TOKEN=sk-test-123', + 'N1=42', + 'N2=3.14', + 'N3= -2 ', + 'B1=true', + 'B2=FALSE', + 'S=hello' + ].join('\n') + '\n' + ); + const es = EnvSitter.fromDotenvFile(filePath); + + assert.equal(await es.matchKey('TOKEN', { op: 'exists' }), true); + assert.equal(await es.matchKey('MISSING', { op: 'exists' }), false); + + assert.equal(await es.matchKey('EMPTY', { op: 'is_empty' }), true); + assert.equal(await es.matchKey('SPACE', { op: 'is_empty' }), false); + + assert.equal(await es.matchKey('TOKEN', { op: 'is_equal', candidate: 'sk-test-123' }), true); + assert.equal(await es.matchKey('TOKEN', { op: 'is_equal', candidate: 'nope' }), false); + + assert.equal(await es.matchKey('TOKEN', { op: 'partial_match_prefix', prefix: 'sk-' }), true); + assert.equal(await es.matchKey('TOKEN', { op: 'partial_match_suffix', suffix: '123' }), true); + assert.equal(await es.matchKey('TOKEN', { op: 'partial_match_regex', regex: /^sk-[a-z]+-\d+$/ }), true); + + assert.equal(await es.matchKey('N1', { op: 'is_number' }), true); + assert.equal(await es.matchKey('N2', { op: 'is_number' }), true); + assert.equal(await es.matchKey('N3', { op: 'is_number' }), true); + assert.equal(await es.matchKey('S', { op: 'is_number' }), false); + + assert.equal(await es.matchKey('B1', { op: 'is_boolean' }), true); + assert.equal(await es.matchKey('B2', { op: 'is_boolean' }), true); + assert.equal(await es.matchKey('N1', { op: 'is_boolean' }), false); + + assert.equal(await es.matchKey('S', { op: 'is_string' }), true); + assert.equal(await es.matchKey('N1', { op: 'is_string' }), false); + assert.equal(await es.matchKey('B1', { op: 'is_string' }), false); +}); + +test('EnvSitter matchKeyBulk supports matcher operators', async () => { + const filePath = await makeTempDotenv('K1=V1\nK2=\nK3=123\n'); + const es = EnvSitter.fromDotenvFile(filePath); + + assert.deepEqual(await es.matchKeyBulk(['K1', 'K2', 'MISSING'], { op: 'exists' }), [ + { key: 'K1', match: true }, + { key: 'K2', match: true }, + { key: 'MISSING', match: false } + ]); + + assert.deepEqual(await es.matchKeyBulk(['K1', 'K2', 'K3'], { op: 'is_empty' }), [ + { key: 'K1', match: false }, + { key: 'K2', match: true }, + { key: 'K3', match: false } + ]); + + assert.deepEqual(await es.matchKeyBulk(['K1', 'K2', 'K3'], { op: 'is_number' }), [ + { key: 'K1', match: false }, + { key: 'K2', match: false }, + { key: 'K3', match: true } + ]); +}); + 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`);