@akshxy/envgit 0.5.1 → 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 CHANGED
@@ -176,6 +176,7 @@ Supports 100+ services out of the box: OpenAI, Anthropic, Groq, Stripe, Supabase
176
176
 
177
177
  ### Utilities
178
178
 
179
+
179
180
  | Command | Description |
180
181
  |---------|-------------|
181
182
  | `envgit doctor` | Check everything — key, envs, git safety — in one shot |
@@ -235,3 +236,75 @@ ENVGIT_KEY=$(cat ~/.config/envgit/keys/<id>.key) envgit run -- node server.js
235
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.
236
237
  - **One-time links** — tokens are deleted on first use via a strongly consistent Durable Object. Replay attacks are impossible.
237
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
@@ -23,6 +23,7 @@ import { join } from '../src/commands/join.js';
23
23
  import { doctor } from '../src/commands/doctor.js';
24
24
  import { audit } from '../src/commands/audit.js';
25
25
  import { template } from '../src/commands/template.js';
26
+ import { scan } from '../src/commands/scan.js';
26
27
 
27
28
  program
28
29
  .name('envgit')
@@ -142,6 +143,11 @@ program
142
143
  .description('Generate a new key and re-encrypt all environments')
143
144
  .action(rotateKey);
144
145
 
146
+ program
147
+ .command('scan')
148
+ .description('Scan codebase for hardcoded secrets using pattern matching and entropy analysis')
149
+ .action(scan);
150
+
145
151
  program
146
152
  .command('doctor')
147
153
  .description('Check project health — key, envs, git safety')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshxy/envgit",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Encrypted per-project environment variable manager",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }