pkg-scaffold 3.3.0 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
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;
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Copyright (C) 2026 DreamLongYT
6
6
  * Licensed under the Apache License, Version 2.0.
7
- * "The original code was from the lovely DreamLong"
7
+ * "The Original Code was made by DreamLongYT"
8
8
  * ============================================================================
9
- * Implements a high-performance Tarjan-based or DFS-based algorithm to
9
+ * Implements a high-performance Tarjan-based algorithm to
10
10
  * detect circular dependencies in the codebase graph.
11
11
  * Addresses Knip Issue #1734.
12
12
  */
@@ -18,54 +18,105 @@ export class CircularDetector {
18
18
  }
19
19
 
20
20
  /**
21
- * Detects cycles in the provided dependency graph
21
+ * Detects cycles in the provided dependency graph using Tarjan's SCC algorithm
22
22
  * @param {Map} graph - The codebase dependency graph
23
23
  * @returns {Array} List of detected cycles
24
24
  */
25
- detectCycles(graph) {
25
+ detectCycles(graph, context = null) {
26
+ if (context) this.context = context;
27
+ this.cwd = context?.cwd || this.context?.cwd || process.cwd();
26
28
  this.cycles = [];
27
- const visited = new Set();
28
- const stack = new Set();
29
- const path = [];
29
+ let index = 0;
30
+ const stack = [];
31
+ const indices = new Map();
32
+ const lowlink = new Map();
33
+ const onStack = new Set();
30
34
 
31
- for (const filePath of graph.keys()) {
32
- if (!visited.has(filePath)) {
33
- this.dfs(filePath, graph, visited, stack, path);
35
+ const strongconnect = (v) => {
36
+ indices.set(v, index);
37
+ lowlink.set(v, index);
38
+ index++;
39
+ stack.push(v);
40
+ onStack.add(v);
41
+
42
+ const node = graph.get(v);
43
+ if (node && node.outgoingEdges) {
44
+ for (const w of node.outgoingEdges) {
45
+ if (!indices.has(w)) {
46
+ strongconnect(w);
47
+ lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
48
+ } else if (onStack.has(w)) {
49
+ lowlink.set(v, Math.min(lowlink.get(v), indices.get(w)));
50
+ }
51
+ }
34
52
  }
35
- }
36
53
 
37
- return this.cycles;
38
- }
54
+ if (lowlink.get(v) === indices.get(v)) {
55
+ const component = [];
56
+ let w;
57
+ do {
58
+ w = stack.pop();
59
+ onStack.delete(w);
60
+ component.push(w);
61
+ } while (w !== v);
39
62
 
40
- dfs(node, graph, visited, stack, path) {
41
- visited.add(node);
42
- stack.add(node);
43
- path.push(node);
63
+ if (component.length > 1) {
64
+ this.cycles.push(component.reverse());
65
+ } else {
66
+ // Check for self-loops
67
+ const node = graph.get(v);
68
+ if (node && node.outgoingEdges && node.outgoingEdges.has(v)) {
69
+ this.cycles.push([v]);
70
+ }
71
+ }
72
+ }
73
+ };
44
74
 
45
- const edges = graph.get(node)?.outgoingEdges || [];
46
- for (const neighbor of edges) {
47
- if (stack.has(neighbor)) {
48
- // Cycle detected
49
- const cycleStartIndex = path.indexOf(neighbor);
50
- const cycle = path.slice(cycleStartIndex);
51
- this.cycles.push(cycle);
52
- } else if (!visited.has(neighbor)) {
53
- this.dfs(neighbor, graph, visited, stack, path);
75
+ for (const v of graph.keys()) {
76
+ if (!indices.has(v)) {
77
+ strongconnect(v);
54
78
  }
55
79
  }
56
80
 
57
- stack.delete(node);
58
- path.pop();
81
+ return this.cycles;
59
82
  }
60
83
 
61
84
  /**
62
- * Formats cycles for reporting
85
+ * Formats cycles for reporting with file paths
63
86
  */
64
87
  formatCycles() {
65
88
  return this.cycles.map(cycle => {
66
- return cycle.join(' -> ') + ' -> ' + cycle[0];
89
+ const paths = cycle.map(p => {
90
+ // Extract relative path for readability
91
+ let rel = p.replace(this.context.cwd, '').replace(/^\//, '');
92
+ // Convert absolute Windows paths
93
+ if (rel.includes(':\\')) {
94
+ rel = rel.split(':\\')[1] || rel;
95
+ }
96
+ return rel;
97
+ });
98
+ if (cycle.length === 1) return `${paths[0]} -> (self-loop)`;
99
+ return paths.join(' -> ') + ' -> ' + paths[0];
67
100
  });
68
101
  }
102
+
103
+ /**
104
+ * Gets detailed cycle information
105
+ */
106
+ getCycleDetails() {
107
+ return this.cycles.map((cycle, idx) => ({
108
+ cycleId: idx + 1,
109
+ files: cycle.map(p => {
110
+ let rel = p.replace(this.context.cwd, '').replace(/^\//, '');
111
+ if (rel.includes(':\\')) {
112
+ rel = rel.split(':\\')[1] || rel;
113
+ }
114
+ return rel;
115
+ }),
116
+ length: cycle.length,
117
+ isSelfLoop: cycle.length === 1
118
+ }));
119
+ }
69
120
  }
70
121
 
71
122
  export default CircularDetector;