@akshxy/envgit 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/bin/envgit.js +26 -0
- package/package.json +1 -1
- package/src/commands/audit.js +86 -0
- package/src/commands/doctor.js +146 -0
- package/src/commands/scan.js +147 -0
- package/src/commands/template.js +53 -0
package/README.md
CHANGED
|
@@ -174,6 +174,16 @@ Supports 100+ services out of the box: OpenAI, Anthropic, Groq, Stripe, Supabase
|
|
|
174
174
|
| `envgit run -- node server.js` | Run a command with env vars injected, nothing written to disk |
|
|
175
175
|
| `envgit import --file .env.local` | Encrypt an existing `.env` file |
|
|
176
176
|
|
|
177
|
+
### Utilities
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
| Command | Description |
|
|
181
|
+
|---------|-------------|
|
|
182
|
+
| `envgit doctor` | Check everything — key, envs, git safety — in one shot |
|
|
183
|
+
| `envgit audit` | Show which keys are missing across environments |
|
|
184
|
+
| `envgit template` | Generate a `.env.example` with all keys, no values |
|
|
185
|
+
| `envgit template --output .env.example --force` | Overwrite existing file |
|
|
186
|
+
|
|
177
187
|
### Status
|
|
178
188
|
|
|
179
189
|
| Command | Description |
|
|
@@ -226,3 +236,75 @@ ENVGIT_KEY=$(cat ~/.config/envgit/keys/<id>.key) envgit run -- node server.js
|
|
|
226
236
|
- **Relay is blind** — `envgit share` encrypts your key with a one-time passphrase before upload. The relay stores only ciphertext and never sees the passphrase. Even a full relay compromise leaks nothing.
|
|
227
237
|
- **One-time links** — tokens are deleted on first use via a strongly consistent Durable Object. Replay attacks are impossible.
|
|
228
238
|
- **24-hour TTL** — unclaimed tokens are automatically destroyed
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Threat model
|
|
243
|
+
|
|
244
|
+
**What envgit protects against**
|
|
245
|
+
|
|
246
|
+
| Threat | Protection |
|
|
247
|
+
|--------|-----------|
|
|
248
|
+
| `.env` accidentally committed to git | Encrypted files are safe to commit — plaintext never touches the repo |
|
|
249
|
+
| Secrets shared over Slack, email, chat | `envgit share` uses a blind relay and one-time encrypted links |
|
|
250
|
+
| Teammate's machine is compromised | Key is per-machine — compromise one machine, not all |
|
|
251
|
+
| Relay is breached | Relay stores only AES-256-GCM ciphertext. Passphrase never sent to relay. Useless without the passphrase. |
|
|
252
|
+
| Secrets hardcoded in source | `envgit scan` detects these using pattern matching and entropy analysis |
|
|
253
|
+
|
|
254
|
+
**What envgit does NOT protect against**
|
|
255
|
+
|
|
256
|
+
- A compromised machine where the key file is readable — if your machine is owned, the key is accessible
|
|
257
|
+
- Secrets that have already been committed in plaintext to git history — use `git filter-repo` to scrub those
|
|
258
|
+
- An attacker who intercepts both the relay token AND the passphrase at the same time — treat the passphrase like a password
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## How it works
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
Your machine Relay Teammate's machine
|
|
266
|
+
──────────────── ────────────────── ──────────────────────
|
|
267
|
+
(Cloudflare Worker)
|
|
268
|
+
project.key (32 bytes)
|
|
269
|
+
│
|
|
270
|
+
▼
|
|
271
|
+
encrypt with ┌──────────────────┐
|
|
272
|
+
one-time passphrase ──────► │ ciphertext only │ ──────► fetch + decrypt
|
|
273
|
+
│ deleted on read │ with passphrase
|
|
274
|
+
│ │ TTL: 24 hours │ │
|
|
275
|
+
▼ └──────────────────┘ ▼
|
|
276
|
+
envgit share envgit join <token>
|
|
277
|
+
prints: --code <passphrase>
|
|
278
|
+
token + passphrase │
|
|
279
|
+
▼
|
|
280
|
+
key saved to
|
|
281
|
+
~/.config/envgit/keys/
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The relay is stateless and blind. It cannot reconstruct the key. Only the machine with the passphrase can decrypt.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Crypto decisions
|
|
289
|
+
|
|
290
|
+
**Why AES-256-GCM?**
|
|
291
|
+
GCM is an authenticated encryption mode — it detects tampering. If anyone modifies the ciphertext (even one byte), decryption fails loudly rather than silently returning corrupt data. This matters for secrets: you want to know immediately if something has been tampered with.
|
|
292
|
+
|
|
293
|
+
**Why 32 bytes of entropy?**
|
|
294
|
+
NIST recommends 128-bit minimum for symmetric keys. envgit uses 256-bit (32 bytes) from `crypto.randomBytes`, which reads from the OS CSPRNG. This is the same source used by OpenSSL and Node's TLS stack.
|
|
295
|
+
|
|
296
|
+
**Why a fresh IV per write?**
|
|
297
|
+
AES-GCM requires a unique IV per (key, message) pair. Reusing an IV with the same key catastrophically breaks confidentiality — an attacker can XOR two ciphertexts to cancel out the keystream. envgit generates a new random IV on every write, making this impossible.
|
|
298
|
+
|
|
299
|
+
**Why zeroize key bytes after use?**
|
|
300
|
+
Node.js buffers live in V8's heap. Without explicit zeroization, key bytes can persist in memory until GC runs — and could potentially be read from a core dump or swap file. envgit calls `buffer.fill(0)` immediately after the crypto operation completes.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Commands
|
|
305
|
+
|
|
306
|
+
### Scan
|
|
307
|
+
|
|
308
|
+
| Command | Description |
|
|
309
|
+
|---------|-------------|
|
|
310
|
+
| `envgit scan` | Scan entire codebase for hardcoded secrets using patterns + entropy |
|
package/bin/envgit.js
CHANGED
|
@@ -20,6 +20,10 @@ import { verify } from '../src/commands/verify.js';
|
|
|
20
20
|
import { rotateKey } from '../src/commands/rotate-key.js';
|
|
21
21
|
import { share } from '../src/commands/share.js';
|
|
22
22
|
import { join } from '../src/commands/join.js';
|
|
23
|
+
import { doctor } from '../src/commands/doctor.js';
|
|
24
|
+
import { audit } from '../src/commands/audit.js';
|
|
25
|
+
import { template } from '../src/commands/template.js';
|
|
26
|
+
import { scan } from '../src/commands/scan.js';
|
|
23
27
|
|
|
24
28
|
program
|
|
25
29
|
.name('envgit')
|
|
@@ -139,6 +143,28 @@ program
|
|
|
139
143
|
.description('Generate a new key and re-encrypt all environments')
|
|
140
144
|
.action(rotateKey);
|
|
141
145
|
|
|
146
|
+
program
|
|
147
|
+
.command('scan')
|
|
148
|
+
.description('Scan codebase for hardcoded secrets using pattern matching and entropy analysis')
|
|
149
|
+
.action(scan);
|
|
150
|
+
|
|
151
|
+
program
|
|
152
|
+
.command('doctor')
|
|
153
|
+
.description('Check project health — key, envs, git safety')
|
|
154
|
+
.action(doctor);
|
|
155
|
+
|
|
156
|
+
program
|
|
157
|
+
.command('audit')
|
|
158
|
+
.description('Show which keys are missing across environments')
|
|
159
|
+
.action(audit);
|
|
160
|
+
|
|
161
|
+
program
|
|
162
|
+
.command('template')
|
|
163
|
+
.description('Generate a .env.example with all keys but no values')
|
|
164
|
+
.option('-o, --output <path>', 'output file path', '.env.example')
|
|
165
|
+
.option('-f, --force', 'overwrite if file already exists')
|
|
166
|
+
.action(template);
|
|
167
|
+
|
|
142
168
|
program
|
|
143
169
|
.command('share')
|
|
144
170
|
.description('Encrypt your key and upload it to a one-time link — send the output to a teammate')
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { readEncEnv } from '../enc.js';
|
|
5
|
+
import { bold, dim, ok } from '../ui.js';
|
|
6
|
+
|
|
7
|
+
export async function audit() {
|
|
8
|
+
const projectRoot = requireProjectRoot();
|
|
9
|
+
const key = loadKey(projectRoot);
|
|
10
|
+
const config = loadConfig(projectRoot);
|
|
11
|
+
|
|
12
|
+
if (config.envs.length < 2) {
|
|
13
|
+
console.log(dim('\n Need at least 2 environments to audit.\n'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Load all envs
|
|
18
|
+
const envVars = {};
|
|
19
|
+
for (const envName of config.envs) {
|
|
20
|
+
envVars[envName] = readEncEnv(projectRoot, envName, key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Collect every key across all envs
|
|
24
|
+
const allKeys = [...new Set(config.envs.flatMap(e => Object.keys(envVars[e])))].sort();
|
|
25
|
+
|
|
26
|
+
if (allKeys.length === 0) {
|
|
27
|
+
console.log(dim('\n No variables found across any environment.\n'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find keys that are missing from at least one env
|
|
32
|
+
const missing = {}; // key → [envs it's missing from]
|
|
33
|
+
const present = {}; // key → [envs it's in]
|
|
34
|
+
|
|
35
|
+
for (const key of allKeys) {
|
|
36
|
+
missing[key] = config.envs.filter(e => !(key in envVars[e]));
|
|
37
|
+
present[key] = config.envs.filter(e => (key in envVars[e]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const problemKeys = allKeys.filter(k => missing[k].length > 0);
|
|
41
|
+
const cleanKeys = allKeys.filter(k => missing[k].length === 0);
|
|
42
|
+
|
|
43
|
+
// ── Header ────────────────────────────────────────────────────────────────
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(bold('Audit') + dim(` — ${config.envs.join(', ')}`));
|
|
46
|
+
console.log('');
|
|
47
|
+
|
|
48
|
+
// ── Missing keys ──────────────────────────────────────────────────────────
|
|
49
|
+
if (problemKeys.length === 0) {
|
|
50
|
+
ok(`All ${allKeys.length} keys are present in every environment.`);
|
|
51
|
+
console.log('');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Column header
|
|
56
|
+
const pad = Math.max(...allKeys.map(k => k.length), 4) + 2;
|
|
57
|
+
const envHeaders = config.envs.map(e => e.padEnd(8)).join(' ');
|
|
58
|
+
console.log(dim(' ' + 'KEY'.padEnd(pad) + envHeaders));
|
|
59
|
+
console.log(dim(' ' + '─'.repeat(pad + config.envs.length * 10)));
|
|
60
|
+
|
|
61
|
+
for (const key of problemKeys) {
|
|
62
|
+
const cols = config.envs.map(e => {
|
|
63
|
+
if (key in envVars[e]) return chalk.green(' ✓'.padEnd(10));
|
|
64
|
+
return chalk.red(' ✗'.padEnd(10));
|
|
65
|
+
}).join('');
|
|
66
|
+
console.log(` ${chalk.bold(key.padEnd(pad))}${cols}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cleanKeys.length > 0) {
|
|
70
|
+
console.log(dim(`\n ${cleanKeys.length} key${cleanKeys.length !== 1 ? 's' : ''} present in all envs (hidden)`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(chalk.yellow(bold(`${problemKeys.length} key${problemKeys.length !== 1 ? 's' : ''} missing from one or more environments.`)));
|
|
75
|
+
|
|
76
|
+
// Per-env fix hints
|
|
77
|
+
console.log('');
|
|
78
|
+
for (const envName of config.envs) {
|
|
79
|
+
const needed = problemKeys.filter(k => !(k in envVars[envName]));
|
|
80
|
+
if (needed.length > 0) {
|
|
81
|
+
console.log(dim(` Fix ${envName}: envgit set ${needed.join('=... ')}=... --env ${envName}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { findProjectRoot, globalKeyPath } from '../keystore.js';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import { readEncEnv } from '../enc.js';
|
|
8
|
+
import { bold, dim } from '../ui.js';
|
|
9
|
+
|
|
10
|
+
function pass(msg) { console.log(chalk.green(` ✓ ${msg}`)); }
|
|
11
|
+
function fail(msg) { console.log(chalk.red(` ✗ ${msg}`)); }
|
|
12
|
+
function warn(msg) { console.log(chalk.yellow(` ⚠ ${msg}`)); }
|
|
13
|
+
function section(title) { console.log(`\n${bold(title)}`); }
|
|
14
|
+
|
|
15
|
+
export async function doctor() {
|
|
16
|
+
let issues = 0;
|
|
17
|
+
|
|
18
|
+
// ── Project ───────────────────────────────────────────────────────────────
|
|
19
|
+
section('Project');
|
|
20
|
+
|
|
21
|
+
const projectRoot = findProjectRoot();
|
|
22
|
+
if (!projectRoot) {
|
|
23
|
+
fail('No envgit project found — run envgit init first.');
|
|
24
|
+
console.log('');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
pass(`Project root: ${dim(projectRoot)}`);
|
|
28
|
+
|
|
29
|
+
let config;
|
|
30
|
+
try {
|
|
31
|
+
config = loadConfig(projectRoot);
|
|
32
|
+
pass(`Config loaded ${dim(`(${config.envs.length} env${config.envs.length !== 1 ? 's' : ''}: ${config.envs.join(', ')})`)}`)
|
|
33
|
+
} catch (e) {
|
|
34
|
+
fail(`Config unreadable — ${e.message}`);
|
|
35
|
+
issues++;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Key ───────────────────────────────────────────────────────────────────
|
|
39
|
+
section('Key');
|
|
40
|
+
|
|
41
|
+
let key = null;
|
|
42
|
+
if (process.env.ENVGIT_KEY) {
|
|
43
|
+
pass('Key loaded from ENVGIT_KEY environment variable');
|
|
44
|
+
key = process.env.ENVGIT_KEY;
|
|
45
|
+
} else if (config?.key_id) {
|
|
46
|
+
const keyPath = globalKeyPath(config.key_id);
|
|
47
|
+
if (existsSync(keyPath)) {
|
|
48
|
+
pass(`Key file found ${dim(keyPath)}`);
|
|
49
|
+
key = readFileSync(keyPath, 'utf8').trim();
|
|
50
|
+
} else {
|
|
51
|
+
fail('Key file missing — run: envgit share / envgit join');
|
|
52
|
+
issues++;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const legacyPath = join(projectRoot, '.envgit.key');
|
|
56
|
+
if (existsSync(legacyPath)) {
|
|
57
|
+
warn(`Legacy key file at project root ${dim('(consider migrating)')}`);
|
|
58
|
+
key = readFileSync(legacyPath, 'utf8').trim();
|
|
59
|
+
} else {
|
|
60
|
+
fail('No key found — run: envgit keygen');
|
|
61
|
+
issues++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (key) {
|
|
66
|
+
const decoded = Buffer.from(key, 'base64');
|
|
67
|
+
if (decoded.length === 32) {
|
|
68
|
+
pass('Key length valid (256-bit)');
|
|
69
|
+
} else {
|
|
70
|
+
fail(`Key length invalid — got ${decoded.length} bytes, expected 32`);
|
|
71
|
+
issues++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Environments ──────────────────────────────────────────────────────────
|
|
76
|
+
if (config && key) {
|
|
77
|
+
section('Environments');
|
|
78
|
+
for (const envName of config.envs) {
|
|
79
|
+
try {
|
|
80
|
+
const vars = readEncEnv(projectRoot, envName, key);
|
|
81
|
+
const count = Object.keys(vars).length;
|
|
82
|
+
pass(`${envName} decrypts OK ${dim(`(${count} var${count !== 1 ? 's' : ''})`)}`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
fail(`${envName} failed to decrypt — ${e.message}`);
|
|
85
|
+
issues++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Git safety ────────────────────────────────────────────────────────────
|
|
91
|
+
section('Git safety');
|
|
92
|
+
|
|
93
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
94
|
+
if (existsSync(gitignorePath)) {
|
|
95
|
+
const gitignore = readFileSync(gitignorePath, 'utf8');
|
|
96
|
+
const lines = gitignore.split('\n').map(l => l.trim());
|
|
97
|
+
|
|
98
|
+
const envIgnored = lines.some(l => l === '.env' || l === '.env.*' || l === '*.env');
|
|
99
|
+
if (envIgnored) {
|
|
100
|
+
pass('.env is in .gitignore');
|
|
101
|
+
} else {
|
|
102
|
+
warn('.env is not in .gitignore — add it to prevent accidental commits');
|
|
103
|
+
issues++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (existsSync(join(projectRoot, '.envgit.key'))) {
|
|
107
|
+
const keyIgnored = lines.some(l => l === '.envgit.key');
|
|
108
|
+
if (!keyIgnored) {
|
|
109
|
+
fail('.envgit.key is not in .gitignore — this would expose your key!');
|
|
110
|
+
issues++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
warn('No .gitignore found');
|
|
115
|
+
issues++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check for .env files tracked by git (no shell, no injection)
|
|
119
|
+
try {
|
|
120
|
+
const tracked = execFileSync('git', ['ls-files', '.env', '.env.local', '.env.production'], {
|
|
121
|
+
cwd: projectRoot,
|
|
122
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
|
+
}).toString().trim();
|
|
124
|
+
|
|
125
|
+
if (tracked) {
|
|
126
|
+
const files = tracked.split('\n').map(f => ` ${f}`).join('\n');
|
|
127
|
+
fail(`Plaintext .env file tracked by git:\n${files}`);
|
|
128
|
+
fail('Remove with: git rm --cached .env');
|
|
129
|
+
issues++;
|
|
130
|
+
} else {
|
|
131
|
+
pass('No plaintext .env files tracked by git');
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
warn('Not a git repo — skipping git tracking check');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
138
|
+
console.log('');
|
|
139
|
+
if (issues === 0) {
|
|
140
|
+
console.log(chalk.green(bold('All checks passed.')));
|
|
141
|
+
} else {
|
|
142
|
+
console.log(chalk.red(bold(`${issues} issue${issues !== 1 ? 's' : ''} found.`)));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.log('');
|
|
146
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, relative, extname } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { findProjectRoot } from '../keystore.js';
|
|
5
|
+
import { bold, dim, ok } from '../ui.js';
|
|
6
|
+
|
|
7
|
+
// ── Known secret patterns ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const PATTERNS = [
|
|
10
|
+
{ name: 'AWS Access Key ID', regex: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
11
|
+
{ name: 'Stripe Secret Key', regex: /\bsk_live_[0-9a-zA-Z]{24,}\b/ },
|
|
12
|
+
{ name: 'Stripe Restricted Key', regex: /\brk_live_[0-9a-zA-Z]{24,}\b/ },
|
|
13
|
+
{ name: 'GitHub Token', regex: /\bghp_[0-9a-zA-Z]{36}\b/ },
|
|
14
|
+
{ name: 'GitHub OAuth Token', regex: /\bgho_[0-9a-zA-Z]{36}\b/ },
|
|
15
|
+
{ name: 'GitHub App Token', regex: /\bghs_[0-9a-zA-Z]{36}\b/ },
|
|
16
|
+
{ name: 'GitHub PAT', regex: /\bgithub_pat_[0-9a-zA-Z_]{82}\b/ },
|
|
17
|
+
{ name: 'OpenAI API Key', regex: /\bsk-[a-zA-Z0-9]{48}\b/ },
|
|
18
|
+
{ name: 'OpenAI Project Key', regex: /\bsk-proj-[0-9a-zA-Z_-]{48,}\b/ },
|
|
19
|
+
{ name: 'Anthropic API Key', regex: /\bsk-ant-[0-9a-zA-Z_-]{80,}\b/ },
|
|
20
|
+
{ name: 'Slack Bot Token', regex: /\bxoxb-[0-9]{10,13}-[0-9]{10,13}-[0-9a-zA-Z]{24}\b/ },
|
|
21
|
+
{ name: 'Slack User Token', regex: /\bxoxp-[0-9]{10,13}-[0-9]{10,13}-[0-9a-zA-Z]{24}\b/ },
|
|
22
|
+
{ name: 'SendGrid API Key', regex: /\bSG\.[0-9a-zA-Z_-]{22}\.[0-9a-zA-Z_-]{43}\b/ },
|
|
23
|
+
{ name: 'Twilio API Key', regex: /\bSK[0-9a-fA-F]{32}\b/ },
|
|
24
|
+
{ name: 'Private Key Block', regex: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
25
|
+
{ name: 'Hardcoded secret', regex: /(?:secret|password|passwd|api_?key|auth_?token)\s*[:=]\s*["']([^"'$`{]{8,})["']/i },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const SKIP_DIRS = new Set([
|
|
29
|
+
'node_modules', '.git', '.envgit', 'dist', 'build', 'out',
|
|
30
|
+
'.next', '.nuxt', 'coverage', '.nyc_output', 'vendor', '.turbo',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const SKIP_EXTENSIONS = new Set([
|
|
34
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
|
|
35
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
36
|
+
'.mp4', '.mp3', '.wav', '.pdf',
|
|
37
|
+
'.zip', '.tar', '.gz', '.tgz',
|
|
38
|
+
'.map', '.lock', '.snap',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const SKIP_FILES = new Set([
|
|
42
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
43
|
+
'.env.example', '.env.sample', '.env.template',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// ── Shannon entropy ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function entropy(str) {
|
|
49
|
+
const freq = {};
|
|
50
|
+
for (const c of str) freq[c] = (freq[c] ?? 0) + 1;
|
|
51
|
+
return Object.values(freq).reduce((h, n) => {
|
|
52
|
+
const p = n / str.length;
|
|
53
|
+
return h - p * Math.log2(p);
|
|
54
|
+
}, 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const HIGH_ENTROPY_PATTERN = /(?:key|token|secret|password|credential|auth|api)\s*[:=]\s*["']([A-Za-z0-9+/=_\-]{20,})["']/gi;
|
|
58
|
+
const ENTROPY_THRESHOLD = 4.0;
|
|
59
|
+
|
|
60
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function* walk(dir) {
|
|
63
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
65
|
+
const full = join(dir, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
yield* walk(full);
|
|
68
|
+
} else if (entry.isFile()) {
|
|
69
|
+
if (SKIP_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
70
|
+
if (SKIP_FILES.has(entry.name)) continue;
|
|
71
|
+
yield full;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function scan() {
|
|
79
|
+
const projectRoot = findProjectRoot() ?? process.cwd();
|
|
80
|
+
const findings = [];
|
|
81
|
+
|
|
82
|
+
for (const filePath of walk(projectRoot)) {
|
|
83
|
+
let content;
|
|
84
|
+
try { content = readFileSync(filePath, 'utf8'); }
|
|
85
|
+
catch { continue; }
|
|
86
|
+
|
|
87
|
+
const relPath = relative(projectRoot, filePath);
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i];
|
|
92
|
+
const lineNum = i + 1;
|
|
93
|
+
|
|
94
|
+
// Known pattern matching
|
|
95
|
+
for (const { name, regex } of PATTERNS) {
|
|
96
|
+
if (regex.test(line)) {
|
|
97
|
+
findings.push({ file: relPath, line: lineNum, type: name, snippet: line.trim(), how: 'pattern' });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Entropy analysis — catches secrets no pattern knows about
|
|
103
|
+
const alreadyCaught = () => findings.some(f => f.file === relPath && f.line === lineNum);
|
|
104
|
+
let match;
|
|
105
|
+
HIGH_ENTROPY_PATTERN.lastIndex = 0;
|
|
106
|
+
while ((match = HIGH_ENTROPY_PATTERN.exec(line)) !== null) {
|
|
107
|
+
if (entropy(match[1]) >= ENTROPY_THRESHOLD && !alreadyCaught()) {
|
|
108
|
+
findings.push({ file: relPath, line: lineNum, type: 'High-entropy secret', snippet: line.trim(), how: 'entropy' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
115
|
+
console.log('');
|
|
116
|
+
|
|
117
|
+
if (findings.length === 0) {
|
|
118
|
+
ok('No hardcoded secrets detected.');
|
|
119
|
+
console.log(dim(` Scanned: ${projectRoot}`));
|
|
120
|
+
console.log('');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(chalk.red(bold(` ${findings.length} potential secret${findings.length !== 1 ? 's' : ''} found\n`)));
|
|
125
|
+
|
|
126
|
+
const byFile = {};
|
|
127
|
+
for (const f of findings) (byFile[f.file] ??= []).push(f);
|
|
128
|
+
|
|
129
|
+
for (const [file, hits] of Object.entries(byFile)) {
|
|
130
|
+
console.log(chalk.cyan(` ${file}`));
|
|
131
|
+
for (const hit of hits) {
|
|
132
|
+
const tag = hit.how === 'entropy' ? chalk.yellow('[entropy]') : chalk.red(`[${hit.type}]`);
|
|
133
|
+
const snippet = hit.snippet.length > 80 ? hit.snippet.slice(0, 77) + '...' : hit.snippet;
|
|
134
|
+
console.log(` ${dim(`line ${String(hit.line).padEnd(4)}`)} ${tag}`);
|
|
135
|
+
console.log(` ${dim(snippet)}`);
|
|
136
|
+
}
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(chalk.yellow(bold(' What to do:')));
|
|
141
|
+
console.log(dim(' 1. Move these values into envgit: envgit set KEY=value'));
|
|
142
|
+
console.log(dim(' 2. Replace hardcoded values with: process.env.KEY'));
|
|
143
|
+
console.log(dim(' 3. Rotate any secrets already in git history'));
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { readEncEnv } from '../enc.js';
|
|
6
|
+
import { ok, warn, fatal, bold, dim } from '../ui.js';
|
|
7
|
+
|
|
8
|
+
export async function template(options) {
|
|
9
|
+
const projectRoot = requireProjectRoot();
|
|
10
|
+
const key = loadKey(projectRoot);
|
|
11
|
+
const config = loadConfig(projectRoot);
|
|
12
|
+
|
|
13
|
+
// Merge keys from all envs (union), sorted
|
|
14
|
+
const allKeys = [...new Set(
|
|
15
|
+
config.envs.flatMap(envName => Object.keys(readEncEnv(projectRoot, envName, key)))
|
|
16
|
+
)].sort();
|
|
17
|
+
|
|
18
|
+
if (allKeys.length === 0) {
|
|
19
|
+
warn('No variables found — nothing to template.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const outPath = join(projectRoot, options.output ?? '.env.example');
|
|
24
|
+
|
|
25
|
+
if (existsSync(outPath) && !options.force) {
|
|
26
|
+
fatal(`${outPath} already exists. Use --force to overwrite.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const projectName = config.project ?? 'your-project';
|
|
30
|
+
const envList = config.envs.join(', ');
|
|
31
|
+
|
|
32
|
+
const lines = [
|
|
33
|
+
`# .env.example — generated by envgit`,
|
|
34
|
+
`# Project : ${projectName}`,
|
|
35
|
+
`# Envs : ${envList}`,
|
|
36
|
+
`#`,
|
|
37
|
+
`# Do not put real values here. This file is safe to commit.`,
|
|
38
|
+
`# To get the real values: envgit join <token> --code <passphrase>`,
|
|
39
|
+
`# then: envgit unpack dev`,
|
|
40
|
+
``,
|
|
41
|
+
...allKeys.map(k => `${k}=`),
|
|
42
|
+
``,
|
|
43
|
+
].join('\n');
|
|
44
|
+
|
|
45
|
+
writeFileSync(outPath, lines, 'utf8');
|
|
46
|
+
|
|
47
|
+
console.log('');
|
|
48
|
+
ok(`Generated ${dim(outPath)} with ${allKeys.length} key${allKeys.length !== 1 ? 's' : ''}`);
|
|
49
|
+
console.log(dim(` Envs scanned: ${envList}`));
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(dim(` Commit it: git add ${options.output ?? '.env.example'} && git commit -m "chore: update .env.example"`));
|
|
52
|
+
console.log('');
|
|
53
|
+
}
|