Add match operators for key checks
This commit is contained in:
56
README.md
56
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
|
||||
|
||||
57
src/cli.ts
57
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<T>(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<string, string | boolean> } {
|
||||
const [cmd = 'help', ...rest] = argv;
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
@@ -89,7 +118,7 @@ function printHelp(): void {
|
||||
'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 --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)',
|
||||
' 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)',
|
||||
'',
|
||||
'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<number> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +98,17 @@ 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();
|
||||
|
||||
if (matcher.op === 'exists') return snapshot.values.has(key);
|
||||
|
||||
const value = snapshot.values.get(key);
|
||||
if (value === undefined) return false;
|
||||
|
||||
if (matcher.op === 'is_equal') {
|
||||
const pepper = await resolvePepper(options.pepper);
|
||||
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes);
|
||||
const candidateFp = fingerprintValueHmacSha256(matcher.candidate, pepper.pepperBytes);
|
||||
const valueFp = fingerprintValueHmacSha256(value, pepper.pepperBytes);
|
||||
|
||||
const a = Buffer.from(candidateFp.digestBytes);
|
||||
@@ -103,10 +118,19 @@ export class EnvSitter {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
async matchCandidateBulk(keys: readonly string[], candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
|
||||
return matchValue(value, matcher);
|
||||
}
|
||||
|
||||
async matchKeyBulk(
|
||||
keys: readonly string[],
|
||||
matcher: EnvSitterMatcher,
|
||||
options: MatchOptions = {}
|
||||
): Promise<EnvSitterKeyMatch[]> {
|
||||
const snapshot = await this.source.load();
|
||||
|
||||
if (matcher.op === 'is_equal') {
|
||||
const pepper = await resolvePepper(options.pepper);
|
||||
const candidateFp = fingerprintValueHmacSha256(candidate, pepper.pepperBytes);
|
||||
const candidateFp = fingerprintValueHmacSha256(matcher.candidate, pepper.pepperBytes);
|
||||
const candidateBuf = Buffer.from(candidateFp.digestBytes);
|
||||
|
||||
const results: EnvSitterKeyMatch[] = [];
|
||||
@@ -126,9 +150,40 @@ export class EnvSitter {
|
||||
return results;
|
||||
}
|
||||
|
||||
async matchCandidateAll(candidate: string, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
results.push({ key, match: matchValue(value, matcher) });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async matchKeyAll(matcher: EnvSitterMatcher, options: MatchOptions = {}): Promise<EnvSitterKeyMatch[]> {
|
||||
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[]> {
|
||||
@@ -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 {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
type Detection,
|
||||
type EnvSitterFingerprint,
|
||||
type EnvSitterKeyMatch,
|
||||
type EnvSitterMatcher,
|
||||
type ListKeysOptions,
|
||||
type MatchOptions,
|
||||
type ScanFinding,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user