Add match operators for key checks

This commit is contained in:
David Ibia
2026-01-12 18:14:33 +01:00
parent 7933e9294f
commit 4568f9d421
5 changed files with 271 additions and 29 deletions

View File

@@ -81,6 +81,62 @@ Exit codes:
- `1` no match - `1` no match
- `2` error/usage - `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 ### Match one candidate against multiple keys
```bash ```bash

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { EnvSitter } from './envsitter.js'; import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
type PepperCliOptions = { type PepperCliOptions = {
pepperFile?: string; pepperFile?: string;
@@ -42,6 +42,35 @@ function requireValue<T>(value: T | undefined, message: string): T {
return value; 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<string, string | boolean> } { function parseArgs(argv: string[]): { cmd: string; args: string[]; flags: Record<string, string | boolean> } {
const [cmd = 'help', ...rest] = argv; const [cmd = 'help', ...rest] = argv;
const flags: Record<string, string | boolean> = {}; const flags: Record<string, string | boolean> = {};
@@ -89,7 +118,7 @@ function printHelp(): void {
'Commands:', 'Commands:',
' keys --file <path> [--filter-regex <re>]', ' keys --file <path> [--filter-regex <re>]',
' fingerprint --file <path> --key <KEY>', ' fingerprint --file <path> --key <KEY>',
' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) (--candidate <value> | --candidate-stdin)', ' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]',
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)', ' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]', ' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
'', '',
@@ -97,6 +126,7 @@ function printHelp(): void {
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)', ' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
'', '',
'Notes:', '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.', ' Candidate values passed via argv may end up in shell history. Prefer --candidate-stdin.',
'' ''
].join('\n') ].join('\n')
@@ -146,32 +176,37 @@ async function run(): Promise<number> {
} }
if (cmd === 'match') { if (cmd === 'match') {
const candidateArg = typeof flags['candidate'] === 'string' ? flags['candidate'] : undefined; const op = typeof flags['op'] === 'string' ? flags['op'] : 'is_equal';
const candidate = flags['candidate-stdin'] === true ? (await readStdinText()).trimEnd() : candidateArg;
const candidateValue = requireValue(candidate, 'Provide --candidate or --candidate-stdin');
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 pepperOptions = pepperMatchOptions(pepper?.pepperFilePath);
const key = typeof flags['key'] === 'string' ? flags['key'] : undefined; const key = typeof flags['key'] === 'string' ? flags['key'] : undefined;
const keysCsv = typeof flags['keys'] === 'string' ? flags['keys'] : undefined; const keysCsv = typeof flags['keys'] === 'string' ? flags['keys'] : undefined;
const allKeys = flags['all-keys'] === true; const allKeys = flags['all-keys'] === true;
const includeOp = typeof flags['op'] === 'string';
if (key) { if (key) {
const match = await envsitter.matchCandidate(key, candidateValue, pepperOptions); const match = await envsitter.matchKey(key, matcher, pepperOptions);
if (flags['json'] === true) jsonOut({ key, match }); if (flags['json'] === true) jsonOut(includeOp ? { key, op: matcher.op, match } : { key, match });
return match ? 0 : 1; return match ? 0 : 1;
} }
if (keysCsv) { if (keysCsv) {
const keys = parseList(keysCsv); const keys = parseList(keysCsv);
const results = await envsitter.matchCandidateBulk(keys, candidateValue, pepperOptions); const results = await envsitter.matchKeyBulk(keys, matcher, pepperOptions);
if (flags['json'] === true) jsonOut({ matches: results }); if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
return results.some((r) => r.match) ? 0 : 1; return results.some((r) => r.match) ? 0 : 1;
} }
if (allKeys) { if (allKeys) {
const results = await envsitter.matchCandidateAll(candidateValue, pepperOptions); const results = await envsitter.matchKeyAll(matcher, pepperOptions);
if (flags['json'] === true) jsonOut({ matches: results }); if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
return results.some((r) => r.match) ? 0 : 1; return results.some((r) => r.match) ? 0 : 1;
} }

View File

@@ -27,6 +27,17 @@ export type EnvSitterKeyMatch = {
match: boolean; 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 Detection = 'jwt' | 'url' | 'base64';
export type ScanFinding = { export type ScanFinding = {
@@ -87,48 +98,92 @@ export class EnvSitter {
}; };
} }
async matchCandidate(key: string, candidate: string, options: MatchOptions = {}): Promise<boolean> { async matchKey(key: string, matcher: EnvSitterMatcher, options: MatchOptions = {}): Promise<boolean> {
const snapshot = await this.source.load(); const snapshot = await this.source.load();
if (matcher.op === 'exists') return snapshot.values.has(key);
const value = snapshot.values.get(key); const value = snapshot.values.get(key);
if (value === undefined) return false; if (value === undefined) return false;
const pepper = await resolvePepper(options.pepper); if (matcher.op === 'is_equal') {
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes); const pepper = await resolvePepper(options.pepper);
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes); const candidateFp = fingerprintValueHmacSha256(matcher.candidate, pepper.pepperBytes);
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
const a = Buffer.from(candidateFp.digestBytes); const a = Buffer.from(candidateFp.digestBytes);
const b = Buffer.from(valueFp.digestBytes); const b = Buffer.from(valueFp.digestBytes);
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return timingSafeEqual(a, b); return timingSafeEqual(a, b);
}
return matchValue(value, matcher);
} }
async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> { async matchKeyBulk(
keys: readonly string[],
matcher: EnvSitterMatcher,
options: MatchOptions = {}
): Promise<EnvSitterKeyMatch[]> {
const snapshot = await this.source.load(); const snapshot = await this.source.load();
const pepper = await resolvePepper(options.pepper);
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes); if (matcher.op === 'is_equal') {
const candidateBuf = Buffer.from(candidateFp.digestBytes); 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[] = []; const results: EnvSitterKeyMatch[] = [];
for (const key of keys) { for (const key of keys) {
if (matcher.op === 'exists') {
results.push({ key, match: snapshot.values.has(key) });
continue;
}
const value = snapshot.values.get(key); const value = snapshot.values.get(key);
if (value === undefined) { if (value === undefined) {
results.push({ key, match: false }); results.push({ key, match: false });
continue; continue;
} }
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes); results.push({ key, match: matchValue(value, matcher) });
const valueBuf = Buffer.from(valueFp.digestBytes);
const match = valueBuf.length === candidateBuf.length && timingSafeEqual(valueBuf, candidateBuf);
results.push({ key, match });
} }
return results; return results;
} }
async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> { async matchKeyAll(matcher: EnvSitterMatcher, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
const keys = await this.listKeys(); 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<boolean> {
return this.matchKey(key, { op: 'is_equal', candidate }, options);
}
async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
return this.matchKeyBulk(keys, { op: 'is_equal', candidate }, options);
}
async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
return this.matchKeyAll({ op: 'is_equal', candidate }, options);
} }
async matchCandidatesByKey(candidatesByKey: Record<string, string>, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> { async matchCandidatesByKey(candidatesByKey: Record<string, string>, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
@@ -176,6 +231,35 @@ export class EnvSitter {
} }
} }
function matchValue(value: string, matcher: Exclude<EnvSitterMatcher, { op: 'exists' } | { op: 'is_equal'; candidate: string }>): 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 { function looksLikeJwt(value: string): boolean {
const parts = value.split('.'); const parts = value.split('.');
if (parts.length !== 3) return false; if (parts.length !== 3) return false;

View File

@@ -3,6 +3,7 @@ export {
type Detection, type Detection,
type EnvSitterFingerprint, type EnvSitterFingerprint,
type EnvSitterKeyMatch, type EnvSitterKeyMatch,
type EnvSitterMatcher,
type ListKeysOptions, type ListKeysOptions,
type MatchOptions, type MatchOptions,
type ScanFinding, type ScanFinding,

View File

@@ -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 () => { test('EnvSitter scan detects JWT-like and URL values without exposing them', async () => {
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.sgn'; const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.sgn';
const filePath = await makeTempDotenv(`JWT=${jwt}\nURL=https://example.com\nNOISE=hello\n`); const filePath = await makeTempDotenv(`JWT=${jwt}\nURL=https://example.com\nNOISE=hello\n`);