@akshxy/envgit 0.5.1 → 0.6.0
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 +73 -14
- package/bin/envgit.js +117 -92
- package/package.json +2 -1
- package/src/commands/add-env.js +4 -0
- package/src/commands/audit.js +4 -1
- package/src/commands/copy.js +15 -13
- package/src/commands/delete.js +11 -5
- package/src/commands/doctor.js +3 -6
- package/src/commands/envs.js +16 -10
- package/src/commands/fix.js +130 -0
- package/src/commands/get.js +6 -5
- package/src/commands/init.js +2 -2
- package/src/commands/join.js +1 -1
- package/src/commands/rename-key.js +8 -9
- package/src/commands/rotate-key.js +2 -5
- package/src/commands/scan.js +207 -0
- package/src/commands/set.js +13 -12
- package/src/commands/status.js +25 -13
- package/src/commands/unpack.js +19 -13
- package/src/commands/use.js +29 -0
- package/src/interactive.js +83 -0
- package/src/keystore.js +2 -2
- package/src/ui.js +30 -5
- package/src/commands/keygen.js +0 -71
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, statSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join, basename } from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { findProjectRoot, globalKeyPath } from '../keystore.js';
|
|
6
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
7
|
+
import { bold, dim, ok, warn } from '../ui.js';
|
|
8
|
+
|
|
9
|
+
function fixed(msg) { console.log(chalk.green(` ✦ fixed ${msg}`)); }
|
|
10
|
+
function check(msg) { console.log(chalk.dim( ` ✓ ok ${msg}`)); }
|
|
11
|
+
function skipped(msg) { console.log(chalk.dim(` – skip ${msg}`)); }
|
|
12
|
+
|
|
13
|
+
export async function fix() {
|
|
14
|
+
const projectRoot = findProjectRoot();
|
|
15
|
+
if (!projectRoot) {
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(chalk.red(' No envgit project found. Run envgit init first.'));
|
|
18
|
+
console.log('');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let fixes = 0;
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log(bold('Running envgit fix...'));
|
|
25
|
+
console.log('');
|
|
26
|
+
|
|
27
|
+
// ── 1. Ensure .envgit dir and state file exist ────────────────────────────
|
|
28
|
+
const envgitDir = join(projectRoot, '.envgit');
|
|
29
|
+
const currentFile = join(envgitDir, '.current');
|
|
30
|
+
mkdirSync(envgitDir, { recursive: true });
|
|
31
|
+
if (!existsSync(currentFile)) {
|
|
32
|
+
writeFileSync(currentFile, '', 'utf8');
|
|
33
|
+
fixed('.envgit/.current state file created');
|
|
34
|
+
fixes++;
|
|
35
|
+
} else {
|
|
36
|
+
check('.envgit/.current exists');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 2. Config migration — add missing fields introduced in newer versions ──
|
|
40
|
+
let config;
|
|
41
|
+
try {
|
|
42
|
+
config = loadConfig(projectRoot);
|
|
43
|
+
let configChanged = false;
|
|
44
|
+
|
|
45
|
+
// v0.2+: default_env field
|
|
46
|
+
if (!config.default_env) {
|
|
47
|
+
config.default_env = config.envs?.[0] ?? 'dev';
|
|
48
|
+
configChanged = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// v0.3+: project name field
|
|
52
|
+
if (!config.project) {
|
|
53
|
+
config.project = basename(projectRoot);
|
|
54
|
+
configChanged = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (configChanged) {
|
|
58
|
+
saveConfig(projectRoot, config);
|
|
59
|
+
fixed('config.yml migrated to latest schema');
|
|
60
|
+
fixes++;
|
|
61
|
+
} else {
|
|
62
|
+
check('config.yml schema is current');
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
warn('Could not load config — skipping migration (run envgit init first)');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 3. Key file permissions ───────────────────────────────────────────────
|
|
69
|
+
if (config?.key_id) {
|
|
70
|
+
const keyPath = globalKeyPath(config.key_id);
|
|
71
|
+
if (existsSync(keyPath)) {
|
|
72
|
+
const mode = statSync(keyPath).mode & 0o777;
|
|
73
|
+
if (mode & 0o077) {
|
|
74
|
+
chmodSync(keyPath, 0o600);
|
|
75
|
+
fixed(`key file permissions set to 600`);
|
|
76
|
+
fixes++;
|
|
77
|
+
} else {
|
|
78
|
+
check('key file permissions are 600');
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
skipped('key file not on this machine (normal for fresh clones)');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 4. .gitignore — ensure .env and .envgit.key are ignored ──────────────
|
|
86
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
87
|
+
let gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
88
|
+
const lines = gitignore.split('\n').map(l => l.trim());
|
|
89
|
+
const missing = [];
|
|
90
|
+
|
|
91
|
+
if (!lines.some(l => l === '.env' || l === '.env.*')) missing.push('.env');
|
|
92
|
+
if (!lines.some(l => l === '.envgit.key')) missing.push('.envgit.key');
|
|
93
|
+
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
gitignore = gitignore.trimEnd() + '\n\n# envgit\n' + missing.join('\n') + '\n';
|
|
96
|
+
writeFileSync(gitignorePath, gitignore, 'utf8');
|
|
97
|
+
fixed(`.gitignore — added: ${missing.join(', ')}`);
|
|
98
|
+
fixes++;
|
|
99
|
+
} else {
|
|
100
|
+
check('.gitignore has .env and .envgit.key');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 5. Remove .env from git tracking if accidentally staged ──────────────
|
|
104
|
+
try {
|
|
105
|
+
const tracked = execFileSync('git', ['ls-files', '.env'], {
|
|
106
|
+
cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
}).toString().trim();
|
|
108
|
+
|
|
109
|
+
if (tracked) {
|
|
110
|
+
execFileSync('git', ['rm', '--cached', '.env'], {
|
|
111
|
+
cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
});
|
|
113
|
+
fixed('.env removed from git tracking (file kept on disk)');
|
|
114
|
+
fixes++;
|
|
115
|
+
} else {
|
|
116
|
+
check('.env is not tracked by git');
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
skipped('not a git repo — skipping git tracking check');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
123
|
+
console.log('');
|
|
124
|
+
if (fixes === 0) {
|
|
125
|
+
console.log(chalk.green(bold(' Everything looks good. Nothing to fix.')));
|
|
126
|
+
} else {
|
|
127
|
+
console.log(chalk.green(bold(` Applied ${fixes} fix${fixes !== 1 ? 'es' : ''}. Run envgit doctor to verify.`)));
|
|
128
|
+
}
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
package/src/commands/get.js
CHANGED
|
@@ -2,7 +2,8 @@ import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
|
2
2
|
import { resolveEnv } from '../config.js';
|
|
3
3
|
import { readEncEnv } from '../enc.js';
|
|
4
4
|
import { getCurrentEnv } from '../state.js';
|
|
5
|
-
import { fatal, label } from '../ui.js';
|
|
5
|
+
import { fatal, label, envLabel } from '../ui.js';
|
|
6
|
+
import { pickKey } from '../interactive.js';
|
|
6
7
|
|
|
7
8
|
export async function get(key, options) {
|
|
8
9
|
const projectRoot = requireProjectRoot();
|
|
@@ -11,9 +12,9 @@ export async function get(key, options) {
|
|
|
11
12
|
|
|
12
13
|
const vars = readEncEnv(projectRoot, envName, encKey);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
fatal(`Key '${key}' not found in ${label(envName)}`);
|
|
16
|
-
}
|
|
15
|
+
const keyName = key ?? await pickKey(vars, `Key to get from [${envName}]`);
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
if (!(keyName in vars)) fatal(`Key '${keyName}' not found in ${envLabel(envName)}`);
|
|
18
|
+
|
|
19
|
+
console.log(vars[keyName]);
|
|
19
20
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -44,8 +44,8 @@ export async function init(options) {
|
|
|
44
44
|
console.log(dim('Commit .envgit/ to share encrypted environments with your team.'));
|
|
45
45
|
console.log(dim('Your key never touches the repo — it lives only on your machine.'));
|
|
46
46
|
console.log('');
|
|
47
|
-
console.log(`Share your key with teammates: ${bold('envgit
|
|
48
|
-
console.log(`Teammates
|
|
47
|
+
console.log(`Share your key with teammates: ${bold('envgit share')}`);
|
|
48
|
+
console.log(`Teammates receive it with: ${bold('envgit join <token> --code <passphrase>')}`);
|
|
49
49
|
console.log('');
|
|
50
50
|
}
|
|
51
51
|
|
package/src/commands/join.js
CHANGED
|
@@ -45,6 +45,6 @@ export async function join(token, options) {
|
|
|
45
45
|
console.log(dim(` Stored at: ${keyPath}`));
|
|
46
46
|
console.log('');
|
|
47
47
|
console.log(dim('Run `envgit verify` to confirm it works.'));
|
|
48
|
-
console.log(dim('Run `envgit unpack
|
|
48
|
+
console.log(dim('Run `envgit unpack` to write your .env file.'));
|
|
49
49
|
console.log('');
|
|
50
50
|
}
|
|
@@ -2,7 +2,8 @@ import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
|
2
2
|
import { resolveEnv } from '../config.js';
|
|
3
3
|
import { readEncEnv, writeEncEnv } from '../enc.js';
|
|
4
4
|
import { getCurrentEnv } from '../state.js';
|
|
5
|
-
import { ok, fatal, label } from '../ui.js';
|
|
5
|
+
import { ok, fatal, label, envLabel } from '../ui.js';
|
|
6
|
+
import { pickKey, promptInput } from '../interactive.js';
|
|
6
7
|
|
|
7
8
|
export async function renameKey(oldName, newName, options) {
|
|
8
9
|
const projectRoot = requireProjectRoot();
|
|
@@ -11,19 +12,17 @@ export async function renameKey(oldName, newName, options) {
|
|
|
11
12
|
|
|
12
13
|
const vars = readEncEnv(projectRoot, envName, key);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
15
|
+
const from = oldName ?? await pickKey(vars, `Key to rename in [${envName}]`);
|
|
16
|
+
const to = newName ?? await promptInput(`New name for ${from}`);
|
|
17
17
|
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
}
|
|
18
|
+
if (!(from in vars)) fatal(`Key '${from}' not found in ${envLabel(envName)}`);
|
|
19
|
+
if (to in vars) fatal(`Key '${to}' already exists in ${envLabel(envName)}`);
|
|
21
20
|
|
|
22
21
|
const ordered = {};
|
|
23
22
|
for (const [k, v] of Object.entries(vars)) {
|
|
24
|
-
ordered[k ===
|
|
23
|
+
ordered[k === from ? to : k] = v;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
writeEncEnv(projectRoot, envName, key, ordered);
|
|
28
|
-
ok(`Renamed ${
|
|
27
|
+
ok(`Renamed ${from} → ${to} in ${envLabel(envName)}`);
|
|
29
28
|
}
|
|
@@ -30,10 +30,7 @@ export async function rotateKey() {
|
|
|
30
30
|
console.log(dim(`Old hint: ${oldHint}`));
|
|
31
31
|
console.log(dim(`New hint: ${newHint}`));
|
|
32
32
|
console.log('');
|
|
33
|
-
|
|
34
|
-
console.log(
|
|
35
|
-
console.log('');
|
|
36
|
-
warn('Share the new key with your team! Old key is now invalid.');
|
|
37
|
-
console.log(dim('They can save it with: envgit keygen --set <key>'));
|
|
33
|
+
warn('Old key is now invalid — teammates need the new one.');
|
|
34
|
+
console.log(dim('Run envgit share, then send teammates the join command.'));
|
|
38
35
|
console.log('');
|
|
39
36
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
// Only flag actual PEM blocks — not code/tests referencing the header string
|
|
25
|
+
{ name: 'Private Key Block', regex: /^-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
26
|
+
{ name: 'Hardcoded secret', regex: /(?:secret|password|passwd|api_?key|auth_?token)\s*[:=]\s*["']([^"'$`{]{8,})["']/i },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const SKIP_DIRS = new Set([
|
|
30
|
+
// JS/TS
|
|
31
|
+
'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo',
|
|
32
|
+
'coverage', '.nyc_output',
|
|
33
|
+
// Python
|
|
34
|
+
'.venv', 'venv', 'env', '__pycache__', '.eggs', '*.egg-info',
|
|
35
|
+
'site-packages', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
36
|
+
// Ruby / Go / Rust
|
|
37
|
+
'vendor', 'target',
|
|
38
|
+
// VCS / tooling
|
|
39
|
+
'.git', '.envgit',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SKIP_EXTENSIONS = new Set([
|
|
43
|
+
// Binaries & media
|
|
44
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
|
|
45
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
46
|
+
'.mp4', '.mp3', '.wav', '.pdf',
|
|
47
|
+
'.zip', '.tar', '.gz', '.tgz', '.rar', '.7z',
|
|
48
|
+
// Compiled / generated
|
|
49
|
+
'.pyc', '.pyo', '.class', '.so', '.dylib', '.dll', '.exe',
|
|
50
|
+
'.map', '.lock', '.snap',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const SKIP_FILES = new Set([
|
|
54
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'poetry.lock',
|
|
55
|
+
'.env.example', '.env.sample', '.env.template',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function isPlaceholder(v) {
|
|
59
|
+
v = v.trim();
|
|
60
|
+
if (v.length < 8) return true;
|
|
61
|
+
|
|
62
|
+
// Template syntax: <YOUR_KEY>, ${VAR}, env(VAR)
|
|
63
|
+
if (/^<.+>$/.test(v)) return true;
|
|
64
|
+
if (/^\$\{?.+\}?$/.test(v)) return true;
|
|
65
|
+
if (/^env\(.+\)$/.test(v)) return true;
|
|
66
|
+
|
|
67
|
+
// ALL_CAPS — it's an env var name being used as a placeholder value
|
|
68
|
+
if (/^[A-Z][A-Z0-9_]{3,}$/.test(v)) return true;
|
|
69
|
+
|
|
70
|
+
// snake_case_with_multiple_parts — looks like a variable name, not a secret
|
|
71
|
+
// e.g. google_api_key, test_api_key, gemini_api_key, client_secret
|
|
72
|
+
if (/^[a-z][a-z0-9]*(_[a-z0-9]+){2,}$/.test(v)) return true;
|
|
73
|
+
|
|
74
|
+
// Stripe / other test-mode key prefixes
|
|
75
|
+
if (/^sk_test_|^pk_test_|^rk_test_/.test(v)) return true;
|
|
76
|
+
|
|
77
|
+
// Contains ellipsis, stars, hashes — clearly masked/truncated
|
|
78
|
+
if (/\.{2,}|\*{3,}|#{3,}/.test(v)) return true;
|
|
79
|
+
|
|
80
|
+
// Repeating character (aaaaaaa, 111111)
|
|
81
|
+
if (/^(.)\1{4,}$/.test(v)) return true;
|
|
82
|
+
|
|
83
|
+
// Common placeholder keywords anywhere in the value
|
|
84
|
+
if (/\b(your|my|sample|example|demo|fake|dummy|mock|placeholder|changeme|replace|insert|fill_?in|put_?here|goes_?here|api_?key_?here)\b/i.test(v)) return true;
|
|
85
|
+
|
|
86
|
+
// Starts with "test" or "TEST" followed by separator (test-key, TEST_TOKEN)
|
|
87
|
+
if (/^test[-_]/i.test(v)) return true;
|
|
88
|
+
|
|
89
|
+
// The value IS the field name (secret = "secret", api_key = "api_key", token = "token")
|
|
90
|
+
if (/^(api[_-]?key|auth[_-]?token|access[_-]?token|client[_-]?secret|bearer[_-]?token|x[_-]?api[_-]?key|private[_-]?key|secret[_-]?key)$/i.test(v)) return true;
|
|
91
|
+
|
|
92
|
+
// Common weak/example passwords
|
|
93
|
+
if (/^(password|passwd|p@ssw0rd|qwerty|admin|login|welcome|letmein|abc123|secret|monkey|dragon|master)/i.test(v)) return true;
|
|
94
|
+
|
|
95
|
+
// For Private Key Block pattern — only flag if it's an actual key block,
|
|
96
|
+
// not a string constant, comment, or code referencing the PEM header format
|
|
97
|
+
// (handled per-pattern in the caller)
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Shannon entropy ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function entropy(str) {
|
|
105
|
+
const freq = {};
|
|
106
|
+
for (const c of str) freq[c] = (freq[c] ?? 0) + 1;
|
|
107
|
+
return Object.values(freq).reduce((h, n) => {
|
|
108
|
+
const p = n / str.length;
|
|
109
|
+
return h - p * Math.log2(p);
|
|
110
|
+
}, 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const HIGH_ENTROPY_PATTERN = /(?:key|token|secret|password|credential|auth|api)\s*[:=]\s*["']([A-Za-z0-9+/=_\-]{20,})["']/gi;
|
|
114
|
+
const ENTROPY_THRESHOLD = 4.0;
|
|
115
|
+
|
|
116
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function* walk(dir) {
|
|
119
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
120
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
121
|
+
const full = join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
yield* walk(full);
|
|
124
|
+
} else if (entry.isFile()) {
|
|
125
|
+
if (SKIP_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
126
|
+
if (SKIP_FILES.has(entry.name)) continue;
|
|
127
|
+
yield full;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export async function scan() {
|
|
135
|
+
const projectRoot = findProjectRoot() ?? process.cwd();
|
|
136
|
+
const findings = [];
|
|
137
|
+
|
|
138
|
+
for (const filePath of walk(projectRoot)) {
|
|
139
|
+
let content;
|
|
140
|
+
try { content = readFileSync(filePath, 'utf8'); }
|
|
141
|
+
catch { continue; }
|
|
142
|
+
|
|
143
|
+
const relPath = relative(projectRoot, filePath);
|
|
144
|
+
const lines = content.split('\n');
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const line = lines[i];
|
|
148
|
+
const lineNum = i + 1;
|
|
149
|
+
|
|
150
|
+
// Known pattern matching
|
|
151
|
+
for (const { name, regex } of PATTERNS) {
|
|
152
|
+
const m = line.match(regex);
|
|
153
|
+
if (m) {
|
|
154
|
+
const captured = m[1] ?? m[0];
|
|
155
|
+
if (isPlaceholder(captured)) continue;
|
|
156
|
+
findings.push({ file: relPath, line: lineNum, type: name, snippet: line.trim(), how: 'pattern' });
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Entropy analysis — catches secrets no pattern knows about
|
|
162
|
+
const alreadyCaught = () => findings.some(f => f.file === relPath && f.line === lineNum);
|
|
163
|
+
let match;
|
|
164
|
+
HIGH_ENTROPY_PATTERN.lastIndex = 0;
|
|
165
|
+
while ((match = HIGH_ENTROPY_PATTERN.exec(line)) !== null) {
|
|
166
|
+
const value = match[1];
|
|
167
|
+
if (!isPlaceholder(value) && entropy(value) >= ENTROPY_THRESHOLD && !alreadyCaught()) {
|
|
168
|
+
findings.push({ file: relPath, line: lineNum, type: 'High-entropy secret', snippet: line.trim(), how: 'entropy' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
175
|
+
console.log('');
|
|
176
|
+
|
|
177
|
+
if (findings.length === 0) {
|
|
178
|
+
ok('No hardcoded secrets detected.');
|
|
179
|
+
console.log(dim(` Scanned: ${projectRoot}`));
|
|
180
|
+
console.log('');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(chalk.red(bold(` ${findings.length} potential secret${findings.length !== 1 ? 's' : ''} found\n`)));
|
|
185
|
+
|
|
186
|
+
const byFile = {};
|
|
187
|
+
for (const f of findings) (byFile[f.file] ??= []).push(f);
|
|
188
|
+
|
|
189
|
+
for (const [file, hits] of Object.entries(byFile)) {
|
|
190
|
+
console.log(chalk.cyan(` ${file}`));
|
|
191
|
+
for (const hit of hits) {
|
|
192
|
+
const tag = hit.how === 'entropy' ? chalk.yellow('[entropy]') : chalk.red(`[${hit.type}]`);
|
|
193
|
+
const snippet = hit.snippet.length > 80 ? hit.snippet.slice(0, 77) + '...' : hit.snippet;
|
|
194
|
+
console.log(` ${dim(`line ${String(hit.line).padEnd(4)}`)} ${tag}`);
|
|
195
|
+
console.log(` ${dim(snippet)}`);
|
|
196
|
+
}
|
|
197
|
+
console.log('');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(chalk.yellow(bold(' What to do:')));
|
|
201
|
+
console.log(dim(' 1. Move these values into envgit: envgit set KEY=value'));
|
|
202
|
+
console.log(dim(' 2. Replace hardcoded values with: process.env.KEY'));
|
|
203
|
+
console.log(dim(' 3. Rotate any secrets already in git history'));
|
|
204
|
+
console.log('');
|
|
205
|
+
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
package/src/commands/set.js
CHANGED
|
@@ -5,7 +5,8 @@ import { resolveEnv } from '../config.js';
|
|
|
5
5
|
import { readEncEnv, writeEncEnv } from '../enc.js';
|
|
6
6
|
import { readEnvFile } from '../envfile.js';
|
|
7
7
|
import { getCurrentEnv } from '../state.js';
|
|
8
|
-
import { ok, fatal, label } from '../ui.js';
|
|
8
|
+
import { ok, fatal, label, envLabel } from '../ui.js';
|
|
9
|
+
import { pickKey, promptValue } from '../interactive.js';
|
|
9
10
|
|
|
10
11
|
export async function set(assignments, options) {
|
|
11
12
|
const projectRoot = requireProjectRoot();
|
|
@@ -16,28 +17,28 @@ export async function set(assignments, options) {
|
|
|
16
17
|
|
|
17
18
|
if (options.file) {
|
|
18
19
|
const filePath = join(projectRoot, options.file);
|
|
19
|
-
if (!existsSync(filePath)) {
|
|
20
|
-
fatal(`File not found: ${options.file}`);
|
|
21
|
-
}
|
|
20
|
+
if (!existsSync(filePath)) fatal(`File not found: ${options.file}`);
|
|
22
21
|
const fileVars = readEnvFile(filePath);
|
|
23
22
|
const entries = Object.entries(fileVars);
|
|
24
|
-
if (entries.length === 0) {
|
|
25
|
-
fatal(`No variables found in ${options.file}`);
|
|
26
|
-
}
|
|
23
|
+
if (entries.length === 0) fatal(`No variables found in ${options.file}`);
|
|
27
24
|
for (const [k, v] of entries) {
|
|
28
25
|
vars[k] = v;
|
|
29
|
-
ok(`Set ${k} in ${
|
|
26
|
+
ok(`Set ${k} in ${envLabel(envName)}`);
|
|
30
27
|
}
|
|
28
|
+
} else if (assignments.length === 0) {
|
|
29
|
+
// ── Interactive mode ───────────────────────────────────────────────────
|
|
30
|
+
const keyName = await pickKey(vars, `Key to set in [${envName}]`, { allowNew: true });
|
|
31
|
+
const value = await promptValue(keyName, vars[keyName]);
|
|
32
|
+
vars[keyName] = value;
|
|
33
|
+
ok(`Set ${keyName} in ${envLabel(envName)}`);
|
|
31
34
|
} else {
|
|
32
35
|
for (const assignment of assignments) {
|
|
33
36
|
const eqIdx = assignment.indexOf('=');
|
|
34
|
-
if (eqIdx === -1) {
|
|
35
|
-
fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
|
|
36
|
-
}
|
|
37
|
+
if (eqIdx === -1) fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
|
|
37
38
|
const k = assignment.slice(0, eqIdx).trim();
|
|
38
39
|
const v = assignment.slice(eqIdx + 1);
|
|
39
40
|
vars[k] = v;
|
|
40
|
-
ok(`Set ${k} in ${
|
|
41
|
+
ok(`Set ${k} in ${envLabel(envName)}`);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
package/src/commands/status.js
CHANGED
|
@@ -4,35 +4,47 @@ import { requireProjectRoot, globalKeyPath } from '../keystore.js';
|
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { getCurrentEnv } from '../state.js';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
-
import { bold,
|
|
7
|
+
import { bold, dim, envLabel } from '../ui.js';
|
|
8
8
|
|
|
9
9
|
export async function status() {
|
|
10
10
|
const projectRoot = requireProjectRoot();
|
|
11
|
-
const config
|
|
12
|
-
const current
|
|
11
|
+
const config = loadConfig(projectRoot);
|
|
12
|
+
const current = getCurrentEnv(projectRoot);
|
|
13
13
|
|
|
14
|
+
// Key source
|
|
14
15
|
let keySource;
|
|
15
16
|
if (process.env.ENVGIT_KEY) {
|
|
16
17
|
keySource = chalk.green('ENVGIT_KEY') + dim(' (env var)');
|
|
17
18
|
} else if (config.key_id) {
|
|
18
19
|
const keyPath = globalKeyPath(config.key_id);
|
|
19
20
|
keySource = existsSync(keyPath)
|
|
20
|
-
? chalk.green('
|
|
21
|
-
: chalk.red('
|
|
21
|
+
? chalk.green('✓ key found') + dim(` ~/.config/envgit/keys/${config.key_id.slice(0, 8)}…`)
|
|
22
|
+
: chalk.red('✗ key missing') + dim(' — run: envgit join <token> --code <passphrase>');
|
|
22
23
|
} else {
|
|
23
|
-
// Legacy
|
|
24
24
|
const legacyPath = join(projectRoot, '.envgit.key');
|
|
25
|
-
keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('
|
|
25
|
+
keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('✗ not found');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const dotenvExists = existsSync(join(projectRoot, '.env'));
|
|
29
29
|
|
|
30
|
+
// Active env banner — front and center
|
|
31
|
+
const activeDisplay = current
|
|
32
|
+
? envLabel(current)
|
|
33
|
+
: chalk.dim('none');
|
|
34
|
+
|
|
35
|
+
console.log('');
|
|
36
|
+
console.log(` ${bold('Active env')} ${activeDisplay}${!current ? chalk.dim(' — run: envgit use <env>') : ''}`);
|
|
30
37
|
console.log('');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
|
|
39
|
+
// All envs in a row, active one highlighted
|
|
40
|
+
const envRow = config.envs.map(e =>
|
|
41
|
+
e === current
|
|
42
|
+
? envLabel(e)
|
|
43
|
+
: chalk.dim(e)
|
|
44
|
+
).join(' ');
|
|
45
|
+
console.log(` ${bold('Environments')} ${envRow}`);
|
|
46
|
+
console.log(` ${bold('Key')} ${keySource}`);
|
|
47
|
+
console.log(` ${bold('.env')} ${dotenvExists ? chalk.green('present') : chalk.dim('missing')}${!dotenvExists && current ? chalk.dim(' — run: envgit unpack') : ''}`);
|
|
48
|
+
console.log(` ${bold('Project')} ${dim(projectRoot)}`);
|
|
37
49
|
console.log('');
|
|
38
50
|
}
|
package/src/commands/unpack.js
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
4
|
-
import { getEncPath } from '../config.js';
|
|
4
|
+
import { loadConfig, getEncPath } from '../config.js';
|
|
5
5
|
import { readEncEnv } from '../enc.js';
|
|
6
6
|
import { writeEnvFile } from '../envfile.js';
|
|
7
|
-
import { setCurrentEnv } from '../state.js';
|
|
8
|
-
import { ok, fatal,
|
|
7
|
+
import { getCurrentEnv, setCurrentEnv } from '../state.js';
|
|
8
|
+
import { ok, fatal, dim, envLabel } from '../ui.js';
|
|
9
|
+
import { pickEnv } from '../interactive.js';
|
|
9
10
|
|
|
10
|
-
export async function unpack(
|
|
11
|
+
export async function unpack(envArg) {
|
|
11
12
|
const projectRoot = requireProjectRoot();
|
|
12
|
-
const key
|
|
13
|
+
const key = loadKey(projectRoot);
|
|
14
|
+
const config = loadConfig(projectRoot);
|
|
15
|
+
const current = getCurrentEnv(projectRoot);
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// Priority: explicit arg → active env → interactive pick
|
|
18
|
+
const name = envArg
|
|
19
|
+
?? current
|
|
20
|
+
?? await pickEnv(config.envs, 'Which environment to unpack?');
|
|
21
|
+
|
|
22
|
+
const encPath = getEncPath(projectRoot, name);
|
|
15
23
|
if (!existsSync(encPath)) {
|
|
16
|
-
fatal(`Environment '${
|
|
24
|
+
fatal(`Environment '${name}' does not exist. Create it with: envgit add-env ${name}`);
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
const vars = readEncEnv(projectRoot,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
setCurrentEnv(projectRoot, envName);
|
|
27
|
+
const vars = readEncEnv(projectRoot, name, key);
|
|
28
|
+
writeEnvFile(join(projectRoot, '.env'), vars, { envName: name, projectRoot });
|
|
29
|
+
setCurrentEnv(projectRoot, name);
|
|
23
30
|
|
|
24
31
|
const count = Object.keys(vars).length;
|
|
25
|
-
|
|
26
|
-
ok(`Unpacked ${label(envName)} → .env ${dim(`(${count} variable${count !== 1 ? 's' : ''})`)}`);
|
|
32
|
+
ok(`Unpacked ${envLabel(name)} → .env ${dim(`(${count} var${count !== 1 ? 's' : ''})`)}`);
|
|
27
33
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { requireProjectRoot } from '../keystore.js';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { setCurrentEnv, getCurrentEnv } from '../state.js';
|
|
4
|
+
import { ok, fatal, envLabel, dim } from '../ui.js';
|
|
5
|
+
import { pickEnv } from '../interactive.js';
|
|
6
|
+
|
|
7
|
+
export async function use(envName) {
|
|
8
|
+
const projectRoot = requireProjectRoot();
|
|
9
|
+
const config = loadConfig(projectRoot);
|
|
10
|
+
const current = getCurrentEnv(projectRoot);
|
|
11
|
+
|
|
12
|
+
const name = envName ?? await pickEnv(config.envs, 'Switch to environment');
|
|
13
|
+
|
|
14
|
+
if (!config.envs.includes(name)) {
|
|
15
|
+
fatal(`Environment '${name}' does not exist. Create it with: envgit add-env ${name}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (name === current) {
|
|
19
|
+
ok(`Already on ${envLabel(name)}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setCurrentEnv(projectRoot, name);
|
|
24
|
+
|
|
25
|
+
console.log('');
|
|
26
|
+
ok(`Switched to ${envLabel(name)}`);
|
|
27
|
+
console.log(dim(` Run envgit unpack to write .env for this environment.`));
|
|
28
|
+
console.log('');
|
|
29
|
+
}
|