memoir-cli 2.5.3 → 2.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.
@@ -0,0 +1,182 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+
5
+ // --- Constants ---
6
+ const ALGORITHM = 'aes-256-gcm';
7
+ const IV_LENGTH = 12; // 96 bits, recommended for GCM
8
+ const TAG_LENGTH = 16; // 128-bit auth tag
9
+ const SALT_LENGTH = 32; // 256-bit salt
10
+ const KEY_LENGTH = 32; // 256 bits for AES-256
11
+ const SCRYPT_COST = 2 ** 14; // N=16384 — fast but secure enough for passphrase KDF
12
+ const MAGIC = Buffer.from('MEMOIR01'); // 8-byte header for format versioning
13
+
14
+ // --- Key Derivation ---
15
+
16
+ /**
17
+ * Derive a 256-bit key from a passphrase using scrypt.
18
+ */
19
+ export function deriveKey(passphrase, salt = null) {
20
+ if (!salt) salt = crypto.randomBytes(SALT_LENGTH);
21
+ const key = crypto.scryptSync(passphrase, salt, KEY_LENGTH, {
22
+ N: SCRYPT_COST,
23
+ r: 8,
24
+ p: 1,
25
+ });
26
+ return { key, salt };
27
+ }
28
+
29
+ // --- Encrypt / Decrypt Buffers ---
30
+
31
+ /**
32
+ * Encrypt a buffer with AES-256-GCM.
33
+ * Output format: MEMOIR01 | salt (32) | iv (12) | authTag (16) | ciphertext
34
+ */
35
+ export function encryptBuffer(plaintext, passphrase) {
36
+ const { key, salt } = deriveKey(passphrase);
37
+ const iv = crypto.randomBytes(IV_LENGTH);
38
+
39
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
40
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
41
+ const tag = cipher.getAuthTag();
42
+
43
+ return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
44
+ }
45
+
46
+ /**
47
+ * Decrypt a buffer. Throws on wrong passphrase or tampered data.
48
+ */
49
+ export function decryptBuffer(data, passphrase) {
50
+ const magic = data.subarray(0, 8);
51
+ if (!magic.equals(MAGIC)) {
52
+ throw new Error('Not a memoir-encrypted file (bad header)');
53
+ }
54
+
55
+ let offset = 8;
56
+ const salt = data.subarray(offset, offset + SALT_LENGTH); offset += SALT_LENGTH;
57
+ const iv = data.subarray(offset, offset + IV_LENGTH); offset += IV_LENGTH;
58
+ const tag = data.subarray(offset, offset + TAG_LENGTH); offset += TAG_LENGTH;
59
+ const ciphertext = data.subarray(offset);
60
+
61
+ const { key } = deriveKey(passphrase, salt);
62
+
63
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
64
+ decipher.setAuthTag(tag);
65
+
66
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
67
+ }
68
+
69
+ // --- Directory-level encryption ---
70
+
71
+ /**
72
+ * Encrypt all files in srcDir → destDir.
73
+ * File names are HMAC-hashed (hidden). Manifest maps hashes → real paths.
74
+ */
75
+ export async function encryptDirectory(srcDir, destDir, passphrase) {
76
+ const { key, salt } = deriveKey(passphrase);
77
+ const dataDir = path.join(destDir, 'data');
78
+ await fs.ensureDir(dataDir);
79
+
80
+ const manifest = {};
81
+ let count = 0;
82
+
83
+ async function walk(dir, relBase = '') {
84
+ const entries = await fs.readdir(dir, { withFileTypes: true });
85
+ for (const entry of entries) {
86
+ const fullPath = path.join(dir, entry.name);
87
+ const relPath = path.join(relBase, entry.name);
88
+ if (entry.isDirectory()) {
89
+ await walk(fullPath, relPath);
90
+ } else {
91
+ // Hash filename so it's opaque
92
+ const hashedName = crypto
93
+ .createHmac('sha256', key)
94
+ .update(relPath)
95
+ .digest('hex')
96
+ .slice(0, 24);
97
+
98
+ const plaintext = await fs.readFile(fullPath);
99
+ const iv = crypto.randomBytes(IV_LENGTH);
100
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
101
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
102
+ const tag = cipher.getAuthTag();
103
+
104
+ // Write: iv | tag | ciphertext (salt shared via manifest file)
105
+ await fs.writeFile(
106
+ path.join(dataDir, `${hashedName}.enc`),
107
+ Buffer.concat([iv, tag, encrypted])
108
+ );
109
+
110
+ manifest[hashedName] = relPath;
111
+ count++;
112
+ }
113
+ }
114
+ }
115
+
116
+ await walk(srcDir);
117
+
118
+ // Encrypt the manifest (it contains real file names)
119
+ const manifestJson = Buffer.from(JSON.stringify(manifest));
120
+ const manifestEncrypted = encryptBuffer(manifestJson, passphrase);
121
+ await fs.writeFile(path.join(destDir, 'manifest.enc'), manifestEncrypted);
122
+
123
+ // Salt is not secret — store it so decrypt can re-derive the same key
124
+ await fs.writeFile(path.join(destDir, 'salt'), salt);
125
+
126
+ return count;
127
+ }
128
+
129
+ /**
130
+ * Decrypt an encrypted directory back to plaintext.
131
+ */
132
+ export async function decryptDirectory(encDir, destDir, passphrase) {
133
+ // Decrypt manifest first
134
+ const manifestData = await fs.readFile(path.join(encDir, 'manifest.enc'));
135
+ const manifestJson = decryptBuffer(manifestData, passphrase);
136
+ const manifest = JSON.parse(manifestJson.toString('utf8'));
137
+
138
+ // Re-derive key with stored salt
139
+ const salt = await fs.readFile(path.join(encDir, 'salt'));
140
+ const { key } = deriveKey(passphrase, salt);
141
+
142
+ const dataDir = path.join(encDir, 'data');
143
+ let count = 0;
144
+
145
+ for (const [hashedName, relPath] of Object.entries(manifest)) {
146
+ const encFilePath = path.join(dataDir, `${hashedName}.enc`);
147
+ if (!(await fs.pathExists(encFilePath))) continue;
148
+
149
+ const data = await fs.readFile(encFilePath);
150
+ const iv = data.subarray(0, IV_LENGTH);
151
+ const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
152
+ const ciphertext = data.subarray(IV_LENGTH + TAG_LENGTH);
153
+
154
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
155
+ decipher.setAuthTag(tag);
156
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
157
+
158
+ const outPath = path.join(destDir, relPath);
159
+ await fs.ensureDir(path.dirname(outPath));
160
+ await fs.writeFile(outPath, decrypted);
161
+ count++;
162
+ }
163
+
164
+ return count;
165
+ }
166
+
167
+ /**
168
+ * Quick passphrase verification token — encrypt a known string,
169
+ * try to decrypt it to check if passphrase is correct before decrypting everything.
170
+ */
171
+ export function createVerifyToken(passphrase) {
172
+ return encryptBuffer(Buffer.from('memoir-ok'), passphrase);
173
+ }
174
+
175
+ export function verifyPassphrase(token, passphrase) {
176
+ try {
177
+ const result = decryptBuffer(token, passphrase);
178
+ return result.toString('utf8') === 'memoir-ok';
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
@@ -0,0 +1,107 @@
1
+ import chalk from 'chalk';
2
+
3
+ // Patterns that match common secret formats
4
+ const SECRET_PATTERNS = [
5
+ // API keys with known prefixes
6
+ { regex: /\b(sk-[a-zA-Z0-9]{20,})/g, label: 'API key (sk-)' },
7
+ { regex: /\b(sk-ant-[a-zA-Z0-9-]{20,})/g, label: 'Anthropic API key' },
8
+ { regex: /\b(sk-proj-[a-zA-Z0-9-]{20,})/g, label: 'OpenAI API key' },
9
+ { regex: /\b(ghp_[a-zA-Z0-9]{36,})/g, label: 'GitHub personal token' },
10
+ { regex: /\b(gho_[a-zA-Z0-9]{36,})/g, label: 'GitHub OAuth token' },
11
+ { regex: /\b(ghs_[a-zA-Z0-9]{36,})/g, label: 'GitHub server token' },
12
+ { regex: /\b(github_pat_[a-zA-Z0-9_]{36,})/g, label: 'GitHub fine-grained token' },
13
+ { regex: /\b(AIza[a-zA-Z0-9_-]{35})/g, label: 'Google API key' },
14
+ { regex: /\b(AKIA[A-Z0-9]{16})/g, label: 'AWS access key' },
15
+ { regex: /\b(xox[bpsa]-[a-zA-Z0-9-]{10,})/g, label: 'Slack token' },
16
+ { regex: /\b(npx?_[a-zA-Z0-9]{30,})/g, label: 'npm token' },
17
+ { regex: /\b(pypi-[a-zA-Z0-9]{50,})/g, label: 'PyPI token' },
18
+ { regex: /\b(glpat-[a-zA-Z0-9_-]{20,})/g, label: 'GitLab token' },
19
+ { regex: /\b(v2\.[a-zA-Z0-9]{20,})/g, label: 'Vercel token' },
20
+ { regex: /\b(re_[a-zA-Z0-9]{20,})/g, label: 'Resend API key' },
21
+ { regex: /\b(sq0[a-z]{3}-[a-zA-Z0-9_-]{22,})/g, label: 'Square token' },
22
+ { regex: /\b(stripe[_-]?(?:sk|pk|rk)_(?:test_|live_)?[a-zA-Z0-9]{20,})/gi, label: 'Stripe key' },
23
+ { regex: /\b(whsec_[a-zA-Z0-9]{20,})/g, label: 'Stripe webhook secret' },
24
+ { regex: /\b(supabase[_-]?(?:anon|service)[_-]?key\s*[:=]\s*["']?eyJ[a-zA-Z0-9+/=]{50,})/gi, label: 'Supabase key' },
25
+
26
+ // Connection strings
27
+ { regex: /(postgres(?:ql)?:\/\/[^\s'"]{10,})/g, label: 'PostgreSQL connection string' },
28
+ { regex: /(mysql:\/\/[^\s'"]{10,})/g, label: 'MySQL connection string' },
29
+ { regex: /(mongodb(?:\+srv)?:\/\/[^\s'"]{10,})/g, label: 'MongoDB connection string' },
30
+ { regex: /(redis:\/\/[^\s'"]{10,})/g, label: 'Redis connection string' },
31
+
32
+ // Generic secrets in env/config patterns
33
+ { regex: /(?:^|[\s;])(?:export\s+)?(?:API_KEY|SECRET_KEY|AUTH_TOKEN|ACCESS_TOKEN|PRIVATE_KEY|DB_PASSWORD|DATABASE_URL|JWT_SECRET|ENCRYPTION_KEY|MASTER_KEY)\s*=\s*["']?([^\s'"]{8,})/gmi, label: 'Environment variable secret' },
34
+ { regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password' },
35
+
36
+ // Private keys
37
+ { regex: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----)/g, label: 'Private key' },
38
+
39
+ // JWTs (eyJ... pattern)
40
+ { regex: /\b(eyJ[a-zA-Z0-9_-]{20,}\.eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,})/g, label: 'JWT token' },
41
+ ];
42
+
43
+ /**
44
+ * Scan text for secrets and return findings
45
+ * @param {string} text - Text to scan
46
+ * @returns {{ found: Array<{label: string, match: string, redacted: string}>, clean: string }}
47
+ */
48
+ export function scanForSecrets(text) {
49
+ const findings = [];
50
+ let clean = text;
51
+
52
+ for (const pattern of SECRET_PATTERNS) {
53
+ // Reset regex state
54
+ pattern.regex.lastIndex = 0;
55
+ let match;
56
+ while ((match = pattern.regex.exec(text)) !== null) {
57
+ const secret = match[1] || match[0];
58
+ // Skip very short matches (likely false positives)
59
+ if (secret.length < 8) continue;
60
+
61
+ const redacted = secret.slice(0, 4) + '****' + secret.slice(-4);
62
+ findings.push({
63
+ label: pattern.label,
64
+ match: secret,
65
+ redacted
66
+ });
67
+
68
+ // Replace in clean text
69
+ clean = clean.replaceAll(secret, `[REDACTED:${pattern.label}]`);
70
+ }
71
+ }
72
+
73
+ // Deduplicate by match value
74
+ const seen = new Set();
75
+ const unique = findings.filter(f => {
76
+ if (seen.has(f.match)) return false;
77
+ seen.add(f.match);
78
+ return true;
79
+ });
80
+
81
+ return { found: unique, clean };
82
+ }
83
+
84
+ /**
85
+ * Redact secrets from text, returning clean version
86
+ * @param {string} text - Text to redact
87
+ * @returns {string} Clean text with secrets replaced
88
+ */
89
+ export function redactSecrets(text) {
90
+ return scanForSecrets(text).clean;
91
+ }
92
+
93
+ /**
94
+ * Print a security report to console
95
+ * @param {Array} findings - Array of findings from scanForSecrets
96
+ */
97
+ export function printSecurityReport(findings) {
98
+ if (findings.length === 0) {
99
+ console.log(chalk.green(' 🔒 No secrets detected'));
100
+ return;
101
+ }
102
+
103
+ console.log(chalk.yellow(` ⚠️ ${findings.length} potential secret(s) redacted:`));
104
+ for (const f of findings) {
105
+ console.log(chalk.gray(` ${f.label}: ${f.redacted}`));
106
+ }
107
+ }