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.
@@ -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();