envsitter
v0.0.4
Published
Safely inspect and match .env secrets without exposing values
Maintainers
Readme
envsitter
Safely inspect and match .env secrets without ever printing values.
envsitter is designed for LLM/agent workflows where you want to:
- List keys present in an env source (
.envfile or external provider) - Compare a key’s value to a candidate value you provide at runtime ("outside-in")
- Do bulk matching (one candidate against many keys, or candidates-by-key)
- Produce deterministic fingerprints for comparisons/auditing
- Ask boolean questions about values (empty/prefix/suffix/regex/type-ish checks) without ever returning the value
Related: https://github.com/boxpositron/envsitter-guard — an OpenCode plugin that blocks agents/tools from reading or editing sensitive .env* files (preventing accidental secret leaks), while still allowing safe inspection via EnvSitter-style tools (keys + deterministic fingerprints; never values).
Security model (what this tool does and does not do)
- Values are read in-process for comparisons, but never returned by the library API and never printed by the CLI.
- Deterministic matching uses HMAC-SHA-256 with a local pepper.
- This avoids publishing raw SHA-256 hashes that are easy to dictionary-guess.
- Candidate secrets should be passed via stdin (
--candidate-stdin) to avoid shell history.
Non-goals:
- This tool is not a secret manager.
- This tool does not encrypt or relocate
.envvalues; it operates on sources in-place.
Install
npm install envsitterOr run the CLI without installing globally:
npx envsitter keys --file .envPepper (required for deterministic fingerprints)
envsitter uses a local "pepper" as the HMAC key.
Resolution order:
process.env.ENVSITTER_PEPPER(orENV_SITTER_PEPPER)- Pepper file at
.envsitter/pepper(auto-created if missing)
The pepper file is created with mode 0600 when possible, and .envsitter/ is gitignored.
CLI usage
Quick reference
| Command | Description |
|---------|-------------|
| keys | List all keys in an env file |
| fingerprint | Get deterministic fingerprint for a key |
| match | Match candidate value(s) against key(s) |
| match-by-key | Bulk match candidates by key |
| scan | Detect value shapes (JWT, URL, base64) |
| validate | Check dotenv syntax |
| copy | Copy keys between env files |
| format / reorder | Sort and organize env files |
| annotate | Add comments to keys |
| add | Add new key (fails if exists) |
| set | Create or update key |
| unset | Set key to empty value |
| delete | Remove key(s) from file |
Commands
keys --file <path> [--filter-regex <re>]fingerprint --file <path> --key <KEY>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]validate --file <path>copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]format --file <path> [--mode sections|global] [--sort alpha|none] [--write]reorder --file <path> [--mode sections|global] [--sort alpha|none] [--write]annotate --file <path> --key <KEY> --comment <text> [--line <n>] [--write]add --file <path> --key <KEY> [--value <v> | --value-stdin] [--write]set --file <path> --key <KEY> [--value <v> | --value-stdin] [--write]unset --file <path> --key <KEY> [--write]delete --file <path> (--key <KEY> | --keys <K1,K2>) [--write]
Notes for file operations:
- Commands that modify files (
copy,format/reorder,annotate,add,set,unset,delete) are dry-run unless--writeis provided. - These commands never print secret values; output includes keys, booleans, and line numbers only.
- When targeting example files (
.env.example,.env.sample,.env.template,.env.dist,.env.default), a warning is emitted. Use--no-example-warningto suppress. - Non-standard env file names are fully supported (e.g.,
api.env,database.env,config.env.local). - Values with special characters (spaces,
#, quotes, newlines) are automatically double-quoted with proper escaping.
List keys
envsitter keys --file .envFilter by key name (regex):
envsitter keys --file .env --filter-regex "/(KEY|TOKEN|SECRET)/i"Fingerprint a single key
envsitter fingerprint --file .env --key OPENAI_API_KEYOutputs JSON containing the key’s fingerprint and metadata (never the value).
Match a candidate against a single key (recommended via stdin)
node -e "process.stdout.write('candidate-secret')" \
| envsitter match --file .env --key OPENAI_API_KEY --candidate-stdin --jsonExit codes:
0match found1no match2error/usage
Match operators (for humans)
envsitter match supports an --op flag.
- Default:
--op is_equal - When
--op is_equalis 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 istrue/false(case-insensitive, whitespace-trimmed) (no candidate required)is_string: value is neitheris_numbernoris_boolean(no candidate required)
Examples:
# 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 --jsonMatch one candidate against multiple keys
node -e "process.stdout.write('candidate-secret')" \
| envsitter match --file .env --keys OPENAI_API_KEY,ANTHROPIC_API_KEY --candidate-stdin --jsonMatch one candidate against all keys
node -e "process.stdout.write('candidate-secret')" \
| envsitter match --file .env --all-keys --candidate-stdin --jsonMatch candidates-by-key (bulk assignment)
Provide a JSON object mapping key -> candidate value.
envsitter match-by-key --file .env \
--candidates-json '{"OPENAI_API_KEY":"sk-...","ANTHROPIC_API_KEY":"sk-..."}'For safer input, pass the JSON via stdin:
cat candidates.json | envsitter match-by-key --file .env --candidates-stdinScan for value shapes (no values returned)
envsitter scan --file .env --detect jwt,url,base64Optionally restrict which keys to scan:
envsitter scan --file .env --keys-regex "/(JWT|URL)/" --detect jwt,urlValidate dotenv syntax
envsitter validate --file .env
envsitter validate --file .env --jsonCopy keys between env files (production → staging)
Dry-run (no file is modified):
envsitter copy --from .env.production --to .env.staging --keys API_URL,REDIS_URL --jsonApply changes:
envsitter copy --from .env.production --to .env.staging --keys API_URL,REDIS_URL --on-conflict overwrite --write --jsonRename while copying:
envsitter copy --from .env.production --to .env.staging --keys DATABASE_URL --rename DATABASE_URL=STAGING_DATABASE_URL --writeAnnotate keys with comments
envsitter annotate --file .env --key DATABASE_URL --comment "prod only" --writeReorder/format env files
envsitter format --file .env --mode sections --sort alpha --write
# alias:
envsitter reorder --file .env --mode sections --sort alpha --writeAdd a new key (fails if key exists)
envsitter add --file .env --key NEW_API_KEY --value "sk-xxx" --write
# or via stdin (recommended to avoid shell history):
node -e "process.stdout.write('sk-xxx')" | envsitter add --file .env --key NEW_API_KEY --value-stdin --writeSet a key (creates or updates)
envsitter set --file .env --key API_KEY --value "new-value" --write
# or via stdin:
node -e "process.stdout.write('new-value')" | envsitter set --file .env --key API_KEY --value-stdin --writeUnset a key (set to empty value)
envsitter unset --file .env --key OLD_KEY --writeDelete keys
# Single key:
envsitter delete --file .env --key DEPRECATED_KEY --write
# Multiple keys:
envsitter delete --file .env --keys OLD_KEY,UNUSED_KEY,LEGACY_KEY --writeOutput contract (for LLMs)
General rules:
- Never output secret values; treat all values as sensitive.
- Prefer
--candidate-stdinover--candidateto avoid shell history. - Exit codes:
0match found,1no match,2error/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 }
- default op (not provided):
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 }> }
- default op (not provided):
match-by-key --json->{ "matches": Array<{ "key": string, "match": boolean }> }scan --json->{ "findings": Array<{ "key": string, "detections": Array<"jwt"|"url"|"base64"> }> }validate --json->{ "ok": boolean, "issues": Array<{ "line": number, "column": number, "message": string }> }copy --json->{ "from": string, "to": string, "onConflict": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": Array<...> }format --json/reorder --json->{ "file": string, "mode": string, "sort": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...> }annotate --json->{ "file": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": { ... } }add --json/set --json/unset --json->{ "file": string, "key": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": { "key": string, "action": "added"|"updated"|"unset"|"key_exists"|"not_found"|"no_change", "line"?: number } }delete --json->{ "file": string, "keys": string[], "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": Array<{ "key": string, "action": "deleted"|"not_found", "line"?: number }> }
Library API
Basic usage
import { EnvSitter } from 'envsitter';
const es = EnvSitter.fromDotenvFile('.env');
const keys = await es.listKeys();
const fp = await es.fingerprintKey('OPENAI_API_KEY');
const match = await es.matchCandidate('OPENAI_API_KEY', 'candidate-secret');File operations via the library
import {
addEnvFileKey,
annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile
} from 'envsitter';
await validateEnvFile('.env');
await copyEnvFileKeys({
from: '.env.production',
to: '.env.staging',
keys: ['API_URL', 'REDIS_URL'],
onConflict: 'overwrite',
write: true
});
await annotateEnvFile({ file: '.env', key: 'DATABASE_URL', comment: 'prod only', write: true });
await formatEnvFile({ file: '.env', mode: 'sections', sort: 'alpha', write: true });
// Add a new key (fails if exists)
await addEnvFileKey({ file: '.env', key: 'NEW_KEY', value: 'new_value', write: true });
// Set a key (creates or updates)
await setEnvFileKey({ file: '.env', key: 'API_KEY', value: 'updated_value', write: true });
// Unset a key (set to empty)
await unsetEnvFileKey({ file: '.env', key: 'OLD_KEY', write: true });
// Delete keys
await deleteEnvFileKeys({ file: '.env', keys: ['DEPRECATED', 'UNUSED'], write: true });Utility functions
import { isExampleEnvFile } from 'envsitter';
// Detect example/template env files
isExampleEnvFile('.env.example'); // true
isExampleEnvFile('api.env.sample'); // true
isExampleEnvFile('.env'); // false
isExampleEnvFile('api.env'); // falseMatch operators via the library
import { EnvSitter } from 'envsitter';
import type { EnvSitterMatcher } from 'envsitter';
const es = EnvSitter.fromDotenvFile('.env');
const matcher: EnvSitterMatcher = { op: 'partial_match_prefix', prefix: 'sk-' };
const ok = await es.matchKey('OPENAI_API_KEY', matcher);Bulk matching
import { EnvSitter } from 'envsitter';
const es = EnvSitter.fromDotenvFile('.env');
// One candidate tested against a set of keys
const matches = await es.matchCandidateBulk(['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], 'candidate-secret');
// One matcher tested against a set of keys
const prefixMatches = await es.matchKeyBulk(['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], { op: 'partial_match_prefix', prefix: 'sk-' });
// Candidates-by-key
const byKey = await es.matchCandidatesByKey({
OPENAI_API_KEY: 'sk-...',
ANTHROPIC_API_KEY: 'sk-...'
});External sources (hooks)
You can load dotenv-formatted output from another tool/secret provider:
import { EnvSitter } from 'envsitter';
const es = EnvSitter.fromExternalCommand('my-secret-provider', ['export', '--format=dotenv']);
const keys = await es.listKeys();Development
npm install
npm run typecheck
npm testRun a single test file:
npm run build
node --test dist/test/envsitter.test.jsRun a single test by name:
npm run build
node --test --test-name-pattern "outside-in" dist/test/envsitter.test.jsLicense
MIT. See LICENSE.
