repo-cloak-cli 1.3.2 → 1.3.4

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.
@@ -69,18 +69,28 @@ function escapeRegex(string) {
69
69
  * Match the case pattern of the original to the replacement
70
70
  */
71
71
  function matchCase(original, replacement) {
72
- // If original is all uppercase, make replacement uppercase
72
+ // Helper to check if string contains any letters
73
+ const hasLetters = /[a-zA-Z]/.test(original);
74
+
75
+ if (!hasLetters) {
76
+ return replacement;
77
+ }
78
+
79
+ // Check if original is entirely uppercase
73
80
  if (original === original.toUpperCase()) {
74
81
  return replacement.toUpperCase();
75
82
  }
76
83
 
77
- // If original is all lowercase, make replacement lowercase
84
+ // Check if original is entirely lowercase
78
85
  if (original === original.toLowerCase()) {
79
86
  return replacement.toLowerCase();
80
87
  }
81
88
 
82
- // If original is Title Case, make replacement Title Case
83
- if (original[0] === original[0].toUpperCase()) {
89
+ // Check if original is Title Case (first letter uppercase, rest lowercase)
90
+ const firstLetter = original.charAt(0);
91
+ const restOfStr = original.slice(1);
92
+
93
+ if (firstLetter === firstLetter.toUpperCase() && restOfStr === restOfStr.toLowerCase()) {
84
94
  return replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase();
85
95
  }
86
96
 
package/src/core/git.js CHANGED
@@ -59,3 +59,71 @@ export async function getChangedFiles(dirPath) {
59
59
  return [];
60
60
  }
61
61
  }
62
+
63
+ /**
64
+ * Get recent commits
65
+ * @param {string} dirPath - Repository root
66
+ * @param {number} count - Number of commits to retrieve
67
+ * @returns {Promise<Array<{hash: string, message: string}>>} List of commit objects
68
+ */
69
+ export async function getRecentCommits(dirPath, count = 10) {
70
+ try {
71
+ const { stdout } = await execAsync(`git log -n ${count} --pretty=format:"%h - %s"`, { cwd: dirPath });
72
+ if (!stdout) return [];
73
+
74
+ return stdout
75
+ .split(/\r?\n/)
76
+ .filter(line => line.trim() !== '')
77
+ .map(line => {
78
+ const sepIndex = line.indexOf(' - ');
79
+ if (sepIndex === -1) return { hash: line.trim(), message: '' };
80
+ const hash = line.substring(0, sepIndex).trim();
81
+ const message = line.substring(sepIndex + 3).trim();
82
+ return { hash, message };
83
+ });
84
+ } catch (error) {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Get list of files changed in specific commits
91
+ * @param {string} dirPath - Repository root
92
+ * @param {string[]} commits - List of commit hashes
93
+ * @returns {Promise<string[]>} List of relative file paths
94
+ */
95
+ export async function getFilesChangedInCommits(dirPath, commits) {
96
+ if (!commits || commits.length === 0) return [];
97
+
98
+ try {
99
+ const filesSet = new Set();
100
+
101
+ for (const commit of commits) {
102
+ // --name-status outputs lines like "status\tfilePath" (e.g., "M\tfile.txt")
103
+ const { stdout } = await execAsync(`git show --name-status --pretty="" ${commit}`, { cwd: dirPath });
104
+ if (stdout) {
105
+ const lines = stdout.split(/\r?\n/).filter(line => line.trim() !== '');
106
+ for (const line of lines) {
107
+ const parts = line.split('\t');
108
+ if (parts.length < 2) continue;
109
+
110
+ const status = parts[0];
111
+ // Skip deleted files since we cannot pull them
112
+ if (!status.startsWith('D')) {
113
+ // For renamed files (Rxxx or Cxxx), parts[2] might be the new file name
114
+ let file = parts.length > 2 ? parts[2] : parts[1];
115
+
116
+ // Remove quotes if present (git status/show quotes files with spaces)
117
+ const cleanFile = file.replace(/^"|"$/g, '');
118
+
119
+ filesSet.add(cleanFile);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ return Array.from(filesSet);
126
+ } catch (error) {
127
+ return [];
128
+ }
129
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Path Cache Module
3
+ * Stores recently used source and destination paths, encrypted with the user's secret key.
4
+ * Persisted to ~/.repo-cloak/path-cache.json
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { encrypt, decrypt, getOrCreateSecret, hasSecret } from './crypto.js';
11
+
12
+ const CONFIG_DIR = join(homedir(), '.repo-cloak');
13
+ const CACHE_FILE = join(CONFIG_DIR, 'path-cache.json');
14
+ const MAX_PATHS = 10;
15
+
16
+ /**
17
+ * Load the raw cache file (entries are stored encrypted)
18
+ * @returns {{ sources: string[], destinations: string[] }}
19
+ */
20
+ function loadRawCache() {
21
+ try {
22
+ if (!existsSync(CACHE_FILE)) {
23
+ return { sources: [], destinations: [] };
24
+ }
25
+ const raw = readFileSync(CACHE_FILE, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ return {
28
+ sources: Array.isArray(parsed.sources) ? parsed.sources : [],
29
+ destinations: Array.isArray(parsed.destinations) ? parsed.destinations : []
30
+ };
31
+ } catch {
32
+ return { sources: [], destinations: [] };
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Save entries back to the cache file
38
+ * @param {{ sources: string[], destinations: string[] }} cache
39
+ */
40
+ function saveRawCache(cache) {
41
+ try {
42
+ if (!existsSync(CONFIG_DIR)) {
43
+ mkdirSync(CONFIG_DIR, { recursive: true });
44
+ }
45
+ writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), { mode: 0o600 });
46
+ } catch {
47
+ // Silently ignore write errors – caching is best-effort
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Decrypt a list of encrypted path strings. Returns only successfully decrypted values.
53
+ * @param {string[]} encrypted
54
+ * @param {string} secret
55
+ * @returns {string[]}
56
+ */
57
+ function decryptPaths(encrypted, secret) {
58
+ return encrypted
59
+ .map(e => {
60
+ try {
61
+ return decrypt(e, secret);
62
+ } catch {
63
+ return null;
64
+ }
65
+ })
66
+ .filter(Boolean);
67
+ }
68
+
69
+ // ─── Public API ──────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Get recently used source paths (decrypted). Returns empty array if no secret exists yet.
73
+ * @returns {string[]}
74
+ */
75
+ export function getSourcePaths() {
76
+ if (!hasSecret()) return [];
77
+ const secret = getOrCreateSecret();
78
+ const cache = loadRawCache();
79
+ return decryptPaths(cache.sources, secret);
80
+ }
81
+
82
+ /**
83
+ * Get recently used destination paths (decrypted). Returns empty array if no secret exists yet.
84
+ * @returns {string[]}
85
+ */
86
+ export function getDestPaths() {
87
+ if (!hasSecret()) return [];
88
+ const secret = getOrCreateSecret();
89
+ const cache = loadRawCache();
90
+ return decryptPaths(cache.destinations, secret);
91
+ }
92
+
93
+ /**
94
+ * Persist a source path to the cache (encrypted). Moves it to the front and deduplicates.
95
+ * @param {string} path - Absolute path
96
+ */
97
+ export function addSourcePath(path) {
98
+ try {
99
+ const secret = getOrCreateSecret();
100
+ const cache = loadRawCache();
101
+
102
+ // Decrypt existing to deduplicate
103
+ const existing = decryptPaths(cache.sources, secret);
104
+ const deduped = [path, ...existing.filter(p => p !== path)].slice(0, MAX_PATHS);
105
+
106
+ cache.sources = deduped.map(p => encrypt(p, secret));
107
+ saveRawCache(cache);
108
+ } catch {
109
+ // Silently ignore
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Persist a destination path to the cache (encrypted). Moves it to the front and deduplicates.
115
+ * @param {string} path - Absolute path
116
+ */
117
+ export function addDestPath(path) {
118
+ try {
119
+ const secret = getOrCreateSecret();
120
+ const cache = loadRawCache();
121
+
122
+ // Decrypt existing to deduplicate
123
+ const existing = decryptPaths(cache.destinations, secret);
124
+ const deduped = [path, ...existing.filter(p => p !== path)].slice(0, MAX_PATHS);
125
+
126
+ cache.destinations = deduped.map(p => encrypt(p, secret));
127
+ saveRawCache(cache);
128
+ } catch {
129
+ // Silently ignore
130
+ }
131
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Secret Data Scanner
3
+ * Scans files for common sensitive data patterns (e.g. AWS keys, private keys, generic tokens)
4
+ */
5
+
6
+ import { readFileSync, statSync } from 'fs';
7
+ import { isBinaryFile } from './scanner.js';
8
+
9
+ // Max file size to scan (2MB). Prevents blocking on massive files like minified bundles.
10
+ const MAX_SCAN_SIZE = 2 * 1024 * 1024;
11
+
12
+ // Common regular expressions for sensitive data detection
13
+ export const SECRET_PATTERNS = [
14
+ {
15
+ name: 'AWS Access Key ID',
16
+ regex: /(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/g
17
+ },
18
+ {
19
+ name: 'AWS Secret Access Key',
20
+ regex: /aws_(?:secret_)?(?:access_)?key(?:\s*=?\s*["']?)(?!<)[a-zA-Z0-9/+=]{40}(?!>)/gi
21
+ },
22
+ {
23
+ name: 'RSA Private Key',
24
+ regex: /-----BEGIN RSA PRIVATE KEY-----/g
25
+ },
26
+ {
27
+ name: 'Generic Private Key',
28
+ regex: /-----BEGIN PRIVATE KEY-----/g
29
+ },
30
+ {
31
+ name: 'DSA Private Key',
32
+ regex: /-----BEGIN DSA PRIVATE KEY-----/g
33
+ },
34
+ {
35
+ name: 'OpenSSH Private Key',
36
+ regex: /-----BEGIN OPENSSH PRIVATE KEY-----/g
37
+ },
38
+ {
39
+ name: 'Generic API Key / Token',
40
+ regex: /(?:api_?key|auth_?token|access_?token|secret_?key|bearer_?token)(?:\s*[:=]\s*["']?)(?!<)[a-zA-Z0-9\-_]{20,}(?!>)/gi
41
+ },
42
+ {
43
+ name: 'Generic Password / Secret',
44
+ regex: /(?:password|passwd|pwd|secret|pass)(?:\s*[:=]\s*["']?)(?!<)[a-zA-Z0-9\-_@!#$%^&*]{8,}(?!>)/gi
45
+ },
46
+ {
47
+ name: 'JSON Web Token (JWT)',
48
+ regex: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g
49
+ },
50
+ {
51
+ name: 'GitHub Token',
52
+ regex: /(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36}/g
53
+ },
54
+ {
55
+ name: 'GitHub OAuth App Token',
56
+ regex: /gho_[a-zA-Z0-9]{36}/g
57
+ },
58
+ {
59
+ name: 'Slack Token',
60
+ regex: /(xox[pboar]-[0-9]{12}-[0-9]{12}-[0-9]{12}-[a-z0-9]{32})/g
61
+ },
62
+ {
63
+ name: 'Slack Webhook',
64
+ regex: /https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8,}\/B[a-zA-Z0-9_]{8,}\/[a-zA-Z0-9_]{24}/g
65
+ },
66
+ {
67
+ name: 'Stripe API Key',
68
+ regex: /(?:sk_live|rk_live|sk_test|rk_test)_[0-9a-zA-Z]{24}/g
69
+ },
70
+ {
71
+ name: 'Google API Key',
72
+ regex: /AIza[0-9A-Za-z\-_]{35}/g
73
+ },
74
+ {
75
+ name: 'Google OAuth Access Token',
76
+ regex: /ya29\.[0-9A-Za-z\-_]+/g
77
+ },
78
+ {
79
+ name: 'Discord Bot Token',
80
+ regex: /[M|N][a-zA-Z0-9_-]{23,}\.[a-zA-Z0-9_-]{6,}\.[a-zA-Z0-9_-]{27,}/g
81
+ },
82
+ {
83
+ name: 'Discord Webhook',
84
+ regex: /https:\/\/discord\.com\/api\/webhooks\/[0-9]{18,19}\/[a-zA-Z0-9_-]{68}/g
85
+ },
86
+ {
87
+ name: 'Database Connection String',
88
+ regex: /(?:mysql|postgresql|mongodb|mssql|sqlite|redis|amqp):\/\/[a-zA-Z0-9_-]+:[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+:[0-9]{1,5}\/[a-zA-Z0-9_-]+/gi
89
+ },
90
+ {
91
+ name: 'Heroku API Key',
92
+ regex: /[h|H]eroku[0-9a-zA-Z_-]{8}-[0-9a-zA-Z_-]{4}-[0-9a-zA-Z_-]{4}-[0-9a-zA-Z_-]{4}-[0-9a-zA-Z_-]{12}/g
93
+ },
94
+ {
95
+ name: 'Mailgun API Key',
96
+ regex: /key-[0-9a-zA-Z]{32}/g
97
+ }
98
+ ];
99
+
100
+ /**
101
+ * Scan a single file for secrets
102
+ * @param {string} filePath - Absolute path to the file
103
+ * @returns {Array<{type: string, file: string, line: number}>} Array of found secrets
104
+ */
105
+ export function scanFileForSecrets(filePath) {
106
+ const findings = [];
107
+
108
+ try {
109
+ if (isBinaryFile(filePath)) {
110
+ return findings;
111
+ }
112
+
113
+ const stats = statSync(filePath);
114
+ if (stats.size > MAX_SCAN_SIZE) {
115
+ return findings; // Skip huge files
116
+ }
117
+
118
+ const content = readFileSync(filePath, 'utf-8');
119
+ const lines = content.split(/\r?\n/);
120
+
121
+ // Keep track of which patterns found what to avoid duplicate reports per line
122
+ const seenOnLine = new Set();
123
+
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const lineContent = lines[i];
126
+
127
+ for (const pattern of SECRET_PATTERNS) {
128
+ // Reset regex state since it has the 'g' flag
129
+ pattern.regex.lastIndex = 0;
130
+
131
+ if (pattern.regex.test(lineContent)) {
132
+ const uniqueKey = `${i}-${pattern.name}`;
133
+ if (!seenOnLine.has(uniqueKey)) {
134
+ findings.push({
135
+ type: pattern.name,
136
+ file: filePath,
137
+ line: i + 1
138
+ });
139
+ seenOnLine.add(uniqueKey);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ } catch (error) {
145
+ // Ignore read errors or permission issues for scanning
146
+ }
147
+
148
+ return findings;
149
+ }
150
+
151
+ /**
152
+ * Scan multiple files for secrets
153
+ * @param {string[]} filePaths - Array of absolute file paths
154
+ * @returns {Array<{type: string, file: string, line: number}>} Flattened array of all findings
155
+ */
156
+ export async function scanFilesForSecrets(filePaths) {
157
+ if (!filePaths || filePaths.length === 0) return [];
158
+
159
+ let allFindings = [];
160
+
161
+ for (const filePath of filePaths) {
162
+ const fileFindings = scanFileForSecrets(filePath);
163
+ if (fileFindings.length > 0) {
164
+ allFindings = allFindings.concat(fileFindings);
165
+ }
166
+ }
167
+
168
+ return allFindings;
169
+ }
package/src/ui/banner.js CHANGED
@@ -1,51 +1,50 @@
1
1
  /**
2
- * Fancy ASCII Banner
3
- * Shows a colorful intro when the CLI starts
2
+ * Banner
3
+ * Clean, elegant CLI header
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
7
  import figlet from 'figlet';
8
+ import { readFileSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ function getVersion() {
13
+ try {
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8'));
16
+ return pkg.version || '1.0.0';
17
+ } catch {
18
+ return '1.0.0';
19
+ }
20
+ }
8
21
 
9
22
  export async function showBanner() {
10
23
  return new Promise((resolve) => {
11
24
  figlet.text('repo-cloak', {
12
- font: 'Standard',
25
+ font: 'Slant',
13
26
  horizontalLayout: 'default',
14
27
  verticalLayout: 'default'
15
28
  }, (err, data) => {
16
- if (err) {
17
- console.log(chalk.magentaBright.bold('\nšŸŽ­ repo-cloak\n'));
29
+ const version = getVersion();
30
+
31
+ if (err || !data) {
32
+ console.log('\n' + chalk.bold.white(' repo-cloak') + chalk.dim(` v${version}\n`));
18
33
  resolve();
19
34
  return;
20
35
  }
21
36
 
22
- // Create gradient effect
23
- const lines = data.split('\n');
24
- const colors = [
25
- chalk.hex('#FF6B6B'), // Coral
26
- chalk.hex('#FF8E53'), // Orange
27
- chalk.hex('#FEC89A'), // Peach
28
- chalk.hex('#A8E6CF'), // Mint
29
- chalk.hex('#88D8B0'), // Seafoam
30
- chalk.hex('#7B68EE'), // Medium Slate Blue
31
- chalk.hex('#9D4EDD'), // Purple
32
- ];
33
-
34
- console.log('\n');
35
- lines.forEach((line, index) => {
36
- const color = colors[index % colors.length];
37
- console.log(color(line));
37
+ console.log('');
38
+ // Render the ASCII art in a single elegant dim-white color
39
+ data.split('\n').forEach(line => {
40
+ console.log(chalk.white.dim(' ' + line));
38
41
  });
39
42
 
40
- // Tagline box
41
- const tagline = 'šŸŽ­ Selectively extract & anonymize repository files';
42
- const version = 'v1.0.0';
43
-
44
43
  console.log('');
45
- console.log(chalk.dim('─'.repeat(55)));
46
- console.log(chalk.white.bold(` ${tagline}`));
47
- console.log(chalk.dim(` Compatible with Windows, macOS, and Linux | ${version}`));
48
- console.log(chalk.dim('─'.repeat(55)));
44
+ console.log(
45
+ chalk.dim(' Selectively extract & anonymize repository files') +
46
+ chalk.dim(` v${version}`)
47
+ );
49
48
  console.log('');
50
49
 
51
50
  resolve();
@@ -54,17 +53,17 @@ export async function showBanner() {
54
53
  }
55
54
 
56
55
  export function showSuccess(message) {
57
- console.log(chalk.green.bold(`\nāœ… ${message}\n`));
56
+ console.log(chalk.green(`\n āœ“ ${message}\n`));
58
57
  }
59
58
 
60
59
  export function showError(message) {
61
- console.log(chalk.red.bold(`\nāŒ ${message}\n`));
60
+ console.log(chalk.red(`\n āœ— ${message}\n`));
62
61
  }
63
62
 
64
63
  export function showWarning(message) {
65
- console.log(chalk.yellow.bold(`\nāš ļø ${message}\n`));
64
+ console.log(chalk.yellow(`\n ! ${message}\n`));
66
65
  }
67
66
 
68
67
  export function showInfo(message) {
69
- console.log(chalk.cyan(`\nā„¹ļø ${message}\n`));
68
+ console.log(chalk.dim(`\n ${message}\n`));
70
69
  }