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.
- package/VISION.md +722 -0
- package/package.json +1 -1
- package/src/commands/init.js +13 -0
- package/src/commands/push.js +105 -3
- package/src/commands/restore.js +125 -2
- package/src/context/capture.js +242 -0
- package/src/security/encryption.js +182 -0
- package/src/security/scanner.js +107 -0
|
@@ -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
|
+
}
|