pkg-scaffold 3.3.3 → 3.3.5
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 +62 -22
- package/bin/cli.js +7 -7
- package/package.json +5 -4
- package/pnpm-workspace.yaml +2 -0
- package/src/EngineContext.js +47 -19
- package/src/ast/ASTAnalyzer.js +287 -132
- package/src/ast/BarrelParser.js +51 -19
- package/src/ast/DeadCodeDetector.js +73 -0
- package/src/ast/MagicDetector.js +111 -11
- package/src/ast/OxcAnalyzer.js +250 -79
- package/src/healing/GitSandbox.js +44 -122
- package/src/healing/SelfHealer.js +29 -130
- package/src/index.js +124 -108
- package/src/performance/WorkerTaskRunner.js +17 -5
- package/src/plugins/PluginRegistry.js +28 -1
- package/src/plugins/ecosystems/MorePlugins.js +184 -0
- package/src/plugins/ecosystems/PluginLoader.js +20 -0
- package/src/resolution/CircularDetector.js +3 -34
- package/src/resolution/ConfigLoader.js +34 -6
- package/src/resolution/DependencyProfiler.js +261 -9
- package/src/resolution/WorkSpaceGraph.js +148 -35
- package/src/performance/SecretDetector.js +0 -378
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
// Native sub-directory crawling removed as it's not in fs/promises in older node, but we use readdir anyway.
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Monorepo Cross-Linking Topology Manager
|
|
7
6
|
* Maps sub-package structural boundaries across pnpm, Yarn, or npm workspaces.
|
|
7
|
+
*
|
|
8
|
+
* Improvements over v1:
|
|
9
|
+
* - Auto-activates workspace mode when workspace config is detected (no manual flag required)
|
|
10
|
+
* - Supports deeper glob patterns beyond simple `packages/*` (e.g. `apps/*`, `libs/**`)
|
|
11
|
+
* - Correctly registers workspace package names as "used" so they are never flagged as unused deps
|
|
12
|
+
* - Handles Bun workspaces (workspaces array in package.json)
|
|
13
|
+
* - Resolves subpath imports for workspace packages (e.g. `@scope/pkg/utils`)
|
|
14
|
+
* - Exposes `markWorkspacePackagesAsUsed()` so the engine can call it after dep audit
|
|
8
15
|
*/
|
|
9
16
|
export class WorkspaceGraph {
|
|
10
17
|
constructor(context) {
|
|
@@ -15,6 +22,7 @@ export class WorkspaceGraph {
|
|
|
15
22
|
|
|
16
23
|
/**
|
|
17
24
|
* Checks the environment layout to discover and map local workspace packages.
|
|
25
|
+
* This method is idempotent and safe to call multiple times.
|
|
18
26
|
*/
|
|
19
27
|
async initializeWorkspaceMesh() {
|
|
20
28
|
const rootPackageJsonPath = path.join(this.context.cwd, 'package.json');
|
|
@@ -23,7 +31,7 @@ export class WorkspaceGraph {
|
|
|
23
31
|
let workspaceGlobs = [];
|
|
24
32
|
this.hoistedDependencies = new Set();
|
|
25
33
|
|
|
26
|
-
// Load hoisted dependencies from root package.json
|
|
34
|
+
// Load hoisted dependencies from root package.json
|
|
27
35
|
try {
|
|
28
36
|
const rootPkg = JSON.parse(await fs.readFile(rootPackageJsonPath, 'utf8'));
|
|
29
37
|
const deps = { ...rootPkg.dependencies, ...rootPkg.devDependencies };
|
|
@@ -32,7 +40,7 @@ export class WorkspaceGraph {
|
|
|
32
40
|
// No root package.json or unreadable
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
// Protocol A: Check for pnpm workspace configurations
|
|
43
|
+
// Protocol A: Check for pnpm workspace configurations (pnpm-workspace.yaml)
|
|
36
44
|
try {
|
|
37
45
|
const yaml = await fs.readFile(pnpmWorkspacePath, 'utf8');
|
|
38
46
|
const lines = yaml.split('\n');
|
|
@@ -40,78 +48,159 @@ export class WorkspaceGraph {
|
|
|
40
48
|
|
|
41
49
|
for (const line of lines) {
|
|
42
50
|
const trimmed = line.trim();
|
|
43
|
-
if (trimmed
|
|
51
|
+
if (trimmed === 'packages:') {
|
|
44
52
|
insidePackagesBlock = true;
|
|
45
53
|
continue;
|
|
46
54
|
}
|
|
47
|
-
if (insidePackagesBlock
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
if (insidePackagesBlock) {
|
|
56
|
+
if (trimmed.startsWith('-')) {
|
|
57
|
+
const pattern = trimmed.replace(/^-\s*/, '').replace(/['"]/g, '').trim();
|
|
58
|
+
if (pattern) workspaceGlobs.push(pattern);
|
|
59
|
+
} else if (trimmed && !trimmed.startsWith('#')) {
|
|
60
|
+
// Another top-level key encountered – stop reading packages block
|
|
61
|
+
insidePackagesBlock = false;
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
}
|
|
52
65
|
} catch {
|
|
53
66
|
// pnpm structure absent; check package.json workspace array paths instead
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
// Protocol B: Check for Yarn/npm workspaces array inside the root package.json
|
|
69
|
+
// Protocol B: Check for Yarn/npm/Bun workspaces array inside the root package.json
|
|
57
70
|
if (workspaceGlobs.length === 0) {
|
|
58
71
|
try {
|
|
59
72
|
const pkgText = await fs.readFile(rootPackageJsonPath, 'utf8');
|
|
60
73
|
const pkg = JSON.parse(pkgText);
|
|
61
74
|
if (pkg.workspaces) {
|
|
62
|
-
workspaceGlobs = Array.isArray(pkg.workspaces)
|
|
75
|
+
workspaceGlobs = Array.isArray(pkg.workspaces)
|
|
76
|
+
? pkg.workspaces
|
|
77
|
+
: (pkg.workspaces.packages || []);
|
|
63
78
|
}
|
|
64
79
|
} catch {
|
|
65
|
-
|
|
80
|
+
// No workspaces found
|
|
66
81
|
}
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
if (workspaceGlobs.length > 0) {
|
|
85
|
+
this.context.isWorkspaceEnabled = true;
|
|
86
|
+
} else {
|
|
87
|
+
return; // Workspace mesh maps skipped for single-package targets
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// Crawl target glob configurations to locate workspace packages
|
|
70
91
|
for (const pattern of workspaceGlobs) {
|
|
71
92
|
await this.locatePackagesViaPattern(pattern);
|
|
72
93
|
}
|
|
94
|
+
|
|
95
|
+
// Register all discovered workspace packages as "used" external packages so they
|
|
96
|
+
// are never incorrectly flagged as unused dependencies.
|
|
97
|
+
this.markWorkspacePackagesAsUsed();
|
|
73
98
|
}
|
|
74
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Expands a workspace glob pattern and registers all found packages.
|
|
102
|
+
* Supports patterns like:
|
|
103
|
+
* - `packages/*` (one level deep)
|
|
104
|
+
* - `apps/*` (one level deep)
|
|
105
|
+
* - `packages/**` (recursive – all subdirectories)
|
|
106
|
+
* - `packages/core` (explicit single package)
|
|
107
|
+
*/
|
|
75
108
|
async locatePackagesViaPattern(globPattern) {
|
|
76
|
-
// Normalizes wildcards down into base query directories
|
|
77
109
|
const standardizedPattern = globPattern.replace(/\\/g, '/');
|
|
110
|
+
|
|
111
|
+
// Determine if this is a recursive pattern (`**`) or a simple wildcard (`*`)
|
|
112
|
+
const isRecursive = standardizedPattern.includes('**');
|
|
113
|
+
const isWildcard = standardizedPattern.includes('*');
|
|
114
|
+
|
|
115
|
+
if (!isWildcard) {
|
|
116
|
+
// Explicit path: treat the pattern itself as a single package directory
|
|
117
|
+
const absolutePath = path.resolve(this.context.cwd, standardizedPattern);
|
|
118
|
+
await this._tryRegisterPackage(absolutePath);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extract the base directory before the first wildcard segment
|
|
78
123
|
const baseDir = standardizedPattern.split('/*')[0];
|
|
79
124
|
const absoluteSearchPath = path.resolve(this.context.cwd, baseDir);
|
|
80
125
|
|
|
126
|
+
if (isRecursive) {
|
|
127
|
+
await this._scanDirectoryRecursively(absoluteSearchPath);
|
|
128
|
+
} else {
|
|
129
|
+
await this._scanDirectoryShallow(absoluteSearchPath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scans a directory one level deep for workspace packages.
|
|
135
|
+
*/
|
|
136
|
+
async _scanDirectoryShallow(absoluteSearchPath) {
|
|
81
137
|
try {
|
|
82
138
|
const contents = await fs.readdir(absoluteSearchPath, { withFileTypes: true });
|
|
83
|
-
|
|
84
139
|
for (const entity of contents) {
|
|
85
140
|
if (!entity.isDirectory()) continue;
|
|
86
|
-
|
|
87
141
|
const subPackageDir = path.join(absoluteSearchPath, entity.name);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const data = await fs.readFile(manifestFile, 'utf8');
|
|
92
|
-
const pkg = JSON.parse(data);
|
|
93
|
-
|
|
94
|
-
if (pkg.name) {
|
|
95
|
-
const entryPoints = this.calculatePackageExportsEntries(pkg, subPackageDir);
|
|
96
|
-
|
|
97
|
-
this.packageManifests.set(pkg.name, {
|
|
98
|
-
packageName: pkg.name,
|
|
99
|
-
rootDirectory: subPackageDir,
|
|
100
|
-
manifestPath: manifestFile,
|
|
101
|
-
entryPoints
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
this.workspacePackageNames.add(pkg.name);
|
|
105
|
-
}
|
|
106
|
-
} catch {
|
|
107
|
-
// package.json parsing failed; ignore invalid directory roots
|
|
108
|
-
}
|
|
142
|
+
await this._tryRegisterPackage(subPackageDir);
|
|
109
143
|
}
|
|
110
144
|
} catch {
|
|
111
145
|
// Unreadable target directories; pass tracking
|
|
112
146
|
}
|
|
113
147
|
}
|
|
114
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Recursively scans a directory tree for workspace packages.
|
|
151
|
+
* Stops descending into `node_modules` directories.
|
|
152
|
+
*/
|
|
153
|
+
async _scanDirectoryRecursively(absoluteSearchPath) {
|
|
154
|
+
try {
|
|
155
|
+
const contents = await fs.readdir(absoluteSearchPath, { withFileTypes: true });
|
|
156
|
+
for (const entity of contents) {
|
|
157
|
+
if (!entity.isDirectory()) continue;
|
|
158
|
+
if (entity.name === 'node_modules' || entity.name === '.git') continue;
|
|
159
|
+
const subDir = path.join(absoluteSearchPath, entity.name);
|
|
160
|
+
// Try to register as a package first
|
|
161
|
+
const registered = await this._tryRegisterPackage(subDir);
|
|
162
|
+
// If not a package root itself, recurse deeper
|
|
163
|
+
if (!registered) {
|
|
164
|
+
await this._scanDirectoryRecursively(subDir);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Unreadable directories; pass
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attempts to register a directory as a workspace package.
|
|
174
|
+
* Returns true if a valid package.json with a `name` field was found.
|
|
175
|
+
*/
|
|
176
|
+
async _tryRegisterPackage(packageDir) {
|
|
177
|
+
const manifestFile = path.join(packageDir, 'package.json');
|
|
178
|
+
try {
|
|
179
|
+
const data = await fs.readFile(manifestFile, 'utf8');
|
|
180
|
+
const pkg = JSON.parse(data);
|
|
181
|
+
|
|
182
|
+
if (pkg.name) {
|
|
183
|
+
const entryPoints = this.calculatePackageExportsEntries(pkg, packageDir);
|
|
184
|
+
|
|
185
|
+
this.packageManifests.set(pkg.name, {
|
|
186
|
+
packageName: pkg.name,
|
|
187
|
+
rootDirectory: packageDir,
|
|
188
|
+
manifestPath: manifestFile,
|
|
189
|
+
entryPoints
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.workspacePackageNames.add(pkg.name);
|
|
193
|
+
// Also register the package root so the resolver can identify files
|
|
194
|
+
// inside this package as "internal" rather than node_modules.
|
|
195
|
+
this.context.monorepoPackageRoots.add(packageDir);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// package.json parsing failed; ignore invalid directory roots
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
115
204
|
/**
|
|
116
205
|
* Tracks package entry points by evaluating standard fields and main/exports configurations.
|
|
117
206
|
*/
|
|
@@ -121,8 +210,9 @@ export class WorkspaceGraph {
|
|
|
121
210
|
// Trace traditional entry fields
|
|
122
211
|
if (pkg.main) entries.add(path.resolve(pkgDir, pkg.main));
|
|
123
212
|
if (pkg.module) entries.add(path.resolve(pkgDir, pkg.module));
|
|
124
|
-
if (pkg.browser) entries.add(path.resolve(pkgDir, pkg.browser));
|
|
213
|
+
if (pkg.browser && typeof pkg.browser === 'string') entries.add(path.resolve(pkgDir, pkg.browser));
|
|
125
214
|
if (pkg.types) entries.add(path.resolve(pkgDir, pkg.types));
|
|
215
|
+
if (pkg.typings) entries.add(path.resolve(pkgDir, pkg.typings));
|
|
126
216
|
|
|
127
217
|
// Handle deep nested conditional exports matrices block parameters
|
|
128
218
|
if (pkg.exports) {
|
|
@@ -131,8 +221,16 @@ export class WorkspaceGraph {
|
|
|
131
221
|
|
|
132
222
|
// Default file index fallback configurations
|
|
133
223
|
if (entries.size === 0) {
|
|
224
|
+
// Standard roots
|
|
134
225
|
entries.add(path.resolve(pkgDir, 'index.js'));
|
|
135
226
|
entries.add(path.resolve(pkgDir, 'index.ts'));
|
|
227
|
+
entries.add(path.resolve(pkgDir, 'index.tsx'));
|
|
228
|
+
entries.add(path.resolve(pkgDir, 'index.jsx'));
|
|
229
|
+
// Common src patterns
|
|
230
|
+
entries.add(path.resolve(pkgDir, 'src/index.ts'));
|
|
231
|
+
entries.add(path.resolve(pkgDir, 'src/index.tsx'));
|
|
232
|
+
entries.add(path.resolve(pkgDir, 'src/index.js'));
|
|
233
|
+
entries.add(path.resolve(pkgDir, 'src/index.jsx'));
|
|
136
234
|
}
|
|
137
235
|
|
|
138
236
|
return Array.from(entries);
|
|
@@ -143,6 +241,8 @@ export class WorkspaceGraph {
|
|
|
143
241
|
if (exportsValue.startsWith('.')) {
|
|
144
242
|
collected.add(path.resolve(pkgDir, exportsValue));
|
|
145
243
|
}
|
|
244
|
+
} else if (Array.isArray(exportsValue)) {
|
|
245
|
+
exportsValue.forEach(v => this.recursivelyUnwindExports(v, pkgDir, collected));
|
|
146
246
|
} else if (typeof exportsValue === 'object' && exportsValue !== null) {
|
|
147
247
|
for (const val of Object.values(exportsValue)) {
|
|
148
248
|
this.recursivelyUnwindExports(val, pkgDir, collected);
|
|
@@ -150,6 +250,19 @@ export class WorkspaceGraph {
|
|
|
150
250
|
}
|
|
151
251
|
}
|
|
152
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Marks all registered workspace package names as used in the global
|
|
255
|
+
* `usedExternalPackages` set so they are never flagged as unused dependencies.
|
|
256
|
+
*
|
|
257
|
+
* This must be called after `initializeWorkspaceMesh()` and before the
|
|
258
|
+
* unused-dependency report is generated.
|
|
259
|
+
*/
|
|
260
|
+
markWorkspacePackagesAsUsed() {
|
|
261
|
+
for (const pkgName of this.workspacePackageNames) {
|
|
262
|
+
this.context.usedExternalPackages.add(pkgName);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
153
266
|
/**
|
|
154
267
|
* Checks if an import specifier matches a package registered in our workspace mesh.
|
|
155
268
|
*/
|
|
@@ -1,378 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================================
|
|
3
|
-
* Secret Detection Engine for pkg-scaffold v3.3.2 (AST + REGEX Fallback)
|
|
4
|
-
*
|
|
5
|
-
* Uses OXC parser for fast, accurate detection of hardcoded secrets.
|
|
6
|
-
* Falls back to REGEX patterns if AST parsing fails.
|
|
7
|
-
* ============================================================================
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import fs from 'fs/promises';
|
|
11
|
-
import path from 'path';
|
|
12
|
-
|
|
13
|
-
export class SecretDetector {
|
|
14
|
-
constructor(context) {
|
|
15
|
-
this.context = context;
|
|
16
|
-
this.secrets = [];
|
|
17
|
-
|
|
18
|
-
// REGEX patterns for detecting secrets (fallback)
|
|
19
|
-
this.regexPatterns = {
|
|
20
|
-
apiKey: /['\"]?api[_-]?key['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_]{20,})['\"]?/gi,
|
|
21
|
-
bearerToken: /bearer\s+([a-zA-Z0-9\-_\.]{20,})/gi,
|
|
22
|
-
jwtToken: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g,
|
|
23
|
-
awsAccessKey: /AKIA[0-9A-Z]{16}/g,
|
|
24
|
-
awsSecretKey: /aws_secret_access_key\s*[:=]\s*['\"]([a-zA-Z0-9\/+]{40})['\"]?/gi,
|
|
25
|
-
databaseUrl: /(postgres|mysql|mongodb|redis):\/\/([a-zA-Z0-9_-]+):([a-zA-Z0-9_\-@!$%^&*()+=]+)@/gi,
|
|
26
|
-
dbPassword: /password\s*[:=]\s*['\"]([^'\"]{6,})['\"]?/gi,
|
|
27
|
-
githubToken: /ghp_[a-zA-Z0-9]{36}/g,
|
|
28
|
-
gitlabToken: /glpat-[a-zA-Z0-9_-]{20,}/g,
|
|
29
|
-
privateKey: /-----BEGIN (RSA|DSA|EC|PGP|OPENSSH) PRIVATE KEY-----/g,
|
|
30
|
-
slackWebhook: /https:\/\/hooks\.slack\.com\/services\/[a-zA-Z0-9\/]+/g,
|
|
31
|
-
discordWebhook: /https:\/\/discord\.com\/api\/webhooks\/[a-zA-Z0-9\/]+/g,
|
|
32
|
-
secretKey: /['\"]?secret[_-]?key['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_]{20,})['\"]?/gi,
|
|
33
|
-
accessToken: /['\"]?access[_-]?token['\"]?\s*[:=]\s*['\"]([a-zA-Z0-9\-_\.]{20,})['\"]?/gi,
|
|
34
|
-
stripeKey: /sk_live_[a-zA-Z0-9]{24,}/g,
|
|
35
|
-
googleApiKey: /AIza[0-9A-Za-z\-_]{35}/g,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// Secret pattern metadata
|
|
39
|
-
this.secretMetadata = {
|
|
40
|
-
apiKey: { severity: 'HIGH', keywords: ['api_key', 'apikey'] },
|
|
41
|
-
bearerToken: { severity: 'CRITICAL', keywords: ['bearer', 'token'] },
|
|
42
|
-
jwtToken: { severity: 'CRITICAL', keywords: ['jwt', 'token'] },
|
|
43
|
-
awsAccessKey: { severity: 'CRITICAL', keywords: ['aws', 'access'] },
|
|
44
|
-
awsSecretKey: { severity: 'CRITICAL', keywords: ['aws', 'secret'] },
|
|
45
|
-
databaseUrl: { severity: 'CRITICAL', keywords: ['database', 'db', 'postgres', 'mysql'] },
|
|
46
|
-
dbPassword: { severity: 'CRITICAL', keywords: ['password', 'passwd'] },
|
|
47
|
-
githubToken: { severity: 'CRITICAL', keywords: ['github', 'token'] },
|
|
48
|
-
gitlabToken: { severity: 'CRITICAL', keywords: ['gitlab', 'token'] },
|
|
49
|
-
privateKey: { severity: 'CRITICAL', keywords: ['private', 'key', 'pem'] },
|
|
50
|
-
slackWebhook: { severity: 'HIGH', keywords: ['slack', 'webhook'] },
|
|
51
|
-
discordWebhook: { severity: 'HIGH', keywords: ['discord', 'webhook'] },
|
|
52
|
-
secretKey: { severity: 'HIGH', keywords: ['secret', 'key'] },
|
|
53
|
-
accessToken: { severity: 'CRITICAL', keywords: ['access', 'token'] },
|
|
54
|
-
stripeKey: { severity: 'CRITICAL', keywords: ['stripe', 'key'] },
|
|
55
|
-
googleApiKey: { severity: 'HIGH', keywords: ['google', 'api'] },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Scans a file for hardcoded secrets using REGEX
|
|
61
|
-
*/
|
|
62
|
-
scanFileForSecretsRegex(filePath, content) {
|
|
63
|
-
const detectedSecrets = [];
|
|
64
|
-
const lines = content.split('\n');
|
|
65
|
-
|
|
66
|
-
lines.forEach((line, lineIndex) => {
|
|
67
|
-
// Skip comments and empty lines
|
|
68
|
-
if (line.trim().startsWith('//') || line.trim().startsWith('#') || line.trim().startsWith('*') || !line.trim()) {
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Check each pattern
|
|
73
|
-
for (const [patternName, pattern] of Object.entries(this.regexPatterns)) {
|
|
74
|
-
const matches = [...line.matchAll(pattern)];
|
|
75
|
-
|
|
76
|
-
for (const match of matches) {
|
|
77
|
-
const metadata = this.secretMetadata[patternName] || { severity: 'MEDIUM', keywords: [] };
|
|
78
|
-
|
|
79
|
-
detectedSecrets.push({
|
|
80
|
-
file: filePath,
|
|
81
|
-
line: lineIndex + 1,
|
|
82
|
-
column: match.index + 1,
|
|
83
|
-
type: patternName,
|
|
84
|
-
secret: match[0].substring(0, 20) + '***',
|
|
85
|
-
severity: metadata.severity,
|
|
86
|
-
variable: this.extractVariableName(line, match.index)
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return detectedSecrets;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Extracts variable name from line
|
|
97
|
-
*/
|
|
98
|
-
extractVariableName(line, matchIndex) {
|
|
99
|
-
// Look backwards for variable assignment
|
|
100
|
-
const beforeMatch = line.substring(0, matchIndex);
|
|
101
|
-
const varMatch = beforeMatch.match(/(?:const|let|var|=)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[:=]?/);
|
|
102
|
-
if (varMatch) return varMatch[1];
|
|
103
|
-
|
|
104
|
-
// Look for object property
|
|
105
|
-
const propMatch = beforeMatch.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[:=]\s*$/);
|
|
106
|
-
if (propMatch) return propMatch[1];
|
|
107
|
-
|
|
108
|
-
return 'unknown';
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Scans entire codebase for secrets (reads from disk)
|
|
113
|
-
*/
|
|
114
|
-
async scanCodebaseForSecrets(context) {
|
|
115
|
-
this.secrets = [];
|
|
116
|
-
this.cwd = context?.cwd || this.context.cwd;
|
|
117
|
-
const cwd = this.cwd;
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
// Recursively scan all source files
|
|
121
|
-
await this.scanDirectory(cwd);
|
|
122
|
-
} catch (e) {
|
|
123
|
-
console.error('Error scanning codebase for secrets:', e.message);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return this.secrets;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Recursively scans directory for source files
|
|
131
|
-
*/
|
|
132
|
-
async scanDirectory(dirPath) {
|
|
133
|
-
try {
|
|
134
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
135
|
-
|
|
136
|
-
for (const entry of entries) {
|
|
137
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
138
|
-
|
|
139
|
-
// Skip node_modules, dist, build, .git
|
|
140
|
-
if (['node_modules', 'dist', 'build', '.git', '.scaffold-cache', '.next', 'out'].includes(entry.name)) {
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (entry.isDirectory()) {
|
|
145
|
-
await this.scanDirectory(fullPath);
|
|
146
|
-
} else if (entry.isFile()) {
|
|
147
|
-
const ext = path.extname(entry.name);
|
|
148
|
-
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.env', '.env.local'].includes(ext)) {
|
|
149
|
-
await this.scanFile(fullPath);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
} catch (e) {
|
|
154
|
-
// Silently skip directories that can't be read
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Scans a single file for secrets
|
|
160
|
-
*/
|
|
161
|
-
async scanFile(filePath) {
|
|
162
|
-
try {
|
|
163
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
164
|
-
|
|
165
|
-
// Try AST parsing first (if available)
|
|
166
|
-
let detectedSecrets = [];
|
|
167
|
-
try {
|
|
168
|
-
detectedSecrets = this.scanFileForSecretsAST(filePath, content);
|
|
169
|
-
} catch (e) {
|
|
170
|
-
// Fall back to REGEX if AST fails
|
|
171
|
-
detectedSecrets = this.scanFileForSecretsRegex(filePath, content);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// If AST returned nothing, try REGEX as additional pass
|
|
175
|
-
if (detectedSecrets.length === 0) {
|
|
176
|
-
detectedSecrets = this.scanFileForSecretsRegex(filePath, content);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
this.secrets.push(...detectedSecrets);
|
|
180
|
-
} catch (e) {
|
|
181
|
-
// Skip files that can't be read
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Scans file using AST (with OXC if available)
|
|
187
|
-
*/
|
|
188
|
-
scanFileForSecretsAST(filePath, content) {
|
|
189
|
-
const detectedSecrets = [];
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
// Try to use OXC parser if available
|
|
193
|
-
let ast;
|
|
194
|
-
try {
|
|
195
|
-
const { parseSync } = require('oxc-parser');
|
|
196
|
-
ast = parseSync(content, {
|
|
197
|
-
sourceType: 'module',
|
|
198
|
-
ecmaVersion: 'latest'
|
|
199
|
-
});
|
|
200
|
-
} catch (e) {
|
|
201
|
-
// OXC not available, fall back to REGEX
|
|
202
|
-
return this.scanFileForSecretsRegex(filePath, content);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Walk AST and find variable assignments with secret values
|
|
206
|
-
this.walkAST(ast, (node) => {
|
|
207
|
-
// Variable declarations: const API_KEY = "sk_..."
|
|
208
|
-
if (node.type === 'VariableDeclarator' && node.init) {
|
|
209
|
-
const varName = node.id?.name || '';
|
|
210
|
-
const secret = this.extractSecretValue(node.init);
|
|
211
|
-
|
|
212
|
-
if (secret) {
|
|
213
|
-
const detectedType = this.classifySecret(varName, secret.value);
|
|
214
|
-
if (detectedType) {
|
|
215
|
-
detectedSecrets.push({
|
|
216
|
-
file: filePath,
|
|
217
|
-
line: node.loc?.start?.line || 0,
|
|
218
|
-
column: node.loc?.start?.column || 0,
|
|
219
|
-
type: detectedType.type,
|
|
220
|
-
severity: detectedType.severity,
|
|
221
|
-
variable: varName,
|
|
222
|
-
secret: secret.value.substring(0, 20) + '***'
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Object properties: { password: "...", apiKey: "..." }
|
|
229
|
-
if (node.type === 'Property' && node.value) {
|
|
230
|
-
const propName = node.key?.name || node.key?.value || '';
|
|
231
|
-
const secret = this.extractSecretValue(node.value);
|
|
232
|
-
|
|
233
|
-
if (secret) {
|
|
234
|
-
const detectedType = this.classifySecret(propName, secret.value);
|
|
235
|
-
if (detectedType) {
|
|
236
|
-
detectedSecrets.push({
|
|
237
|
-
file: filePath,
|
|
238
|
-
line: node.loc?.start?.line || 0,
|
|
239
|
-
column: node.loc?.start?.column || 0,
|
|
240
|
-
type: detectedType.type,
|
|
241
|
-
severity: detectedType.severity,
|
|
242
|
-
variable: propName,
|
|
243
|
-
secret: secret.value.substring(0, 20) + '***'
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Assignment expressions: API_KEY = "..."
|
|
250
|
-
if (node.type === 'AssignmentExpression' && node.right) {
|
|
251
|
-
const varName = node.left?.name || '';
|
|
252
|
-
const secret = this.extractSecretValue(node.right);
|
|
253
|
-
|
|
254
|
-
if (secret) {
|
|
255
|
-
const detectedType = this.classifySecret(varName, secret.value);
|
|
256
|
-
if (detectedType) {
|
|
257
|
-
detectedSecrets.push({
|
|
258
|
-
file: filePath,
|
|
259
|
-
line: node.loc?.start?.line || 0,
|
|
260
|
-
column: node.loc?.start?.column || 0,
|
|
261
|
-
type: detectedType.type,
|
|
262
|
-
severity: detectedType.severity,
|
|
263
|
-
variable: varName,
|
|
264
|
-
secret: secret.value.substring(0, 20) + '***'
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
} catch (e) {
|
|
271
|
-
// Return empty on error, will fall back to REGEX
|
|
272
|
-
return [];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return detectedSecrets;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Extracts string value from AST node
|
|
280
|
-
*/
|
|
281
|
-
extractSecretValue(node) {
|
|
282
|
-
if (node.type === 'StringLiteral' || node.type === 'Literal') {
|
|
283
|
-
return { value: node.value || '' };
|
|
284
|
-
}
|
|
285
|
-
if (node.type === 'TemplateLiteral') {
|
|
286
|
-
return { value: node.quasis?.[0]?.value?.raw || '' };
|
|
287
|
-
}
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Classifies a secret based on variable name and value
|
|
293
|
-
*/
|
|
294
|
-
classifySecret(variableName, value) {
|
|
295
|
-
const lowerName = variableName.toLowerCase();
|
|
296
|
-
|
|
297
|
-
for (const [type, metadata] of Object.entries(this.secretMetadata)) {
|
|
298
|
-
const pattern = this.regexPatterns[type];
|
|
299
|
-
if (!pattern) continue;
|
|
300
|
-
|
|
301
|
-
// Check if variable name matches keywords
|
|
302
|
-
const nameMatches = metadata.keywords.some(kw => lowerName.includes(kw));
|
|
303
|
-
|
|
304
|
-
// Check if value matches pattern
|
|
305
|
-
const valueMatches = pattern.test(value);
|
|
306
|
-
|
|
307
|
-
if ((nameMatches && value.length > 8) || valueMatches) {
|
|
308
|
-
return { type, severity: metadata.severity };
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Simple AST walker
|
|
317
|
-
*/
|
|
318
|
-
walkAST(node, callback) {
|
|
319
|
-
if (!node || typeof node !== 'object') return;
|
|
320
|
-
|
|
321
|
-
callback(node);
|
|
322
|
-
|
|
323
|
-
for (const key in node) {
|
|
324
|
-
if (key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue;
|
|
325
|
-
|
|
326
|
-
const child = node[key];
|
|
327
|
-
if (Array.isArray(child)) {
|
|
328
|
-
child.forEach(item => this.walkAST(item, callback));
|
|
329
|
-
} else if (typeof child === 'object') {
|
|
330
|
-
this.walkAST(child, callback);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Formats secrets for reporting
|
|
337
|
-
*/
|
|
338
|
-
formatSecretsForReport() {
|
|
339
|
-
if (this.secrets.length === 0) return [];
|
|
340
|
-
|
|
341
|
-
return this.secrets.map(secret => ({
|
|
342
|
-
file: secret.file,
|
|
343
|
-
line: secret.line,
|
|
344
|
-
column: secret.column,
|
|
345
|
-
type: secret.type,
|
|
346
|
-
severity: secret.severity,
|
|
347
|
-
variable: secret.variable,
|
|
348
|
-
redacted: secret.secret
|
|
349
|
-
}));
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Gets secrets by severity level
|
|
354
|
-
*/
|
|
355
|
-
getSecretsBySeverity(severity) {
|
|
356
|
-
return this.secrets.filter(s => s.severity === severity);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Gets critical secrets only
|
|
361
|
-
*/
|
|
362
|
-
getCriticalSecrets() {
|
|
363
|
-
return this.getSecretsBySeverity('CRITICAL');
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Gets count of secrets by type
|
|
368
|
-
*/
|
|
369
|
-
getSecretStats() {
|
|
370
|
-
const stats = {};
|
|
371
|
-
this.secrets.forEach(secret => {
|
|
372
|
-
stats[secret.type] = (stats[secret.type] || 0) + 1;
|
|
373
|
-
});
|
|
374
|
-
return stats;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export default SecretDetector;
|