pkg-scaffold 3.3.4 → 3.3.6
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/README.md +7 -5
- package/bin/cli.js +4 -4
- package/package.json +10 -4
- package/src/EngineContext.js +52 -27
- package/src/ast/ASTAnalyzer.js +111 -77
- package/src/ast/BarrelParser.js +24 -4
- package/src/ast/MagicDetector.js +106 -13
- package/src/ast/OxcAnalyzer.js +121 -20
- package/src/ast/SecretScanner.js +304 -0
- package/src/healing/GitSandbox.js +44 -122
- package/src/healing/SelfHealer.js +29 -130
- package/src/index.js +175 -97
- package/src/performance/WorkerPool.js +6 -3
- package/src/plugins/PluginRegistry.js +27 -1
- package/src/resolution/DependencyProfiler.js +261 -9
- package/src/resolution/WorkSpaceGraph.js +142 -34
- package/src/performance/SecretDetector.js +0 -378
- package/src/performance/WorkerTaskRunner.js +0 -71
package/src/ast/MagicDetector.js
CHANGED
|
@@ -6,6 +6,15 @@ import { PluginRegistry } from '../plugins/PluginRegistry.js';
|
|
|
6
6
|
* Ecosystem Entry Point Manifest & Dynamic Framework Router Heuristic Validator
|
|
7
7
|
* Intercepts implicit conventions to handle cases where direct import statements are absent.
|
|
8
8
|
* Now refactored to use a pluggable architecture.
|
|
9
|
+
*
|
|
10
|
+
* Improvements over v1:
|
|
11
|
+
* - Extended config-file detection list (Biome, Oxlint, tsup, unbuild, etc.)
|
|
12
|
+
* - Next.js App Router conventions (page.tsx, layout.tsx, loading.tsx, error.tsx, etc.)
|
|
13
|
+
* - Remix conventions (route files under app/routes/)
|
|
14
|
+
* - SvelteKit conventions (+page.svelte, +layout.svelte, etc.)
|
|
15
|
+
* - Astro page/layout conventions
|
|
16
|
+
* - Common entry-point patterns (bin/, cli.ts, server.ts, main.ts, app.ts)
|
|
17
|
+
* - Test file patterns extended to cover Vitest workspace files
|
|
9
18
|
*/
|
|
10
19
|
export class MagicDetector {
|
|
11
20
|
constructor(context) {
|
|
@@ -60,20 +69,104 @@ export class MagicDetector {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
isCoreToolingSuiteElement(normalizedPath) {
|
|
63
|
-
//
|
|
64
|
-
if (/\.(test|spec|e2e|cy)\.(js|ts|tsx|jsx
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'.
|
|
71
|
-
'
|
|
72
|
-
|
|
73
|
-
'
|
|
72
|
+
// ── Test / spec files ──────────────────────────────────────────────────────
|
|
73
|
+
if (/\.(test|spec|e2e|cy)\.(js|ts|tsx|jsx)$/i.test(normalizedPath)) return true;
|
|
74
|
+
if (/\.stories\.(js|ts|tsx|jsx)$/i.test(normalizedPath)) return true;
|
|
75
|
+
|
|
76
|
+
// ── Build / bundler config files ───────────────────────────────────────────
|
|
77
|
+
const configFragments = [
|
|
78
|
+
// Test runners
|
|
79
|
+
'jest.config.', 'vitest.config.', 'vitest.workspace.',
|
|
80
|
+
'playwright.config.', 'cypress.config.',
|
|
81
|
+
// Bundlers
|
|
82
|
+
'webpack.config.', 'vite.config.', 'rollup.config.',
|
|
83
|
+
'esbuild.config.', 'parcel.config.',
|
|
84
|
+
'tsup.config.', 'unbuild.config.', 'pkgroll.config.',
|
|
85
|
+
// CSS / styling
|
|
86
|
+
'tailwind.config.', 'postcss.config.', '.postcssrc.',
|
|
87
|
+
// Linters / formatters
|
|
88
|
+
'.eslintrc.', 'eslint.config.', 'prettier.config.', '.prettierrc.',
|
|
89
|
+
'.stylelintrc.', 'stylelint.config.',
|
|
90
|
+
'biome.json', '.oxlintrc.',
|
|
91
|
+
// Babel / transpilation
|
|
92
|
+
'.babelrc.', 'babel.config.',
|
|
93
|
+
// Commit / git hooks
|
|
94
|
+
'.commitlintrc.', 'commitlint.config.',
|
|
95
|
+
'.lintstagedrc.', 'lint-staged.config.',
|
|
96
|
+
// Documentation
|
|
97
|
+
'typedoc.config.', 'typedoc.json',
|
|
98
|
+
// Monorepo tools
|
|
99
|
+
'turbo.json', 'nx.json', 'lerna.json',
|
|
100
|
+
// Misc tooling
|
|
101
|
+
'knip.config.', 'knip.json',
|
|
102
|
+
'syncpack.config.',
|
|
103
|
+
// Internal worker
|
|
104
|
+
'WorkerTaskRunner.js'
|
|
74
105
|
];
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
if (configFragments.some(f => normalizedPath.includes(f))) return true;
|
|
107
|
+
|
|
108
|
+
// ── Common application entry points ───────────────────────────────────────
|
|
109
|
+
const entryPatterns = [
|
|
110
|
+
// CLI binaries
|
|
111
|
+
'/bin/cli.js', '/bin/cli.ts', '/bin/cli.mjs',
|
|
112
|
+
'/bin/index.js', '/bin/index.ts',
|
|
113
|
+
// Server / app entry points (Reduced in v3.3.6 to avoid false positives in libraries)
|
|
114
|
+
'/src/main.ts', '/src/main.js',
|
|
115
|
+
'/src/app.ts', '/src/app.js',
|
|
116
|
+
'/src/api/HeadlessAPI.js', '/src/api/PluginSDK.js',
|
|
117
|
+
'/main.ts', '/main.js',
|
|
118
|
+
'/app.ts', '/app.js',
|
|
119
|
+
];
|
|
120
|
+
if (entryPatterns.some(p => normalizedPath.endsWith(p))) return true;
|
|
121
|
+
|
|
122
|
+
// ── Next.js App Router conventions ────────────────────────────────────────
|
|
123
|
+
// Files under app/ directory with Next.js special names
|
|
124
|
+
if (/\/app\/(page|layout|loading|error|not-found|template|default|route|middleware)\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
125
|
+
// Next.js Pages Router
|
|
126
|
+
if (/\/pages\/.*\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
127
|
+
// Next.js API routes
|
|
128
|
+
if (/\/pages\/api\/.*\.(js|ts)$/.test(normalizedPath)) return true;
|
|
129
|
+
// Next.js middleware
|
|
130
|
+
if (/\/middleware\.(js|ts)$/.test(normalizedPath)) return true;
|
|
131
|
+
// Next.js config
|
|
132
|
+
if (/\/next\.config\.(js|ts|mjs|cjs)$/.test(normalizedPath)) return true;
|
|
133
|
+
|
|
134
|
+
// ── Remix conventions ─────────────────────────────────────────────────────
|
|
135
|
+
if (/\/app\/routes\/.*\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
136
|
+
if (/\/app\/root\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
137
|
+
if (/\/app\/entry\.(client|server)\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
138
|
+
|
|
139
|
+
// ── SvelteKit conventions ─────────────────────────────────────────────────
|
|
140
|
+
if (/\/\+page(\.(server|client))?\.(svelte|js|ts)$/.test(normalizedPath)) return true;
|
|
141
|
+
if (/\/\+layout(\.(server|client))?\.(svelte|js|ts)$/.test(normalizedPath)) return true;
|
|
142
|
+
if (/\/\+error\.(svelte|js|ts)$/.test(normalizedPath)) return true;
|
|
143
|
+
if (/\/\+server\.(js|ts)$/.test(normalizedPath)) return true;
|
|
144
|
+
if (/\/svelte\.config\.(js|ts|mjs)$/.test(normalizedPath)) return true;
|
|
145
|
+
|
|
146
|
+
// ── Astro conventions ─────────────────────────────────────────────────────
|
|
147
|
+
if (/\/src\/pages\/.*\.astro$/.test(normalizedPath)) return true;
|
|
148
|
+
if (/\/src\/layouts\/.*\.astro$/.test(normalizedPath)) return true;
|
|
149
|
+
if (/\/astro\.config\.(mjs|js|ts)$/.test(normalizedPath)) return true;
|
|
150
|
+
|
|
151
|
+
// ── Nuxt conventions ──────────────────────────────────────────────────────
|
|
152
|
+
if (/\/pages\/.*\.vue$/.test(normalizedPath)) return true;
|
|
153
|
+
if (/\/layouts\/.*\.vue$/.test(normalizedPath)) return true;
|
|
154
|
+
if (/\/server\/api\/.*\.(js|ts)$/.test(normalizedPath)) return true;
|
|
155
|
+
if (/\/nuxt\.config\.(js|ts|mjs)$/.test(normalizedPath)) return true;
|
|
156
|
+
|
|
157
|
+
// ── React / Vite entry points ─────────────────────────────────────────────
|
|
158
|
+
if (/\/vite\.config\.(js|ts|mjs)$/.test(normalizedPath)) return true;
|
|
159
|
+
|
|
160
|
+
// ── Angular conventions ───────────────────────────────────────────────────
|
|
161
|
+
if (/\/main\.(ts|js)$/.test(normalizedPath)) return true;
|
|
162
|
+
if (/\/app\.module\.(ts|js)$/.test(normalizedPath)) return true;
|
|
163
|
+
if (/\/angular\.json$/.test(normalizedPath)) return true;
|
|
164
|
+
|
|
165
|
+
// ── Expo / React Native ───────────────────────────────────────────────────
|
|
166
|
+
if (/\/app\/_layout\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
167
|
+
if (/\/app\/index\.(js|ts|tsx|jsx)$/.test(normalizedPath)) return true;
|
|
168
|
+
|
|
169
|
+
return false;
|
|
77
170
|
}
|
|
78
171
|
|
|
79
172
|
/**
|
package/src/ast/OxcAnalyzer.js
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
export class OxcAnalyzer {
|
|
2
2
|
constructor(context) {
|
|
3
3
|
this.context = context;
|
|
4
|
+
this.oxc = null;
|
|
5
|
+
this.isAvailable = false;
|
|
6
|
+
// Initialization is handled via init() or lazily during parseFile
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async init() {
|
|
10
|
+
if (this.isAvailable) return true;
|
|
4
11
|
try {
|
|
5
|
-
|
|
12
|
+
// In ESM, we use dynamic import()
|
|
13
|
+
const oxc = await import("oxc-parser");
|
|
14
|
+
this.oxc = oxc;
|
|
6
15
|
this.isAvailable = true;
|
|
16
|
+
return true;
|
|
7
17
|
} catch (e) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
18
|
+
try {
|
|
19
|
+
// Fallback for older node versions or specific bundling setups
|
|
20
|
+
const { createRequire } = await import('module');
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
this.oxc = require("oxc-parser");
|
|
23
|
+
this.isAvailable = true;
|
|
24
|
+
return true;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
this.isAvailable = false;
|
|
27
|
+
if (this.context.verbose) {
|
|
28
|
+
console.warn("[OxcAnalyzer] oxc-parser not found or failed to load, falling back to TypeScript compiler API.");
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
11
31
|
}
|
|
12
32
|
}
|
|
13
33
|
}
|
|
14
34
|
|
|
15
|
-
parseFile(filePath, content, fileNode) {
|
|
16
|
-
if (!this.isAvailable)
|
|
35
|
+
async parseFile(filePath, content, fileNode) {
|
|
36
|
+
if (!this.isAvailable) {
|
|
37
|
+
const initialized = await this.init();
|
|
38
|
+
if (!initialized) return false;
|
|
39
|
+
}
|
|
17
40
|
|
|
18
41
|
try {
|
|
19
42
|
if (this.context.verbose) {
|
|
@@ -32,6 +55,26 @@ export class OxcAnalyzer {
|
|
|
32
55
|
fileNode.decorators = new Set();
|
|
33
56
|
|
|
34
57
|
this.walkOxcAst(ast.program, fileNode, content);
|
|
58
|
+
|
|
59
|
+
// Compute line/column for each export start position
|
|
60
|
+
const lines = content.split('\n');
|
|
61
|
+
const getLineCol = (pos) => {
|
|
62
|
+
let count = 0;
|
|
63
|
+
for (let i = 0; i < lines.length; i++) {
|
|
64
|
+
if (count + lines[i].length + 1 > pos) {
|
|
65
|
+
return { line: i + 1, column: pos - count + 1 };
|
|
66
|
+
}
|
|
67
|
+
count += lines[i].length + 1;
|
|
68
|
+
}
|
|
69
|
+
return { line: 1, column: 1 };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const [name, meta] of fileNode.internalExports.entries()) {
|
|
73
|
+
if (meta.start !== undefined) {
|
|
74
|
+
fileNode.symbolSourceLocations.set(name, getLineCol(meta.start));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
return true;
|
|
36
79
|
} catch (e) {
|
|
37
80
|
if (this.context.verbose) {
|
|
@@ -63,6 +106,10 @@ export class OxcAnalyzer {
|
|
|
63
106
|
case "Decorator":
|
|
64
107
|
this.handleDecorator(node, fileNode);
|
|
65
108
|
break;
|
|
109
|
+
case "StringLiteral":
|
|
110
|
+
// Track for secret scanning if it looks like a secret
|
|
111
|
+
fileNode.rawStringReferences.add(node.value);
|
|
112
|
+
break;
|
|
66
113
|
}
|
|
67
114
|
|
|
68
115
|
// Traverse children
|
|
@@ -81,11 +128,15 @@ export class OxcAnalyzer {
|
|
|
81
128
|
const specifier = node.source.value;
|
|
82
129
|
fileNode.explicitImports.add(specifier);
|
|
83
130
|
|
|
131
|
+
// Track external package usage for dependency analysis
|
|
132
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
|
|
133
|
+
fileNode.externalPackageUsage.add(this._extractPackageName(specifier));
|
|
134
|
+
}
|
|
135
|
+
|
|
84
136
|
if (node.specifiers) {
|
|
85
137
|
node.specifiers.forEach((spec) => {
|
|
86
138
|
if (spec.type === "ImportSpecifier") {
|
|
87
139
|
const importedName = spec.imported.name;
|
|
88
|
-
const localName = spec.local.name;
|
|
89
140
|
fileNode.importedSymbols.add(`${specifier}:${importedName}`);
|
|
90
141
|
} else if (spec.type === "ImportDefaultSpecifier") {
|
|
91
142
|
fileNode.importedSymbols.add(`${specifier}:default`);
|
|
@@ -103,20 +154,45 @@ export class OxcAnalyzer {
|
|
|
103
154
|
}
|
|
104
155
|
|
|
105
156
|
if (node.type === "ExportAllDeclaration") {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
157
|
+
const sourceSpecifier = node.source ? node.source.value : null;
|
|
158
|
+
if (sourceSpecifier) {
|
|
159
|
+
// Register re-export source as an explicit import so the graph linker
|
|
160
|
+
// creates an incomingEdge on the re-exported file.
|
|
161
|
+
fileNode.explicitImports.add(sourceSpecifier);
|
|
162
|
+
|
|
163
|
+
// Track external package usage from re-exports
|
|
164
|
+
if (!sourceSpecifier.startsWith('.') && !sourceSpecifier.startsWith('/')) {
|
|
165
|
+
fileNode.externalPackageUsage.add(this._extractPackageName(sourceSpecifier));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (node.exported) {
|
|
169
|
+
// export * as name from 'module'
|
|
170
|
+
const name = node.exported.name || (node.exported.type === "Identifier" ? node.exported.name : null);
|
|
171
|
+
if (name) {
|
|
172
|
+
fileNode.internalExports.set(name, { type: "re-export-namespace", source: sourceSpecifier, originalName: "*", start: node.start, end: node.end });
|
|
173
|
+
fileNode.importedSymbols.add(`${sourceSpecifier}:*`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
// export * from 'module'
|
|
177
|
+
fileNode.internalExports.set("*", { type: "re-export-all", source: sourceSpecifier });
|
|
178
|
+
// Register as wildcard importedSymbol so graph linker creates incomingEdge
|
|
179
|
+
fileNode.importedSymbols.add(`${sourceSpecifier}:*`);
|
|
180
|
+
}
|
|
113
181
|
}
|
|
114
182
|
return;
|
|
115
183
|
}
|
|
116
184
|
|
|
117
185
|
if (node.source) {
|
|
118
|
-
// Re-export
|
|
186
|
+
// Re-export with source: export { x } from 'module'
|
|
119
187
|
const specifier = node.source.value;
|
|
188
|
+
// Register re-export source as an explicit import
|
|
189
|
+
fileNode.explicitImports.add(specifier);
|
|
190
|
+
|
|
191
|
+
// Track external package usage from re-exports
|
|
192
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
|
|
193
|
+
fileNode.externalPackageUsage.add(this._extractPackageName(specifier));
|
|
194
|
+
}
|
|
195
|
+
|
|
120
196
|
if (node.specifiers) {
|
|
121
197
|
node.specifiers.forEach((spec) => {
|
|
122
198
|
const exportedName = spec.exported.name;
|
|
@@ -128,6 +204,8 @@ export class OxcAnalyzer {
|
|
|
128
204
|
start: node.start,
|
|
129
205
|
end: node.end,
|
|
130
206
|
});
|
|
207
|
+
// Register as importedSymbol so barrel-tracer can resolve origin file
|
|
208
|
+
fileNode.importedSymbols.add(`${specifier}:${localName}`);
|
|
131
209
|
});
|
|
132
210
|
}
|
|
133
211
|
} else if (node.declaration) {
|
|
@@ -184,12 +262,28 @@ export class OxcAnalyzer {
|
|
|
184
262
|
}
|
|
185
263
|
|
|
186
264
|
handleCallExpression(node, fileNode) {
|
|
187
|
-
|
|
265
|
+
// Dynamic import(): import('./module')
|
|
266
|
+
if (node.callee.type === "Import" && node.arguments.length > 0) {
|
|
267
|
+
const arg = node.arguments[0];
|
|
268
|
+
if (arg.type === "StringLiteral") {
|
|
269
|
+
const specifier = arg.value;
|
|
270
|
+
fileNode.explicitImports.add(specifier);
|
|
271
|
+
fileNode.dynamicImports.add(specifier);
|
|
272
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
|
|
273
|
+
fileNode.externalPackageUsage.add(this._extractPackageName(specifier));
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
// Non-literal dynamic import: record as calculated
|
|
277
|
+
if (fileNode.calculatedDynamicImports) {
|
|
278
|
+
fileNode.calculatedDynamicImports.push({ kind: arg.type, start: arg.start });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments.length > 0 && node.arguments[0].type === "StringLiteral") {
|
|
188
282
|
const specifier = node.arguments[0].value;
|
|
189
283
|
fileNode.explicitImports.add(specifier);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
284
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
|
|
285
|
+
fileNode.externalPackageUsage.add(this._extractPackageName(specifier));
|
|
286
|
+
}
|
|
193
287
|
}
|
|
194
288
|
}
|
|
195
289
|
|
|
@@ -229,8 +323,15 @@ export class OxcAnalyzer {
|
|
|
229
323
|
if (node.expression.type === "CallExpression") {
|
|
230
324
|
node.expression.arguments.forEach(arg => {
|
|
231
325
|
// Further analysis of arguments can be done here if needed
|
|
232
|
-
// e.g., if (arg.type === "StringLiteral") fileNode.decoratorArgs.add(`${decoratorName}:${arg.value}`);
|
|
233
326
|
});
|
|
234
327
|
}
|
|
235
328
|
}
|
|
329
|
+
|
|
330
|
+
_extractPackageName(specifier) {
|
|
331
|
+
if (specifier.startsWith('@')) {
|
|
332
|
+
const parts = specifier.split('/');
|
|
333
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier;
|
|
334
|
+
}
|
|
335
|
+
return specifier.split('/')[0];
|
|
336
|
+
}
|
|
236
337
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 🔐 SecretScanner – Hardcoded Credential & API Key Detector
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* Scans source files for hardcoded secrets such as API keys, tokens,
|
|
6
|
+
* passwords, and other sensitive credentials using heuristic pattern matching
|
|
7
|
+
* on both variable names and string literal values.
|
|
8
|
+
*
|
|
9
|
+
* New in v3.3.6: integrated into the main analysis pipeline so that secrets
|
|
10
|
+
* are surfaced alongside dead-code and unused-dependency findings.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Severity levels for detected secrets.
|
|
15
|
+
*/
|
|
16
|
+
export const SecretSeverity = {
|
|
17
|
+
CRITICAL: 'CRITICAL',
|
|
18
|
+
HIGH: 'HIGH',
|
|
19
|
+
MEDIUM: 'MEDIUM',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Patterns that flag a variable/property *name* as likely containing a secret.
|
|
24
|
+
* Matched case-insensitively against the identifier text.
|
|
25
|
+
*/
|
|
26
|
+
const SENSITIVE_NAME_PATTERNS = [
|
|
27
|
+
// Generic credential names
|
|
28
|
+
/api[_-]?key/i,
|
|
29
|
+
/apikey/i,
|
|
30
|
+
/api[_-]?secret/i,
|
|
31
|
+
/access[_-]?token/i,
|
|
32
|
+
/auth[_-]?token/i,
|
|
33
|
+
/bearer[_-]?token/i,
|
|
34
|
+
/secret[_-]?key/i,
|
|
35
|
+
/private[_-]?key/i,
|
|
36
|
+
/client[_-]?secret/i,
|
|
37
|
+
/app[_-]?secret/i,
|
|
38
|
+
// Database credentials
|
|
39
|
+
/db[_-]?pass(word)?/i,
|
|
40
|
+
/database[_-]?pass(word)?/i,
|
|
41
|
+
/db[_-]?url/i,
|
|
42
|
+
/database[_-]?url/i,
|
|
43
|
+
/connection[_-]?string/i,
|
|
44
|
+
// Passwords
|
|
45
|
+
/^password$/i,
|
|
46
|
+
/^passwd$/i,
|
|
47
|
+
/^pwd$/i,
|
|
48
|
+
/[_-]password$/i,
|
|
49
|
+
// Tokens
|
|
50
|
+
/[_-]token$/i,
|
|
51
|
+
/^token$/i,
|
|
52
|
+
/jwt[_-]?secret/i,
|
|
53
|
+
/session[_-]?secret/i,
|
|
54
|
+
/cookie[_-]?secret/i,
|
|
55
|
+
// Cloud provider keys
|
|
56
|
+
/aws[_-]?(access[_-]?key|secret|session[_-]?token)/i,
|
|
57
|
+
/gcp[_-]?key/i,
|
|
58
|
+
/azure[_-]?(key|secret|connection)/i,
|
|
59
|
+
// Service-specific
|
|
60
|
+
/stripe[_-]?(key|secret)/i,
|
|
61
|
+
/twilio[_-]?(auth|token|sid)/i,
|
|
62
|
+
/sendgrid[_-]?key/i,
|
|
63
|
+
/github[_-]?token/i,
|
|
64
|
+
/slack[_-]?(token|webhook)/i,
|
|
65
|
+
/discord[_-]?(token|secret)/i,
|
|
66
|
+
/openai[_-]?(key|token)/i,
|
|
67
|
+
/anthropic[_-]?key/i,
|
|
68
|
+
/webhook[_-]?(url|secret)/i,
|
|
69
|
+
/encryption[_-]?key/i,
|
|
70
|
+
/signing[_-]?key/i,
|
|
71
|
+
/hmac[_-]?key/i,
|
|
72
|
+
/salt$/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Patterns that flag a *string literal value* as likely being a secret.
|
|
77
|
+
* These are matched against the raw string content.
|
|
78
|
+
*/
|
|
79
|
+
const SENSITIVE_VALUE_PATTERNS = [
|
|
80
|
+
// AWS Access Key IDs
|
|
81
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS_ACCESS_KEY_ID', severity: SecretSeverity.CRITICAL },
|
|
82
|
+
// AWS Secret Access Keys (40-char base64-ish)
|
|
83
|
+
{ pattern: /[A-Za-z0-9/+=]{40}/, label: 'AWS_SECRET_KEY_CANDIDATE', severity: SecretSeverity.MEDIUM },
|
|
84
|
+
// Generic high-entropy hex strings (32+ chars)
|
|
85
|
+
{ pattern: /^[0-9a-f]{32,}$/i, label: 'HEX_SECRET', severity: SecretSeverity.HIGH },
|
|
86
|
+
// Generic high-entropy base64 strings (32+ chars)
|
|
87
|
+
{ pattern: /^[A-Za-z0-9+/]{32,}={0,2}$/, label: 'BASE64_SECRET', severity: SecretSeverity.MEDIUM },
|
|
88
|
+
// JWT tokens
|
|
89
|
+
{ pattern: /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, label: 'JWT_TOKEN', severity: SecretSeverity.CRITICAL },
|
|
90
|
+
// GitHub personal access tokens
|
|
91
|
+
{ pattern: /ghp_[A-Za-z0-9]{36}/, label: 'GITHUB_PAT', severity: SecretSeverity.CRITICAL },
|
|
92
|
+
{ pattern: /github_pat_[A-Za-z0-9_]{82}/, label: 'GITHUB_PAT_FINE', severity: SecretSeverity.CRITICAL },
|
|
93
|
+
// Stripe keys
|
|
94
|
+
{ pattern: /sk_(live|test)_[A-Za-z0-9]{24,}/, label: 'STRIPE_SECRET_KEY', severity: SecretSeverity.CRITICAL },
|
|
95
|
+
{ pattern: /pk_(live|test)_[A-Za-z0-9]{24,}/, label: 'STRIPE_PUBLIC_KEY', severity: SecretSeverity.HIGH },
|
|
96
|
+
// Slack tokens
|
|
97
|
+
{ pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/, label: 'SLACK_TOKEN', severity: SecretSeverity.CRITICAL },
|
|
98
|
+
// Discord tokens
|
|
99
|
+
{ pattern: /[MN][A-Za-z0-9]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}/, label: 'DISCORD_TOKEN', severity: SecretSeverity.CRITICAL },
|
|
100
|
+
// Twilio SID
|
|
101
|
+
{ pattern: /AC[a-f0-9]{32}/, label: 'TWILIO_SID', severity: SecretSeverity.HIGH },
|
|
102
|
+
// SendGrid API key
|
|
103
|
+
{ pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/, label: 'SENDGRID_KEY', severity: SecretSeverity.CRITICAL },
|
|
104
|
+
// OpenAI API key
|
|
105
|
+
{ pattern: /sk-[A-Za-z0-9]{32,}/, label: 'OPENAI_KEY', severity: SecretSeverity.CRITICAL },
|
|
106
|
+
// Generic UUID-like tokens that look like secrets (not just any UUID)
|
|
107
|
+
{ pattern: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, label: 'UUID_SECRET_CANDIDATE', severity: SecretSeverity.MEDIUM },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Minimum length a string value must have to be considered a potential secret.
|
|
112
|
+
* Short strings like "test", "dev", "localhost" are excluded.
|
|
113
|
+
*/
|
|
114
|
+
const MIN_SECRET_VALUE_LENGTH = 8;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Values that are obviously not secrets (common placeholders / env-var references).
|
|
118
|
+
*/
|
|
119
|
+
const SAFE_VALUE_ALLOWLIST = new Set([
|
|
120
|
+
'your_api_key_here',
|
|
121
|
+
'your-api-key',
|
|
122
|
+
'YOUR_API_KEY',
|
|
123
|
+
'YOUR_SECRET',
|
|
124
|
+
'REPLACE_ME',
|
|
125
|
+
'changeme',
|
|
126
|
+
'placeholder',
|
|
127
|
+
'example',
|
|
128
|
+
'test',
|
|
129
|
+
'demo',
|
|
130
|
+
'localhost',
|
|
131
|
+
'127.0.0.1',
|
|
132
|
+
'process.env',
|
|
133
|
+
'',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Checks whether a string value looks like an environment-variable reference
|
|
138
|
+
* (e.g. `process.env.SECRET` or `import.meta.env.SECRET`).
|
|
139
|
+
*/
|
|
140
|
+
function isEnvReference(value) {
|
|
141
|
+
return (
|
|
142
|
+
value.startsWith('process.env') ||
|
|
143
|
+
value.startsWith('import.meta.env') ||
|
|
144
|
+
value.startsWith('${') ||
|
|
145
|
+
/^[A-Z_][A-Z0-9_]*$/.test(value) // ALL_CAPS env-var placeholder
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Determines whether a string value is high-entropy enough to be a real secret.
|
|
151
|
+
* Uses Shannon entropy as a heuristic.
|
|
152
|
+
*/
|
|
153
|
+
function shannonEntropy(str) {
|
|
154
|
+
const freq = {};
|
|
155
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
156
|
+
let entropy = 0;
|
|
157
|
+
for (const count of Object.values(freq)) {
|
|
158
|
+
const p = count / str.length;
|
|
159
|
+
entropy -= p * Math.log2(p);
|
|
160
|
+
}
|
|
161
|
+
return entropy;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Main scanner class. Operates on pre-parsed AST nodes produced by either
|
|
166
|
+
* ASTAnalyzer (TypeScript compiler API) or OxcAnalyzer (oxc-parser).
|
|
167
|
+
*/
|
|
168
|
+
export class SecretScanner {
|
|
169
|
+
/**
|
|
170
|
+
* Scans a source file's text for hardcoded secrets.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} filePath - Absolute path to the file being scanned.
|
|
173
|
+
* @param {string} fileContent - Raw source text of the file.
|
|
174
|
+
* @returns {Array<SecretFinding>} - Array of detected secret findings.
|
|
175
|
+
*/
|
|
176
|
+
scanFileContent(filePath, fileContent) {
|
|
177
|
+
const findings = [];
|
|
178
|
+
const lines = fileContent.split('\n');
|
|
179
|
+
|
|
180
|
+
lines.forEach((line, lineIndex) => {
|
|
181
|
+
const lineNumber = lineIndex + 1;
|
|
182
|
+
|
|
183
|
+
// Skip comment-only lines and import statements
|
|
184
|
+
const trimmed = line.trim();
|
|
185
|
+
if (
|
|
186
|
+
trimmed.startsWith('//') ||
|
|
187
|
+
trimmed.startsWith('*') ||
|
|
188
|
+
trimmed.startsWith('/*') ||
|
|
189
|
+
trimmed.startsWith('import ') ||
|
|
190
|
+
trimmed.startsWith('export { ')
|
|
191
|
+
) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Strategy 1: Name-based heuristic ──────────────────────────────────
|
|
196
|
+
// Look for variable/property assignments where the name matches a
|
|
197
|
+
// sensitive pattern and the value is a non-trivial string literal.
|
|
198
|
+
const assignmentMatch = line.match(
|
|
199
|
+
/(?:const|let|var|readonly)?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*[=:]\s*["'`]([^"'`]+)["'`]/
|
|
200
|
+
);
|
|
201
|
+
if (assignmentMatch) {
|
|
202
|
+
const [, varName, value] = assignmentMatch;
|
|
203
|
+
if (
|
|
204
|
+
value.length >= MIN_SECRET_VALUE_LENGTH &&
|
|
205
|
+
!SAFE_VALUE_ALLOWLIST.has(value) &&
|
|
206
|
+
!isEnvReference(value) &&
|
|
207
|
+
SENSITIVE_NAME_PATTERNS.some(p => p.test(varName))
|
|
208
|
+
) {
|
|
209
|
+
findings.push({
|
|
210
|
+
file: filePath,
|
|
211
|
+
line: lineNumber,
|
|
212
|
+
column: line.indexOf(value),
|
|
213
|
+
variableName: varName,
|
|
214
|
+
valueSnippet: this._redact(value),
|
|
215
|
+
label: this._labelFromName(varName),
|
|
216
|
+
severity: SecretSeverity.CRITICAL,
|
|
217
|
+
});
|
|
218
|
+
return; // Don't double-report the same line
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Strategy 2: Value-pattern matching ────────────────────────────────
|
|
223
|
+
// Extract all string literals on this line and test them against known
|
|
224
|
+
// secret value patterns.
|
|
225
|
+
const stringLiterals = [...line.matchAll(/["'`]([^"'`\n]{8,})["'`]/g)];
|
|
226
|
+
for (const match of stringLiterals) {
|
|
227
|
+
const value = match[1];
|
|
228
|
+
if (SAFE_VALUE_ALLOWLIST.has(value) || isEnvReference(value)) continue;
|
|
229
|
+
|
|
230
|
+
for (const { pattern, label, severity } of SENSITIVE_VALUE_PATTERNS) {
|
|
231
|
+
if (pattern.test(value)) {
|
|
232
|
+
// Avoid duplicate findings for the same position
|
|
233
|
+
const alreadyReported = findings.some(
|
|
234
|
+
f => f.line === lineNumber && f.valueSnippet === this._redact(value)
|
|
235
|
+
);
|
|
236
|
+
if (!alreadyReported) {
|
|
237
|
+
findings.push({
|
|
238
|
+
file: filePath,
|
|
239
|
+
line: lineNumber,
|
|
240
|
+
column: line.indexOf(match[0]),
|
|
241
|
+
variableName: null,
|
|
242
|
+
valueSnippet: this._redact(value),
|
|
243
|
+
label,
|
|
244
|
+
severity,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Strategy 3: High-entropy string heuristic ─────────────────────────
|
|
253
|
+
// Any string literal with Shannon entropy > 4.5 and length >= 20 that
|
|
254
|
+
// is assigned to a sensitive-named variable is flagged.
|
|
255
|
+
if (assignmentMatch) {
|
|
256
|
+
const [, varName, value] = assignmentMatch;
|
|
257
|
+
if (
|
|
258
|
+
value.length >= 20 &&
|
|
259
|
+
shannonEntropy(value) > 4.5 &&
|
|
260
|
+
!SAFE_VALUE_ALLOWLIST.has(value) &&
|
|
261
|
+
!isEnvReference(value) &&
|
|
262
|
+
SENSITIVE_NAME_PATTERNS.some(p => p.test(varName))
|
|
263
|
+
) {
|
|
264
|
+
const alreadyReported = findings.some(f => f.line === lineNumber);
|
|
265
|
+
if (!alreadyReported) {
|
|
266
|
+
findings.push({
|
|
267
|
+
file: filePath,
|
|
268
|
+
line: lineNumber,
|
|
269
|
+
column: line.indexOf(value),
|
|
270
|
+
variableName: varName,
|
|
271
|
+
valueSnippet: this._redact(value),
|
|
272
|
+
label: 'HIGH_ENTROPY_SECRET',
|
|
273
|
+
severity: SecretSeverity.HIGH,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return findings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Redacts a secret value for safe display in reports.
|
|
285
|
+
* Shows the first 4 characters followed by asterisks.
|
|
286
|
+
*/
|
|
287
|
+
_redact(value) {
|
|
288
|
+
if (value.length <= 4) return '****';
|
|
289
|
+
return value.slice(0, 4) + '*'.repeat(Math.min(value.length - 4, 8));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Derives a human-readable label from a variable name.
|
|
294
|
+
*/
|
|
295
|
+
_labelFromName(name) {
|
|
296
|
+
const upper = name.toUpperCase();
|
|
297
|
+
if (/API[_-]?KEY/.test(upper)) return 'API_KEY';
|
|
298
|
+
if (/TOKEN/.test(upper)) return 'AUTH_TOKEN';
|
|
299
|
+
if (/PASSWORD|PASSWD|PWD/.test(upper)) return 'PASSWORD';
|
|
300
|
+
if (/SECRET/.test(upper)) return 'SECRET';
|
|
301
|
+
if (/DATABASE|DB/.test(upper)) return 'DATABASE_CREDENTIAL';
|
|
302
|
+
return 'SENSITIVE_VALUE';
|
|
303
|
+
}
|
|
304
|
+
}
|