@chappibunny/repolens 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/ai/document-plan.js +16 -0
- package/src/ai/generate-sections.js +6 -6
- package/src/ai/provider.js +27 -3
- package/src/analyzers/complexity-analyzer.js +297 -0
- package/src/analyzers/jsdoc-analyzer.js +354 -0
- package/src/analyzers/security-patterns.js +329 -0
- package/src/docs/generate-doc-set.js +34 -4
- package/src/publishers/github-wiki.js +10 -1
- package/src/publishers/markdown.js +3 -1
- package/src/renderers/render.js +96 -1
- package/src/renderers/renderAnalysis.js +184 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// JSDoc/TSDoc extraction for API Surface enrichment
|
|
2
|
+
// Parses @param, @returns, @deprecated, @example, @description tags
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { info } from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pattern to match JSDoc/TSDoc block comments
|
|
12
|
+
* Captures the entire comment block including * prefixes
|
|
13
|
+
*/
|
|
14
|
+
const JSDOC_BLOCK = /\/\*\*[\s\S]*?\*\//g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pattern to match exported functions (named exports and default exports)
|
|
18
|
+
* Captures function name and parameter list
|
|
19
|
+
*/
|
|
20
|
+
const EXPORT_FUNCTION = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g;
|
|
21
|
+
const EXPORT_CONST_ARROW = /export\s+const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
|
|
22
|
+
const EXPORT_DEFAULT_FUNCTION = /export\s+default\s+(?:async\s+)?function(?:\s+(\w+))?\s*\(([^)]*)\)/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a JSDoc block into structured tags
|
|
26
|
+
*/
|
|
27
|
+
function parseJSDocBlock(block) {
|
|
28
|
+
const result = {
|
|
29
|
+
description: "",
|
|
30
|
+
params: [],
|
|
31
|
+
returns: null,
|
|
32
|
+
deprecated: null,
|
|
33
|
+
examples: [],
|
|
34
|
+
throws: [],
|
|
35
|
+
see: [],
|
|
36
|
+
since: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Remove comment markers and normalize
|
|
40
|
+
const lines = block
|
|
41
|
+
.replace(/^\/\*\*/, "")
|
|
42
|
+
.replace(/\*\/$/, "")
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map(line => line.replace(/^\s*\*\s?/, "").trim())
|
|
45
|
+
.filter(line => line.length > 0);
|
|
46
|
+
|
|
47
|
+
let currentTag = null;
|
|
48
|
+
let currentValue = [];
|
|
49
|
+
|
|
50
|
+
function flushTag() {
|
|
51
|
+
if (!currentTag) {
|
|
52
|
+
// If no tag yet, this is the description
|
|
53
|
+
if (currentValue.length > 0) {
|
|
54
|
+
result.description = currentValue.join(" ").trim();
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const value = currentValue.join(" ").trim();
|
|
58
|
+
|
|
59
|
+
switch (currentTag) {
|
|
60
|
+
case "param":
|
|
61
|
+
case "arg":
|
|
62
|
+
case "argument": {
|
|
63
|
+
// Parse @param {Type} name - description
|
|
64
|
+
const paramMatch = value.match(/^(?:\{([^}]+)\}\s*)?(\[?[\w.]+\]?)\s*(?:-\s*)?(.*)$/);
|
|
65
|
+
if (paramMatch) {
|
|
66
|
+
result.params.push({
|
|
67
|
+
type: paramMatch[1] || "any",
|
|
68
|
+
name: paramMatch[2].replace(/^\[|\]$/g, ""), // Remove optional brackets
|
|
69
|
+
optional: paramMatch[2].startsWith("["),
|
|
70
|
+
description: paramMatch[3] || "",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "returns":
|
|
76
|
+
case "return": {
|
|
77
|
+
// Parse @returns {Type} description
|
|
78
|
+
const returnMatch = value.match(/^(?:\{([^}]+)\}\s*)?(.*)$/);
|
|
79
|
+
if (returnMatch) {
|
|
80
|
+
result.returns = {
|
|
81
|
+
type: returnMatch[1] || "void",
|
|
82
|
+
description: returnMatch[2] || "",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "deprecated":
|
|
88
|
+
result.deprecated = value || "This function is deprecated.";
|
|
89
|
+
break;
|
|
90
|
+
case "example":
|
|
91
|
+
result.examples.push(value);
|
|
92
|
+
break;
|
|
93
|
+
case "throws":
|
|
94
|
+
case "exception":
|
|
95
|
+
result.throws.push(value);
|
|
96
|
+
break;
|
|
97
|
+
case "see":
|
|
98
|
+
result.see.push(value);
|
|
99
|
+
break;
|
|
100
|
+
case "since":
|
|
101
|
+
result.since = value;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
currentTag = null;
|
|
106
|
+
currentValue = [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const tagMatch = line.match(/^@(\w+)\s*(.*)?$/);
|
|
111
|
+
if (tagMatch) {
|
|
112
|
+
flushTag();
|
|
113
|
+
currentTag = tagMatch[1];
|
|
114
|
+
if (tagMatch[2]) {
|
|
115
|
+
currentValue.push(tagMatch[2]);
|
|
116
|
+
}
|
|
117
|
+
} else if (line.length > 0) {
|
|
118
|
+
currentValue.push(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
flushTag();
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find the JSDoc block immediately preceding a position in source code
|
|
128
|
+
*/
|
|
129
|
+
function findPrecedingJSDoc(source, position) {
|
|
130
|
+
// Look backwards from position for a JSDoc block
|
|
131
|
+
const beforePos = source.slice(0, position);
|
|
132
|
+
const blocks = [...beforePos.matchAll(JSDOC_BLOCK)];
|
|
133
|
+
|
|
134
|
+
if (blocks.length === 0) return null;
|
|
135
|
+
|
|
136
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
137
|
+
const blockEnd = lastBlock.index + lastBlock[0].length;
|
|
138
|
+
|
|
139
|
+
// Check if the JSDoc block is immediately before the function (allow whitespace/newlines)
|
|
140
|
+
const between = source.slice(blockEnd, position);
|
|
141
|
+
if (/^[\s]*$/.test(between)) {
|
|
142
|
+
return parseJSDocBlock(lastBlock[0]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract all documented exports from a file
|
|
150
|
+
*/
|
|
151
|
+
function extractDocumentedExports(source, filePath) {
|
|
152
|
+
const exports = [];
|
|
153
|
+
const relativePath = filePath;
|
|
154
|
+
|
|
155
|
+
// Named function exports
|
|
156
|
+
const funcRegex = new RegExp(EXPORT_FUNCTION.source, "g");
|
|
157
|
+
let match;
|
|
158
|
+
while ((match = funcRegex.exec(source)) !== null) {
|
|
159
|
+
const jsdoc = findPrecedingJSDoc(source, match.index);
|
|
160
|
+
exports.push({
|
|
161
|
+
name: match[1],
|
|
162
|
+
type: "function",
|
|
163
|
+
params: match[2] ? match[2].split(",").map(p => p.trim()).filter(Boolean) : [],
|
|
164
|
+
source: relativePath,
|
|
165
|
+
line: source.slice(0, match.index).split("\n").length,
|
|
166
|
+
jsdoc: jsdoc,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Arrow function exports (export const x = () => ...)
|
|
171
|
+
const arrowRegex = new RegExp(EXPORT_CONST_ARROW.source, "g");
|
|
172
|
+
while ((match = arrowRegex.exec(source)) !== null) {
|
|
173
|
+
const jsdoc = findPrecedingJSDoc(source, match.index);
|
|
174
|
+
exports.push({
|
|
175
|
+
name: match[1],
|
|
176
|
+
type: "arrow",
|
|
177
|
+
source: relativePath,
|
|
178
|
+
line: source.slice(0, match.index).split("\n").length,
|
|
179
|
+
jsdoc: jsdoc,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Default function exports
|
|
184
|
+
const defaultRegex = new RegExp(EXPORT_DEFAULT_FUNCTION.source, "g");
|
|
185
|
+
while ((match = defaultRegex.exec(source)) !== null) {
|
|
186
|
+
const jsdoc = findPrecedingJSDoc(source, match.index);
|
|
187
|
+
exports.push({
|
|
188
|
+
name: match[1] || "default",
|
|
189
|
+
type: "function",
|
|
190
|
+
params: match[2] ? match[2].split(",").map(p => p.trim()).filter(Boolean) : [],
|
|
191
|
+
source: relativePath,
|
|
192
|
+
line: source.slice(0, match.index).split("\n").length,
|
|
193
|
+
isDefault: true,
|
|
194
|
+
jsdoc: jsdoc,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return exports;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readFileSafe(filePath) {
|
|
202
|
+
try {
|
|
203
|
+
return await fs.readFile(filePath, "utf8");
|
|
204
|
+
} catch {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Analyze JSDoc/TSDoc across all JavaScript/TypeScript files
|
|
211
|
+
* @param {string[]} files - List of file paths relative to repo root
|
|
212
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
213
|
+
* @returns {Promise<Object>} Analysis result with documented exports
|
|
214
|
+
*/
|
|
215
|
+
export async function analyzeJSDoc(files, repoRoot) {
|
|
216
|
+
const result = {
|
|
217
|
+
detected: false,
|
|
218
|
+
exports: [],
|
|
219
|
+
documented: 0,
|
|
220
|
+
undocumented: 0,
|
|
221
|
+
deprecated: [],
|
|
222
|
+
byFile: {},
|
|
223
|
+
summary: null,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const jsFiles = files.filter(f => JS_EXTENSIONS.some(ext => f.endsWith(ext)));
|
|
227
|
+
if (jsFiles.length === 0) return result;
|
|
228
|
+
|
|
229
|
+
for (const file of jsFiles) {
|
|
230
|
+
const content = await readFileSafe(path.join(repoRoot, file));
|
|
231
|
+
if (!content) continue;
|
|
232
|
+
|
|
233
|
+
const fileExports = extractDocumentedExports(content, file);
|
|
234
|
+
if (fileExports.length === 0) continue;
|
|
235
|
+
|
|
236
|
+
result.detected = true;
|
|
237
|
+
result.byFile[file] = fileExports;
|
|
238
|
+
|
|
239
|
+
for (const exp of fileExports) {
|
|
240
|
+
result.exports.push(exp);
|
|
241
|
+
|
|
242
|
+
if (exp.jsdoc) {
|
|
243
|
+
result.documented++;
|
|
244
|
+
if (exp.jsdoc.deprecated) {
|
|
245
|
+
result.deprecated.push({
|
|
246
|
+
name: exp.name,
|
|
247
|
+
source: exp.source,
|
|
248
|
+
reason: exp.jsdoc.deprecated,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
result.undocumented++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Build summary
|
|
258
|
+
const total = result.documented + result.undocumented;
|
|
259
|
+
const coverage = total > 0 ? Math.round((result.documented / total) * 100) : 0;
|
|
260
|
+
|
|
261
|
+
result.summary = {
|
|
262
|
+
totalExports: total,
|
|
263
|
+
documented: result.documented,
|
|
264
|
+
undocumented: result.undocumented,
|
|
265
|
+
coverage: `${coverage}%`,
|
|
266
|
+
deprecatedCount: result.deprecated.length,
|
|
267
|
+
filesWithExports: Object.keys(result.byFile).length,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (total > 0) {
|
|
271
|
+
info(`JSDoc analysis: ${result.documented}/${total} exports documented (${coverage}%), ${result.deprecated.length} deprecated`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get documentation for a specific export
|
|
279
|
+
* @param {Object} jsdocResult - Result from analyzeJSDoc
|
|
280
|
+
* @param {string} exportName - Name of the export to find
|
|
281
|
+
* @returns {Object|null} Export info with JSDoc or null if not found
|
|
282
|
+
*/
|
|
283
|
+
export function getExportDoc(jsdocResult, exportName) {
|
|
284
|
+
return jsdocResult.exports.find(e => e.name === exportName) || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get all documented exports from a specific file
|
|
289
|
+
* @param {Object} jsdocResult - Result from analyzeJSDoc
|
|
290
|
+
* @param {string} filePath - Relative path to the file
|
|
291
|
+
* @returns {Object[]} Array of exports from that file
|
|
292
|
+
*/
|
|
293
|
+
export function getFileExports(jsdocResult, filePath) {
|
|
294
|
+
return jsdocResult.byFile[filePath] || [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Format JSDoc for display in documentation
|
|
299
|
+
* @param {Object} jsdoc - Parsed JSDoc object
|
|
300
|
+
* @returns {string} Markdown-formatted documentation
|
|
301
|
+
*/
|
|
302
|
+
export function formatJSDocAsMarkdown(jsdoc) {
|
|
303
|
+
if (!jsdoc) return "";
|
|
304
|
+
|
|
305
|
+
const lines = [];
|
|
306
|
+
|
|
307
|
+
if (jsdoc.description) {
|
|
308
|
+
lines.push(jsdoc.description);
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (jsdoc.deprecated) {
|
|
313
|
+
lines.push(`> ⚠️ **Deprecated**: ${jsdoc.deprecated}`);
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (jsdoc.params.length > 0) {
|
|
318
|
+
lines.push("**Parameters:**");
|
|
319
|
+
for (const param of jsdoc.params) {
|
|
320
|
+
const opt = param.optional ? " *(optional)*" : "";
|
|
321
|
+
lines.push(`- \`${param.name}\` (\`${param.type}\`)${opt}: ${param.description}`);
|
|
322
|
+
}
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (jsdoc.returns) {
|
|
327
|
+
lines.push(`**Returns:** \`${jsdoc.returns.type}\` — ${jsdoc.returns.description}`);
|
|
328
|
+
lines.push("");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (jsdoc.throws.length > 0) {
|
|
332
|
+
lines.push("**Throws:**");
|
|
333
|
+
for (const t of jsdoc.throws) {
|
|
334
|
+
lines.push(`- ${t}`);
|
|
335
|
+
}
|
|
336
|
+
lines.push("");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (jsdoc.examples.length > 0) {
|
|
340
|
+
lines.push("**Example:**");
|
|
341
|
+
for (const ex of jsdoc.examples) {
|
|
342
|
+
lines.push("```javascript");
|
|
343
|
+
lines.push(ex);
|
|
344
|
+
lines.push("```");
|
|
345
|
+
}
|
|
346
|
+
lines.push("");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (jsdoc.since) {
|
|
350
|
+
lines.push(`*Since: ${jsdoc.since}*`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return lines.join("\n").trim();
|
|
354
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Security pattern detection — scans source files for risky code patterns
|
|
2
|
+
// Detects: eval(), innerHTML, SQL concatenation, command injection,
|
|
3
|
+
// hardcoded secrets, prototype pollution, regex DoS, path traversal
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { info, warn } from "../utils/logger.js";
|
|
8
|
+
|
|
9
|
+
const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Security pattern categories with regex detection and severity
|
|
13
|
+
*/
|
|
14
|
+
const SECURITY_PATTERNS = [
|
|
15
|
+
// Code Injection
|
|
16
|
+
{
|
|
17
|
+
id: "eval-usage",
|
|
18
|
+
category: "Code Injection",
|
|
19
|
+
name: "eval() usage",
|
|
20
|
+
severity: "high",
|
|
21
|
+
pattern: /\beval\s*\(/g,
|
|
22
|
+
description: "eval() executes arbitrary code and is a major injection risk. Replace with JSON.parse(), Function constructor, or domain-specific parsers.",
|
|
23
|
+
cwe: "CWE-95",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "new-function",
|
|
27
|
+
category: "Code Injection",
|
|
28
|
+
name: "new Function() constructor",
|
|
29
|
+
severity: "high",
|
|
30
|
+
pattern: /new\s+Function\s*\(/g,
|
|
31
|
+
description: "Function constructor is equivalent to eval(). Use safe alternatives.",
|
|
32
|
+
cwe: "CWE-95",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "set-timeout-string",
|
|
36
|
+
category: "Code Injection",
|
|
37
|
+
name: "setTimeout/setInterval with string argument",
|
|
38
|
+
severity: "medium",
|
|
39
|
+
// Matches setTimeout("...", ...) or setTimeout('...', ...) or setTimeout(`...`, ...)
|
|
40
|
+
pattern: /(?:setTimeout|setInterval)\s*\(\s*['"`]/g,
|
|
41
|
+
description: "Passing a string to setTimeout/setInterval triggers eval-like behavior. Pass a function reference instead.",
|
|
42
|
+
cwe: "CWE-95",
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// XSS
|
|
46
|
+
{
|
|
47
|
+
id: "innerhtml-assignment",
|
|
48
|
+
category: "Cross-Site Scripting (XSS)",
|
|
49
|
+
name: "innerHTML assignment",
|
|
50
|
+
severity: "high",
|
|
51
|
+
pattern: /\.innerHTML\s*[=+](?!=)/g,
|
|
52
|
+
description: "Direct innerHTML assignment can execute injected scripts. Use textContent, or sanitize with DOMPurify.",
|
|
53
|
+
cwe: "CWE-79",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "outerhtml-assignment",
|
|
57
|
+
category: "Cross-Site Scripting (XSS)",
|
|
58
|
+
name: "outerHTML assignment",
|
|
59
|
+
severity: "high",
|
|
60
|
+
pattern: /\.outerHTML\s*[=+](?!=)/g,
|
|
61
|
+
description: "outerHTML assignment is equivalent to innerHTML and carries the same XSS risk.",
|
|
62
|
+
cwe: "CWE-79",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "document-write",
|
|
66
|
+
category: "Cross-Site Scripting (XSS)",
|
|
67
|
+
name: "document.write()",
|
|
68
|
+
severity: "high",
|
|
69
|
+
pattern: /document\.write(?:ln)?\s*\(/g,
|
|
70
|
+
description: "document.write() can inject unescaped HTML. Use DOM APIs to manipulate the page.",
|
|
71
|
+
cwe: "CWE-79",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "dangerously-set-inner-html",
|
|
75
|
+
category: "Cross-Site Scripting (XSS)",
|
|
76
|
+
name: "React dangerouslySetInnerHTML",
|
|
77
|
+
severity: "medium",
|
|
78
|
+
pattern: /dangerouslySetInnerHTML/g,
|
|
79
|
+
description: "dangerouslySetInnerHTML bypasses React's XSS protection. Ensure content is sanitized before use.",
|
|
80
|
+
cwe: "CWE-79",
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// SQL Injection
|
|
84
|
+
{
|
|
85
|
+
id: "sql-concatenation",
|
|
86
|
+
category: "SQL Injection",
|
|
87
|
+
name: "SQL string concatenation",
|
|
88
|
+
severity: "high",
|
|
89
|
+
// Matches SQL keywords followed by string concat with variables
|
|
90
|
+
pattern: /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*(?:\w+|['"`])/gi,
|
|
91
|
+
description: "Concatenating user input into SQL queries enables injection attacks. Use parameterized queries or prepared statements.",
|
|
92
|
+
cwe: "CWE-89",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "sql-template-literal",
|
|
96
|
+
category: "SQL Injection",
|
|
97
|
+
name: "SQL in template literals with interpolation",
|
|
98
|
+
severity: "medium",
|
|
99
|
+
pattern: /`(?:SELECT|INSERT|UPDATE|DELETE|DROP)\s+[^`]*\$\{/gi,
|
|
100
|
+
description: "Template literals with SQL and variable interpolation may be vulnerable. Use parameterized queries.",
|
|
101
|
+
cwe: "CWE-89",
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// Command Injection
|
|
105
|
+
{
|
|
106
|
+
id: "exec-usage",
|
|
107
|
+
category: "Command Injection",
|
|
108
|
+
name: "child_process exec()",
|
|
109
|
+
severity: "high",
|
|
110
|
+
// exec with string that includes variable interpolation
|
|
111
|
+
pattern: /(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|['"][^)]*\+)/g,
|
|
112
|
+
description: "exec() with string interpolation enables command injection. Use execFile() or spawn() with argument arrays.",
|
|
113
|
+
cwe: "CWE-78",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "shell-true",
|
|
117
|
+
category: "Command Injection",
|
|
118
|
+
name: "spawn/execFile with shell: true",
|
|
119
|
+
severity: "medium",
|
|
120
|
+
pattern: /shell\s*:\s*true/g,
|
|
121
|
+
description: "shell: true enables shell interpretation of arguments, risking injection. Remove when possible.",
|
|
122
|
+
cwe: "CWE-78",
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Path Traversal
|
|
126
|
+
{
|
|
127
|
+
id: "path-traversal",
|
|
128
|
+
category: "Path Traversal",
|
|
129
|
+
name: "Unsanitized path join with user input",
|
|
130
|
+
severity: "medium",
|
|
131
|
+
// req.params, req.query, req.body used in path operations
|
|
132
|
+
pattern: /path\.(?:join|resolve)\s*\([^)]*req\.(?:params|query|body)/g,
|
|
133
|
+
description: "Combining user input with path operations without sanitization enables directory traversal. Validate and normalize paths.",
|
|
134
|
+
cwe: "CWE-22",
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Prototype Pollution
|
|
138
|
+
{
|
|
139
|
+
id: "prototype-assignment",
|
|
140
|
+
category: "Prototype Pollution",
|
|
141
|
+
name: "__proto__ assignment",
|
|
142
|
+
severity: "high",
|
|
143
|
+
pattern: /__proto__\s*[=\[]/g,
|
|
144
|
+
description: "__proto__ manipulation can pollute Object prototype. Use Object.create(null) for dictionary objects.",
|
|
145
|
+
cwe: "CWE-1321",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "object-merge-unsafe",
|
|
149
|
+
category: "Prototype Pollution",
|
|
150
|
+
name: "Recursive object merge without prototype check",
|
|
151
|
+
severity: "low",
|
|
152
|
+
// Deep/recursive merge or extend patterns
|
|
153
|
+
pattern: /(?:deepMerge|deepExtend|merge|assign)\s*\(/g,
|
|
154
|
+
description: "Deep merge utilities may be vulnerable to prototype pollution if they don't check hasOwnProperty. Verify the implementation.",
|
|
155
|
+
cwe: "CWE-1321",
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Hardcoded Credentials
|
|
159
|
+
{
|
|
160
|
+
id: "hardcoded-password",
|
|
161
|
+
category: "Hardcoded Credentials",
|
|
162
|
+
name: "Hardcoded password or secret",
|
|
163
|
+
severity: "high",
|
|
164
|
+
// password = "...", secret = "...", apiKey = "..." (not env vars)
|
|
165
|
+
pattern: /(?:password|passwd|secret|api_?key|token)\s*[:=]\s*['"][^'"]{8,}['"]/gi,
|
|
166
|
+
description: "Hardcoded credentials should be moved to environment variables or a secrets manager.",
|
|
167
|
+
cwe: "CWE-798",
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// Insecure Randomness
|
|
171
|
+
{
|
|
172
|
+
id: "math-random-security",
|
|
173
|
+
category: "Insecure Randomness",
|
|
174
|
+
name: "Math.random() for security-sensitive operation",
|
|
175
|
+
severity: "low",
|
|
176
|
+
pattern: /Math\.random\s*\(\s*\)/g,
|
|
177
|
+
description: "Math.random() is not cryptographically secure. Use crypto.randomUUID() or crypto.getRandomValues() for security tokens.",
|
|
178
|
+
cwe: "CWE-330",
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// Regex DoS
|
|
182
|
+
{
|
|
183
|
+
id: "regex-dos",
|
|
184
|
+
category: "ReDoS",
|
|
185
|
+
name: "Potentially catastrophic regex",
|
|
186
|
+
severity: "low",
|
|
187
|
+
// Nested quantifiers like (a+)+ or (a*)*
|
|
188
|
+
pattern: /new\s+RegExp\s*\(\s*(?:\w+|['"`])/g,
|
|
189
|
+
description: "Dynamic regex construction from user input can cause ReDoS. Use static regex or input validation.",
|
|
190
|
+
cwe: "CWE-1333",
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
// Files/patterns to skip (test files, config, generated code)
|
|
195
|
+
const SKIP_PATTERNS = [
|
|
196
|
+
/node_modules\//,
|
|
197
|
+
/\.test\.[jt]sx?$/,
|
|
198
|
+
/\.spec\.[jt]sx?$/,
|
|
199
|
+
/\btest[s]?\//,
|
|
200
|
+
/\bdist\//,
|
|
201
|
+
/\bbuild\//,
|
|
202
|
+
/\.min\.[jt]s$/,
|
|
203
|
+
/\.d\.ts$/,
|
|
204
|
+
/\.config\.[jt]s$/,
|
|
205
|
+
/\.repolens\//,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function shouldSkipFile(filePath) {
|
|
209
|
+
return SKIP_PATTERNS.some(pattern => pattern.test(filePath));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function readFileSafe(filePath) {
|
|
213
|
+
try {
|
|
214
|
+
return await fs.readFile(filePath, "utf8");
|
|
215
|
+
} catch {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Scan source files for security anti-patterns
|
|
222
|
+
* @param {string[]} files - List of file paths relative to repo root
|
|
223
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
224
|
+
* @returns {Promise<Object>} Security analysis result
|
|
225
|
+
*/
|
|
226
|
+
export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
227
|
+
const result = {
|
|
228
|
+
detected: false,
|
|
229
|
+
findings: [],
|
|
230
|
+
summary: null,
|
|
231
|
+
byCategory: {},
|
|
232
|
+
bySeverity: { high: 0, medium: 0, low: 0 },
|
|
233
|
+
filesScanned: 0,
|
|
234
|
+
filesWithFindings: new Set(),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const jsFiles = files
|
|
238
|
+
.filter(f => JS_EXTENSIONS.some(ext => f.endsWith(ext)))
|
|
239
|
+
.filter(f => !shouldSkipFile(f));
|
|
240
|
+
|
|
241
|
+
if (jsFiles.length === 0) return result;
|
|
242
|
+
|
|
243
|
+
result.filesScanned = jsFiles.length;
|
|
244
|
+
|
|
245
|
+
for (const file of jsFiles) {
|
|
246
|
+
const content = await readFileSafe(path.join(repoRoot, file));
|
|
247
|
+
if (!content) continue;
|
|
248
|
+
|
|
249
|
+
// Strip comments to reduce false positives
|
|
250
|
+
const stripped = stripComments(content);
|
|
251
|
+
|
|
252
|
+
for (const pattern of SECURITY_PATTERNS) {
|
|
253
|
+
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
|
|
254
|
+
let match;
|
|
255
|
+
|
|
256
|
+
while ((match = regex.exec(stripped)) !== null) {
|
|
257
|
+
// Get line number
|
|
258
|
+
const lineNumber = stripped.slice(0, match.index).split("\n").length;
|
|
259
|
+
|
|
260
|
+
// Get the surrounding code snippet
|
|
261
|
+
const lines = stripped.split("\n");
|
|
262
|
+
const snippetStart = Math.max(0, lineNumber - 2);
|
|
263
|
+
const snippetEnd = Math.min(lines.length, lineNumber + 1);
|
|
264
|
+
const snippet = lines.slice(snippetStart, snippetEnd).join("\n").trim();
|
|
265
|
+
|
|
266
|
+
result.detected = true;
|
|
267
|
+
result.findings.push({
|
|
268
|
+
id: pattern.id,
|
|
269
|
+
category: pattern.category,
|
|
270
|
+
name: pattern.name,
|
|
271
|
+
severity: pattern.severity,
|
|
272
|
+
file,
|
|
273
|
+
line: lineNumber,
|
|
274
|
+
snippet: snippet.length > 200 ? snippet.slice(0, 200) + "..." : snippet,
|
|
275
|
+
description: pattern.description,
|
|
276
|
+
cwe: pattern.cwe,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
result.bySeverity[pattern.severity]++;
|
|
280
|
+
result.filesWithFindings.add(file);
|
|
281
|
+
|
|
282
|
+
if (!result.byCategory[pattern.category]) {
|
|
283
|
+
result.byCategory[pattern.category] = [];
|
|
284
|
+
}
|
|
285
|
+
result.byCategory[pattern.category].push(result.findings[result.findings.length - 1]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Convert Set to count for serialization
|
|
291
|
+
result.filesWithFindingsCount = result.filesWithFindings.size;
|
|
292
|
+
result.filesWithFindings = [...result.filesWithFindings];
|
|
293
|
+
|
|
294
|
+
result.summary = buildSummary(result);
|
|
295
|
+
|
|
296
|
+
if (result.findings.length > 0) {
|
|
297
|
+
info(`Security patterns: ${result.findings.length} finding(s) in ${result.filesWithFindingsCount} file(s) (${result.bySeverity.high} high, ${result.bySeverity.medium} medium, ${result.bySeverity.low} low)`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Strip single-line and multi-line comments from source code
|
|
305
|
+
* to reduce false positives in pattern matching
|
|
306
|
+
*/
|
|
307
|
+
function stripComments(source) {
|
|
308
|
+
// Remove multi-line comments
|
|
309
|
+
let stripped = source.replace(/\/\*[\s\S]*?\*\//g, (match) => {
|
|
310
|
+
// Preserve line count for accurate line numbers
|
|
311
|
+
return match.replace(/[^\n]/g, " ");
|
|
312
|
+
});
|
|
313
|
+
// Remove single-line comments (but not URLs like https://)
|
|
314
|
+
stripped = stripped.replace(/(?<!:)\/\/.*$/gm, "");
|
|
315
|
+
return stripped;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function buildSummary(result) {
|
|
319
|
+
if (result.findings.length === 0) {
|
|
320
|
+
return "No security hotspots detected.";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const parts = [`${result.findings.length} security finding(s)`];
|
|
324
|
+
if (result.bySeverity.high > 0) parts.push(`${result.bySeverity.high} high severity`);
|
|
325
|
+
if (result.bySeverity.medium > 0) parts.push(`${result.bySeverity.medium} medium severity`);
|
|
326
|
+
if (result.bySeverity.low > 0) parts.push(`${result.bySeverity.low} low severity`);
|
|
327
|
+
parts.push(`across ${result.filesWithFindingsCount} file(s)`);
|
|
328
|
+
return parts.join(" · ");
|
|
329
|
+
}
|