Compare commits

..

6 Commits

Author SHA1 Message Date
David Ibia d1b749f483 Release 0.1.0
CI / Test (ubuntu-latest, Node 22) (push) Has been skipped
CI / Test (ubuntu-latest, Node 26) (push) Has been skipped
Release / Verify (push) Has been skipped
Release / Publish to npm (push) Has been skipped
Release / GitHub Release (push) Has been skipped
CI / Test (ubuntu-latest, Node 24) (push) Has been skipped
CI / Test (windows-latest, Node 24) (push) Has been skipped
CI / Test (macos-latest, Node 24) (push) Has been skipped
2026-10-21 00:01:08 +01:00
David Ibia 5675f9c15a Keep macOS and Windows CI jobs off the Gitea runner
CI / Test (macos-latest, Node 24) (push) Has been skipped
CI / Test (windows-latest, Node 24) (push) Has been skipped
CI / Test (ubuntu-latest, Node 22) (push) Has been skipped
CI / Test (ubuntu-latest, Node 24) (push) Has been skipped
CI / Test (ubuntu-latest, Node 26) (push) Has been skipped
Gitea matches runs-on against its runners before evaluating the job's if, so the macOS and Windows matrix jobs queued forever there. Off GitHub, every job now asks for ubuntu-latest and is skipped.
2026-10-20 15:14:32 +01:00
David Ibia 2bd7359056 Document 0.1.0 changes, releasing, and the docs site
CI / Test (ubuntu-latest, Node 22) (push) Has been skipped
CI / Test (ubuntu-latest, Node 24) (push) Has been skipped
CI / Test (ubuntu-latest, Node 26) (push) Has been skipped
Pages / Build site (push) Has been skipped
Pages / Deploy (push) Has been skipped
CI / Test (macos-latest, Node 24) (push) Has been cancelled
CI / Test (windows-latest, Node 24) (push) Has been cancelled
2026-10-20 15:10:15 +01:00
David Ibia af471c9821 Prepare 0.1.0: one dotenv grammar, path policy, redactor, and safer writes
- parseDotenv, validate, and every edit share the round-trip document parser
- Path policy helpers (isEnvFile, isSensitiveEnvFile, isEnvSitterPepperFile) used by envsitter-guard
- CLI file commands run through the library file operations
- EnvSitter#redactor() and the redact command replace secret-looking values in text
- Writes keep file permissions and go through symlinks; new files are 0600
- An existing pepper is never replaced; a new .envsitter/ gets a .gitignore
- set/add/copy into empty files write clean output
- Regexes with g/y flags no longer skip keys
- TypeScript 6, Node 22+ engines, prepack build, dist/test excluded from the package
2026-10-20 15:10:13 +01:00
David Ibia 33b4a72f2c Add documentation site for envsitter and envsitter-guard
Static HTML and CSS with self-hosted Geist fonts and light/dark themes, deployed by pages.yml to https://boxpositron.github.io/envsitter/ (guard page at /guard/).
2026-10-20 15:10:11 +01:00
David Ibia aa2c8f2d6d Add CI, release, and Pages workflows with Dependabot
- ci.yml: typecheck, tests, and pack check on Ubuntu (Node 22, 24, 26), macOS and Windows (Node 24)
- release.yml: tag vX.Y.Z reruns CI, checks tag, package.json and CHANGELOG agree, publishes with npm trusted publishing, and creates the GitHub Release
- pages.yml: deploys site/ to GitHub Pages
- Jobs run on GitHub only; the Gitea origin's runner skips them
- Dependabot proposes grouped npm and Actions updates weekly
2026-10-20 15:10:09 +01:00
41 changed files with 3127 additions and 300 deletions
+28
View File
@@ -0,0 +1,28 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
dev-dependencies:
dependency-type: development
ignore:
# TypeScript 7 ships no tsserver.js, which editors and typescript-language-server still need.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
# @types/node tracks the oldest Node version CI tests (22).
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns: ["*"]
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Prints the CHANGELOG.md section for a version (used as GitHub Release notes).
// Fails when the section is missing or still marked "Unreleased", so a release cannot ship undated notes.
import { readFileSync } from 'node:fs';
const version = process.argv[2];
if (!version) {
console.error('Usage: changelog-section.mjs <version>');
process.exit(2);
}
const lines = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), 'utf8').split('\n');
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const start = lines.findIndex((line) => new RegExp(`^## ${escaped}(\\s|$)`).test(line));
if (start === -1) {
console.error(`CHANGELOG.md has no "## ${version}" section.`);
process.exit(1);
}
if (/unreleased/i.test(lines[start] ?? '')) {
console.error(`CHANGELOG.md section "${lines[start]}" is still marked Unreleased; replace it with the release date.`);
process.exit(1);
}
const end = lines.findIndex((line, index) => index > start && line.startsWith('## '));
const body = lines.slice(start + 1, end === -1 ? undefined : end).join('\n').trim();
process.stdout.write(`${body}\n`);
+45
View File
@@ -0,0 +1,45 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_call:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
# GitHub only: the Gitea origin also runs .github/workflows but has no macOS/Windows runners.
# Gitea matches runs-on before evaluating `if`, so off GitHub every job asks for ubuntu-latest and is skipped.
if: github.server_url == 'https://github.com'
runs-on: ${{ github.server_url == 'https://github.com' && matrix.os || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: [22, 24, 26]
include:
- os: macos-latest
node: 24
- os: windows-latest
node: 24
env:
HUSKY: "0"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- name: Check package contents
run: npm pack --dry-run
+45
View File
@@ -0,0 +1,45 @@
name: Pages
on:
push:
branches: [main]
paths:
- "site/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build site
# GitHub only: the Gitea origin also runs .github/workflows but has no Pages; deploy skips with this job.
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/configure-pages@v6
- name: Stage site without source notes
run: rsync -a --exclude='*.md' site/ _site/
- uses: actions/upload-pages-artifact@v5
with:
path: _site/
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
+79
View File
@@ -0,0 +1,79 @@
name: Release
# Push a vX.Y.Z tag after bumping package.json and dating the CHANGELOG section.
# Publishing uses npm trusted publishing (OIDC): no npm token is stored in this repository.
on:
push:
tags: ["v*.*.*"]
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
name: Verify
# GitHub only: npm trusted publishing needs GitHub's OIDC token; later jobs skip with this one.
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/ci.yml
publish:
name: Publish to npm
needs: verify
runs-on: ubuntu-latest
environment: npm
permissions:
contents: read
id-token: write
env:
HUSKY: "0"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Use npm 12 (trusted publishing needs 11.5.1 or later)
run: npm install -g npm@12
- name: Check tag, package.json, and CHANGELOG agree
run: |
version="${GITHUB_REF_NAME#v}"
package_version="$(node -p "require('./package.json').version")"
if [ "$version" != "$package_version" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $package_version"
exit 1
fi
node .github/scripts/changelog-section.mjs "$version" > /dev/null
- run: npm ci
- run: npm run build
- name: Publish
run: |
version="${GITHUB_REF_NAME#v}"
if [[ "$version" == *-* ]]; then
npm publish --access public --tag next
else
npm publish --access public
fi
github-release:
name: GitHub Release
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- name: Create release from CHANGELOG
env:
GH_TOKEN: ${{ github.token }}
run: |
version="${GITHUB_REF_NAME#v}"
node .github/scripts/changelog-section.mjs "$version" > release-notes.md
prerelease=""
if [[ "$version" == *-* ]]; then prerelease="--prerelease"; fi
gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md $prerelease
+40 -34
View File
@@ -9,50 +9,56 @@ This repo contains `envsitter`: a TypeScript (ESM) library + CLI for safely insp
- Prefer comparisons via deterministic fingerprints (HMAC) and boolean match results.
- Prefer passing candidate secrets via stdin (to avoid shell history): `--candidate-stdin`.
- Keep all generated peppers/secrets out of git (see `.gitignore`).
- `envsitter-guard` (separate repo) exposes this package's exports to AI agents as tools, so every new export is agent-reachable.
## Project layout
- `src/envsitter.ts`: main library surface (`EnvSitter`)
- `src/cli.ts`: CLI entrypoint
- `src/dotenv/parse.ts`: minimal dotenv parser
- `src/pepper.ts`: pepper resolution + auto-create
- `src/test/*.test.ts`: Node test suite (compiled to `dist/test/*.test.js`)
- `src/index.ts`: public exports (the only surface npm users and `envsitter-guard` see)
- `src/envsitter.ts`: `EnvSitter` read-only facade (keys, fingerprints, matchers, scan, redactor)
- `src/cli.ts`: CLI entrypoint (`bin` -> `dist/cli.js`); one `if (cmd === ...)` block per command in `run()`
- `src/file-ops.ts`: file-level mutations for the library API (read, edit, atomic write only when `write: true`)
- `src/pepper.ts`: pepper resolution + auto-create; `src/fingerprint.ts`: HMAC-SHA-256
- `src/sources/`: `DotenvFileSource`, `ExternalCommandSource` (both parse with `dotenv/parse.ts`)
- `src/dotenv/document.ts`: the only dotenv grammar; round-trippable, keeps comments, blank lines, and line/column issues
- `src/dotenv/parse.ts`: values-only view of `document.ts` (read path)
- `src/dotenv/edit.ts`: pure string-in/string-out edits (copy, format, annotate, add/set/unset/delete); owns the edit types that `file-ops.ts` re-exports
- `src/dotenv/io.ts`: `readTextFileOrEmpty`, `writeTextFileAtomic` (keeps file mode, writes through symlinks, new files `0600`); `src/dotenv/utils.ts`: value quoting
- `src/paths.ts`: path policy (`isEnvFile`, `isExampleEnvFile`, `isSensitiveEnvFile`, `isEnvSitterPepperFile`)
- `src/regex.ts`: `regexTest`; use it for every user-supplied regex (resets `lastIndex` for `g`/`y` flags)
- `src/redact.ts`: `createRedactor` behind `EnvSitter#redactor()` and the `redact` command; decides which values look secret (key names, token shape, URL passwords, PEM)
- `src/test/*.test.ts`: Node test suite (compiled to `dist/test/*.test.js`); `cli.test.ts` spawns `dist/cli.js`
- `.github/workflows/`: `ci.yml` (push, PR, reusable), `release.yml` (tag `vX.Y.Z` -> CI -> npm trusted publishing -> GitHub Release), `pages.yml` (deploys `site/`); `.github/dependabot.yml` groups weekly npm and Actions updates
- `.github/scripts/changelog-section.mjs`: prints a version's CHANGELOG section; fails if missing or still `(Unreleased)`
- `site/`: static docs site for envsitter and envsitter-guard (no build step); `site/DESIGN.md` is its design contract and is not published
### Gotchas
- Grammar changes go in `dotenv/document.ts` only; `parseDotenv`, `validate`, and every edit follow automatically.
- CLI file commands are thin wrappers over `file-ops.ts`; change behavior there, then keep the CLI's text (non-JSON) output in step.
- `src/paths.ts` is the policy `envsitter-guard` enforces. Widening or narrowing `isSensitiveEnvFile` changes what agents are blocked from; add a case to `paths.test.ts` for every change.
- `src/test/envsitter.test.ts` resolves the default pepper (`<cwd>/.envsitter/pepper`) unless `ENVSITTER_PEPPER` is set, so `npm test` creates that file on first run.
## Build / typecheck / test
There is no linter configured (no ESLint/Prettier). Use `tsc` + tests as quality gates.
There is no linter configured (no ESLint/Prettier). Use `tsc` + tests as quality gates. Husky pre-commit runs `npm test`.
### Install
```bash
npm install
npm run build # tsc -p tsconfig.json -> dist/
npm run typecheck # tsc --noEmit
npm test # build, then scripts/run-tests.mjs runs every dist/test/*.test.js
# tests execute from compiled JS, so build before running one file or one test
npm run build && node --test dist/test/envsitter.test.js
npm run build && node --test --test-name-pattern "outside-in" dist/test/envsitter.test.js
```
- `npm install`
`--test-name-pattern` takes a substring/regex matching the `test('name', ...)` title.
### Build
### Releasing
- `npm run build`
- Runs `tsc -p tsconfig.json` and outputs to `dist/`
### Typecheck only
- `npm run typecheck`
### Run all tests
- `npm test`
- Runs `npm run build` then `node scripts/run-tests.mjs`
### Run a single test file
Tests are written in TS but executed from compiled JS.
- `npm run build`
- `node --test dist/test/envsitter.test.js`
### Run a single test by name/pattern
- `npm run build`
- `node --test --test-name-pattern "outside-in" dist/test/envsitter.test.js`
(Use a substring/regex that matches the `test('name', ...)` title.)
- Bump `package.json`, date the CHANGELOG heading, commit, push tag `vX.Y.Z`. The Release workflow does the rest; never `npm publish` by hand.
- Push to `origin` (Gitea, `git.boxpositron.dev`); it mirrors to GitHub, where the workflows run. Jobs are guarded with `if: github.server_url == 'https://github.com'` so Gitea's runner skips them.
- npm trusted publishing (OIDC) authenticates the publish; no npm token exists in the repo. Keep `release.yml` as the publishing workflow name, because the npm trusted-publisher config pins it.
### Bun
+36
View File
@@ -4,6 +4,42 @@ All notable changes to this project are documented in this file.
This project follows [Semantic Versioning](https://semver.org/) and the format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
## 0.1.0 (2026-10-21)
### Added
- Path policy helpers: `isEnvFile`, `isSensitiveEnvFile`, and `isEnvSitterPepperFile`, alongside `isExampleEnvFile`.
- Env files are `.env`, `.env.<suffix>`, `<name>.env`, and `<name>.env.<suffix>`; source files such as `app.env.ts` are not.
- Sensitive env files are every env file except example/template files.
- `envsitter-guard` uses these helpers, so the library and the plugin agree on what is sensitive.
- Type exports: `AnnotatePlan`, `CopyConflictPolicy`, `CopyPlanItem`, `DotenvIssue`, `FormatMode`, `FormatSort`.
- Release automation: CI on every push and pull request (Node 22, 24, and 26), and tag-driven releases published with npm trusted publishing, so packages carry provenance attestations.
- Documentation site at https://boxpositron.github.io/envsitter/ (source in `site/`, deployed by GitHub Pages).
- `EnvSitter#redactor()` and the `envsitter redact` command replace secret-looking values in arbitrary text with `[envsitter: KEY redacted]` without returning the values. `envsitter-guard` uses it to scrub tool output.
- When envsitter creates `.envsitter/`, it also writes `.envsitter/.gitignore` (`*`), so the pepper stays out of git even if the project's `.gitignore` does not list it.
- CI also runs on macOS and Windows, and Dependabot proposes npm and GitHub Actions updates weekly (with a 7-day cooldown).
### Changed
- One dotenv grammar: `parseDotenv` (used by `EnvSitter` sources) now derives from the round-trip document parser used by `validate` and the file operations.
- Read-path parse errors now carry a `column` and use the same messages as `validate`.
- `export` followed by a tab is accepted on the read path.
- CLI file commands (`validate`, `copy`, `format`/`reorder`, `annotate`, `add`, `set`, `unset`, `delete`) now run through the library file operations, so the CLI and the library cannot drift.
- `validate --json` output now includes `file`; `annotate --json` output now includes `key`.
- Package version bumped to `0.1.0`.
- `npm pack` and `npm publish` now build `dist/` first (`prepack`), so a publish can never ship stale or missing output.
- Requires Node.js 22 or later (`engines`).
- The npm package no longer ships the compiled test suite (`dist/test`).
### Fixed
- File writes no longer loosen permissions: a `0600` env file came back `0644` (world-readable) after any `--write` / `write: true` edit. Writes now keep the existing mode, and new files are created `0600`.
- Writing to a symlinked env file now updates the link's target instead of replacing the symlink with a regular file.
- An existing pepper file that is empty, too short, or unreadable is no longer silently replaced with a new pepper (which changed every fingerprint); envsitter now stops with an error naming the file. Two processes creating the pepper at the same time can no longer overwrite each other's.
- `set`, `add`, and `copy` into an empty or new env file write `KEY=value` plus a trailing newline instead of a leading blank line and no trailing newline, and report the key on line 1.
- Regexes with the `g` or `y` flag no longer skip every other key in `listKeys` filters, `scan` key filters, `partial_match_regex`, and `copy` include/exclude.
- `match-by-key` rejects non-object JSON and non-string candidates with a clear error instead of a `TypeError`.
## 0.0.4 (2026-01-15)
### Added
+61 -5
View File
@@ -1,7 +1,12 @@
# envsitter
[![CI](https://github.com/boxpositron/envsitter/actions/workflows/ci.yml/badge.svg)](https://github.com/boxpositron/envsitter/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/envsitter)](https://www.npmjs.com/package/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 (`.env` file or external provider)
@@ -18,6 +23,7 @@ Related: https://github.com/boxpositron/envsitter-guard — an OpenCode plugin t
- 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:
@@ -30,6 +36,8 @@ Non-goals:
npm install envsitter
```
Requires Node.js 22 or later.
Or run the CLI without installing globally:
```bash
@@ -45,7 +53,7 @@ Resolution order:
1. `process.env.ENVSITTER_PEPPER` (or `ENV_SITTER_PEPPER`)
2. Pepper file at `.envsitter/pepper` (auto-created if missing)
The pepper file is created with mode `0600` when possible, and `.envsitter/` is gitignored.
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
@@ -58,6 +66,7 @@ The pepper file is created with mode `0600` when possible, and `.envsitter/` is
| `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 |
@@ -74,6 +83,7 @@ The pepper file is created with mode `0600` when possible, and `.envsitter/` is
- `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]`
@@ -201,6 +211,17 @@ 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`):
```bash
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
```bash
@@ -294,10 +315,10 @@ JSON outputs:
- 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"> }> }`
- `validate --json` -> `{ "ok": boolean, "issues": Array<{ "line": number, "column": number, "message": string }> }`
- `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, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": { ... } }`
- `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 }> }`
@@ -358,15 +379,28 @@ await deleteEnvFileKeys({ file: '.env', keys: ['DEPRECATED', 'UNUSED'], write: t
### Utility functions
```ts
import { isExampleEnvFile } from 'envsitter';
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
isExampleEnvFile('api.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
```ts
@@ -410,6 +444,18 @@ const es = EnvSitter.fromExternalCommand('my-secret-provider', ['export', '--for
const keys = await es.listKeys();
```
### Redact secret values
```ts
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
```bash
@@ -432,6 +478,16 @@ 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.
1. Bump `version` in `package.json` and replace `(Unreleased)` in the matching `CHANGELOG.md` heading with the release date.
2. Commit, then push a matching tag: `git tag v0.1.0 && git push origin main v0.1.0`.
3. The Release workflow reruns CI, checks that the tag, `package.json`, and the CHANGELOG agree, publishes to npm (prereleases such as `v0.2.0-beta.1` go to the `next` dist-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`.
+13 -10
View File
@@ -1,26 +1,29 @@
{
"name": "envsitter",
"version": "0.0.2",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "envsitter",
"version": "0.0.2",
"version": "0.1.0",
"license": "MIT",
"bin": {
"envsitter": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/node": "^22.20.5",
"husky": "^9.1.7",
"typescript": "^5.7.3"
"typescript": "^6.0.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@types/node": {
"version": "22.19.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz",
"integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==",
"version": "22.20.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
"integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -44,9 +47,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+10 -4
View File
@@ -1,9 +1,10 @@
{
"name": "envsitter",
"version": "0.0.4",
"version": "0.1.0",
"private": false,
"type": "module",
"description": "Safely inspect and match .env secrets without exposing values",
"homepage": "https://boxpositron.github.io/envsitter/",
"repository": {
"type": "git",
"url": "git+https://github.com/boxpositron/envsitter.git"
@@ -23,6 +24,9 @@
"config"
],
"license": "MIT",
"engines": {
"node": ">=22"
},
"bin": {
"envsitter": "dist/cli.js"
},
@@ -33,17 +37,19 @@
}
},
"files": [
"dist"
"dist",
"!dist/test"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node scripts/run-tests.mjs",
"prepack": "npm run build",
"prepare": "husky"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/node": "^22.20.5",
"husky": "^9.1.7",
"typescript": "^5.7.3"
"typescript": "^6.0.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found: envsitter</title>
<meta name="robots" content="noindex">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<link rel="icon" href="/envsitter/assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/envsitter/assets/styles.css">
</head>
<body>
<main id="main" class="container not-found">
<div class="stack">
<p class="eyebrow">404</p>
<h1>This page is redacted.</h1>
<p class="lede">Nothing lives at this address. The value is not hidden, it just does not exist.</p>
<div class="cta-row">
<a class="btn btn-primary" href="/envsitter/">envsitter home</a>
<a class="btn btn-secondary" href="/envsitter/guard/">OpenCode guard</a>
</div>
</div>
</main>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
# EnvSitter site design system
Source of truth for `site/`. Every color, size, radius, shadow, and motion value in `assets/styles.css` maps to a token here. Add the token here first, then use it. This file is excluded from the published Pages artifact.
## 0. Research Log
- Embedded references: shortlisted `vercel`, `linear.app`, `supabase` (developer tooling, monochrome, precise). Picked `vercel` as Layer B for its achromatic palette, shadow-as-border elevation stack, compressed display tracking, and mono technical labels, which suit a CLI security library. Layer A: `minimalist-skill` for macro whitespace, plain copy, and no gradient or emoji slop.
- Lazyweb real-product screens: skipped. The site is a two-page static docs landing whose content is fixed by the READMEs; no product-screen grammar was needed.
- StyleGallery spatial patterns: skipped. Both pages are scroll-the-document pages with no app shell; the document owns the scroll.
- Imagen concept drafts: skipped. The focal object is a typographic artifact (redacted env file plus terminal), rendered in HTML/CSS so it stays exact, accessible, and theme-aware.
## 1. Direction
A redacted dossier for developer infrastructure. The canvas is engineering white (deep graphite in dark mode), and the only "imagery" is the act of redaction: secret values appear as solid redaction bars while keys stay legible in mono. The memorable moment is the hero, where values are redacted in front of the visitor and a terminal beside them answers with keys and booleans only. Color is functional: green for a match or an allowed action, red for a blocked one, blue for fingerprints and focus.
## 2. Color tokens
All theme tokens use `light-dark()` with `color-scheme: light dark`, so one declaration serves both themes and the OS preference decides.
| Token | Light | Dark | Role |
|---|---|---|---|
| `--bg` | `#ffffff` | `#0a0a0a` | Page canvas |
| `--bg-subtle` | `#fafafa` | `#0f0f0f` | Alternate section band |
| `--surface` | `#ffffff` | `#141414` | Cards, panels |
| `--surface-2` | `#f5f5f5` | `#1c1c1c` | Inline code, chips, table header |
| `--text` | `#171717` | `#ededed` | Headings, primary text |
| `--text-2` | `#4d4d4d` | `#a3a3a3` | Body copy |
| `--text-3` | `#666666` | `#8f8f8f` | Captions, metadata (AA on both canvases) |
| `--ring` | `rgb(0 0 0 / 0.08)` | `rgb(255 255 255 / 0.10)` | Shadow-as-border |
| `--ring-strong` | `rgb(0 0 0 / 0.14)` | `rgb(255 255 255 / 0.18)` | Hover border, dividers |
| `--inner` | `#fafafa` | `rgb(255 255 255 / 0.03)` | Inner highlight ring in the card stack |
| `--redact` | `#171717` | `#e5e5e5` | Redaction bar ink |
| `--ok` | `#0f7b3d` | `#3fcf7f` | Match true, allowed |
| `--block` | `#c4291c` | `#ff7a6e` | Blocked, refused |
| `--hash` | `#0068d6` | `#5aa9ff` | Fingerprints, links |
| `--focus` | `hsl(212 100% 48%)` | same | Focus ring |
Terminal palette (dark in both themes, so the terminal reads as a real console): `--term-bg #0b0b0c`, `--term-bar #151517`, `--term-text #e6e6e6`, `--term-muted #8f8f94`, `--term-ok #3fcf7f`, `--term-block #ff7a6e`, `--term-hash #5aa9ff`, `--term-key #f2c46d` (keys in JSON output).
Atmosphere: hero dot grid `--dot` (`rgb(0 0 0 / 0.07)` / `rgb(255 255 255 / 0.06)`) masked to fade out, plus one radial glow `--glow` (`rgb(0 104 214 / 0.07)` / `rgb(90 169 255 / 0.08)`) behind the hero visual.
## 3. Typography
- `--font-sans`: `"Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif` (self-hosted variable woff2, OFL)
- `--font-mono`: `"Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace` (self-hosted variable woff2, OFL)
- Ligatures on (`font-feature-settings: "liga"`); weights 400 read, 500 interact, 600 announce. No 700.
| Role | Size | Weight | Line height | Tracking |
|---|---|---|---|---|
| Display (h1) | `clamp(2.5rem, 4.2vw + 1rem, 4rem)` | 600 | 1.02 | `-0.045em` |
| Section (h2) | `clamp(1.75rem, 2vw + 1rem, 2.5rem)` | 600 | 1.1 | `-0.04em` |
| Card title (h3) | `1.25rem` | 600 | 1.3 | `-0.02em` |
| Lede | `clamp(1.0625rem, 0.4vw + 1rem, 1.25rem)` | 400 | 1.7 | normal |
| Body | `1rem` | 400 | 1.65 | normal |
| Small | `0.875rem` | 400/500 | 1.55 | normal |
| Eyebrow / label | `0.75rem` mono, uppercase | 500 | 1.3 | `0.08em` |
| Code | `0.8125rem` to `0.875rem` mono | 400 | 1.7 | normal |
## 4. Space, radius, elevation
- Spacing (4px base): `--s-1 4px`, `--s-2 8px`, `--s-3 12px`, `--s-4 16px`, `--s-5 24px`, `--s-6 32px`, `--s-7 48px`, `--s-8 64px`, `--s-9 96px`, `--s-10 128px`. Section padding `--section-y: clamp(64px, 9vw, 128px)`.
- Container: `--container: 1120px`, gutters `clamp(20px, 4vw, 32px)`.
- Radius: `--r-xs 4px` (inline code), `--r-sm 6px` (buttons), `--r-md 8px` (cards), `--r-lg 12px` (panels), `--r-pill 9999px` (badges only).
- Elevation (vercel shadow-as-border stack, never a CSS border on cards):
- `--e-1`: `0 0 0 1px var(--ring)`
- `--e-2`: `0 0 0 1px var(--ring), 0 2px 2px rgb(0 0 0 / 0.04)`
- `--e-3`: `0 0 0 1px var(--ring), 0 2px 2px rgb(0 0 0 / 0.04), 0 12px 24px -12px rgb(0 0 0 / 0.12), inset 0 0 0 1px var(--inner)`
- `--e-term`: `0 0 0 1px rgb(255 255 255 / 0.06), 0 24px 48px -24px rgb(0 0 0 / 0.45), 0 2px 6px rgb(0 0 0 / 0.10)`
## 5. Primitives and states
| Primitive | Anatomy | States |
|---|---|---|
| `.btn-primary` | `--text` fill, `--bg` text, `--r-sm`, 40px min height | hover: 85% fill; active: `scale(0.98)`; focus-visible: 2px `--focus` outline, 2px offset |
| `.btn-secondary` | `--surface` fill, `--e-1` ring | hover: `--ring-strong` ring; active, focus as above |
| `.copy-cmd` | mono command in a `--surface` box with `--e-2`, copy button on the right | button hidden until JS confirms clipboard support; copied: check glyph plus "Copied" announced through an `aria-live` region |
| `.card` | `--surface`, `--e-2`, `--r-md`, 24px to 32px padding | static (no hover lift: cards are not interactive) |
| `.card-link` | `.card` that is a link | hover: `--e-3`; focus-visible ring |
| `.badge` | pill, `--surface-2`, mono 12px uppercase | static |
| `.op` chip | mono op name plus one-line description | static |
| `.terminal` | window bar with three dots and a title, mono body lines `.ln-cmd`, `.ln-out`, `.ln-ok`, `.ln-block`, `.ln-muted` | static; horizontal scroll on narrow screens with a visible focusable region |
| `.envfile` | panel with key column and `.redact` bars | bars animate in once on load (see motion) |
| `.data-table` | hairline rows, `--surface-2` header | static |
| `.site-header` | sticky, translucent `--bg` with blur, hairline bottom | nav links: hover `--text`, current page `aria-current` |
| `.section-head` | eyebrow label plus h2 plus lede | static |
No coloured edge stripes anywhere: state is shown with tone (`--surface-2`), ink color, or glyphs.
## 6. Motion
- Redaction reveal (signature): `.redact` bars `transform: scaleX(0 -> 1)` from the left, 520ms `cubic-bezier(0.16, 1, 0.3, 1)`, staggered 90ms per row, after a 250ms delay. It demonstrates the product idea (values become bars) and runs once.
- Buttons: `transform` and `background-color` 150ms ease; `scale(0.98)` on active.
- Copy confirmation: glyph swap with `opacity` 150ms.
- `prefers-reduced-motion: reduce`: no transitions or animations; bars render in their final state.
- Only `transform` and `opacity` animate. No scroll-triggered reveals.
## 7. Responsive
- Breakpoints: `640px` (nav collapses to essentials, single column), `960px` (hero switches from stacked to two columns, grids to 2 or 3 columns).
- Hero visual stacks below the copy under 960px. Terminal and code blocks scroll horizontally inside their own focusable region instead of overflowing the page.
- Tap targets at least 40px; body text never below 16px.
## 8. Accessibility constraints and accepted debt
- WCAG AA contrast for all text tokens in both themes (verified pairs listed in section 2); terminal colors checked against `--term-bg`.
- Semantic landmarks (`header`, `nav`, `main`, `footer`), one `h1` per page, skip link, `aria-current="page"` on the active nav item.
- Redaction bars are decorative: each value cell has `aria-label="value hidden"` text for assistive tech.
- Every page works without JavaScript; JS only adds copy buttons.
- Accepted debt: no manual theme toggle (the OS preference decides); no search; content is duplicated from the READMEs and must be updated by hand when the tool surface changes.
- Accepted debt: Lighthouse mobile performance is 99 (simulated slow 4G with gzip: FCP 1.2s, LCP 1.8s, TBT 0, CLS 0); desktop is 100, and accessibility, best practices, and SEO are 100 on both. The last point needs the stylesheet inlined at deploy time, deferred to keep the site build-free. Dropping the Geist preload was measured and made FCP worse (1.7s), so the preload stays.
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
.tile { fill: #171717; }
.key { fill: #ffffff; opacity: 0.5; }
.bar { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.tile { fill: #ededed; }
.key, .bar { fill: #0a0a0a; }
}
</style>
<rect class="tile" width="32" height="32" rx="8"/>
<rect class="key" x="7" y="10" width="5" height="3" rx="1.5"/>
<rect class="bar" x="14" y="10" width="11" height="3" rx="1.5"/>
<rect class="key" x="7" y="15.5" width="4" height="3" rx="1.5"/>
<rect class="bar" x="13" y="15.5" width="12" height="3" rx="1.5"/>
<rect class="key" x="7" y="21" width="6" height="3" rx="1.5"/>
<rect class="bar" x="15" y="21" width="7" height="3" rx="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2023 Vercel, in collaboration with basement.studio
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
(() => {
if (!navigator.clipboard || !window.isSecureContext) return;
const status = document.getElementById("copy-status");
const announce = (message) => {
if (status) status.textContent = message;
};
document.querySelectorAll("button[data-copy]").forEach((button) => {
let resetTimer;
button.hidden = false;
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy);
button.dataset.state = "copied";
announce("Copied to clipboard");
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
delete button.dataset.state;
announce("");
}, 2000);
} catch {
announce("Copy failed. Select the text and copy it manually.");
}
});
});
})();
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>envsitter-guard: keep AI agents out of your .env files</title>
<meta name="description" content="envsitter-guard is an OpenCode plugin for OpenCode 1 and 2 that blocks agents from reading or editing secret env files and gives them fifteen envsitter tools that never return values.">
<link rel="canonical" href="https://boxpositron.github.io/envsitter/guard/">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<meta property="og:type" content="website">
<meta property="og:title" content="envsitter-guard: keep AI agents out of your .env files">
<meta property="og:description" content="An OpenCode plugin that blocks agents from reading or editing secret env files and gives them safe envsitter tools instead.">
<meta property="og:url" content="https://boxpositron.github.io/envsitter/guard/">
<link rel="icon" href="../assets/favicon.svg" type="image/svg+xml">
<link rel="preload" href="../assets/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="../assets/styles.css">
<script src="../assets/site.js" defer></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="container header-row">
<a class="wordmark" href="../">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="4.5" width="19" height="15" rx="3.5" stroke="currentColor" stroke-width="1.5"/><rect x="6" y="9" width="3.5" height="2" rx="1" fill="currentColor" opacity=".45"/><rect x="11" y="9" width="7" height="2" rx="1" fill="currentColor"/><rect x="6" y="13" width="2.5" height="2" rx="1" fill="currentColor" opacity=".45"/><rect x="10" y="13" width="8" height="2" rx="1" fill="currentColor"/></svg>
envsitter<span class="wordmark-sub">/guard</span>
</a>
<nav class="site-nav" aria-label="Primary">
<a class="nav-optional" href="#install">Install</a>
<a class="nav-optional" href="#blocking">Blocking</a>
<a class="nav-optional" href="#tools">Tools</a>
<a href="./" aria-current="page">OpenCode guard</a>
<a class="nav-github" href="https://github.com/boxpositron/envsitter-guard">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>
<span class="nav-github-label">GitHub</span>
</a>
</nav>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">OpenCode plugin</p>
<h1 id="hero-title">Keep AI agents out of your <code>.env</code> files.</h1>
<p class="lede">envsitter-guard stops coding agents from reading or editing secret env files, then gives them fifteen envsitter tools that work with keys, fingerprints, and booleans instead of values.</p>
<div class="badge-row">
<span class="badge"><span class="dot" aria-hidden="true"></span>OpenCode 2.0.32+</span>
<span class="badge"><span class="dot" aria-hidden="true"></span>OpenCode 1.18.29+</span>
</div>
<div class="cta-row">
<div class="copy-cmd">
<code><span class="prompt">$ </span>opencode plugin add envsitter-guard@latest</code>
<button class="copy-btn" type="button" data-copy="opencode plugin add envsitter-guard@latest" aria-label="Copy opencode plugin add envsitter-guard@latest" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<a class="btn btn-secondary" href="#install">Other install options</a>
</div>
</div>
<figure class="hero-visual" aria-label="An agent session where reading .env and running cat .env.local are blocked, and envsitter_keys returns only key names">
<div class="terminal">
<div class="panel-bar">
<span class="dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="panel-title">opencode, agent session</span>
</div>
<div class="terminal-body" tabindex="0" role="region" aria-label="Agent session"><span class="ln ln-cmd"><span class="ps">tool </span>read .env</span><span class="ln ln-block"><span class="tag">blocked</span>Reading `.env*` is blocked to
<span class="tag"></span>prevent secret leaks (`.env`).
<span class="tag"></span>Use EnvSitter tools instead
<span class="tag"></span>(never prints values).</span><span class="ln ln-cmd"><span class="ps">tool </span>shell cat .env.local</span><span class="ln ln-block"><span class="tag">blocked</span>Shell commands that reference
<span class="tag"></span>`.env*` files or
<span class="tag"></span>`.envsitter/pepper` are blocked
<span class="tag"></span>(`.env.local`).</span><span class="ln ln-cmd"><span class="ps">tool </span>envsitter_keys {"filePath": ".env"}</span><span class="ln ln-out">{
<span class="tk-key">"file"</span>: ".env",
<span class="tk-key">"keys"</span>: [
"API_TOKEN",
"DB_PASSWORD"
]
}</span><span class="ln ln-cmd"><span class="ps">tool </span>envsitter_match {"key": "NODE_ENV",
"op": "is_equal", ...}</span><span class="ln ln-out">{ ..., <span class="tk-key">"match"</span>: <span class="tk-true">true</span> }</span></div>
</div>
</figure>
</div>
</section>
<section class="section section-subtle" id="install" aria-labelledby="install-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">Install</p>
<h2 id="install-title">One package for both OpenCode versions.</h2>
<p class="lede">OpenCode 2 loads the plugin through <code>setup()</code> and OpenCode 1 through <code>server()</code>, both from the same default export. Restart OpenCode 1 after changing config; OpenCode 2 reloads watched config on its own.</p>
</div>
<div class="grid grid-2">
<div class="code-block">
<div class="panel-bar">
<span class="panel-title">OpenCode 2: opencode.json</span>
<button class="copy-btn" type="button" data-copy='{
"$schema": "https://opencode.ai/config.json",
"plugins": ["envsitter-guard@latest"]
}' aria-label="Copy OpenCode 2 config" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<pre tabindex="0" role="region" aria-label="OpenCode 2 config"><code>{
<span class="tk-str">"$schema"</span>: <span class="tk-str">"https://opencode.ai/config.json"</span>,
<span class="tk-str">"plugins"</span>: [<span class="tk-str">"envsitter-guard@latest"</span>]
}</code></pre>
</div>
<div class="code-block">
<div class="panel-bar">
<span class="panel-title">OpenCode 1: opencode.json</span>
<button class="copy-btn" type="button" data-copy='{
"$schema": "https://opencode.ai/config.json",
"plugin": ["envsitter-guard@latest"]
}' aria-label="Copy OpenCode 1 config" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<pre tabindex="0" role="region" aria-label="OpenCode 1 config"><code>{
<span class="tk-str">"$schema"</span>: <span class="tk-str">"https://opencode.ai/config.json"</span>,
<span class="tk-str">"plugin"</span>: [<span class="tk-str">"envsitter-guard@latest"</span>]
}</code></pre>
</div>
</div>
<p class="muted section-note">Prefer a local file? Re-export the package from <code>.opencode/plugins/envsitter-guard.ts</code> (OpenCode 2) or <code>.opencode/plugin/</code> (OpenCode 1). The <a class="text-link" href="https://github.com/boxpositron/envsitter-guard#readme">README</a> has the snippet.</p>
</div>
</section>
<section class="section" id="blocking" aria-labelledby="blocking-title">
<div class="container split">
<div class="section-head">
<p class="eyebrow">Blocking</p>
<h2 id="blocking-title">Every path to a secret file, closed.</h2>
<p class="lede">Protected files are <code>.env</code>, <code>.env.&lt;anything&gt;</code>, <code>&lt;name&gt;.env</code>, <code>&lt;name&gt;.env.&lt;anything&gt;</code>, and <code>.envsitter/pepper</code>. Blocked calls fail with a message that points the agent to <code>envsitter_help</code>.</p>
</div>
<div class="stack">
<ul class="check-list" aria-label="Blocked">
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Reads and edits.</strong> Any non-envsitter tool whose path arguments point at a protected file: <code>read</code>, <code>edit</code>, <code>write</code>, <code>grep</code> on a file, MCP filesystem tools.</span></li>
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Patches.</strong> <code>patch</code> and <code>apply_patch</code> calls that add, update, move, or delete a protected file.</span></li>
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Shell commands.</strong> Commands that name a protected file, such as <code>cat .env</code>, <code>source .env</code>, <code>echo X &gt;&gt; .env</code>, or <code>node --env-file=.env</code>.</span></li>
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Search results.</strong> <code>grep</code> matches inside protected files are removed from the output.</span></li>
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Secret values in tool output.</strong> Secret-looking values from protected env files are replaced with <code>[envsitter: KEY redacted]</code> in every other tool's output, so <code>grep -r</code>, <code>cat *</code>, and log files do not hand them to the model.</span></li>
<li class="is-block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></svg><span><strong>Permission checks (OpenCode 2).</strong> <code>read</code>, <code>edit</code>, and <code>shell</code> checks on protected files are denied, including calls made from Code Mode.</span></li>
</ul>
<ul class="check-list" aria-label="Allowed">
<li class="is-ok"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/></svg><span><strong>Templates stay readable.</strong> <code>.env.example</code>, <code>.env.sample</code>, <code>.env.template</code>, <code>.env.dist</code>, and <code>.env.default(s)</code>.</span></li>
<li class="is-ok"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/></svg><span><strong>The envsitter CLI still runs.</strong> Shell calls such as <code>npx envsitter keys --file .env</code> are allowed because the CLI never prints values.</span></li>
</ul>
</div>
</div>
</section>
<section class="section section-subtle" id="tools" aria-labelledby="tools-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">Tools</p>
<h2 id="tools-title">Fifteen tools that never return a value.</h2>
<p class="lede">Agents call them by name. Mutations and file operations are dry runs unless the agent passes <code>write: true</code>, and output is limited to keys, booleans, line numbers, and plans.</p>
</div>
<div class="grid grid-3">
<div class="card tool-group">
<h3>Reading</h3>
<ul class="tool-list"><li><code>envsitter_keys</code></li><li><code>envsitter_fingerprint</code></li><li><code>envsitter_scan</code></li></ul>
<p>List keys, fingerprint one value, or detect JWT, URL, and base64 shapes.</p>
</div>
<div class="card tool-group">
<h3>Matching</h3>
<ul class="tool-list"><li><code>envsitter_match</code></li><li><code>envsitter_match_by_key</code></li></ul>
<p>Boolean checks with every envsitter operator, for one key, many keys, or all keys.</p>
</div>
<div class="card tool-group">
<h3>Mutations</h3>
<ul class="tool-list"><li><code>envsitter_add</code></li><li><code>envsitter_set</code></li><li><code>envsitter_unset</code></li><li><code>envsitter_delete</code></li></ul>
<p>Add, update, empty, or remove keys without the agent ever reading the file.</p>
</div>
<div class="card tool-group">
<h3>File operations</h3>
<ul class="tool-list"><li><code>envsitter_validate</code></li><li><code>envsitter_copy</code></li><li><code>envsitter_format</code></li><li><code>envsitter_reorder</code></li><li><code>envsitter_annotate</code></li></ul>
<p>Validate syntax, copy keys between files, sort them, or add comments.</p>
</div>
<div class="card tool-group">
<h3>Help</h3>
<ul class="tool-list"><li><code>envsitter_help</code></li></ul>
<p>A built-in guide the agent can read when a call is blocked.</p>
</div>
</div>
</div>
</section>
<section class="section" id="compatibility" aria-labelledby="compat-title">
<div class="container split">
<div class="section-head">
<p class="eyebrow">Compatibility</p>
<h2 id="compat-title">Verified against real OpenCode builds.</h2>
<p class="lede">Before every release, an end-to-end check runs the real OpenCode 1 and OpenCode 2 CLIs against a scripted model and confirms each blocking scenario holds.</p>
</div>
<div class="stack">
<div class="table-wrap" tabindex="0" role="region" aria-label="Compatibility">
<table class="data-table">
<thead><tr><th scope="col">OpenCode</th><th scope="col">Minimum</th><th scope="col">Verified</th><th scope="col">Entry point</th></tr></thead>
<tbody>
<tr><td>2.x</td><td><code>2.0.32</code></td><td><code>2.0.32</code>, <code>2.1.0</code></td><td><code>setup(ctx)</code></td></tr>
<tr><td>1.x</td><td><code>1.18.29</code></td><td><code>1.18.31</code>, <code>1.19.3</code></td><td><code>server()</code></td></tr>
</tbody>
</table>
</div>
<ul class="check-list" aria-label="Limits">
<li class="is-note"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/></svg><span><strong>A guardrail, not a sandbox.</strong> Shell commands that reach file contents without naming the file, such as <code>grep -r TOKEN .</code> or <code>cat *</code>, are not blocked; their output is scrubbed instead. Encoded or transformed values (base64, split strings) and values that do not look secret get through.</span></li>
<li class="is-note"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/></svg><span><strong>Default pepper location only.</strong> A pepper file at a custom path is not recognized as protected.</span></li>
</ul>
</div>
</div>
</section>
<section class="section section-subtle" aria-labelledby="library-title">
<div class="container">
<div class="band">
<div class="stack">
<p class="eyebrow">Built on envsitter</p>
<h2 id="library-title">The same tools work outside OpenCode.</h2>
<p>Every guard tool is backed by the envsitter library and CLI, which you can use directly in scripts, CI, and your own agent integrations.</p>
</div>
<a class="btn btn-primary" href="../">Explore envsitter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container footer-row">
<p>envsitter-guard is open source under the MIT license.</p>
<ul class="footer-links">
<li><a href="https://github.com/boxpositron/envsitter-guard">GitHub</a></li>
<li><a href="https://www.npmjs.com/package/envsitter-guard">npm</a></li>
<li><a href="https://github.com/boxpositron/envsitter-guard/blob/main/CHANGELOG.md">Changelog</a></li>
<li><a href="../">envsitter</a></li>
</ul>
</div>
</footer>
<p id="copy-status" class="visually-hidden" aria-live="polite"></p>
</body>
</html>
+283
View File
@@ -0,0 +1,283 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>envsitter: inspect .env secrets without exposing values</title>
<meta name="description" content="envsitter is a TypeScript library and CLI that lists keys, fingerprints values with HMAC-SHA-256, and answers yes or no questions about .env secrets without ever printing them.">
<link rel="canonical" href="https://boxpositron.github.io/envsitter/">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<meta property="og:type" content="website">
<meta property="og:title" content="envsitter: inspect .env secrets without exposing values">
<meta property="og:description" content="List keys, fingerprint values, and match candidates against .env secrets without ever printing them.">
<meta property="og:url" content="https://boxpositron.github.io/envsitter/">
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="preload" href="assets/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="assets/styles.css">
<script src="assets/site.js" defer></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="container header-row">
<a class="wordmark" href="./">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="4.5" width="19" height="15" rx="3.5" stroke="currentColor" stroke-width="1.5"/><rect x="6" y="9" width="3.5" height="2" rx="1" fill="currentColor" opacity=".45"/><rect x="11" y="9" width="7" height="2" rx="1" fill="currentColor"/><rect x="6" y="13" width="2.5" height="2" rx="1" fill="currentColor" opacity=".45"/><rect x="10" y="13" width="8" height="2" rx="1" fill="currentColor"/></svg>
envsitter
</a>
<nav class="site-nav" aria-label="Primary">
<a class="nav-optional" href="#cli">CLI</a>
<a class="nav-optional" href="#library">Library</a>
<a href="guard/">OpenCode guard</a>
<a class="nav-github" href="https://github.com/boxpositron/envsitter">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>
<span class="nav-github-label">GitHub</span>
</a>
</nav>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Open source library and CLI</p>
<h1 id="hero-title">Work with <code>.env</code> secrets without ever seeing them.</h1>
<p class="lede">envsitter lists keys, fingerprints values with HMAC-SHA-256, and answers yes or no questions about them. Values stay in memory, so they never reach your terminal, your logs, or an AI agent's context.</p>
<div class="cta-row">
<div class="copy-cmd">
<code><span class="prompt">$ </span>npm install envsitter</code>
<button class="copy-btn" type="button" data-copy="npm install envsitter" aria-label="Copy npm install envsitter" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<a class="btn btn-secondary" href="https://github.com/boxpositron/envsitter#readme">Read the docs</a>
</div>
</div>
<figure class="hero-visual" aria-label="An env file whose values are redacted, next to envsitter terminal output that shows only keys and a boolean match">
<div class="panel">
<div class="panel-bar">
<span class="dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="panel-title">.env, as envsitter reports it</span>
</div>
<div class="envfile-body">
<span class="env-key">API_TOKEN</span><span class="redact" style="--w: 78%; --i: 0" role="img" aria-label="value hidden"></span>
<span class="env-key">DATABASE_URL</span><span class="redact" style="--w: 94%; --i: 1" role="img" aria-label="value hidden"></span>
<span class="env-key">NODE_ENV</span><span class="redact" style="--w: 34%; --i: 2" role="img" aria-label="value hidden"></span>
<span class="env-key">PORT</span><span class="redact" style="--w: 16%; --i: 3" role="img" aria-label="value hidden"></span>
<span class="env-key">STRIPE_SECRET_KEY</span><span class="redact" style="--w: 70%; --i: 4" role="img" aria-label="value hidden"></span>
</div>
<p class="envfile-foot">Keys are readable. Values never leave memory.</p>
</div>
<div class="terminal">
<div class="panel-bar">
<span class="dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="panel-title">zsh</span>
</div>
<div class="terminal-body" tabindex="0" role="region" aria-label="Terminal output"><span class="ln ln-cmd"><span class="ps">$ </span>envsitter keys --file .env</span><span class="ln ln-out">API_TOKEN
DATABASE_URL
NODE_ENV
PORT
STRIPE_SECRET_KEY</span><span class="ln ln-cmd"><span class="ps">$ </span>pbpaste | envsitter match \
--file .env \
--key STRIPE_SECRET_KEY \
--candidate-stdin --json</span><span class="ln ln-out">{
<span class="tk-key">"key"</span>: "STRIPE_SECRET_KEY",
<span class="tk-key">"match"</span>: <span class="tk-true">true</span>
}</span></div>
</div>
</figure>
</div>
</section>
<section class="section section-subtle" aria-labelledby="ops-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">Match operators</p>
<h2 id="ops-title">Ask questions about a value. Get a boolean back.</h2>
<p class="lede">Every operator evaluates the value in-process and returns only <code>true</code> or <code>false</code>. Equality is checked by comparing HMAC digests, never the raw strings.</p>
</div>
<div class="ops">
<div class="op"><code>exists</code><p>The key is present in the source.</p></div>
<div class="op"><code>is_empty</code><p>The value is exactly the empty string.</p></div>
<div class="op"><code>is_equal</code><span class="needs">needs a candidate</span><p>Deterministic match against a candidate you supply, compared in constant time.</p></div>
<div class="op"><code>partial_match_prefix</code><span class="needs">needs a candidate</span><p>The value starts with the candidate.</p></div>
<div class="op"><code>partial_match_suffix</code><span class="needs">needs a candidate</span><p>The value ends with the candidate.</p></div>
<div class="op"><code>partial_match_regex</code><span class="needs">needs a candidate</span><p>The value matches a regex such as <code>/^sk-/</code>.</p></div>
<div class="op"><code>is_number</code><p>The value parses as a finite number.</p></div>
<div class="op"><code>is_boolean</code><p>The value is <code>true</code> or <code>false</code>, ignoring case and surrounding spaces.</p></div>
<div class="op"><code>is_string</code><p>The value is neither a number nor a boolean.</p></div>
</div>
</div>
</section>
<section class="section" aria-labelledby="surfaces-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">One rule everywhere</p>
<h2 id="surfaces-title">Keys, fingerprints, and booleans. Never values.</h2>
<p class="lede">The same guarantee holds whether you run a command, call the library, or let an AI coding agent work in your project.</p>
</div>
<div class="grid grid-3">
<a class="card card-link" href="#cli">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="19" height="17" rx="2.5"/><path d="m7 9 3 3-3 3M12.5 15h4.5"/></svg></span>
<h3>CLI</h3>
<p>Inspect, validate, copy, and edit env files from your shell with JSON output and useful exit codes.</p>
<span class="more">Command reference <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a class="card card-link" href="#library">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 4C6 4 5.5 5 5.5 7v2c0 1.5-.8 2.5-2 3 1.2.5 2 1.5 2 3v2c0 2 .5 3 2.5 3M16 4c2 0 2.5 1 2.5 3v2c0 1.5.8 2.5 2 3-1.2.5-2 1.5-2 3v2c0 2-.5 3-2.5 3"/></svg></span>
<h3>Library</h3>
<p>A typed TypeScript API for keys, fingerprints, matchers, scans, and file operations inside your own tools.</p>
<span class="more">Library usage <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a class="card card-link" href="guard/">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4.5 6v5.5c0 4.6 3.1 8.2 7.5 9.5 4.4-1.3 7.5-4.9 7.5-9.5V6L12 3Z"/><path d="m9 12 2 2 4-4"/></svg></span>
<h3>OpenCode guard</h3>
<p>A plugin that blocks AI agents from reading or editing secret env files and hands them envsitter tools instead.</p>
<span class="more">Guard for agents <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
</div>
</div>
</section>
<section class="section section-subtle" id="cli" aria-labelledby="cli-title">
<div class="container split">
<div class="section-head">
<p class="eyebrow">CLI</p>
<h2 id="cli-title">Every command keeps values in memory.</h2>
<p class="lede">Output contains keys, booleans, lengths, fingerprints, and line numbers. Commands that change files are dry runs until you pass <code>--write</code>.</p>
<div class="copy-cmd">
<code><span class="prompt">$ </span>npx envsitter keys --file .env</code>
<button class="copy-btn" type="button" data-copy="npx envsitter keys --file .env" aria-label="Copy npx envsitter keys --file .env" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<p class="muted">Exit codes: <code>0</code> success or match, <code>1</code> no match, <code>2</code> usage or runtime error.</p>
</div>
<div class="table-wrap" tabindex="0" role="region" aria-label="CLI commands">
<table class="data-table">
<thead><tr><th scope="col">Command</th><th scope="col">What it does</th></tr></thead>
<tbody>
<tr><td class="cmd"><code>keys</code></td><td>List the keys in an env file</td></tr>
<tr><td class="cmd"><code>fingerprint</code></td><td>Deterministic HMAC fingerprint of one key</td></tr>
<tr><td class="cmd"><code>match</code></td><td>Match a candidate or operator against one, many, or all keys</td></tr>
<tr><td class="cmd"><code>match-by-key</code></td><td>Bulk match a different candidate per key</td></tr>
<tr><td class="cmd"><code>scan</code></td><td>Detect value shapes: JWT, URL, base64</td></tr>
<tr><td class="cmd"><code>redact</code></td><td>Replace secret values in text read from stdin</td></tr>
<tr><td class="cmd"><code>validate</code></td><td>Check dotenv syntax with line and column</td></tr>
<tr><td class="cmd"><code>copy</code></td><td>Copy keys between env files, with rename and conflict policy</td></tr>
<tr><td class="cmd"><code>format</code>, <code>reorder</code></td><td>Sort and organize keys, by section or globally</td></tr>
<tr><td class="cmd"><code>annotate</code></td><td>Add a comment above a key</td></tr>
<tr><td class="cmd"><code>add</code></td><td>Add a new key, failing if it already exists</td></tr>
<tr><td class="cmd"><code>set</code></td><td>Create or update a key</td></tr>
<tr><td class="cmd"><code>unset</code></td><td>Set a key to an empty value</td></tr>
<tr><td class="cmd"><code>delete</code></td><td>Remove one or more keys</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="section" id="library" aria-labelledby="library-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">Library</p>
<h2 id="library-title">The same guarantees, typed.</h2>
<p class="lede">Build envsitter into release scripts, audits, or your own agent tools. Nothing in the API returns a plaintext value.</p>
<p class="muted">Path helpers such as <code>isSensitiveEnvFile</code> tell your tooling which files hold real secrets and which are committed templates like <code>.env.example</code>.</p>
</div>
<div class="code-block">
<div class="panel-bar">
<span class="panel-title">check-secrets.ts</span>
<button class="copy-btn" type="button" data-copy="import { EnvSitter, setEnvFileKey } from 'envsitter';
const es = EnvSitter.fromDotenvFile('.env');
await es.listKeys();
await es.matchCandidate('API_TOKEN', candidate);
await es.matchKey('PORT', { op: 'is_number' });
await es.fingerprintKey('API_TOKEN');
await setEnvFileKey({ file: '.env', key: 'API_TOKEN', value: next, write: true });" aria-label="Copy library example" hidden>
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2.5"/><path d="M15.5 8.5V6A2.5 2.5 0 0 0 13 3.5H6A2.5 2.5 0 0 0 3.5 6v7A2.5 2.5 0 0 0 6 15.5h2.5"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
</button>
</div>
<pre tabindex="0" role="region" aria-label="Library example"><code><span class="tk-kw">import</span> { EnvSitter, setEnvFileKey } <span class="tk-kw">from</span> <span class="tk-str">'envsitter'</span>;
<span class="tk-kw">const</span> es = EnvSitter.<span class="tk-fn">fromDotenvFile</span>(<span class="tk-str">'.env'</span>);
<span class="tk-kw">await</span> es.<span class="tk-fn">listKeys</span>(); <span class="tk-cm">// ['API_TOKEN', 'PORT', ...]</span>
<span class="tk-kw">await</span> es.<span class="tk-fn">matchCandidate</span>(<span class="tk-str">'API_TOKEN'</span>, candidate); <span class="tk-cm">// true or false</span>
<span class="tk-kw">await</span> es.<span class="tk-fn">matchKey</span>(<span class="tk-str">'PORT'</span>, { op: <span class="tk-str">'is_number'</span> }); <span class="tk-cm">// true</span>
<span class="tk-kw">await</span> es.<span class="tk-fn">fingerprintKey</span>(<span class="tk-str">'API_TOKEN'</span>); <span class="tk-cm">// hmac-sha256 digest + length</span>
<span class="tk-cm">// Dry run unless write: true. Returns a plan, never the value.</span>
<span class="tk-kw">await</span> <span class="tk-fn">setEnvFileKey</span>({ file: <span class="tk-str">'.env'</span>, key: <span class="tk-str">'API_TOKEN'</span>, value: next, write: <span class="tk-kw">true</span> });</code></pre>
</div>
</div>
</section>
<section class="section section-subtle" aria-labelledby="security-title">
<div class="container">
<div class="section-head">
<p class="eyebrow">Security model</p>
<h2 id="security-title">Comparisons without disclosure.</h2>
<p class="lede">Values are read in-process for comparisons, but the library never returns them and the CLI never prints them.</p>
</div>
<div class="grid grid-3">
<div class="card">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4 7 20M17 4l-2 16M4.5 9h16M3.5 15h16"/></svg></span>
<h3>Keyed fingerprints</h3>
<p>Fingerprints are HMAC-SHA-256 with a local pepper, not raw hashes that are easy to dictionary-guess. The pepper comes from <code>ENVSITTER_PEPPER</code> or a <code>.envsitter/pepper</code> file created with mode 0600.</p>
</div>
<div class="card">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="19" height="10" rx="5"/><circle cx="16.5" cy="12" r="2.5"/></svg></span>
<h3>Constant-time equality</h3>
<p><code>is_equal</code> hashes the candidate and the stored value with the same pepper and compares the digests in constant time.</p>
</div>
<div class="card">
<span class="glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="19" height="17" rx="2.5"/><path d="m7 9 3 3-3 3M12.5 15h4.5"/></svg></span>
<h3>Candidates over stdin</h3>
<p>Pass candidates and new values with <code>--candidate-stdin</code> or <code>--value-stdin</code> so secrets stay out of your shell history.</p>
</div>
</div>
<p class="muted section-note">Not a secret manager: envsitter does not encrypt, sync, or relocate values. It works on your env sources in place.</p>
</div>
</section>
<section class="section" aria-labelledby="guard-title">
<div class="container">
<div class="band">
<div class="stack">
<p class="eyebrow">For AI coding agents</p>
<h2 id="guard-title">Give your agent envsitter, not your secrets.</h2>
<p>envsitter-guard is an OpenCode plugin for OpenCode 1 and 2. It blocks agents from reading or editing secret env files and gives them fifteen envsitter tools that never return values.</p>
</div>
<a class="btn btn-primary" href="guard/">Explore the guard <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container footer-row">
<p>envsitter is open source under the MIT license.</p>
<ul class="footer-links">
<li><a href="https://github.com/boxpositron/envsitter">GitHub</a></li>
<li><a href="https://www.npmjs.com/package/envsitter">npm</a></li>
<li><a href="https://github.com/boxpositron/envsitter/blob/main/CHANGELOG.md">Changelog</a></li>
<li><a href="guard/">OpenCode guard</a></li>
</ul>
</div>
</footer>
<p id="copy-status" class="visually-hidden" aria-live="polite"></p>
</body>
</html>
+67 -66
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
import { addDotenvKey, annotateDotenvKey, copyDotenvKeys, deleteDotenvKeys, formatDotenv, setDotenvKey, unsetDotenvKey, validateDotenv } from './dotenv/edit.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
import { isExampleEnvFile } from './dotenv/utils.js';
import {
addEnvFileKey,
annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile
} from './file-ops.js';
import { isExampleEnvFile } from './paths.js';
function parseRegex(input: string): RegExp {
@@ -37,6 +44,19 @@ function requireValue<T>(value: T | undefined, message: string): T {
return value;
}
function toCandidatesByKey(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
}
const candidates: Record<string, string> = {};
for (const [key, candidate] of Object.entries(value)) {
if (typeof candidate !== 'string') throw new Error(`Candidate for ${key} must be a string`);
candidates[key] = candidate;
}
return candidates;
}
function parseMatcher(op: string, candidate: string | undefined): EnvSitterMatcher {
if (op === 'exists') return { op: 'exists' };
if (op === 'is_empty') return { op: 'is_empty' };
@@ -122,6 +142,7 @@ function printHelp(): void {
' 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>] Reads text on stdin, writes it with secret values replaced',
' 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]',
@@ -169,8 +190,7 @@ async function run(): Promise<number> {
if (cmd === 'validate') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const contents = await readFile(file, 'utf8');
const result = validateDotenv(contents);
const result = await validateEnvFile(file);
if (json) jsonOut(result);
else {
@@ -197,37 +217,21 @@ async function run(): Promise<number> {
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
const sourceContents = await readFile(from, 'utf8');
const targetContents = await readTextFileOrEmpty(to);
const result = copyDotenvKeys({
sourceContents,
targetContents,
const result = await copyEnvFileKeys({
from,
to,
...(keysRaw ? { keys: parseList(keysRaw) } : {}),
...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
...(renameRaw ? { rename: renameRaw } : {}),
onConflict
onConflict,
write: flags['write'] === true
});
const conflicts = result.plan.filter((p) => p.action === 'conflict');
const willWrite = flags['write'] === true;
if (willWrite && conflicts.length === 0 && result.hasChanges) {
await writeTextFileAtomic(to, result.output);
}
if (json) {
jsonOut({
from,
to,
onConflict,
willWrite,
wrote: willWrite && conflicts.length === 0 && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
});
jsonOut(result);
} else {
for (const p of result.plan) {
const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
@@ -248,14 +252,10 @@ async function run(): Promise<number> {
const mode = modeRaw === 'global' ? 'global' : 'sections';
const sort = sortRaw === 'none' ? 'none' : 'alpha';
const contents = await readFile(file, 'utf8');
const result = formatDotenv({ contents, mode, sort });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await formatEnvFile({ file, mode, sort, write: flags['write'] === true });
if (json) {
jsonOut({ file, mode, sort, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues });
jsonOut(result);
} else {
process.stdout.write(result.hasChanges ? 'CHANGED\n' : 'NO_CHANGES\n');
}
@@ -270,14 +270,10 @@ async function run(): Promise<number> {
const lineRaw = typeof flags['line'] === 'string' ? flags['line'] : undefined;
const line = lineRaw ? Number(lineRaw) : undefined;
const contents = await readFile(file, 'utf8');
const result = annotateDotenvKey({ contents, key, comment, ...(line ? { line } : {}) });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await annotateEnvFile({ file, key, comment, ...(line ? { line } : {}), write: flags['write'] === true });
if (json) {
jsonOut({ file, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}\n`);
}
@@ -295,14 +291,10 @@ async function run(): Promise<number> {
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = addDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await addEnvFileKey({ file, key, value, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
@@ -320,14 +312,10 @@ async function run(): Promise<number> {
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = setDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await setEnvFileKey({ file, key, value, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
@@ -341,14 +329,10 @@ async function run(): Promise<number> {
const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning);
const contents = await readFile(file, 'utf8');
const result = unsetDotenvKey({ contents, key });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await unsetEnvFileKey({ file, key, write: flags['write'] === true });
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
@@ -367,14 +351,10 @@ async function run(): Promise<number> {
const keys = keyArg ? [keyArg] : keysArg ? parseList(keysArg) : undefined;
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
const contents = await readFile(file, 'utf8');
const result = deleteDotenvKeys({ contents, keys });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
const result = await deleteEnvFileKeys({ file, keys, write: flags['write'] === true });
if (json) {
jsonOut({ file, keys, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
jsonOut(result);
} else {
for (const p of result.plan) {
process.stdout.write(`${p.action}: ${p.key}${p.line ? ` L${p.line}` : ''}\n`);
@@ -451,18 +431,39 @@ async function run(): Promise<number> {
const raw = candidatesJson ?? candidatesStdin;
const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
let candidates: Record<string, string>;
let parsedJson: unknown;
try {
candidates = JSON.parse(parsed) as Record<string, string>;
parsedJson = JSON.parse(parsed);
} catch {
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
}
const candidates = toCandidatesByKey(parsedJson);
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
jsonOut({ matches });
return matches.some((m) => m.match) ? 0 : 1;
}
if (cmd === 'redact') {
const minLengthRaw = typeof flags['min-length'] === 'string' ? Number(flags['min-length']) : undefined;
if (minLengthRaw !== undefined && (!Number.isInteger(minLengthRaw) || minLengthRaw < 1)) {
throw new Error('--min-length must be a positive integer');
}
const redactor = await envsitter.redactor({
...(minLengthRaw !== undefined ? { minLength: minLengthRaw } : {}),
...(flags['all'] === true ? { all: true } : {})
});
const result = redactor.redact(await readStdinText());
if (json) jsonOut(result);
else {
process.stdout.write(result.text);
for (const r of result.redactions) process.stderr.write(`envsitter: redacted ${r.count} occurrence(s) of ${r.key}\n`);
}
return 0;
}
if (cmd === 'scan') {
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined;
const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined;
+3
View File
@@ -160,6 +160,8 @@ function parseAssignmentLine(raw: string, line: number, issues: DotenvIssue[]):
export function parseDotenvDocument(contents: string): DotenvDocument {
const issues: DotenvIssue[] = [];
if (contents.length === 0) return { lines: [], issues, endsWithNewline: true };
const endsWithNewline = contents.endsWith('\n');
const split = contents.split(/\r?\n/);
@@ -190,6 +192,7 @@ export function parseDotenvDocument(contents: string): DotenvDocument {
}
export function stringifyDotenvDocument(doc: DotenvDocument): string {
if (doc.lines.length === 0) return '';
const out = doc.lines.map((l) => l.raw).join('\n');
return doc.endsWithNewline ? `${out}\n` : out;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
import { regexTest } from '../regex.js';
import { buildAssignmentLine } from './utils.js';
export type DotenvWriteMode = 'dry-run' | 'write';
@@ -91,8 +92,8 @@ export function copyDotenvKeys(options: {
let hasChanges = false;
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
if (options.include && !options.include.test(fromKey)) continue;
if (options.exclude && options.exclude.test(fromKey)) continue;
if (options.include && !regexTest(options.include, fromKey)) continue;
if (options.exclude && regexTest(options.exclude, fromKey)) continue;
const toKey = renameMap.get(fromKey) ?? fromKey;
+33 -6
View File
@@ -1,23 +1,50 @@
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const newFileMode = 0o600;
function isNotFound(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT';
}
export async function readTextFileOrEmpty(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT') return '';
if (isNotFound(error)) return '';
throw error;
}
}
async function resolveWriteTarget(filePath: string): Promise<string> {
try {
return await realpath(filePath);
} catch (error: unknown) {
if (isNotFound(error)) return filePath;
throw error;
}
}
async function existingFileMode(filePath: string): Promise<number | undefined> {
try {
return (await stat(filePath)).mode & 0o7777;
} catch (error: unknown) {
if (isNotFound(error)) return undefined;
throw error;
}
}
// Writes through symlinks to the real file and keeps its permission bits; new files are owner-only.
export async function writeTextFileAtomic(filePath: string, contents: string): Promise<void> {
const dir = dirname(filePath);
const tmp = await mkdtemp(join(dir, '.envsitter-tmp-'));
const target = await resolveWriteTarget(filePath);
const mode = (await existingFileMode(target)) ?? newFileMode;
const tmp = await mkdtemp(join(dirname(target), '.envsitter-tmp-'));
const tmpFile = join(tmp, 'file');
try {
await writeFile(tmpFile, contents, 'utf8');
await rename(tmpFile, filePath);
await writeFile(tmpFile, contents, { encoding: 'utf8', mode });
await chmod(tmpFile, mode);
await rename(tmpFile, target);
} finally {
await rm(tmp, { recursive: true, force: true });
}
+7 -109
View File
@@ -1,121 +1,19 @@
export type DotenvParseError = {
line: number;
message: string;
};
import { parseDotenvDocument, type DotenvIssue } from './document.js';
export type DotenvParseError = DotenvIssue;
export type DotenvParseResult = {
values: Map<string, string>;
errors: DotenvParseError[];
};
function isValidKeyChar(char: string): boolean {
return /[A-Za-z0-9_]/.test(char);
}
function parseKey(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
for (let i = 0; i < trimmed.length; i++) {
if (!isValidKeyChar(trimmed[i] ?? '')) return undefined;
}
return trimmed;
}
function stripInlineComment(unquotedValue: string): string {
for (let i = 0; i < unquotedValue.length; i++) {
const c = unquotedValue[i];
if (c === '#') {
const prev = i > 0 ? (unquotedValue[i - 1] ?? '') : '';
if (prev === '' || /\s/.test(prev)) {
return unquotedValue.slice(0, i);
}
}
}
return unquotedValue;
}
function unescapeDoubleQuoted(value: string): string {
let out = '';
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c !== '\\') {
out += c;
continue;
}
const next = value[i + 1];
if (next === undefined) {
out += '\\';
continue;
}
i++;
if (next === 'n') out += '\n';
else if (next === 'r') out += '\r';
else if (next === 't') out += '\t';
else out += next;
}
return out;
}
function parseValue(raw: string, line: number, errors: DotenvParseError[]): string {
const trimmed = raw.trimStart();
if (!trimmed) return '';
const first = trimmed[0];
if (first === "'") {
const end = trimmed.indexOf("'", 1);
if (end === -1) {
errors.push({ line, message: 'Unterminated single-quoted value' });
return trimmed.slice(1);
}
return trimmed.slice(1, end);
}
if (first === '"') {
let end = 1;
for (; end < trimmed.length; end++) {
const c = trimmed[end];
if (c === '"' && trimmed[end - 1] !== '\\') break;
}
if (end >= trimmed.length || trimmed[end] !== '"') {
errors.push({ line, message: 'Unterminated double-quoted value' });
return unescapeDoubleQuoted(trimmed.slice(1));
}
return unescapeDoubleQuoted(trimmed.slice(1, end));
}
return stripInlineComment(trimmed).trimEnd();
}
export function parseDotenv(contents: string): DotenvParseResult {
const doc = parseDotenvDocument(contents);
const values = new Map<string, string>();
const errors: DotenvParseError[] = [];
const lines = contents.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = lines[i] ?? '';
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
const eq = withoutExport.indexOf('=');
if (eq === -1) {
errors.push({ line: lineNumber, message: 'Missing = in assignment' });
continue;
}
const key = parseKey(withoutExport.slice(0, eq));
if (!key) {
errors.push({ line: lineNumber, message: 'Invalid key name' });
continue;
}
const rawValue = withoutExport.slice(eq + 1);
const value = parseValue(rawValue, lineNumber, errors);
values.set(key, value);
for (const line of doc.lines) {
if (line.kind === 'assignment') values.set(line.key, line.value);
}
return { values, errors };
return { values, errors: doc.issues };
}
-6
View File
@@ -1,9 +1,3 @@
const EXAMPLE_FILE_PATTERN = /\.env\.(example|sample|template|dist|defaults?)$/i;
export function isExampleEnvFile(filePath: string): boolean {
return EXAMPLE_FILE_PATTERN.test(filePath);
}
export function quoteValue(value: string): string {
if (value === '') return '';
+13 -4
View File
@@ -2,6 +2,8 @@ import { timingSafeEqual } from 'node:crypto';
import { base64UrlEncode } from './encoding.js';
import { fingerprintValueHmacSha256 } from './fingerprint.js';
import { resolvePepper, type PepperOptions } from './pepper.js';
import { createRedactor, type EnvSitterRedactor, type RedactOptions } from './redact.js';
import { regexTest } from './regex.js';
import { DotenvFileSource } from './sources/dotenvFile.js';
import { ExternalCommandSource } from './sources/externalCommand.js';
@@ -76,8 +78,9 @@ export class EnvSitter {
async listKeys(options: ListKeysOptions = {}): Promise<string[]> {
const snapshot = await this.source.load();
const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b));
if (!options.filter) return keys;
return keys.filter((k) => options.filter?.test(k));
const filter = options.filter;
if (!filter) return keys;
return keys.filter((k) => regexTest(filter, k));
}
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
@@ -209,13 +212,19 @@ export class EnvSitter {
return results;
}
/** Builds a redactor that replaces secret-looking values in arbitrary text; the values never leave it. */
async redactor(options: RedactOptions = {}): Promise<EnvSitterRedactor> {
const snapshot = await this.source.load();
return createRedactor(snapshot.values, options);
}
async scan(options: ScanOptions = {}): Promise<ScanFinding[]> {
const snapshot = await this.source.load();
const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64'];
const findings: ScanFinding[] = [];
for (const [key, value] of snapshot.values.entries()) {
if (options.keysFilter && !options.keysFilter.test(key)) continue;
if (options.keysFilter && !regexTest(options.keysFilter, key)) continue;
const detections: Detection[] = [];
for (const kind of detectionsToRun) {
@@ -236,7 +245,7 @@ function matchValue(value: string, matcher: Exclude<EnvSitterMatcher, { op: 'exi
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 === 'partial_match_regex') return regexTest(matcher.regex, value);
if (matcher.op === 'is_number') return isNumberLike(value);
if (matcher.op === 'is_boolean') return isBooleanLike(value);
+7 -25
View File
@@ -9,36 +9,18 @@ import {
setDotenvKey,
unsetDotenvKey,
validateDotenv,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyPlanItem,
type FormatMode,
type FormatSort,
type KeyMutationAction,
type KeyMutationPlanItem
} from './dotenv/edit.js';
import type { DotenvIssue } from './dotenv/document.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
export type DotenvIssue = {
line: number;
column: number;
message: string;
};
export type CopyConflictPolicy = 'error' | 'skip' | 'overwrite';
export type CopyPlanItem = {
fromKey: string;
toKey: string;
action: 'copy' | 'skip' | 'overwrite' | 'missing_source' | 'conflict';
fromLine?: number;
toLine?: number;
};
export type FormatMode = 'sections' | 'global';
export type FormatSort = 'alpha' | 'none';
export type AnnotatePlan = {
key: string;
action: 'inserted' | 'updated' | 'not_found' | 'ambiguous';
keyLines?: number[];
line?: number;
};
export type { AnnotatePlan, CopyConflictPolicy, CopyPlanItem, DotenvIssue, FormatMode, FormatSort };
export type CopyEnvFilesResult = {
from: string;
+9 -1
View File
@@ -12,6 +12,8 @@ export {
export { type PepperOptions, resolvePepper } from './pepper.js';
export { type EnvSitterRedactor, type RedactionCount, type RedactOptions, type RedactResult } from './redact.js';
export {
addEnvFileKey,
annotateEnvFile,
@@ -23,9 +25,15 @@ export {
validateEnvFile,
type AddEnvFileKeyResult,
type AnnotateEnvFileResult,
type AnnotatePlan,
type CopyConflictPolicy,
type CopyEnvFilesResult,
type CopyPlanItem,
type DeleteEnvFileKeysResult,
type DotenvIssue,
type FormatEnvFileResult,
type FormatMode,
type FormatSort,
type KeyMutationAction,
type KeyMutationPlanItem,
type SetEnvFileKeyResult,
@@ -33,4 +41,4 @@ export {
type ValidateEnvFileResult
} from './file-ops.js';
export { isExampleEnvFile } from './dotenv/utils.js';
export { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from './paths.js';
+65
View File
@@ -0,0 +1,65 @@
const EXAMPLE_FILE_PATTERN = /\.env\.(example|sample|template|dist|defaults?)$/i;
const PEPPER_FILE_PATTERN = /(^|\/)\.envsitter\/pepper$/;
// `<name>.env.<ext>` with one of these extensions is source code (e.g. `app.env.ts`), not a dotenv file.
const SOURCE_EXTENSIONS = new Set([
'js',
'cjs',
'mjs',
'jsx',
'ts',
'cts',
'mts',
'tsx',
'md',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'php',
'cs',
'lua',
'dart',
'ex',
'exs'
]);
function normalizePath(filePath: string): string {
return filePath.trim().replace(/\\/g, '/').replace(/\/+$/, '');
}
function baseName(filePath: string): string {
const normalized = normalizePath(filePath);
return normalized.slice(normalized.lastIndexOf('/') + 1);
}
/**
* True for dotenv-style file names: `.env`, `.env.local`, `api.env`, `config.env.local`, `.env.example`.
* Matches on the file name only, so it works for relative, absolute, and Windows paths.
*/
export function isEnvFile(filePath: string): boolean {
const parts = baseName(filePath).toLowerCase().split('.');
const envIndex = parts.indexOf('env', 1);
if (envIndex === -1) return false;
const last = parts[parts.length - 1] ?? '';
return !(parts.length - 1 > envIndex && SOURCE_EXTENSIONS.has(last));
}
/** True for committed template files (`.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.default(s)`). */
export function isExampleEnvFile(filePath: string): boolean {
return EXAMPLE_FILE_PATTERN.test(normalizePath(filePath));
}
/** True for dotenv files that may hold real values: every env file except example/template files. */
export function isSensitiveEnvFile(filePath: string): boolean {
return isEnvFile(filePath) && !isExampleEnvFile(filePath);
}
/** True for the default pepper location (`.envsitter/pepper`). Custom `--pepper-file` paths are not detectable. */
export function isEnvSitterPepperFile(filePath: string): boolean {
return PEPPER_FILE_PATTERN.test(normalizePath(filePath));
}
+55 -25
View File
@@ -1,5 +1,5 @@
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { basename, dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export type PepperOptions = {
@@ -26,14 +26,38 @@ function getPepperFromEnv(envVarNames: string[]): string | undefined {
return undefined;
}
function parsePepperFileContentToBytes(content: string): Uint8Array {
function errorCode(error: unknown): string | undefined {
return error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : undefined;
}
function parsePepperFileContentToBytes(content: string, pepperFilePath: string): Uint8Array {
const recovery = 'Restore it, or delete it to generate a new pepper (fingerprints made with the old pepper will stop matching).';
const trimmed = content.trim();
if (!trimmed) throw new Error('Pepper file is empty');
if (!trimmed) throw new Error(`Pepper file ${pepperFilePath} is empty. ${recovery}`);
const decoded = Buffer.from(trimmed, 'base64');
if (decoded.length < 16) throw new Error('Pepper file content is too short');
if (decoded.length < 16) throw new Error(`Pepper file ${pepperFilePath} is too short. ${recovery}`);
return new Uint8Array(decoded);
}
async function readPepperFile(pepperFilePath: string): Promise<string | undefined> {
try {
return await readFile(pepperFilePath, 'utf8');
} catch (error: unknown) {
if (errorCode(error) === 'ENOENT') return undefined;
throw error;
}
}
// Keeps a project's .envsitter/ out of git even when the project's own .gitignore does not list it.
async function ignorePepperDirectory(dir: string): Promise<void> {
try {
await writeFile(join(dir, '.gitignore'), '*\n', { encoding: 'utf8', flag: 'wx' });
} catch (error: unknown) {
if (errorCode(error) === 'EEXIST') return;
throw error;
}
}
export async function resolvePepper(options: PepperOptions = {}): Promise<PepperResult> {
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
const pepperFromEnv = getPepperFromEnv(envVarNames);
@@ -47,26 +71,32 @@ export async function resolvePepper(options: PepperOptions = {}): Promise<Pepper
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
const createIfMissing = options.createIfMissing ?? true;
try {
const content = await readFile(pepperFilePath, 'utf8');
return { pepperBytes: parsePepperFileContentToBytes(content), source: 'file', pepperFilePath };
} catch (error) {
if (!createIfMissing) throw error;
const dir = dirname(pepperFilePath);
await mkdir(dir, { recursive: true });
const pepper = randomBytes(32);
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600 });
try {
await chmod(pepperFilePath, 0o600);
} catch {
if (process.env.ENVSITTER_DEBUG === '1') {
console.error(`envsitter: could not set permissions on ${pepperFilePath}`);
}
}
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
const existing = await readPepperFile(pepperFilePath);
if (existing !== undefined) {
return { pepperBytes: parsePepperFileContentToBytes(existing, pepperFilePath), source: 'file', pepperFilePath };
}
if (!createIfMissing) throw new Error(`Pepper file ${pepperFilePath} does not exist`);
const dir = dirname(pepperFilePath);
await mkdir(dir, { recursive: true });
if (basename(dir) === '.envsitter') await ignorePepperDirectory(dir);
const pepper = randomBytes(32);
try {
await writeFile(pepperFilePath, pepper.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
} catch (error: unknown) {
if (errorCode(error) !== 'EEXIST') throw error;
const created = await readFile(pepperFilePath, 'utf8');
return { pepperBytes: parsePepperFileContentToBytes(created, pepperFilePath), source: 'file', pepperFilePath };
}
try {
await chmod(pepperFilePath, 0o600);
} catch {
if (process.env.ENVSITTER_DEBUG === '1') {
console.error(`envsitter: could not set permissions on ${pepperFilePath}`);
}
}
return { pepperBytes: new Uint8Array(pepper), source: 'file', pepperFilePath };
}
+127
View File
@@ -0,0 +1,127 @@
export type RedactOptions = {
/** Shortest value that is redacted. Shorter values match ordinary text too often. Default 8. */
minLength?: number;
/** Redact every value at least `minLength` long, not only values that look secret. Default false. */
all?: boolean;
};
export type RedactionCount = {
key: string;
count: number;
};
export type RedactResult = {
text: string;
redactions: RedactionCount[];
};
export type EnvSitterRedactor = {
/** Keys whose values this redactor replaces (never the values themselves). */
readonly keys: readonly string[];
redact(text: string): RedactResult;
};
const defaultMinLength = 8;
const randomTokenMinLength = 16;
const secretKeyName =
/SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PRIVATE_?KEY|CREDENTIAL|API_?KEY|ACCESS_?KEY|SIGNING_?KEY|WEBHOOK|DSN|(?:^|_)(?:KEY|PASS|PWD|SALT|PEPPER)$/i;
export function redactionPlaceholder(key: string): string {
return `[envsitter: ${key} redacted]`;
}
function isPlainScalar(value: string): boolean {
const trimmed = value.trim().toLowerCase();
if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) return true;
return ['true', 'false', 'yes', 'no', 'on', 'off', 'null', 'none'].includes(trimmed);
}
function bitsPerChar(value: string): number {
const counts = new Map<string, number>();
for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
let bits = 0;
for (const count of counts.values()) {
const p = count / value.length;
bits -= p * Math.log2(p);
}
return bits;
}
// Random-looking tokens: long, letters and digits, no whitespace, and no ':' (which rules out URLs, ARNs, host:port).
function looksLikeRandomToken(value: string): boolean {
if (value.length < randomTokenMinLength || /[\s:]/.test(value)) return false;
if (!/[A-Za-z]/.test(value) || !/\d/.test(value)) return false;
return bitsPerChar(value) >= 3;
}
function urlPasswords(value: string): string[] {
if (!value.includes('://')) return [];
let password: string;
try {
password = new URL(value).password;
} catch {
return [];
}
if (!password) return [];
const decoded = decodeURIComponent(password);
return decoded === password ? [password] : [password, decoded];
}
function redactionTerms(key: string, value: string, options: RedactOptions): string[] {
const minLength = options.minLength ?? defaultMinLength;
if (value.length < minLength || isPlainScalar(value)) return [];
const passwords = urlPasswords(value);
const isPem = value.includes('-----BEGIN');
const isSecret = options.all === true || secretKeyName.test(key) || looksLikeRandomToken(value) || passwords.length > 0 || isPem;
if (!isSecret) return [];
const terms = [value, ...passwords.filter((password) => password.length >= minLength)];
if (value.includes('\n')) {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (looksLikeRandomToken(trimmed)) terms.push(trimmed);
}
}
return terms;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function createRedactor(values: ReadonlyMap<string, string>, options: RedactOptions = {}): EnvSitterRedactor {
const keyByTerm = new Map<string, string>();
for (const [key, value] of values) {
for (const term of redactionTerms(key, value, options)) {
if (!keyByTerm.has(term)) keyByTerm.set(term, key);
}
}
const terms = [...keyByTerm.keys()].sort((a, b) => b.length - a.length);
const pattern = terms.length > 0 ? new RegExp(terms.map(escapeRegExp).join('|'), 'g') : undefined;
const keys = [...new Set(keyByTerm.values())].sort((a, b) => a.localeCompare(b));
return {
keys,
redact(text: string): RedactResult {
if (!pattern || text.length === 0) return { text, redactions: [] };
const counts = new Map<string, number>();
const redacted = text.replace(pattern, (match) => {
const key = keyByTerm.get(match);
if (key === undefined) return match;
counts.set(key, (counts.get(key) ?? 0) + 1);
return redactionPlaceholder(key);
});
const redactions = [...counts.entries()]
.map(([key, count]) => ({ key, count }))
.sort((a, b) => a.key.localeCompare(b.key));
return { text: redacted, redactions };
}
};
}
+5
View File
@@ -0,0 +1,5 @@
// RegExp#test advances lastIndex for global/sticky patterns, which makes repeated calls alternate.
export function regexTest(regex: RegExp, input: string): boolean {
if (regex.global || regex.sticky) regex.lastIndex = 0;
return regex.test(input);
}
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const cliPath = fileURLToPath(new URL('../cli.js', import.meta.url));
type CliResult = { code: number; stdout: string; stderr: string };
function runCli(args: string[], input?: string): Promise<CliResult> {
return new Promise((resolve) => {
const child = execFile(process.execPath, [cliPath, ...args], { env: { ...process.env, ENVSITTER_PEPPER: 'cli-test-pepper' } }, (error, stdout, stderr) => {
const code = error && typeof error.code === 'number' ? error.code : 0;
resolve({ code, stdout, stderr });
});
if (input !== undefined) child.stdin?.end(input);
});
}
async function makeTempFile(fileName: string, contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-cli-'));
const filePath = join(dir, fileName);
await writeFile(filePath, contents, 'utf8');
return filePath;
}
test('cli validate --json reports file, ok, and issues', async () => {
const file = await makeTempFile('.env', 'GOOD=secret-value\nBAD-KEY=x\n');
const result = await runCli(['validate', '--file', file, '--json']);
assert.equal(result.code, 2);
assert.ok(!result.stdout.includes('secret-value'));
const parsed = JSON.parse(result.stdout) as { file: string; ok: boolean; issues: Array<{ line: number; column: number }> };
assert.equal(parsed.file, file);
assert.equal(parsed.ok, false);
assert.equal(parsed.issues[0]?.line, 2);
});
test('cli set is a dry run until --write and never prints the value', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const dryRun = await runCli(['set', '--file', file, '--key', 'TOKEN', '--value', 'super-secret', '--json']);
assert.equal(dryRun.code, 0);
assert.ok(!dryRun.stdout.includes('super-secret'));
const planned = JSON.parse(dryRun.stdout) as { willWrite: boolean; wrote: boolean; plan: { action: string } };
assert.equal(planned.wrote, false);
assert.equal(planned.plan.action, 'added');
assert.equal(await readFile(file, 'utf8'), 'A=1\n');
const written = await runCli(['set', '--file', file, '--key', 'TOKEN', '--value', 'super-secret', '--write', '--json']);
assert.equal(written.code, 0);
assert.equal((JSON.parse(written.stdout) as { wrote: boolean }).wrote, true);
assert.equal(await readFile(file, 'utf8'), 'A=1\nTOKEN=super-secret\n');
});
test('cli delete exits 2 when no key was found', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const result = await runCli(['delete', '--file', file, '--key', 'MISSING', '--json']);
assert.equal(result.code, 2);
const parsed = JSON.parse(result.stdout) as { keys: string[]; plan: Array<{ action: string }> };
assert.deepEqual(parsed.keys, ['MISSING']);
assert.equal(parsed.plan[0]?.action, 'not_found');
});
test('cli redact replaces secret values in stdin and reports counts', async () => {
const file = await makeTempFile('.env', 'API_TOKEN=tok_live_9f8a7b6c5d4e\nNODE_ENV=development\n');
const log = 'start development\ncalling with tok_live_9f8a7b6c5d4e\n';
const plain = await runCli(['redact', '--file', file], log);
assert.equal(plain.code, 0);
assert.equal(plain.stdout, 'start development\ncalling with [envsitter: API_TOKEN redacted]\n');
assert.match(plain.stderr, /redacted 1 occurrence\(s\) of API_TOKEN/);
const json = await runCli(['redact', '--file', file, '--json'], log);
assert.equal(json.code, 0);
assert.ok(!json.stdout.includes('tok_live_9f8a7b6c5d4e'));
assert.deepEqual((JSON.parse(json.stdout) as { redactions: unknown }).redactions, [{ key: 'API_TOKEN', count: 1 }]);
const bad = await runCli(['redact', '--file', file, '--min-length', '0'], log);
assert.equal(bad.code, 2);
assert.match(bad.stderr, /--min-length must be a positive integer/);
});
test('cli match-by-key rejects non-string candidates', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const result = await runCli(['match-by-key', '--file', file, '--candidates-json', '{"A":1}']);
assert.equal(result.code, 2);
assert.match(result.stderr, /Candidate for A must be a string/);
});
+73 -2
View File
@@ -1,8 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { chmod, lstat, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { addEnvFileKey, annotateEnvFile, copyEnvFileKeys, deleteEnvFileKeys, formatEnvFile, isExampleEnvFile, setEnvFileKey, unsetEnvFileKey, validateEnvFile } from '../index.js';
@@ -46,6 +46,18 @@ test('copyEnvFileKeys copies selected keys and supports rename', async () => {
assert.ok(out.includes('D=keep'));
});
test('copyEnvFileKeys include/exclude regexes work with the global flag', async () => {
const from = await makeTempFile('.env.prod', ['API_A=1', 'API_B=2', 'API_C=3', 'DB_URL=4'].join('\n') + '\n');
const to = await makeTempFile('.env.staging', '');
const res = await copyEnvFileKeys({ from, to, include: /^API_/g, exclude: /_C$/g });
assert.deepEqual(
res.plan.map((p) => p.fromKey),
['API_A', 'API_B']
);
});
test('annotateEnvFile inserts and updates envsitter comment', async () => {
const file = await makeTempFile('.env', 'A=1\n');
@@ -190,3 +202,62 @@ test('isExampleEnvFile detects example/template files', () => {
assert.equal(isExampleEnvFile('config.env.template'), true);
assert.equal(isExampleEnvFile('/path/to/api.env.example'), true);
});
test('edits into an empty env file write one clean line', async () => {
const setFile = await makeTempFile('.env', '');
const set = await setEnvFileKey({ file: setFile, key: 'A', value: '1', write: true });
assert.equal(set.plan.line, 1);
assert.equal(await readFile(setFile, 'utf8'), 'A=1\n');
const addFile = await makeTempFile('.env', '');
await addEnvFileKey({ file: addFile, key: 'A', value: '1', write: true });
assert.equal(await readFile(addFile, 'utf8'), 'A=1\n');
const from = await makeTempFile('.env.prod', 'A=1\nB=2\n');
const to = join(dirname(from), '.env.staging');
await copyEnvFileKeys({ from, to, keys: ['A'], write: true });
assert.equal(await readFile(to, 'utf8'), 'A=1\n');
const empty = await makeTempFile('.env', '');
const formatted = await formatEnvFile({ file: empty, write: true });
assert.equal(formatted.hasChanges, false);
assert.equal(await readFile(empty, 'utf8'), '');
});
const posixOnly = { skip: process.platform === 'win32' ? 'POSIX permissions and symlinks' : false };
test('writes keep the existing file mode', posixOnly, async () => {
const file = await makeTempFile('.env', 'A=1\n');
await chmod(file, 0o600);
const res = await setEnvFileKey({ file, key: 'B', value: '2', write: true });
assert.equal(res.wrote, true);
assert.equal((await stat(file)).mode & 0o777, 0o600);
await chmod(file, 0o640);
await setEnvFileKey({ file, key: 'C', value: '3', write: true });
assert.equal((await stat(file)).mode & 0o777, 0o640);
});
test('writes create new env files readable only by the owner', posixOnly, async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
const file = join(dir, '.env.local');
const res = await setEnvFileKey({ file, key: 'A', value: '1', write: true });
assert.equal(res.wrote, true);
assert.equal(await readFile(file, 'utf8'), 'A=1\n');
assert.equal((await stat(file)).mode & 0o777, 0o600);
});
test('writes through a symlinked env file update its target and keep the link', posixOnly, async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
const target = join(dir, 'shared.env');
const link = join(dir, '.env');
await writeFile(target, 'A=1\n', 'utf8');
await symlink('shared.env', link);
const res = await setEnvFileKey({ file: link, key: 'B', value: '2', write: true });
assert.equal(res.wrote, true);
assert.equal((await lstat(link)).isSymbolicLink(), true);
assert.equal(await readFile(target, 'utf8'), 'A=1\nB=2\n');
});
+17
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseDotenvDocument } from '../dotenv/document.js';
import { parseDotenv } from '../dotenv/parse.js';
test('parseDotenv parses basic assignments and ignores comments', () => {
@@ -38,3 +39,19 @@ test('parseDotenv reports invalid keys', () => {
assert.ok(parsed.errors.length >= 1);
assert.equal(parsed.values.get('OK'), 'value2');
});
test('parseDotenv shares the document grammar, including issue columns', () => {
const input = ['export\tTABBED=1', 'DUP=first', 'DUP=second', "BAD='unterminated", ' NOEQ', ''].join('\n');
const parsed = parseDotenv(input);
assert.equal(parsed.values.get('TABBED'), '1');
assert.equal(parsed.values.get('DUP'), 'second');
assert.deepEqual(parsed.errors, parseDotenvDocument(input).issues);
assert.deepEqual(
parsed.errors.map((e) => [e.line, e.column]),
[
[4, 1],
[5, 7]
]
);
});
+19
View File
@@ -129,3 +129,22 @@ test('EnvSitter scan detects JWT-like and URL values without exposing them', asy
{ key: 'URL', detections: ['url'] }
]);
});
test('EnvSitter regex filters and matchers are stable with the global flag', async () => {
const filePath = await makeTempDotenv('API_A=https://a.example\nAPI_B=https://b.example\nAPI_C=https://c.example\nOTHER=https://x.example\n');
const es = EnvSitter.fromDotenvFile(filePath);
assert.deepEqual(await es.listKeys({ filter: /^API_/g }), ['API_A', 'API_B', 'API_C']);
const matches = await es.matchKeyBulk(['API_A', 'API_B', 'API_C'], { op: 'partial_match_regex', regex: /^https:/g });
assert.deepEqual(
matches.map((m) => m.match),
[true, true, true]
);
const findings = await es.scan({ keysFilter: /^API_/gy, detect: ['url'] });
assert.deepEqual(
findings.map((f) => f.key),
['API_A', 'API_B', 'API_C']
);
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from '../index.js';
test('isEnvFile recognizes dotenv file names in any directory', () => {
for (const name of ['.env', '.env.local', '.env.production.local', 'api.env', 'config.env.local', 'nested/dir/.env', 'C:\\app\\.env.keys', '.ENV']) {
assert.equal(isEnvFile(name), true, name);
}
for (const name of ['env', '.envrc', 'environment.ts', 'src/env.ts', 'app.env.ts', '.env.d.ts', 'vite-env.d.ts', 'README.md', '.envsitter/pepper']) {
assert.equal(isEnvFile(name), false, name);
}
});
test('isSensitiveEnvFile excludes example and template files', () => {
for (const name of ['.env', '.env.local', 'api.env', '.env.example.local', 'deploy/.env.production']) {
assert.equal(isSensitiveEnvFile(name), true, name);
}
for (const name of ['.env.example', '.env.sample', '.env.template', '.env.dist', '.env.default', '.env.defaults', 'api.env.example', 'src/index.ts']) {
assert.equal(isSensitiveEnvFile(name), false, name);
}
assert.equal(isExampleEnvFile('api.env.sample'), true);
});
test('isEnvSitterPepperFile matches the default pepper location only', () => {
assert.equal(isEnvSitterPepperFile('.envsitter/pepper'), true);
assert.equal(isEnvSitterPepperFile('/repo/.envsitter/pepper'), true);
assert.equal(isEnvSitterPepperFile('C:\\repo\\.envsitter\\pepper'), true);
assert.equal(isEnvSitterPepperFile('.envsitter/pepper.bak'), false);
assert.equal(isEnvSitterPepperFile('pepper'), false);
});
+55 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolvePepper } from '../pepper.js';
@@ -36,6 +36,10 @@ test('resolvePepper creates a pepper file when missing', async () => {
const persisted = (await readFile(pepperPath, 'utf8')).trim();
assert.ok(persisted.length > 0);
const projectPepperPath = join(dir, 'project', '.envsitter', 'pepper');
await resolvePepper({ pepperFilePath: projectPepperPath });
assert.equal(await readFile(join(dir, 'project', '.envsitter', '.gitignore'), 'utf8'), '*\n');
} finally {
if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER;
else process.env.ENVSITTER_PEPPER = prevEnvSitterPepper;
@@ -44,3 +48,53 @@ test('resolvePepper creates a pepper file when missing', async () => {
else process.env.ENV_SITTER_PEPPER = prevEnvSitterPepperAlt;
}
});
async function withoutEnvPepper(fn: () => Promise<void>): Promise<void> {
const saved = [process.env.ENVSITTER_PEPPER, process.env.ENV_SITTER_PEPPER] as const;
delete process.env.ENVSITTER_PEPPER;
delete process.env.ENV_SITTER_PEPPER;
try {
await fn();
} finally {
if (saved[0] === undefined) delete process.env.ENVSITTER_PEPPER;
else process.env.ENVSITTER_PEPPER = saved[0];
if (saved[1] === undefined) delete process.env.ENV_SITTER_PEPPER;
else process.env.ENV_SITTER_PEPPER = saved[1];
}
}
test('resolvePepper refuses to replace a malformed pepper file', async () => {
await withoutEnvPepper(async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const empty = join(dir, 'empty-pepper');
await writeFile(empty, '');
await assert.rejects(() => resolvePepper({ pepperFilePath: empty }), /is empty/);
assert.equal(await readFile(empty, 'utf8'), '');
const short = join(dir, 'short-pepper');
await writeFile(short, 'c2hvcnQ=');
await assert.rejects(() => resolvePepper({ pepperFilePath: short }), /too short/);
assert.equal(await readFile(short, 'utf8'), 'c2hvcnQ=');
await assert.rejects(() => resolvePepper({ pepperFilePath: join(dir, 'missing'), createIfMissing: false }), /does not exist/);
});
});
const canTestUnreadable = process.platform !== 'win32' && process.getuid?.() !== 0;
test('resolvePepper surfaces an unreadable pepper file instead of replacing it', { skip: canTestUnreadable ? false : 'needs POSIX permissions and a non-root user' }, async () => {
await withoutEnvPepper(async () => {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-'));
const locked = join(dir, 'pepper');
await writeFile(locked, 'original-pepper');
await chmod(locked, 0o000);
try {
await assert.rejects(() => resolvePepper({ pepperFilePath: locked }), { code: 'EACCES' });
} finally {
await chmod(locked, 0o600);
}
assert.equal(await readFile(locked, 'utf8'), 'original-pepper');
});
});
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EnvSitter } from '../index.js';
import { createRedactor } from '../redact.js';
const envContents = [
'API_TOKEN=tok_live_9f8a7b6c5d4e',
'NODE_ENV=development',
'PORT=3000',
'DEBUG=true',
'NEXTAUTH_URL=http://localhost:3000',
'DATABASE_URL=postgres://app:s3cretPassw0rd@db:5432/app',
'SESSION_SIGNING=Zq8xV2mN4pL7rT1y'
].join('\n');
async function makeEnvFile(contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-redact-'));
const file = join(dir, '.env');
await writeFile(file, contents, 'utf8');
return file;
}
test('redactor replaces secret-looking values and leaves ordinary config alone', async () => {
const redactor = await EnvSitter.fromDotenvFile(await makeEnvFile(envContents)).redactor();
assert.deepEqual(redactor.keys, ['API_TOKEN', 'DATABASE_URL', 'SESSION_SIGNING']);
const text = [
'auth: Bearer tok_live_9f8a7b6c5d4e',
'env=development port=3000 debug=true url=http://localhost:3000',
'db: postgres://app:s3cretPassw0rd@db:5432/app (password s3cretPassw0rd)',
'signing: Zq8xV2mN4pL7rT1y'
].join('\n');
const result = redactor.redact(text);
for (const secret of ['tok_live_9f8a7b6c5d4e', 's3cretPassw0rd', 'Zq8xV2mN4pL7rT1y']) {
assert.ok(!result.text.includes(secret), `${secret} should be redacted`);
}
assert.ok(result.text.includes('Bearer [envsitter: API_TOKEN redacted]'));
assert.ok(result.text.includes('env=development port=3000 debug=true url=http://localhost:3000'));
assert.deepEqual(result.redactions, [
{ key: 'API_TOKEN', count: 1 },
{ key: 'DATABASE_URL', count: 2 },
{ key: 'SESSION_SIGNING', count: 1 }
]);
});
test('redactor options widen or narrow what is redacted', async () => {
const sitter = EnvSitter.fromDotenvFile(await makeEnvFile(envContents));
const all = await sitter.redactor({ all: true });
assert.ok(all.keys.includes('NODE_ENV'));
assert.ok(!all.keys.includes('PORT'), 'plain numbers are never redacted');
assert.equal(all.redact('mode development').text, 'mode [envsitter: NODE_ENV redacted]');
const strict = await sitter.redactor({ all: true, minLength: 12 });
assert.ok(!strict.keys.includes('NODE_ENV'));
assert.equal(strict.redact('mode development').text, 'mode development');
});
test('redactor covers each random-looking line of a multi-line key', () => {
const pemBody = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7';
const pem = ['-----BEGIN PRIVATE KEY-----', pemBody, '-----END PRIVATE KEY-----'].join('\n');
const redactor = createRedactor(new Map([['TLS_CERT_DATA', pem]]));
const result = redactor.redact(`partial dump: ${pemBody}\nheader: -----BEGIN PRIVATE KEY-----`);
assert.ok(!result.text.includes(pemBody));
assert.ok(result.text.includes('header: -----BEGIN PRIVATE KEY-----'));
assert.deepEqual(result.redactions, [{ key: 'TLS_CERT_DATA', count: 1 }]);
});
test('redactor returns text unchanged when nothing looks secret', () => {
const redactor = createRedactor(new Map([['NODE_ENV', 'production'], ['PORT', '8080']]));
assert.deepEqual(redactor.keys, []);
assert.deepEqual(redactor.redact('production on 8080'), { text: 'production on 8080', redactions: [] });
});
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"types": ["node"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,