18 KiB
envsitter
Safely inspect and match .env secrets without ever printing values.
Docs: https://boxpositron.github.io/envsitter/
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. - File writes keep the target file's permission bits and go through symlinks to the real file; new env files are created with mode
0600.
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 envsitter
Requires Node.js 22 or later.
Or run the CLI without installing globally:
npx envsitter keys --file .env
Pepper (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. When envsitter creates .envsitter/, it also writes .envsitter/.gitignore (*), so the pepper stays out of git even if your project's .gitignore does not list it. An existing pepper file is never replaced: if it is empty, malformed, or unreadable, envsitter stops with an error instead of generating a new pepper (which would change every fingerprint).
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) |
redact |
Replace secret values in text read from stdin |
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]redact --file <path> [--all] [--min-length <n>]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 .env
Filter 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_KEY
Outputs 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 --json
Exit 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 --json
Match 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 --json
Match one candidate against all keys
node -e "process.stdout.write('candidate-secret')" \
| envsitter match --file .env --all-keys --candidate-stdin --json
Match 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-stdin
Scan for value shapes (no values returned)
envsitter scan --file .env --detect jwt,url,base64
Optionally restrict which keys to scan:
envsitter scan --file .env --keys-regex "/(JWT|URL)/" --detect jwt,url
Redact secret values from text
Replace secret values in any text (logs, command output, support bundles) before sharing it. redact reads stdin, writes the text to stdout with each value replaced by [envsitter: KEY redacted], and reports counts per key on stderr (or in the output with --json):
envsitter redact --file .env < app.log > app.redacted.log
envsitter redact --file .env --json < app.log
A value is redacted when it is at least 8 characters long, is not a plain number or boolean, and looks secret: its key name contains a word such as TOKEN, SECRET, PASSWORD, or API_KEY; it is shaped like a random token; it is a URL with a password (the bare password is redacted too); or it is a PEM block. Ordinary config such as NODE_ENV=development is left alone. --all redacts every value, and --min-length <n> changes the length floor. Encoded or transformed values (base64, split strings) are not detected.
Validate dotenv syntax
envsitter validate --file .env
envsitter validate --file .env --json
Copy 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 --json
Apply changes:
envsitter copy --from .env.production --to .env.staging --keys API_URL,REDIS_URL --on-conflict overwrite --write --json
Rename while copying:
envsitter copy --from .env.production --to .env.staging --keys DATABASE_URL --rename DATABASE_URL=STAGING_DATABASE_URL --write
Annotate keys with comments
envsitter annotate --file .env --key DATABASE_URL --comment "prod only" --write
Reorder/format env files
envsitter format --file .env --mode sections --sort alpha --write
# alias:
envsitter reorder --file .env --mode sections --sort alpha --write
Add 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 --write
Set 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 --write
Unset a key (set to empty value)
envsitter unset --file .env --key OLD_KEY --write
Delete keys
# Single key:
envsitter delete --file .env --key DEPRECATED_KEY --write
# Multiple keys:
envsitter delete --file .env --keys OLD_KEY,UNUSED_KEY,LEGACY_KEY --write
Output 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->{ "file": string, "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, "key": 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 { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from 'envsitter';
// Dotenv-style file names (any directory, POSIX or Windows separators)
isEnvFile('.env.local'); // true
isEnvFile('config.env.local'); // true
isEnvFile('app.env.ts'); // false (source file)
// Detect example/template env files
isExampleEnvFile('.env.example'); // true
isExampleEnvFile('api.env.sample'); // true
isExampleEnvFile('.env'); // false
// Env files that may hold real values (every env file except examples/templates)
isSensitiveEnvFile('.env.production'); // true
isSensitiveEnvFile('.env.example'); // false
// The default pepper location
isEnvSitterPepperFile('.envsitter/pepper'); // true
envsitter-guard uses isSensitiveEnvFile and isEnvSitterPepperFile to decide what agents may not read or edit.
Match 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();
Redact secret values
import { EnvSitter } from 'envsitter';
const redactor = await EnvSitter.fromDotenvFile('.env').redactor();
const { text, redactions } = redactor.redact(logOutput);
// text has each secret value replaced by [envsitter: KEY redacted]; redactions is [{ key, count }]
redactor.keys lists the keys it covers, and the values never leave the redactor. Options: { all?: boolean; minLength?: number }, with the same rules as the redact command.
Development
npm install
npm run typecheck
npm test
Run a single test file:
npm run build
node --test dist/test/envsitter.test.js
Run a single test by name:
npm run build
node --test --test-name-pattern "outside-in" dist/test/envsitter.test.js
Releasing
Releases are published by GitHub Actions with npm trusted publishing, so no npm token is stored anywhere and every version ships with a provenance attestation.
- Bump
versioninpackage.jsonand replace(Unreleased)in the matchingCHANGELOG.mdheading with the release date. - Commit, then push a matching tag:
git tag v0.1.0 && git push origin main v0.1.0. - The Release workflow reruns CI, checks that the tag,
package.json, and the CHANGELOG agree, publishes to npm (prereleases such asv0.2.0-beta.1go to thenextdist-tag), and creates the GitHub Release from the CHANGELOG section.
The docs site in site/ deploys to GitHub Pages on every push to main that changes it.
License
MIT. See LICENSE.