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
package/src/scanner.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner Module
|
|
3
|
+
* Comprehensive API key scanning system for detecting exposed secrets in codebases
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* API key patterns for various services
|
|
12
|
+
* Each pattern includes the service name and regex to match
|
|
13
|
+
* @type {Array<{service: string, pattern: RegExp}>}
|
|
14
|
+
*/
|
|
15
|
+
const API_KEY_PATTERNS = [
|
|
16
|
+
{ service: 'OpenAI', pattern: /sk-[A-Za-z0-9]{48}/g },
|
|
17
|
+
{ service: 'Groq', pattern: /gsk_[A-Za-z0-9]{52}/g },
|
|
18
|
+
{ service: 'GitHub', pattern: /gh[ps]_[A-Za-z0-9]{36}/g },
|
|
19
|
+
{ service: 'Stripe', pattern: /sk_(live|test)_[A-Za-z0-9]{24}/g },
|
|
20
|
+
{ service: 'Google/Firebase', pattern: /AIza[A-Za-z0-9_-]{35}/g },
|
|
21
|
+
{ service: 'AWS', pattern: /AKIA[A-Z0-9]{16}/g },
|
|
22
|
+
{ service: 'Anthropic', pattern: /sk-ant-[A-Za-z0-9-_]{95}/g },
|
|
23
|
+
{ service: 'Hugging Face', pattern: /hf_[A-Za-z0-9]{34}/g }
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default directories to ignore during scanning
|
|
28
|
+
* @type {string[]}
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_IGNORE_DIRS = [
|
|
31
|
+
'node_modules',
|
|
32
|
+
'.git',
|
|
33
|
+
'dist',
|
|
34
|
+
'build',
|
|
35
|
+
'.next',
|
|
36
|
+
'venv'
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default files to ignore during scanning
|
|
41
|
+
* @type {string[]}
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_IGNORE_FILES = [
|
|
44
|
+
'package-lock.json',
|
|
45
|
+
'yarn.lock',
|
|
46
|
+
'.env.example',
|
|
47
|
+
'.env.sample'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Masks an API key for safe display
|
|
52
|
+
* Shows first 4 and last 4 characters, replaces middle with asterisks
|
|
53
|
+
*
|
|
54
|
+
* @param {string} key - The full API key to mask
|
|
55
|
+
* @returns {string} - Masked key (e.g., "sk-ab***...***xyz")
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* maskKey('sk-abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH')
|
|
59
|
+
* // Returns: 'sk-a***...***EFGH'
|
|
60
|
+
*/
|
|
61
|
+
export function maskKey(key) {
|
|
62
|
+
if (key.length <= 8) {
|
|
63
|
+
return '*'.repeat(key.length);
|
|
64
|
+
}
|
|
65
|
+
const first = key.substring(0, 4);
|
|
66
|
+
const last = key.substring(key.length - 4);
|
|
67
|
+
return `${first}***...***${last}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Finds the line number and column position of a match in text
|
|
72
|
+
*
|
|
73
|
+
* @param {string} content - The full file content
|
|
74
|
+
* @param {number} matchIndex - The character index where the match starts
|
|
75
|
+
* @returns {{lineNumber: number, column: number}} - 1-indexed line and column
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* getLineAndColumn('hello\nworld\ntest', 6)
|
|
79
|
+
* // Returns: { lineNumber: 2, column: 1 }
|
|
80
|
+
*/
|
|
81
|
+
export function getLineAndColumn(content, matchIndex) {
|
|
82
|
+
const lines = content.substring(0, matchIndex).split('\n');
|
|
83
|
+
const lineNumber = lines.length;
|
|
84
|
+
const column = lines[lines.length - 1].length + 1;
|
|
85
|
+
return { lineNumber, column };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scans a single file for API key patterns
|
|
90
|
+
*
|
|
91
|
+
* @param {string} filePath - Absolute path to the file
|
|
92
|
+
* @param {string} basePath - Base directory for relative path calculation
|
|
93
|
+
* @returns {Promise<Array<{service: string, key: string, fullKey: string, filePath: string, lineNumber: number, column: number}>>}
|
|
94
|
+
* Array of found API keys with their locations
|
|
95
|
+
*
|
|
96
|
+
* @throws {Error} If file cannot be read (permissions, binary, etc.)
|
|
97
|
+
*/
|
|
98
|
+
async function scanFile(filePath, basePath) {
|
|
99
|
+
const results = [];
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
103
|
+
const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
|
|
104
|
+
|
|
105
|
+
for (const { service, pattern } of API_KEY_PATTERNS) {
|
|
106
|
+
// Reset regex lastIndex for each file
|
|
107
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
108
|
+
let match;
|
|
109
|
+
|
|
110
|
+
while ((match = regex.exec(content)) !== null) {
|
|
111
|
+
const { lineNumber, column } = getLineAndColumn(content, match.index);
|
|
112
|
+
|
|
113
|
+
results.push({
|
|
114
|
+
service,
|
|
115
|
+
key: maskKey(match[0]),
|
|
116
|
+
fullKey: match[0],
|
|
117
|
+
filePath: relativePath,
|
|
118
|
+
lineNumber,
|
|
119
|
+
column
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// Check if it's a binary file or permission error
|
|
125
|
+
if (error.code === 'ENOENT') {
|
|
126
|
+
console.warn(`⚠️ File not found: ${filePath}`);
|
|
127
|
+
} else if (error.code === 'EACCES') {
|
|
128
|
+
console.warn(`⚠️ Permission denied: ${filePath}`);
|
|
129
|
+
} else if (error.message.includes('encoding')) {
|
|
130
|
+
// Binary file, skip silently
|
|
131
|
+
} else {
|
|
132
|
+
console.warn(`⚠️ Could not read file: ${filePath} (${error.message})`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Checks if a file should be ignored based on ignore patterns
|
|
141
|
+
*
|
|
142
|
+
* @param {string} filePath - Path to check
|
|
143
|
+
* @param {string[]} ignorePatterns - Array of patterns/names to ignore
|
|
144
|
+
* @returns {boolean} - True if file should be ignored
|
|
145
|
+
*/
|
|
146
|
+
function shouldIgnoreFile(filePath, ignorePatterns) {
|
|
147
|
+
const fileName = path.basename(filePath);
|
|
148
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
149
|
+
|
|
150
|
+
for (const pattern of ignorePatterns) {
|
|
151
|
+
// Check if pattern matches filename directly
|
|
152
|
+
if (fileName === pattern) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
// Check if pattern appears in path (for directory patterns)
|
|
156
|
+
if (normalizedPath.includes(`/${pattern}/`) || normalizedPath.includes(`${pattern}/`)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Removes duplicate API keys from results
|
|
166
|
+
* Keys are considered duplicates if they have the same fullKey value
|
|
167
|
+
*
|
|
168
|
+
* @param {Array<{service: string, key: string, fullKey: string, filePath: string, lineNumber: number, column: number}>} keys
|
|
169
|
+
* Array of found keys with possible duplicates
|
|
170
|
+
* @returns {Array} - Deduplicated array (keeps first occurrence)
|
|
171
|
+
*/
|
|
172
|
+
function deduplicateKeys(keys) {
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
return keys.filter(keyInfo => {
|
|
175
|
+
if (seen.has(keyInfo.fullKey)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
seen.add(keyInfo.fullKey);
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Scans a directory for exposed API keys in source files
|
|
185
|
+
*
|
|
186
|
+
* Recursively searches through all files in the specified directory,
|
|
187
|
+
* applying regex patterns to detect API keys from various services
|
|
188
|
+
* (OpenAI, GitHub, Stripe, AWS, etc.)
|
|
189
|
+
*
|
|
190
|
+
* @param {string} dirPath - Root directory to scan (absolute or relative path)
|
|
191
|
+
* @param {Object} [options={}] - Scanning options
|
|
192
|
+
* @param {boolean} [options.recursive=true] - Whether to scan subdirectories
|
|
193
|
+
* @param {string[]} [options.ignorePatterns=[]] - Additional patterns to ignore
|
|
194
|
+
*
|
|
195
|
+
* @returns {Promise<{totalFiles: number, keysFound: Array<{service: string, key: string, fullKey: string, filePath: string, lineNumber: number, column: number}>}>}
|
|
196
|
+
* Scan results with total files scanned and array of found keys
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* const results = await scanDirectory('./src', { recursive: true });
|
|
200
|
+
* console.log(`Found ${results.keysFound.length} keys in ${results.totalFiles} files`);
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* // With custom ignore patterns
|
|
204
|
+
* const results = await scanDirectory('./project', {
|
|
205
|
+
* recursive: true,
|
|
206
|
+
* ignorePatterns: ['*.test.js', 'fixtures']
|
|
207
|
+
* });
|
|
208
|
+
*/
|
|
209
|
+
export async function scanDirectory(dirPath, options = {}) {
|
|
210
|
+
const {
|
|
211
|
+
recursive = true,
|
|
212
|
+
ignorePatterns = []
|
|
213
|
+
} = options;
|
|
214
|
+
|
|
215
|
+
// Combine default and custom ignore patterns
|
|
216
|
+
const allIgnorePatterns = [
|
|
217
|
+
...DEFAULT_IGNORE_DIRS,
|
|
218
|
+
...DEFAULT_IGNORE_FILES,
|
|
219
|
+
...ignorePatterns
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// Build glob pattern
|
|
223
|
+
const globPattern = recursive
|
|
224
|
+
? path.join(dirPath, '**', '*').replace(/\\/g, '/')
|
|
225
|
+
: path.join(dirPath, '*').replace(/\\/g, '/');
|
|
226
|
+
|
|
227
|
+
// Build ignore patterns for glob
|
|
228
|
+
const globIgnore = DEFAULT_IGNORE_DIRS.map(dir => `**/${dir}/**`);
|
|
229
|
+
|
|
230
|
+
let files = [];
|
|
231
|
+
let totalFiles = 0;
|
|
232
|
+
const allKeys = [];
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Find all files matching pattern
|
|
236
|
+
files = await glob(globPattern, {
|
|
237
|
+
nodir: true,
|
|
238
|
+
ignore: globIgnore,
|
|
239
|
+
dot: true // Include dotfiles
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Filter out ignored files
|
|
243
|
+
files = files.filter(file => !shouldIgnoreFile(file, allIgnorePatterns));
|
|
244
|
+
totalFiles = files.length;
|
|
245
|
+
|
|
246
|
+
// Scan each file in parallel with concurrency limit
|
|
247
|
+
const BATCH_SIZE = 50;
|
|
248
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
249
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
250
|
+
const batchResults = await Promise.all(
|
|
251
|
+
batch.map(file => scanFile(file, dirPath))
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
for (const results of batchResults) {
|
|
255
|
+
allKeys.push(...results);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error(`❌ Error scanning directory: ${error.message}`);
|
|
261
|
+
// Return partial results
|
|
262
|
+
return {
|
|
263
|
+
totalFiles,
|
|
264
|
+
keysFound: deduplicateKeys(allKeys),
|
|
265
|
+
error: error.message
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Remove duplicates and return
|
|
270
|
+
return {
|
|
271
|
+
totalFiles,
|
|
272
|
+
keysFound: deduplicateKeys(allKeys)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Gets the list of supported API key patterns
|
|
278
|
+
* Useful for documentation or UI display
|
|
279
|
+
*
|
|
280
|
+
* @returns {Array<{service: string, pattern: string}>} - Array of service names and their patterns
|
|
281
|
+
*/
|
|
282
|
+
export function getSupportedPatterns() {
|
|
283
|
+
return API_KEY_PATTERNS.map(({ service, pattern }) => ({
|
|
284
|
+
service,
|
|
285
|
+
pattern: pattern.source
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export default {
|
|
290
|
+
scanDirectory,
|
|
291
|
+
maskKey,
|
|
292
|
+
getLineAndColumn,
|
|
293
|
+
getSupportedPatterns
|
|
294
|
+
};
|