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.
@@ -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 (Knip Issue #1792 fix)
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.startsWith('packages:')) {
51
+ if (trimmed === 'packages:') {
44
52
  insidePackagesBlock = true;
45
53
  continue;
46
54
  }
47
- if (insidePackagesBlock && trimmed.startsWith('-')) {
48
- const pattern = trimmed.replace(/^-|['"]/g, '').trim();
49
- workspaceGlobs.push(pattern);
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) ? pkg.workspaces : (pkg.workspaces.packages || []);
75
+ workspaceGlobs = Array.isArray(pkg.workspaces)
76
+ ? pkg.workspaces
77
+ : (pkg.workspaces.packages || []);
63
78
  }
64
79
  } catch {
65
- return; // Workspace mesh maps skipped for single-package targets
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
- const manifestFile = path.join(subPackageDir, 'package.json');
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;