apigraveyard 1.0.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/.github/ISSUE_TEMPLATE/bug_report.md +28 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
- package/.github/ROADMAP_ISSUES.md +169 -0
- package/LICENSE +21 -0
- package/README.md +501 -0
- package/bin/apigraveyard.js +686 -0
- package/hooks/pre-commit +203 -0
- package/package.json +34 -0
- package/scripts/install-hooks.js +182 -0
- package/src/database.js +518 -0
- package/src/display.js +534 -0
- package/src/scanner.js +294 -0
- package/src/tester.js +578 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* APIgraveyard CLI Entry Point
|
|
5
|
+
* Find the dead APIs haunting your codebase
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import ora from 'ora';
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { createInterface } from 'readline';
|
|
15
|
+
|
|
16
|
+
// Import modules
|
|
17
|
+
import { scanDirectory } from '../src/scanner.js';
|
|
18
|
+
import { testKeys, KeyStatus } from '../src/tester.js';
|
|
19
|
+
import {
|
|
20
|
+
initDatabase,
|
|
21
|
+
saveProject,
|
|
22
|
+
getProject,
|
|
23
|
+
getAllProjects,
|
|
24
|
+
updateKeysStatus,
|
|
25
|
+
deleteProject,
|
|
26
|
+
addBannedKey,
|
|
27
|
+
isBanned,
|
|
28
|
+
getDatabaseStats
|
|
29
|
+
} from '../src/database.js';
|
|
30
|
+
import {
|
|
31
|
+
showBanner,
|
|
32
|
+
displayScanResults,
|
|
33
|
+
displayTestResults,
|
|
34
|
+
displayProjectList,
|
|
35
|
+
displayKeyDetails,
|
|
36
|
+
showWarning,
|
|
37
|
+
showError,
|
|
38
|
+
showSuccess,
|
|
39
|
+
showInfo,
|
|
40
|
+
displayStats,
|
|
41
|
+
createSpinner
|
|
42
|
+
} from '../src/display.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Log file path
|
|
46
|
+
* @constant {string}
|
|
47
|
+
*/
|
|
48
|
+
const LOG_FILE = path.join(os.homedir(), '.apigraveyard.log');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exit codes
|
|
52
|
+
* @enum {number}
|
|
53
|
+
*/
|
|
54
|
+
const EXIT_CODES = {
|
|
55
|
+
SUCCESS: 0,
|
|
56
|
+
ERROR: 1,
|
|
57
|
+
INVALID_ARGS: 2
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Logs an error to the log file
|
|
62
|
+
*
|
|
63
|
+
* @param {Error} error - Error to log
|
|
64
|
+
* @param {string} context - Context where error occurred
|
|
65
|
+
*/
|
|
66
|
+
async function logError(error, context = '') {
|
|
67
|
+
const timestamp = new Date().toISOString();
|
|
68
|
+
const logEntry = `[${timestamp}] ${context}\n${error.stack || error.message}\n\n`;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await fs.appendFile(LOG_FILE, logEntry);
|
|
72
|
+
} catch {
|
|
73
|
+
// Silently fail if we can't write to log
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Prompts user for confirmation
|
|
79
|
+
*
|
|
80
|
+
* @param {string} question - Question to ask
|
|
81
|
+
* @returns {Promise<boolean>} - User's response
|
|
82
|
+
*/
|
|
83
|
+
async function confirm(question) {
|
|
84
|
+
const rl = createInterface({
|
|
85
|
+
input: process.stdin,
|
|
86
|
+
output: process.stdout
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
rl.question(chalk.yellow(`${question} (y/N): `), (answer) => {
|
|
91
|
+
rl.close();
|
|
92
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validates that a directory exists
|
|
99
|
+
*
|
|
100
|
+
* @param {string} dirPath - Directory path to validate
|
|
101
|
+
* @returns {Promise<boolean>} - True if valid directory
|
|
102
|
+
*/
|
|
103
|
+
async function validateDirectory(dirPath) {
|
|
104
|
+
try {
|
|
105
|
+
const stats = await fs.stat(dirPath);
|
|
106
|
+
return stats.isDirectory();
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handles graceful shutdown
|
|
114
|
+
*/
|
|
115
|
+
function setupGracefulShutdown() {
|
|
116
|
+
let isShuttingDown = false;
|
|
117
|
+
|
|
118
|
+
const shutdown = async (signal) => {
|
|
119
|
+
if (isShuttingDown) return;
|
|
120
|
+
isShuttingDown = true;
|
|
121
|
+
|
|
122
|
+
console.log(chalk.dim(`\n\nReceived ${signal}. Cleaning up...`));
|
|
123
|
+
|
|
124
|
+
// Give time for any pending database writes
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
126
|
+
|
|
127
|
+
console.log(chalk.dim('Goodbye! 🪦'));
|
|
128
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
132
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates the CLI program
|
|
137
|
+
*
|
|
138
|
+
* @returns {Command} - Commander program instance
|
|
139
|
+
*/
|
|
140
|
+
function createProgram() {
|
|
141
|
+
const program = new Command();
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.name('apigraveyard')
|
|
145
|
+
.description('🪦 Find the dead APIs haunting your codebase')
|
|
146
|
+
.version('1.0.0')
|
|
147
|
+
.hook('preAction', () => {
|
|
148
|
+
// Show banner before each command
|
|
149
|
+
showBanner();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ============================================
|
|
153
|
+
// SCAN COMMAND
|
|
154
|
+
// ============================================
|
|
155
|
+
program
|
|
156
|
+
.command('scan <directory>')
|
|
157
|
+
.description('Scan a project directory for exposed API keys')
|
|
158
|
+
.option('-r, --recursive', 'Scan directories recursively', true)
|
|
159
|
+
.option('-t, --test', 'Test keys after scanning', false)
|
|
160
|
+
.option('-i, --ignore <patterns...>', 'Additional patterns to ignore')
|
|
161
|
+
.action(async (directory, options) => {
|
|
162
|
+
try {
|
|
163
|
+
const dirPath = path.resolve(directory);
|
|
164
|
+
|
|
165
|
+
// Validate directory
|
|
166
|
+
if (!await validateDirectory(dirPath)) {
|
|
167
|
+
showError(`Directory not found: ${dirPath}`);
|
|
168
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
showInfo(`Scanning directory: ${chalk.cyan(dirPath)}`);
|
|
172
|
+
|
|
173
|
+
// Start scanning
|
|
174
|
+
const spinner = createSpinner('Scanning files for API keys...').start();
|
|
175
|
+
|
|
176
|
+
const scanResults = await scanDirectory(dirPath, {
|
|
177
|
+
recursive: options.recursive,
|
|
178
|
+
ignorePatterns: options.ignore || []
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
spinner.succeed(`Scanned ${scanResults.totalFiles} files`);
|
|
182
|
+
|
|
183
|
+
// Display results
|
|
184
|
+
displayScanResults(scanResults);
|
|
185
|
+
|
|
186
|
+
// Check for banned keys
|
|
187
|
+
for (const key of scanResults.keysFound) {
|
|
188
|
+
if (await isBanned(key.fullKey)) {
|
|
189
|
+
showWarning(`Found banned key: ${key.key} in ${key.filePath}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Save to database
|
|
194
|
+
if (scanResults.keysFound.length > 0) {
|
|
195
|
+
await saveProject(dirPath, scanResults);
|
|
196
|
+
showSuccess(`Project saved to database`);
|
|
197
|
+
|
|
198
|
+
// Test keys if flag is set
|
|
199
|
+
if (options.test) {
|
|
200
|
+
console.log('');
|
|
201
|
+
showInfo('Testing found keys...');
|
|
202
|
+
|
|
203
|
+
const testResults = await testKeys(scanResults.keysFound, { showSpinner: true });
|
|
204
|
+
|
|
205
|
+
// Update database with test results
|
|
206
|
+
await updateKeysStatus(dirPath, testResults);
|
|
207
|
+
|
|
208
|
+
// Display test results
|
|
209
|
+
displayTestResults(testResults);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
await logError(error, 'scan command');
|
|
216
|
+
showError(`Scan failed: ${error.message}`);
|
|
217
|
+
process.exit(EXIT_CODES.ERROR);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ============================================
|
|
222
|
+
// TEST COMMAND
|
|
223
|
+
// ============================================
|
|
224
|
+
program
|
|
225
|
+
.command('test [project-path]')
|
|
226
|
+
.description('Test validity of stored API keys')
|
|
227
|
+
.option('-a, --all', 'Test all projects', false)
|
|
228
|
+
.action(async (projectPath, options) => {
|
|
229
|
+
try {
|
|
230
|
+
await initDatabase();
|
|
231
|
+
|
|
232
|
+
let projectsToTest = [];
|
|
233
|
+
|
|
234
|
+
if (projectPath) {
|
|
235
|
+
const project = await getProject(path.resolve(projectPath));
|
|
236
|
+
if (!project) {
|
|
237
|
+
showError(`Project not found in database: ${projectPath}`);
|
|
238
|
+
showInfo('Run "apigraveyard scan <directory>" first');
|
|
239
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
240
|
+
}
|
|
241
|
+
projectsToTest = [project];
|
|
242
|
+
} else {
|
|
243
|
+
projectsToTest = await getAllProjects();
|
|
244
|
+
if (projectsToTest.length === 0) {
|
|
245
|
+
showWarning('No projects in database');
|
|
246
|
+
showInfo('Run "apigraveyard scan <directory>" to scan a project');
|
|
247
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
showInfo(`Testing keys from ${projectsToTest.length} project(s)...`);
|
|
252
|
+
|
|
253
|
+
for (const project of projectsToTest) {
|
|
254
|
+
console.log(`\n${chalk.bold.cyan(`📁 ${project.name}`)} ${chalk.dim(project.path)}`);
|
|
255
|
+
|
|
256
|
+
if (!project.keys || project.keys.length === 0) {
|
|
257
|
+
showInfo('No keys to test in this project');
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const testResults = await testKeys(project.keys, { showSpinner: true });
|
|
262
|
+
|
|
263
|
+
// Update database
|
|
264
|
+
await updateKeysStatus(project.path, testResults);
|
|
265
|
+
|
|
266
|
+
// Display results
|
|
267
|
+
displayTestResults(testResults);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
showSuccess('Testing complete');
|
|
271
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
await logError(error, 'test command');
|
|
274
|
+
showError(`Test failed: ${error.message}`);
|
|
275
|
+
process.exit(EXIT_CODES.ERROR);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ============================================
|
|
280
|
+
// LIST COMMAND
|
|
281
|
+
// ============================================
|
|
282
|
+
program
|
|
283
|
+
.command('list')
|
|
284
|
+
.description('List all scanned projects')
|
|
285
|
+
.option('-s, --stats', 'Show database statistics', false)
|
|
286
|
+
.action(async (options) => {
|
|
287
|
+
try {
|
|
288
|
+
await initDatabase();
|
|
289
|
+
|
|
290
|
+
if (options.stats) {
|
|
291
|
+
const stats = await getDatabaseStats();
|
|
292
|
+
displayStats(stats);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const projects = await getAllProjects();
|
|
296
|
+
displayProjectList(projects);
|
|
297
|
+
|
|
298
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await logError(error, 'list command');
|
|
301
|
+
showError(`List failed: ${error.message}`);
|
|
302
|
+
process.exit(EXIT_CODES.ERROR);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ============================================
|
|
307
|
+
// SHOW COMMAND
|
|
308
|
+
// ============================================
|
|
309
|
+
program
|
|
310
|
+
.command('show <project-path>')
|
|
311
|
+
.description('Show details for a specific project')
|
|
312
|
+
.option('-k, --key <index>', 'Show details for specific key by index')
|
|
313
|
+
.action(async (projectPath, options) => {
|
|
314
|
+
try {
|
|
315
|
+
await initDatabase();
|
|
316
|
+
|
|
317
|
+
const project = await getProject(path.resolve(projectPath));
|
|
318
|
+
|
|
319
|
+
if (!project) {
|
|
320
|
+
showError(`Project not found: ${projectPath}`);
|
|
321
|
+
showInfo('Run "apigraveyard list" to see all projects');
|
|
322
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log(`\n${chalk.bold.cyan(`📁 Project: ${project.name}`)}`);
|
|
326
|
+
console.log(chalk.dim(`Path: ${project.path}`));
|
|
327
|
+
console.log(chalk.dim(`Scanned: ${new Date(project.scannedAt).toLocaleString()}`));
|
|
328
|
+
console.log(chalk.dim(`Total files: ${project.totalFiles}`));
|
|
329
|
+
console.log(chalk.dim('─'.repeat(50)));
|
|
330
|
+
|
|
331
|
+
if (!project.keys || project.keys.length === 0) {
|
|
332
|
+
showInfo('No keys found in this project');
|
|
333
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (options.key !== undefined) {
|
|
337
|
+
const keyIndex = parseInt(options.key, 10);
|
|
338
|
+
if (keyIndex < 0 || keyIndex >= project.keys.length) {
|
|
339
|
+
showError(`Invalid key index. Valid range: 0-${project.keys.length - 1}`);
|
|
340
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
341
|
+
}
|
|
342
|
+
displayKeyDetails(project.keys[keyIndex]);
|
|
343
|
+
} else {
|
|
344
|
+
console.log(`\n${chalk.bold('Found Keys:')}\n`);
|
|
345
|
+
project.keys.forEach((key, index) => {
|
|
346
|
+
console.log(` ${chalk.dim(`[${index}]`)} ${chalk.white(key.service.padEnd(15))} ${chalk.yellow(key.key)}`);
|
|
347
|
+
console.log(` ${chalk.dim(`${key.filePath}:${key.lineNumber}`)} ${key.status ? `[${key.status}]` : ''}`);
|
|
348
|
+
});
|
|
349
|
+
console.log('');
|
|
350
|
+
showInfo('Use --key <index> to see full details for a specific key');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
await logError(error, 'show command');
|
|
356
|
+
showError(`Show failed: ${error.message}`);
|
|
357
|
+
process.exit(EXIT_CODES.ERROR);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ============================================
|
|
362
|
+
// CLEAN COMMAND
|
|
363
|
+
// ============================================
|
|
364
|
+
program
|
|
365
|
+
.command('clean')
|
|
366
|
+
.description('Remove invalid/expired keys from database')
|
|
367
|
+
.option('-f, --force', 'Skip confirmation prompt', false)
|
|
368
|
+
.action(async (options) => {
|
|
369
|
+
try {
|
|
370
|
+
await initDatabase();
|
|
371
|
+
|
|
372
|
+
const projects = await getAllProjects();
|
|
373
|
+
|
|
374
|
+
if (projects.length === 0) {
|
|
375
|
+
showInfo('No projects in database');
|
|
376
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Count keys to remove
|
|
380
|
+
let totalToRemove = 0;
|
|
381
|
+
const keysToRemove = [];
|
|
382
|
+
|
|
383
|
+
for (const project of projects) {
|
|
384
|
+
if (!project.keys) continue;
|
|
385
|
+
|
|
386
|
+
for (const key of project.keys) {
|
|
387
|
+
if (key.status === KeyStatus.INVALID || key.status === KeyStatus.EXPIRED) {
|
|
388
|
+
totalToRemove++;
|
|
389
|
+
keysToRemove.push({
|
|
390
|
+
project: project.name,
|
|
391
|
+
service: key.service,
|
|
392
|
+
key: key.key,
|
|
393
|
+
status: key.status
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (totalToRemove === 0) {
|
|
400
|
+
showSuccess('No invalid or expired keys found');
|
|
401
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log(`\n${chalk.bold('Keys to remove:')}\n`);
|
|
405
|
+
keysToRemove.forEach(k => {
|
|
406
|
+
const statusColor = k.status === KeyStatus.INVALID ? chalk.red : chalk.yellow;
|
|
407
|
+
console.log(` ${chalk.dim(k.project)} / ${k.service}: ${chalk.yellow(k.key)} ${statusColor(`[${k.status}]`)}`);
|
|
408
|
+
});
|
|
409
|
+
console.log('');
|
|
410
|
+
|
|
411
|
+
// Confirm unless --force
|
|
412
|
+
if (!options.force) {
|
|
413
|
+
const confirmed = await confirm(`Remove ${totalToRemove} key(s) from database?`);
|
|
414
|
+
if (!confirmed) {
|
|
415
|
+
showInfo('Cancelled');
|
|
416
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Remove keys
|
|
421
|
+
let removedCount = 0;
|
|
422
|
+
for (const project of projects) {
|
|
423
|
+
if (!project.keys) continue;
|
|
424
|
+
|
|
425
|
+
const originalCount = project.keys.length;
|
|
426
|
+
project.keys = project.keys.filter(
|
|
427
|
+
k => k.status !== KeyStatus.INVALID && k.status !== KeyStatus.EXPIRED
|
|
428
|
+
);
|
|
429
|
+
removedCount += originalCount - project.keys.length;
|
|
430
|
+
|
|
431
|
+
// Re-save project
|
|
432
|
+
await saveProject(project.path, {
|
|
433
|
+
totalFiles: project.totalFiles,
|
|
434
|
+
keysFound: project.keys
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
showSuccess(`Removed ${removedCount} invalid/expired key(s) from database`);
|
|
439
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
await logError(error, 'clean command');
|
|
442
|
+
showError(`Clean failed: ${error.message}`);
|
|
443
|
+
process.exit(EXIT_CODES.ERROR);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ============================================
|
|
448
|
+
// EXPORT COMMAND
|
|
449
|
+
// ============================================
|
|
450
|
+
program
|
|
451
|
+
.command('export')
|
|
452
|
+
.description('Export all keys to a file')
|
|
453
|
+
.option('-f, --format <format>', 'Output format (json|csv)', 'json')
|
|
454
|
+
.option('-o, --output <file>', 'Output file path')
|
|
455
|
+
.option('--include-full-keys', 'Include unmasked keys (dangerous!)', false)
|
|
456
|
+
.action(async (options) => {
|
|
457
|
+
try {
|
|
458
|
+
await initDatabase();
|
|
459
|
+
|
|
460
|
+
const projects = await getAllProjects();
|
|
461
|
+
|
|
462
|
+
if (projects.length === 0) {
|
|
463
|
+
showWarning('No projects to export');
|
|
464
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const format = options.format.toLowerCase();
|
|
468
|
+
if (format !== 'json' && format !== 'csv') {
|
|
469
|
+
showError('Invalid format. Use "json" or "csv"');
|
|
470
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Warn about including full keys
|
|
474
|
+
if (options.includeFullKeys) {
|
|
475
|
+
showWarning('You are about to export unmasked API keys!');
|
|
476
|
+
const confirmed = await confirm('Are you sure you want to include full keys?');
|
|
477
|
+
if (!confirmed) {
|
|
478
|
+
showInfo('Export cancelled');
|
|
479
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const outputFile = options.output || `apigraveyard-export.${format}`;
|
|
484
|
+
let content = '';
|
|
485
|
+
|
|
486
|
+
if (format === 'json') {
|
|
487
|
+
const exportData = {
|
|
488
|
+
exportedAt: new Date().toISOString(),
|
|
489
|
+
projects: projects.map(p => ({
|
|
490
|
+
...p,
|
|
491
|
+
keys: p.keys?.map(k => ({
|
|
492
|
+
...k,
|
|
493
|
+
fullKey: options.includeFullKeys ? k.fullKey : undefined
|
|
494
|
+
}))
|
|
495
|
+
}))
|
|
496
|
+
};
|
|
497
|
+
content = JSON.stringify(exportData, null, 2);
|
|
498
|
+
} else {
|
|
499
|
+
// CSV format
|
|
500
|
+
const headers = ['Project', 'Service', 'Key', 'Status', 'File', 'Line', 'Last Tested'];
|
|
501
|
+
if (options.includeFullKeys) headers.push('Full Key');
|
|
502
|
+
|
|
503
|
+
const rows = [headers.join(',')];
|
|
504
|
+
|
|
505
|
+
for (const project of projects) {
|
|
506
|
+
if (!project.keys) continue;
|
|
507
|
+
for (const key of project.keys) {
|
|
508
|
+
const row = [
|
|
509
|
+
`"${project.name}"`,
|
|
510
|
+
`"${key.service}"`,
|
|
511
|
+
`"${key.key}"`,
|
|
512
|
+
`"${key.status || 'UNTESTED'}"`,
|
|
513
|
+
`"${key.filePath}"`,
|
|
514
|
+
key.lineNumber,
|
|
515
|
+
`"${key.lastTested || ''}"`
|
|
516
|
+
];
|
|
517
|
+
if (options.includeFullKeys) {
|
|
518
|
+
row.push(`"${key.fullKey}"`);
|
|
519
|
+
}
|
|
520
|
+
rows.push(row.join(','));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
content = rows.join('\n');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await fs.writeFile(outputFile, content, 'utf-8');
|
|
528
|
+
showSuccess(`Exported to ${chalk.cyan(outputFile)}`);
|
|
529
|
+
|
|
530
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
await logError(error, 'export command');
|
|
533
|
+
showError(`Export failed: ${error.message}`);
|
|
534
|
+
process.exit(EXIT_CODES.ERROR);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ============================================
|
|
539
|
+
// BAN COMMAND
|
|
540
|
+
// ============================================
|
|
541
|
+
program
|
|
542
|
+
.command('ban <key>')
|
|
543
|
+
.description('Ban an API key (mark as compromised)')
|
|
544
|
+
.option('-d, --delete', 'Offer to delete from all files', false)
|
|
545
|
+
.action(async (keyValue, options) => {
|
|
546
|
+
try {
|
|
547
|
+
await initDatabase();
|
|
548
|
+
|
|
549
|
+
// Add to banned list
|
|
550
|
+
const added = await addBannedKey(keyValue);
|
|
551
|
+
|
|
552
|
+
if (added) {
|
|
553
|
+
showSuccess(`Key has been banned: ${chalk.yellow(keyValue.substring(0, 10) + '...')}`);
|
|
554
|
+
} else {
|
|
555
|
+
showInfo('Key is already banned');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Find occurrences in projects
|
|
559
|
+
const projects = await getAllProjects();
|
|
560
|
+
const occurrences = [];
|
|
561
|
+
|
|
562
|
+
for (const project of projects) {
|
|
563
|
+
if (!project.keys) continue;
|
|
564
|
+
for (const key of project.keys) {
|
|
565
|
+
if (key.fullKey === keyValue) {
|
|
566
|
+
occurrences.push({
|
|
567
|
+
project: project.name,
|
|
568
|
+
projectPath: project.path,
|
|
569
|
+
filePath: key.filePath,
|
|
570
|
+
lineNumber: key.lineNumber
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (occurrences.length > 0) {
|
|
577
|
+
console.log(`\n${chalk.bold('Key found in these locations:')}\n`);
|
|
578
|
+
occurrences.forEach((occ, idx) => {
|
|
579
|
+
console.log(` ${idx + 1}. ${chalk.dim(occ.project)} / ${occ.filePath}:${occ.lineNumber}`);
|
|
580
|
+
});
|
|
581
|
+
console.log('');
|
|
582
|
+
|
|
583
|
+
if (options.delete) {
|
|
584
|
+
showWarning('File deletion is not yet implemented');
|
|
585
|
+
showInfo('Please manually remove the key from the listed files');
|
|
586
|
+
} else {
|
|
587
|
+
showInfo('Use --delete flag to remove key from files');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
showWarning('Remember to rotate this key with your service provider!');
|
|
592
|
+
|
|
593
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
await logError(error, 'ban command');
|
|
596
|
+
showError(`Ban failed: ${error.message}`);
|
|
597
|
+
process.exit(EXIT_CODES.ERROR);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ============================================
|
|
602
|
+
// DELETE COMMAND
|
|
603
|
+
// ============================================
|
|
604
|
+
program
|
|
605
|
+
.command('delete <project-path>')
|
|
606
|
+
.description('Remove a project from tracking')
|
|
607
|
+
.option('-f, --force', 'Skip confirmation prompt', false)
|
|
608
|
+
.action(async (projectPath, options) => {
|
|
609
|
+
try {
|
|
610
|
+
await initDatabase();
|
|
611
|
+
|
|
612
|
+
const resolvedPath = path.resolve(projectPath);
|
|
613
|
+
const project = await getProject(resolvedPath);
|
|
614
|
+
|
|
615
|
+
if (!project) {
|
|
616
|
+
showError(`Project not found: ${projectPath}`);
|
|
617
|
+
process.exit(EXIT_CODES.INVALID_ARGS);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!options.force) {
|
|
621
|
+
const confirmed = await confirm(`Remove project "${project.name}" from database?`);
|
|
622
|
+
if (!confirmed) {
|
|
623
|
+
showInfo('Cancelled');
|
|
624
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const deleted = await deleteProject(resolvedPath);
|
|
629
|
+
|
|
630
|
+
if (deleted) {
|
|
631
|
+
showSuccess(`Project "${project.name}" removed from database`);
|
|
632
|
+
} else {
|
|
633
|
+
showError('Failed to delete project');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
await logError(error, 'delete command');
|
|
639
|
+
showError(`Delete failed: ${error.message}`);
|
|
640
|
+
process.exit(EXIT_CODES.ERROR);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ============================================
|
|
645
|
+
// STATS COMMAND
|
|
646
|
+
// ============================================
|
|
647
|
+
program
|
|
648
|
+
.command('stats')
|
|
649
|
+
.description('Show database statistics')
|
|
650
|
+
.action(async () => {
|
|
651
|
+
try {
|
|
652
|
+
await initDatabase();
|
|
653
|
+
const stats = await getDatabaseStats();
|
|
654
|
+
displayStats(stats);
|
|
655
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
await logError(error, 'stats command');
|
|
658
|
+
showError(`Stats failed: ${error.message}`);
|
|
659
|
+
process.exit(EXIT_CODES.ERROR);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return program;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Main entry point
|
|
668
|
+
*/
|
|
669
|
+
async function main() {
|
|
670
|
+
// Setup graceful shutdown handlers
|
|
671
|
+
setupGracefulShutdown();
|
|
672
|
+
|
|
673
|
+
const program = createProgram();
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
await program.parseAsync(process.argv);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
await logError(error, 'main');
|
|
679
|
+
showError(`An unexpected error occurred: ${error.message}`);
|
|
680
|
+
console.log(chalk.dim(`See ${LOG_FILE} for details`));
|
|
681
|
+
process.exit(EXIT_CODES.ERROR);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Run the CLI
|
|
686
|
+
main();
|