Compare commits
6 Commits
55ba6d4b4d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b749f483 | |||
| 5675f9c15a | |||
| 2bd7359056 | |||
| af471c9821 | |||
| 33b4a72f2c | |||
| aa2c8f2d6d |
@@ -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: ["*"]
|
||||||
@@ -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`);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 comparisons via deterministic fingerprints (HMAC) and boolean match results.
|
||||||
- Prefer passing candidate secrets via stdin (to avoid shell history): `--candidate-stdin`.
|
- Prefer passing candidate secrets via stdin (to avoid shell history): `--candidate-stdin`.
|
||||||
- Keep all generated peppers/secrets out of git (see `.gitignore`).
|
- 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
|
## Project layout
|
||||||
|
|
||||||
- `src/envsitter.ts`: main library surface (`EnvSitter`)
|
- `src/index.ts`: public exports (the only surface npm users and `envsitter-guard` see)
|
||||||
- `src/cli.ts`: CLI entrypoint
|
- `src/envsitter.ts`: `EnvSitter` read-only facade (keys, fingerprints, matchers, scan, redactor)
|
||||||
- `src/dotenv/parse.ts`: minimal dotenv parser
|
- `src/cli.ts`: CLI entrypoint (`bin` -> `dist/cli.js`); one `if (cmd === ...)` block per command in `run()`
|
||||||
- `src/pepper.ts`: pepper resolution + auto-create
|
- `src/file-ops.ts`: file-level mutations for the library API (read, edit, atomic write only when `write: true`)
|
||||||
- `src/test/*.test.ts`: Node test suite (compiled to `dist/test/*.test.js`)
|
- `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
|
## 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`
|
- Bump `package.json`, date the CHANGELOG heading, commit, push tag `vX.Y.Z`. The Release workflow does the rest; never `npm publish` by hand.
|
||||||
- Runs `tsc -p tsconfig.json` and outputs to `dist/`
|
- 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.
|
||||||
### 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.)
|
|
||||||
|
|
||||||
### Bun
|
### Bun
|
||||||
|
|
||||||
|
|||||||
@@ -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/).
|
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)
|
## 0.0.4 (2026-01-15)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# envsitter
|
# envsitter
|
||||||
|
|
||||||
|
[](https://github.com/boxpositron/envsitter/actions/workflows/ci.yml)
|
||||||
|
[](https://www.npmjs.com/package/envsitter)
|
||||||
|
|
||||||
Safely inspect and match `.env` secrets **without ever printing values**.
|
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:
|
`envsitter` is designed for LLM/agent workflows where you want to:
|
||||||
|
|
||||||
- List keys present in an env source (`.env` file or external provider)
|
- 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.
|
- Deterministic matching uses **HMAC-SHA-256** with a local pepper.
|
||||||
- This avoids publishing raw SHA-256 hashes that are easy to dictionary-guess.
|
- 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.
|
- 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:
|
Non-goals:
|
||||||
|
|
||||||
@@ -30,6 +36,8 @@ Non-goals:
|
|||||||
npm install envsitter
|
npm install envsitter
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requires Node.js 22 or later.
|
||||||
|
|
||||||
Or run the CLI without installing globally:
|
Or run the CLI without installing globally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,7 +53,7 @@ Resolution order:
|
|||||||
1. `process.env.ENVSITTER_PEPPER` (or `ENV_SITTER_PEPPER`)
|
1. `process.env.ENVSITTER_PEPPER` (or `ENV_SITTER_PEPPER`)
|
||||||
2. Pepper file at `.envsitter/pepper` (auto-created if missing)
|
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
|
## 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` | Match candidate value(s) against key(s) |
|
||||||
| `match-by-key` | Bulk match candidates by key |
|
| `match-by-key` | Bulk match candidates by key |
|
||||||
| `scan` | Detect value shapes (JWT, URL, base64) |
|
| `scan` | Detect value shapes (JWT, URL, base64) |
|
||||||
|
| `redact` | Replace secret values in text read from stdin |
|
||||||
| `validate` | Check dotenv syntax |
|
| `validate` | Check dotenv syntax |
|
||||||
| `copy` | Copy keys between env files |
|
| `copy` | Copy keys between env files |
|
||||||
| `format` / `reorder` | Sort and organize 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 --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]`
|
||||||
- `match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)`
|
- `match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)`
|
||||||
- `scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]`
|
- `scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]`
|
||||||
|
- `redact --file <path> [--all] [--min-length <n>]`
|
||||||
- `validate --file <path>`
|
- `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]`
|
- `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]`
|
- `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
|
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
|
### Validate dotenv syntax
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -294,10 +315,10 @@ JSON outputs:
|
|||||||
- with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }`
|
- with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }`
|
||||||
- `match-by-key --json` -> `{ "matches": Array<{ "key": string, "match": boolean }> }`
|
- `match-by-key --json` -> `{ "matches": Array<{ "key": string, "match": boolean }> }`
|
||||||
- `scan --json` -> `{ "findings": Array<{ "key": string, "detections": Array<"jwt"|"url"|"base64"> }> }`
|
- `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<...> }`
|
- `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<...> }`
|
- `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 } }`
|
- `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 }> }`
|
- `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
|
### Utility functions
|
||||||
|
|
||||||
```ts
|
```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
|
// Detect example/template env files
|
||||||
isExampleEnvFile('.env.example'); // true
|
isExampleEnvFile('.env.example'); // true
|
||||||
isExampleEnvFile('api.env.sample'); // true
|
isExampleEnvFile('api.env.sample'); // true
|
||||||
isExampleEnvFile('.env'); // false
|
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
|
### Match operators via the library
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -410,6 +444,18 @@ const es = EnvSitter.fromExternalCommand('my-secret-provider', ['export', '--for
|
|||||||
const keys = await es.listKeys();
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -432,6 +478,16 @@ npm run build
|
|||||||
node --test --test-name-pattern "outside-in" dist/test/envsitter.test.js
|
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
|
## License
|
||||||
|
|
||||||
MIT. See `LICENSE`.
|
MIT. See `LICENSE`.
|
||||||
|
|||||||
Generated
+13
-10
@@ -1,26 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter",
|
"name": "envsitter",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "envsitter",
|
"name": "envsitter",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"envsitter": "dist/cli.js"
|
"envsitter": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.20.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.5",
|
"version": "22.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
|
||||||
"integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==",
|
"integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -44,9 +47,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+10
-4
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter",
|
"name": "envsitter",
|
||||||
"version": "0.0.4",
|
"version": "0.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Safely inspect and match .env secrets without exposing values",
|
"description": "Safely inspect and match .env secrets without exposing values",
|
||||||
|
"homepage": "https://boxpositron.github.io/envsitter/",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/boxpositron/envsitter.git"
|
"url": "git+https://github.com/boxpositron/envsitter.git"
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
"config"
|
"config"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"envsitter": "dist/cli.js"
|
"envsitter": "dist/cli.js"
|
||||||
},
|
},
|
||||||
@@ -33,17 +37,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"!dist/test"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "npm run build && node scripts/run-tests.mjs",
|
"test": "npm run build && node scripts/run-tests.mjs",
|
||||||
|
"prepack": "npm run build",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.20.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
@@ -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.
|
||||||
@@ -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
@@ -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.<anything></code>, <code><name>.env</code>, <code><name>.env.<anything></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 >> .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
@@ -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
@@ -1,9 +1,16 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
|
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
|
||||||
import { addDotenvKey, annotateDotenvKey, copyDotenvKeys, deleteDotenvKeys, formatDotenv, setDotenvKey, unsetDotenvKey, validateDotenv } from './dotenv/edit.js';
|
import {
|
||||||
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
|
addEnvFileKey,
|
||||||
import { isExampleEnvFile } from './dotenv/utils.js';
|
annotateEnvFile,
|
||||||
|
copyEnvFileKeys,
|
||||||
|
deleteEnvFileKeys,
|
||||||
|
formatEnvFile,
|
||||||
|
setEnvFileKey,
|
||||||
|
unsetEnvFileKey,
|
||||||
|
validateEnvFile
|
||||||
|
} from './file-ops.js';
|
||||||
|
import { isExampleEnvFile } from './paths.js';
|
||||||
|
|
||||||
|
|
||||||
function parseRegex(input: string): RegExp {
|
function parseRegex(input: string): RegExp {
|
||||||
@@ -37,6 +44,19 @@ function requireValue<T>(value: T | undefined, message: string): T {
|
|||||||
return value;
|
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 {
|
function parseMatcher(op: string, candidate: string | undefined): EnvSitterMatcher {
|
||||||
if (op === 'exists') return { op: 'exists' };
|
if (op === 'exists') return { op: 'exists' };
|
||||||
if (op === 'is_empty') return { op: 'is_empty' };
|
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 --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]',
|
||||||
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
|
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
|
||||||
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
|
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
|
||||||
|
' redact --file <path> [--all] [--min-length <n>] Reads text on stdin, writes it with secret values replaced',
|
||||||
' validate --file <path>',
|
' 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]',
|
' 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]',
|
' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
|
||||||
@@ -169,8 +190,7 @@ async function run(): Promise<number> {
|
|||||||
|
|
||||||
if (cmd === 'validate') {
|
if (cmd === 'validate') {
|
||||||
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
|
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
|
||||||
const contents = await readFile(file, 'utf8');
|
const result = await validateEnvFile(file);
|
||||||
const result = validateDotenv(contents);
|
|
||||||
|
|
||||||
if (json) jsonOut(result);
|
if (json) jsonOut(result);
|
||||||
else {
|
else {
|
||||||
@@ -197,37 +217,21 @@ async function run(): Promise<number> {
|
|||||||
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
|
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
|
||||||
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
|
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
|
||||||
|
|
||||||
const sourceContents = await readFile(from, 'utf8');
|
const result = await copyEnvFileKeys({
|
||||||
const targetContents = await readTextFileOrEmpty(to);
|
from,
|
||||||
|
to,
|
||||||
const result = copyDotenvKeys({
|
|
||||||
sourceContents,
|
|
||||||
targetContents,
|
|
||||||
...(keysRaw ? { keys: parseList(keysRaw) } : {}),
|
...(keysRaw ? { keys: parseList(keysRaw) } : {}),
|
||||||
...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
|
...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
|
||||||
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
|
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
|
||||||
...(renameRaw ? { rename: renameRaw } : {}),
|
...(renameRaw ? { rename: renameRaw } : {}),
|
||||||
onConflict
|
onConflict,
|
||||||
|
write: flags['write'] === true
|
||||||
});
|
});
|
||||||
|
|
||||||
const conflicts = result.plan.filter((p) => p.action === 'conflict');
|
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) {
|
if (json) {
|
||||||
jsonOut({
|
jsonOut(result);
|
||||||
from,
|
|
||||||
to,
|
|
||||||
onConflict,
|
|
||||||
willWrite,
|
|
||||||
wrote: willWrite && conflicts.length === 0 && result.hasChanges,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
issues: result.issues,
|
|
||||||
plan: result.plan
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
for (const p of result.plan) {
|
for (const p of result.plan) {
|
||||||
const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
|
const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
|
||||||
@@ -248,14 +252,10 @@ async function run(): Promise<number> {
|
|||||||
const mode = modeRaw === 'global' ? 'global' : 'sections';
|
const mode = modeRaw === 'global' ? 'global' : 'sections';
|
||||||
const sort = sortRaw === 'none' ? 'none' : 'alpha';
|
const sort = sortRaw === 'none' ? 'none' : 'alpha';
|
||||||
|
|
||||||
const contents = await readFile(file, 'utf8');
|
const result = await formatEnvFile({ file, mode, sort, write: flags['write'] === true });
|
||||||
const result = formatDotenv({ contents, mode, sort });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, mode, sort, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(result.hasChanges ? 'CHANGED\n' : 'NO_CHANGES\n');
|
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 lineRaw = typeof flags['line'] === 'string' ? flags['line'] : undefined;
|
||||||
const line = lineRaw ? Number(lineRaw) : undefined;
|
const line = lineRaw ? Number(lineRaw) : undefined;
|
||||||
|
|
||||||
const contents = await readFile(file, 'utf8');
|
const result = await annotateEnvFile({ file, key, comment, ...(line ? { line } : {}), write: flags['write'] === true });
|
||||||
const result = annotateDotenvKey({ contents, key, comment, ...(line ? { line } : {}) });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${result.plan.action}: ${result.plan.key}\n`);
|
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 valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
|
||||||
const value = valueStdin ?? valueArg ?? '';
|
const value = valueStdin ?? valueArg ?? '';
|
||||||
|
|
||||||
const contents = await readTextFileOrEmpty(file);
|
const result = await addEnvFileKey({ file, key, value, write: flags['write'] === true });
|
||||||
const result = addDotenvKey({ contents, key, value });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
|
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 valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
|
||||||
const value = valueStdin ?? valueArg ?? '';
|
const value = valueStdin ?? valueArg ?? '';
|
||||||
|
|
||||||
const contents = await readTextFileOrEmpty(file);
|
const result = await setEnvFileKey({ file, key, value, write: flags['write'] === true });
|
||||||
const result = setDotenvKey({ contents, key, value });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
|
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;
|
const noExampleWarning = flags['no-example-warning'] === true;
|
||||||
warnIfExampleFile(file, noExampleWarning);
|
warnIfExampleFile(file, noExampleWarning);
|
||||||
|
|
||||||
const contents = await readFile(file, 'utf8');
|
const result = await unsetEnvFileKey({ file, key, write: flags['write'] === true });
|
||||||
const result = unsetDotenvKey({ contents, key });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
|
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;
|
const keys = keyArg ? [keyArg] : keysArg ? parseList(keysArg) : undefined;
|
||||||
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
|
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
|
||||||
|
|
||||||
const contents = await readFile(file, 'utf8');
|
const result = await deleteEnvFileKeys({ file, keys, write: flags['write'] === true });
|
||||||
const result = deleteDotenvKeys({ contents, keys });
|
|
||||||
|
|
||||||
const willWrite = flags['write'] === true;
|
|
||||||
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
|
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
jsonOut({ file, keys, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
|
jsonOut(result);
|
||||||
} else {
|
} else {
|
||||||
for (const p of result.plan) {
|
for (const p of result.plan) {
|
||||||
process.stdout.write(`${p.action}: ${p.key}${p.line ? ` L${p.line}` : ''}\n`);
|
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 raw = candidatesJson ?? candidatesStdin;
|
||||||
const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
|
const parsed = requireValue(raw, 'Provide --candidates-json or --candidates-stdin');
|
||||||
|
|
||||||
let candidates: Record<string, string>;
|
let parsedJson: unknown;
|
||||||
try {
|
try {
|
||||||
candidates = JSON.parse(parsed) as Record<string, string>;
|
parsedJson = JSON.parse(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
|
throw new Error('Candidates JSON must be an object: {"KEY":"candidate"}');
|
||||||
}
|
}
|
||||||
|
const candidates = toCandidatesByKey(parsedJson);
|
||||||
|
|
||||||
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
|
const matches = await envsitter.matchCandidatesByKey(candidates, pepperMatchOptions(pepper?.pepperFilePath));
|
||||||
jsonOut({ matches });
|
jsonOut({ matches });
|
||||||
return matches.some((m) => m.match) ? 0 : 1;
|
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') {
|
if (cmd === 'scan') {
|
||||||
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined;
|
const keysRegexRaw = typeof flags['keys-regex'] === 'string' ? flags['keys-regex'] : undefined;
|
||||||
const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined;
|
const detectRaw = typeof flags['detect'] === 'string' ? flags['detect'] : undefined;
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ function parseAssignmentLine(raw: string, line: number, issues: DotenvIssue[]):
|
|||||||
|
|
||||||
export function parseDotenvDocument(contents: string): DotenvDocument {
|
export function parseDotenvDocument(contents: string): DotenvDocument {
|
||||||
const issues: DotenvIssue[] = [];
|
const issues: DotenvIssue[] = [];
|
||||||
|
if (contents.length === 0) return { lines: [], issues, endsWithNewline: true };
|
||||||
|
|
||||||
const endsWithNewline = contents.endsWith('\n');
|
const endsWithNewline = contents.endsWith('\n');
|
||||||
|
|
||||||
const split = contents.split(/\r?\n/);
|
const split = contents.split(/\r?\n/);
|
||||||
@@ -190,6 +192,7 @@ export function parseDotenvDocument(contents: string): DotenvDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyDotenvDocument(doc: DotenvDocument): string {
|
export function stringifyDotenvDocument(doc: DotenvDocument): string {
|
||||||
|
if (doc.lines.length === 0) return '';
|
||||||
const out = doc.lines.map((l) => l.raw).join('\n');
|
const out = doc.lines.map((l) => l.raw).join('\n');
|
||||||
return doc.endsWithNewline ? `${out}\n` : out;
|
return doc.endsWithNewline ? `${out}\n` : out;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
|
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
|
||||||
|
import { regexTest } from '../regex.js';
|
||||||
import { buildAssignmentLine } from './utils.js';
|
import { buildAssignmentLine } from './utils.js';
|
||||||
|
|
||||||
export type DotenvWriteMode = 'dry-run' | 'write';
|
export type DotenvWriteMode = 'dry-run' | 'write';
|
||||||
@@ -91,8 +92,8 @@ export function copyDotenvKeys(options: {
|
|||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
|
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
|
||||||
if (options.include && !options.include.test(fromKey)) continue;
|
if (options.include && !regexTest(options.include, fromKey)) continue;
|
||||||
if (options.exclude && options.exclude.test(fromKey)) continue;
|
if (options.exclude && regexTest(options.exclude, fromKey)) continue;
|
||||||
|
|
||||||
const toKey = renameMap.get(fromKey) ?? fromKey;
|
const toKey = renameMap.get(fromKey) ?? fromKey;
|
||||||
|
|
||||||
|
|||||||
+33
-6
@@ -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';
|
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> {
|
export async function readTextFileOrEmpty(filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await readFile(filePath, 'utf8');
|
return await readFile(filePath, 'utf8');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT') return '';
|
if (isNotFound(error)) return '';
|
||||||
throw error;
|
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> {
|
export async function writeTextFileAtomic(filePath: string, contents: string): Promise<void> {
|
||||||
const dir = dirname(filePath);
|
const target = await resolveWriteTarget(filePath);
|
||||||
const tmp = await mkdtemp(join(dir, '.envsitter-tmp-'));
|
const mode = (await existingFileMode(target)) ?? newFileMode;
|
||||||
|
const tmp = await mkdtemp(join(dirname(target), '.envsitter-tmp-'));
|
||||||
const tmpFile = join(tmp, 'file');
|
const tmpFile = join(tmp, 'file');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFile(tmpFile, contents, 'utf8');
|
await writeFile(tmpFile, contents, { encoding: 'utf8', mode });
|
||||||
await rename(tmpFile, filePath);
|
await chmod(tmpFile, mode);
|
||||||
|
await rename(tmpFile, target);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tmp, { recursive: true, force: true });
|
await rm(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-109
@@ -1,121 +1,19 @@
|
|||||||
export type DotenvParseError = {
|
import { parseDotenvDocument, type DotenvIssue } from './document.js';
|
||||||
line: number;
|
|
||||||
message: string;
|
export type DotenvParseError = DotenvIssue;
|
||||||
};
|
|
||||||
|
|
||||||
export type DotenvParseResult = {
|
export type DotenvParseResult = {
|
||||||
values: Map<string, string>;
|
values: Map<string, string>;
|
||||||
errors: DotenvParseError[];
|
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 {
|
export function parseDotenv(contents: string): DotenvParseResult {
|
||||||
|
const doc = parseDotenvDocument(contents);
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
const errors: DotenvParseError[] = [];
|
|
||||||
|
|
||||||
const lines = contents.split(/\r?\n/);
|
for (const line of doc.lines) {
|
||||||
for (let i = 0; i < lines.length; i++) {
|
if (line.kind === 'assignment') values.set(line.key, line.value);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { values, errors };
|
return { values, errors: doc.issues };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function quoteValue(value: string): string {
|
||||||
if (value === '') return '';
|
if (value === '') return '';
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -2,6 +2,8 @@ import { timingSafeEqual } from 'node:crypto';
|
|||||||
import { base64UrlEncode } from './encoding.js';
|
import { base64UrlEncode } from './encoding.js';
|
||||||
import { fingerprintValueHmacSha256 } from './fingerprint.js';
|
import { fingerprintValueHmacSha256 } from './fingerprint.js';
|
||||||
import { resolvePepper, type PepperOptions } from './pepper.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 { DotenvFileSource } from './sources/dotenvFile.js';
|
||||||
import { ExternalCommandSource } from './sources/externalCommand.js';
|
import { ExternalCommandSource } from './sources/externalCommand.js';
|
||||||
|
|
||||||
@@ -76,8 +78,9 @@ export class EnvSitter {
|
|||||||
async listKeys(options: ListKeysOptions = {}): Promise<string[]> {
|
async listKeys(options: ListKeysOptions = {}): Promise<string[]> {
|
||||||
const snapshot = await this.source.load();
|
const snapshot = await this.source.load();
|
||||||
const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b));
|
const keys = [...snapshot.values.keys()].sort((a, b) => a.localeCompare(b));
|
||||||
if (!options.filter) return keys;
|
const filter = options.filter;
|
||||||
return keys.filter((k) => options.filter?.test(k));
|
if (!filter) return keys;
|
||||||
|
return keys.filter((k) => regexTest(filter, k));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
|
async fingerprintKey(key: string, options: MatchOptions = {}): Promise<EnvSitterFingerprint> {
|
||||||
@@ -209,13 +212,19 @@ export class EnvSitter {
|
|||||||
return results;
|
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[]> {
|
async scan(options: ScanOptions = {}): Promise<ScanFinding[]> {
|
||||||
const snapshot = await this.source.load();
|
const snapshot = await this.source.load();
|
||||||
const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64'];
|
const detectionsToRun = options.detect ?? ['jwt', 'url', 'base64'];
|
||||||
|
|
||||||
const findings: ScanFinding[] = [];
|
const findings: ScanFinding[] = [];
|
||||||
for (const [key, value] of snapshot.values.entries()) {
|
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[] = [];
|
const detections: Detection[] = [];
|
||||||
for (const kind of detectionsToRun) {
|
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_prefix') return value.startsWith(matcher.prefix);
|
||||||
if (matcher.op === 'partial_match_suffix') return value.endsWith(matcher.suffix);
|
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_number') return isNumberLike(value);
|
||||||
if (matcher.op === 'is_boolean') return isBooleanLike(value);
|
if (matcher.op === 'is_boolean') return isBooleanLike(value);
|
||||||
|
|||||||
+7
-25
@@ -9,36 +9,18 @@ import {
|
|||||||
setDotenvKey,
|
setDotenvKey,
|
||||||
unsetDotenvKey,
|
unsetDotenvKey,
|
||||||
validateDotenv,
|
validateDotenv,
|
||||||
|
type AnnotatePlan,
|
||||||
|
type CopyConflictPolicy,
|
||||||
|
type CopyPlanItem,
|
||||||
|
type FormatMode,
|
||||||
|
type FormatSort,
|
||||||
type KeyMutationAction,
|
type KeyMutationAction,
|
||||||
type KeyMutationPlanItem
|
type KeyMutationPlanItem
|
||||||
} from './dotenv/edit.js';
|
} from './dotenv/edit.js';
|
||||||
|
import type { DotenvIssue } from './dotenv/document.js';
|
||||||
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
|
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
|
||||||
|
|
||||||
export type DotenvIssue = {
|
export type { AnnotatePlan, CopyConflictPolicy, CopyPlanItem, DotenvIssue, FormatMode, FormatSort };
|
||||||
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 CopyEnvFilesResult = {
|
export type CopyEnvFilesResult = {
|
||||||
from: string;
|
from: string;
|
||||||
|
|||||||
+9
-1
@@ -12,6 +12,8 @@ export {
|
|||||||
|
|
||||||
export { type PepperOptions, resolvePepper } from './pepper.js';
|
export { type PepperOptions, resolvePepper } from './pepper.js';
|
||||||
|
|
||||||
|
export { type EnvSitterRedactor, type RedactionCount, type RedactOptions, type RedactResult } from './redact.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addEnvFileKey,
|
addEnvFileKey,
|
||||||
annotateEnvFile,
|
annotateEnvFile,
|
||||||
@@ -23,9 +25,15 @@ export {
|
|||||||
validateEnvFile,
|
validateEnvFile,
|
||||||
type AddEnvFileKeyResult,
|
type AddEnvFileKeyResult,
|
||||||
type AnnotateEnvFileResult,
|
type AnnotateEnvFileResult,
|
||||||
|
type AnnotatePlan,
|
||||||
|
type CopyConflictPolicy,
|
||||||
type CopyEnvFilesResult,
|
type CopyEnvFilesResult,
|
||||||
|
type CopyPlanItem,
|
||||||
type DeleteEnvFileKeysResult,
|
type DeleteEnvFileKeysResult,
|
||||||
|
type DotenvIssue,
|
||||||
type FormatEnvFileResult,
|
type FormatEnvFileResult,
|
||||||
|
type FormatMode,
|
||||||
|
type FormatSort,
|
||||||
type KeyMutationAction,
|
type KeyMutationAction,
|
||||||
type KeyMutationPlanItem,
|
type KeyMutationPlanItem,
|
||||||
type SetEnvFileKeyResult,
|
type SetEnvFileKeyResult,
|
||||||
@@ -33,4 +41,4 @@ export {
|
|||||||
type ValidateEnvFileResult
|
type ValidateEnvFileResult
|
||||||
} from './file-ops.js';
|
} from './file-ops.js';
|
||||||
|
|
||||||
export { isExampleEnvFile } from './dotenv/utils.js';
|
export { isEnvFile, isEnvSitterPepperFile, isExampleEnvFile, isSensitiveEnvFile } from './paths.js';
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
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';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
export type PepperOptions = {
|
export type PepperOptions = {
|
||||||
@@ -26,14 +26,38 @@ function getPepperFromEnv(envVarNames: string[]): string | undefined {
|
|||||||
return 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();
|
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');
|
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);
|
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> {
|
export async function resolvePepper(options: PepperOptions = {}): Promise<PepperResult> {
|
||||||
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
|
const envVarNames = options.envVarNames ?? ['ENVSITTER_PEPPER', 'ENV_SITTER_PEPPER'];
|
||||||
const pepperFromEnv = getPepperFromEnv(envVarNames);
|
const pepperFromEnv = getPepperFromEnv(envVarNames);
|
||||||
@@ -47,26 +71,32 @@ export async function resolvePepper(options: PepperOptions = {}): Promise<Pepper
|
|||||||
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
|
const pepperFilePath = options.pepperFilePath ?? defaultPepperFilePath();
|
||||||
const createIfMissing = options.createIfMissing ?? true;
|
const createIfMissing = options.createIfMissing ?? true;
|
||||||
|
|
||||||
try {
|
const existing = await readPepperFile(pepperFilePath);
|
||||||
const content = await readFile(pepperFilePath, 'utf8');
|
if (existing !== undefined) {
|
||||||
return { pepperBytes: parsePepperFileContentToBytes(content), source: 'file', pepperFilePath };
|
return { pepperBytes: parsePepperFileContentToBytes(existing, pepperFilePath), 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 };
|
|
||||||
}
|
}
|
||||||
|
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
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { 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';
|
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'));
|
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 () => {
|
test('annotateEnvFile inserts and updates envsitter comment', async () => {
|
||||||
const file = await makeTempFile('.env', 'A=1\n');
|
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('config.env.template'), true);
|
||||||
assert.equal(isExampleEnvFile('/path/to/api.env.example'), 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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseDotenvDocument } from '../dotenv/document.js';
|
||||||
import { parseDotenv } from '../dotenv/parse.js';
|
import { parseDotenv } from '../dotenv/parse.js';
|
||||||
|
|
||||||
test('parseDotenv parses basic assignments and ignores comments', () => {
|
test('parseDotenv parses basic assignments and ignores comments', () => {
|
||||||
@@ -38,3 +39,19 @@ test('parseDotenv reports invalid keys', () => {
|
|||||||
assert.ok(parsed.errors.length >= 1);
|
assert.ok(parsed.errors.length >= 1);
|
||||||
assert.equal(parsed.values.get('OK'), 'value2');
|
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]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -129,3 +129,22 @@ test('EnvSitter scan detects JWT-like and URL values without exposing them', asy
|
|||||||
{ key: 'URL', detections: ['url'] }
|
{ 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']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { resolvePepper } from '../pepper.js';
|
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();
|
const persisted = (await readFile(pepperPath, 'utf8')).trim();
|
||||||
assert.ok(persisted.length > 0);
|
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 {
|
} finally {
|
||||||
if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER;
|
if (prevEnvSitterPepper === undefined) delete process.env.ENVSITTER_PEPPER;
|
||||||
else process.env.ENVSITTER_PEPPER = prevEnvSitterPepper;
|
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;
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"types": ["node"],
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user