@kernlang/review 3.1.6 → 3.1.7

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.
Files changed (150) hide show
  1. package/dist/cache.d.ts +1 -1
  2. package/dist/cache.js +5 -3
  3. package/dist/cache.js.map +1 -1
  4. package/dist/call-graph.d.ts +63 -0
  5. package/dist/call-graph.js +380 -0
  6. package/dist/call-graph.js.map +1 -0
  7. package/dist/concept-rules/boundary-mutation.d.ts +1 -1
  8. package/dist/concept-rules/boundary-mutation.js.map +1 -1
  9. package/dist/concept-rules/ignored-error.d.ts +1 -1
  10. package/dist/concept-rules/ignored-error.js.map +1 -1
  11. package/dist/concept-rules/illegal-dependency.d.ts +1 -1
  12. package/dist/concept-rules/illegal-dependency.js.map +1 -1
  13. package/dist/concept-rules/index.js +1 -6
  14. package/dist/concept-rules/index.js.map +1 -1
  15. package/dist/concept-rules/unguarded-effect.d.ts +1 -1
  16. package/dist/concept-rules/unguarded-effect.js.map +1 -1
  17. package/dist/concept-rules/unrecovered-effect.d.ts +1 -1
  18. package/dist/concept-rules/unrecovered-effect.js +2 -1
  19. package/dist/concept-rules/unrecovered-effect.js.map +1 -1
  20. package/dist/confidence.js +12 -8
  21. package/dist/confidence.js.map +1 -1
  22. package/dist/differ.js +3 -7
  23. package/dist/differ.js.map +1 -1
  24. package/dist/external-tools.js +5 -6
  25. package/dist/external-tools.js.map +1 -1
  26. package/dist/file-context.d.ts +21 -0
  27. package/dist/file-context.js +234 -0
  28. package/dist/file-context.js.map +1 -0
  29. package/dist/file-role.js +14 -7
  30. package/dist/file-role.js.map +1 -1
  31. package/dist/graph.d.ts +1 -1
  32. package/dist/graph.js +24 -16
  33. package/dist/graph.js.map +1 -1
  34. package/dist/index.d.ts +44 -35
  35. package/dist/index.js +210 -121
  36. package/dist/index.js.map +1 -1
  37. package/dist/inferrer.d.ts +8 -2
  38. package/dist/inferrer.js +80 -47
  39. package/dist/inferrer.js.map +1 -1
  40. package/dist/kern-lint.d.ts +3 -4
  41. package/dist/kern-lint.js +7 -5
  42. package/dist/kern-lint.js.map +1 -1
  43. package/dist/llm-bridge.d.ts +23 -7
  44. package/dist/llm-bridge.js +267 -31
  45. package/dist/llm-bridge.js.map +1 -1
  46. package/dist/llm-review.d.ts +16 -2
  47. package/dist/llm-review.js +240 -35
  48. package/dist/llm-review.js.map +1 -1
  49. package/dist/mappers/ts-concepts.d.ts +1 -1
  50. package/dist/mappers/ts-concepts.js +303 -32
  51. package/dist/mappers/ts-concepts.js.map +1 -1
  52. package/dist/norm-miner.d.ts +31 -0
  53. package/dist/norm-miner.js +119 -0
  54. package/dist/norm-miner.js.map +1 -0
  55. package/dist/obligations.d.ts +63 -0
  56. package/dist/obligations.js +158 -0
  57. package/dist/obligations.js.map +1 -0
  58. package/dist/quality-rules.d.ts +3 -3
  59. package/dist/quality-rules.js +4 -2
  60. package/dist/quality-rules.js.map +1 -1
  61. package/dist/reporter.d.ts +7 -2
  62. package/dist/reporter.js +82 -51
  63. package/dist/reporter.js.map +1 -1
  64. package/dist/rule-eval.d.ts +1 -2
  65. package/dist/rule-eval.js +5 -9
  66. package/dist/rule-eval.js.map +1 -1
  67. package/dist/rule-loader.js +16 -14
  68. package/dist/rule-loader.js.map +1 -1
  69. package/dist/rules/base.js +153 -69
  70. package/dist/rules/base.js.map +1 -1
  71. package/dist/rules/cli.js +23 -19
  72. package/dist/rules/cli.js.map +1 -1
  73. package/dist/rules/confidence.d.ts +1 -1
  74. package/dist/rules/confidence.js +5 -5
  75. package/dist/rules/confidence.js.map +1 -1
  76. package/dist/rules/dead-code.d.ts +10 -0
  77. package/dist/rules/dead-code.js +75 -0
  78. package/dist/rules/dead-code.js.map +1 -0
  79. package/dist/rules/dead-logic.js +35 -31
  80. package/dist/rules/dead-logic.js.map +1 -1
  81. package/dist/rules/express.d.ts +2 -1
  82. package/dist/rules/express.js +380 -126
  83. package/dist/rules/express.js.map +1 -1
  84. package/dist/rules/fastapi.js +53 -19
  85. package/dist/rules/fastapi.js.map +1 -1
  86. package/dist/rules/ground-layer.js +3 -3
  87. package/dist/rules/ground-layer.js.map +1 -1
  88. package/dist/rules/index.js +574 -105
  89. package/dist/rules/index.js.map +1 -1
  90. package/dist/rules/ink.js +9 -8
  91. package/dist/rules/ink.js.map +1 -1
  92. package/dist/rules/kern-source.js +202 -63
  93. package/dist/rules/kern-source.js.map +1 -1
  94. package/dist/rules/nextjs.js +88 -33
  95. package/dist/rules/nextjs.js.map +1 -1
  96. package/dist/rules/null-safety.js +52 -26
  97. package/dist/rules/null-safety.js.map +1 -1
  98. package/dist/rules/nuxt.js +24 -29
  99. package/dist/rules/nuxt.js.map +1 -1
  100. package/dist/rules/react.js +355 -69
  101. package/dist/rules/react.js.map +1 -1
  102. package/dist/rules/security-v2.js +71 -57
  103. package/dist/rules/security-v2.js.map +1 -1
  104. package/dist/rules/security-v3.js.map +1 -1
  105. package/dist/rules/security-v4.js +54 -27
  106. package/dist/rules/security-v4.js.map +1 -1
  107. package/dist/rules/security.js +35 -5
  108. package/dist/rules/security.js.map +1 -1
  109. package/dist/rules/terminal.js +17 -5
  110. package/dist/rules/terminal.js.map +1 -1
  111. package/dist/rules/vue.js +162 -107
  112. package/dist/rules/vue.js.map +1 -1
  113. package/dist/semantic-diff.d.ts +52 -0
  114. package/dist/semantic-diff.js +342 -0
  115. package/dist/semantic-diff.js.map +1 -0
  116. package/dist/spec-checker.js +11 -10
  117. package/dist/spec-checker.js.map +1 -1
  118. package/dist/suppression/apply-suppression.d.ts +2 -3
  119. package/dist/suppression/apply-suppression.js +3 -3
  120. package/dist/suppression/apply-suppression.js.map +1 -1
  121. package/dist/suppression/index.d.ts +2 -2
  122. package/dist/suppression/index.js +1 -1
  123. package/dist/suppression/index.js.map +1 -1
  124. package/dist/suppression/parse-directives.d.ts +1 -1
  125. package/dist/suppression/parse-directives.js +9 -4
  126. package/dist/suppression/parse-directives.js.map +1 -1
  127. package/dist/taint-ast.d.ts +20 -0
  128. package/dist/taint-ast.js +427 -0
  129. package/dist/taint-ast.js.map +1 -0
  130. package/dist/taint-crossfile.d.ts +28 -0
  131. package/dist/taint-crossfile.js +174 -0
  132. package/dist/taint-crossfile.js.map +1 -0
  133. package/dist/taint-findings.d.ts +17 -0
  134. package/dist/taint-findings.js +131 -0
  135. package/dist/taint-findings.js.map +1 -0
  136. package/dist/taint-regex.d.ts +61 -0
  137. package/dist/taint-regex.js +379 -0
  138. package/dist/taint-regex.js.map +1 -0
  139. package/dist/taint-types.d.ts +128 -0
  140. package/dist/taint-types.js +174 -0
  141. package/dist/taint-types.js.map +1 -0
  142. package/dist/taint.d.ts +13 -107
  143. package/dist/taint.js +16 -1067
  144. package/dist/taint.js.map +1 -1
  145. package/dist/template-detector.d.ts +2 -2
  146. package/dist/template-detector.js +11 -16
  147. package/dist/template-detector.js.map +1 -1
  148. package/dist/types.d.ts +35 -0
  149. package/dist/types.js.map +1 -1
  150. package/package.json +2 -2
package/dist/taint.js CHANGED
@@ -1,1081 +1,30 @@
1
1
  /**
2
- * Taint Tracking — source→sink analysis on KERN IR.
2
+ * Taint Tracking — barrel re-export module.
3
3
  *
4
- * Phase 2 of the security pipeline. Works on InferResult[] handler bodies.
5
- *
6
- * Two modes:
7
- * analyzeTaint() — intra-procedural (single file)
8
- * analyzeTaintCrossFile() inter-procedural (follows imports across files)
9
- *
10
- * Also validates sanitizer sufficiency: parseInt stops SQL injection on
11
- * numeric values but NOT command injection. DOMPurify stops XSS but
12
- * NOT SQL injection. The sufficiency matrix catches these mismatches.
13
- */
14
- import { SyntaxKind } from 'ts-morph';
15
- import { createFingerprint } from './types.js';
16
- // ── Source Classification ────────────────────────────────────────────────
17
- /** Param names/types that indicate HTTP handler context */
18
- const HTTP_PARAM_NAMES = /^(req|request)$/i;
19
- const HTTP_PARAM_TYPES = /Request|IncomingMessage|FastifyRequest|KoaContext|Context/;
20
- /** User input access patterns — what flows from HTTP params */
21
- const USER_INPUT_ACCESS = [
22
- { pattern: /\breq\.body\b/, origin: 'req.body' },
23
- { pattern: /\breq\.query\b/, origin: 'req.query' },
24
- { pattern: /\breq\.params\b/, origin: 'req.params' },
25
- { pattern: /\breq\.headers\b/, origin: 'req.headers' },
26
- { pattern: /\brequest\.body\b/, origin: 'request.body' },
27
- { pattern: /\brequest\.query\b/, origin: 'request.query' },
28
- { pattern: /\brequest\.params\b/, origin: 'request.params' },
29
- { pattern: /\bprocess\.argv\b/, origin: 'process.argv' },
30
- { pattern: /\bprocess\.env\b/, origin: 'process.env' },
31
- // DB read results (indirect injection sources)
32
- { pattern: /\bdb\.query\b/, origin: 'db.query' },
33
- { pattern: /\bfindOne\b/, origin: 'findOne' },
34
- { pattern: /\bfindById\b/, origin: 'findById' },
35
- { pattern: /\bgetItem\b/, origin: 'getItem' },
36
- { pattern: /\bcollection\.find\b/, origin: 'collection.find' },
37
- // RAG/retrieval results
38
- { pattern: /\bvectorStore\.search\b/, origin: 'vectorStore.search' },
39
- { pattern: /\bsimilaritySearch\b/, origin: 'similaritySearch' },
40
- { pattern: /\bindex\.query\b/, origin: 'index.query' },
41
- ];
42
- const SINK_PATTERNS = [
43
- // Command execution
44
- { pattern: /\bexec\s*\(/, name: 'exec', category: 'command' },
45
- { pattern: /\bexecSync\s*\(/, name: 'execSync', category: 'command' },
46
- { pattern: /\bspawn\s*\(/, name: 'spawn', category: 'command' },
47
- { pattern: /\bspawnSync\s*\(/, name: 'spawnSync', category: 'command' },
48
- { pattern: /\bexecFile\s*\(/, name: 'execFile', category: 'command' },
49
- // Filesystem
50
- { pattern: /\bwriteFile\s*\(/, name: 'writeFile', category: 'fs' },
51
- { pattern: /\bwriteFileSync\s*\(/, name: 'writeFileSync', category: 'fs' },
52
- { pattern: /\bcreateWriteStream\s*\(/, name: 'createWriteStream', category: 'fs' },
53
- { pattern: /\bunlink\s*\(/, name: 'unlink', category: 'fs' },
54
- { pattern: /\bunlinkSync\s*\(/, name: 'unlinkSync', category: 'fs' },
55
- // SQL (template literal with query-like calls)
56
- { pattern: /\bquery\s*\(/, name: 'query', category: 'sql' },
57
- { pattern: /\b\$execute\s*\(/, name: '$execute', category: 'sql' },
58
- { pattern: /\braw\s*\(/, name: 'raw', category: 'sql' },
59
- // Redirect
60
- { pattern: /\bredirect\s*\(/, name: 'redirect', category: 'redirect' },
61
- // Eval
62
- { pattern: /\beval\s*\(/, name: 'eval', category: 'eval' },
63
- { pattern: /\bnew\s+Function\s*\(/, name: 'new Function', category: 'eval' },
64
- // LLM API calls (prompt injection sinks)
65
- { pattern: /\bgenerateContent\s*\(/, name: 'generateContent', category: 'template' },
66
- { pattern: /\bsendMessage\s*\(/, name: 'sendMessage', category: 'template' },
67
- { pattern: /\bchat\.completions\.create\s*\(/, name: 'chat.completions.create', category: 'template' },
68
- // VM execution sinks (LLM output execution)
69
- { pattern: /\bvm\.runInContext\s*\(/, name: 'vm.runInContext', category: 'eval' },
70
- { pattern: /\bvm\.runInNewContext\s*\(/, name: 'vm.runInNewContext', category: 'eval' },
71
- ];
72
- // ── Sanitizer Detection ─────────────────────────────────────────────────
73
- const SANITIZER_PATTERNS = [
74
- // Type coercion (sanitizes to safe type)
75
- { pattern: /\bparseInt\s*\(/, name: 'parseInt' },
76
- { pattern: /\bparseFloat\s*\(/, name: 'parseFloat' },
77
- { pattern: /\bNumber\s*\(/, name: 'Number()' },
78
- { pattern: /\bBoolean\s*\(/, name: 'Boolean()' },
79
- // Schema validation
80
- { pattern: /\.parse\s*\(/, name: 'schema.parse' },
81
- { pattern: /\.safeParse\s*\(/, name: 'schema.safeParse' },
82
- { pattern: /\.validate\s*\(/, name: 'schema.validate' },
83
- { pattern: /\.validateSync\s*\(/, name: 'schema.validateSync' },
84
- // String sanitization
85
- { pattern: /\bsanitize\w*\s?\(/, name: 'sanitize()' },
86
- { pattern: /\bescape\w*\s?\(/, name: 'escape()' },
87
- { pattern: /\bDOMPurify\b/, name: 'DOMPurify' },
88
- { pattern: /\bencodeURI(Component)?\s*\(/, name: 'encodeURIComponent' },
89
- // Path sanitization
90
- { pattern: /path\.(resolve|normalize|basename)\s*\(/, name: 'path.normalize' },
91
- { pattern: /\.replace\s*\(\s*\/.*\.\.\//, name: 'replace(../)' },
92
- // SQL parameterization
93
- { pattern: /\$\d+/, name: 'parameterized query ($N)' },
94
- { pattern: /\?\s*,/, name: 'parameterized query (?)' },
95
- // Prompt sanitization
96
- { pattern: /\bsanitizeForPrompt\s*\(/, name: 'sanitizeForPrompt' },
97
- { pattern: /\bescapePrompt\s*\(/, name: 'escapePrompt' },
98
- // LLM-specific sanitizers
99
- { pattern: /\bstripDelimiters\s*\(/, name: 'stripDelimiters' },
100
- { pattern: /\bcleanForPrompt\s*\(/, name: 'cleanForPrompt' },
101
- ];
102
- const SANITIZER_SUFFICIENCY = {
103
- 'parseInt': new Set(['sql']),
104
- 'parseFloat': new Set(['sql']),
105
- 'Number()': new Set(['sql']),
106
- 'Boolean()': new Set([]), // too weak for anything
107
- 'schema.parse': new Set(['command', 'fs', 'sql', 'redirect', 'eval', 'template']),
108
- 'schema.safeParse': new Set(['command', 'fs', 'sql', 'redirect', 'eval', 'template']),
109
- 'schema.validate': new Set(['command', 'fs', 'sql', 'redirect', 'eval', 'template']),
110
- 'schema.validateSync': new Set(['command', 'fs', 'sql', 'redirect', 'eval', 'template']),
111
- 'sanitize()': new Set(['template']),
112
- 'escape()': new Set(['sql', 'template']),
113
- 'DOMPurify': new Set(['template']),
114
- 'encodeURIComponent': new Set(['redirect']),
115
- 'path.normalize': new Set(['fs']),
116
- 'replace(../)': new Set(['fs']),
117
- 'parameterized query ($N)': new Set(['sql']),
118
- 'parameterized query (?)': new Set(['sql']),
119
- 'sanitizeForPrompt': new Set(['template']),
120
- 'escapePrompt': new Set(['template']),
121
- 'stripDelimiters': new Set(['template']),
122
- 'cleanForPrompt': new Set(['template']),
123
- };
124
- /**
125
- * Check if a sanitizer is actually sufficient for a given sink category.
126
- * Returns true if the sanitizer protects against the sink, false if it's
127
- * a mismatch (e.g., parseInt used to "sanitize" command injection).
128
- */
129
- export function isSanitizerSufficient(sanitizerName, sinkCategory) {
130
- const allowed = SANITIZER_SUFFICIENCY[sanitizerName];
131
- if (!allowed)
132
- return false; // Unknown sanitizer — default deny, verify manually
133
- return allowed.has(sinkCategory);
134
- }
135
- // ── Main Analysis ────────────────────────────────────────────────────────
4
+ * Re-exports everything from the 5 taint sub-modules so existing
5
+ * imports from './taint.js' continue to work unchanged.
6
+ */
7
+ import { analyzeTaintAST } from './taint-ast.js';
8
+ import { analyzeTaintRegex } from './taint-regex.js';
9
+ export { HTTP_PARAM_NAMES, HTTP_PARAM_TYPES, isSanitizerSufficient, SANITIZER_PATTERN_NAMES, SANITIZER_PATTERNS, SINK_NAMES, SINK_PATTERNS, USER_INPUT_ACCESS, } from './taint-types.js';
10
+ // ── AST Engine ──────────────────────────────────────────────────────────
11
+ export { analyzeTaintAST, buildInternalSinkMap } from './taint-ast.js';
12
+ // ── Regex Engine ────────────────────────────────────────────────────────
13
+ export { analyzeTaintRegex, buildPaths, classifyParams, detectSanitizers, extractAllAssignments, extractDependencies, findClosingParen, findTaintedSinks, isCircularAssignment, parseLineAssignments, propagateTaint, propagateTaintMultiHop, } from './taint-regex.js';
14
+ // ── Finding Generation ──────────────────────────────────────────────────
15
+ export { crossFileTaintToFindings, taintToFindings } from './taint-findings.js';
16
+ // ── Cross-File Analysis ─────────────────────────────────────────────────
17
+ export { analyzeTaintCrossFile, buildExportMap, buildImportMap } from './taint-crossfile.js';
18
+ // ── Main Entry Point ────────────────────────────────────────────────────
136
19
  /**
137
20
  * Run taint analysis on all fn nodes in inferred results.
138
21
  * When sourceFile is provided, uses AST-based analysis (more accurate).
139
22
  * Falls back to regex-based analysis when no SourceFile available.
140
23
  */
141
24
  export function analyzeTaint(inferred, filePath, sourceFile) {
142
- // Use AST-based analysis when SourceFile is available
143
25
  if (sourceFile) {
144
26
  return analyzeTaintAST(inferred, filePath, sourceFile);
145
27
  }
146
28
  return analyzeTaintRegex(inferred, filePath);
147
29
  }
148
- /**
149
- * AST-based taint analysis — walks real ts-morph AST nodes instead of regex on strings.
150
- * Handles destructuring, method chains, computed property access.
151
- */
152
- function analyzeTaintAST(inferred, filePath, sourceFile) {
153
- const results = [];
154
- // Collect all function-like AST nodes from the SourceFile
155
- const allFns = [];
156
- for (const fn of sourceFile.getFunctions())
157
- allFns.push({ node: fn, startLine: fn.getStartLineNumber() });
158
- for (const stmt of sourceFile.getVariableStatements()) {
159
- for (const decl of stmt.getDeclarations()) {
160
- const init = decl.getInitializer();
161
- if (init) {
162
- const initKind = init.getKindName();
163
- if (initKind === 'ArrowFunction' || initKind === 'FunctionExpression') {
164
- allFns.push({ node: init, startLine: stmt.getStartLineNumber() });
165
- }
166
- }
167
- }
168
- }
169
- for (const cls of sourceFile.getClasses()) {
170
- for (const method of cls.getMethods()) {
171
- allFns.push({ node: method, startLine: method.getStartLineNumber() });
172
- }
173
- }
174
- for (const { node: fn, startLine } of allFns) {
175
- const params = fn.getParameters();
176
- const fnName = 'getName' in fn && typeof fn.getName === 'function' ? fn.getName() || 'anonymous' : 'anonymous';
177
- // Step 1: Classify params as tainted using type info
178
- const taintedParams = [];
179
- for (const param of params) {
180
- const name = param.getName();
181
- const typeText = param.getType().getText(param);
182
- if (HTTP_PARAM_NAMES.test(name) || HTTP_PARAM_TYPES.test(typeText)) {
183
- taintedParams.push({ name, origin: `${name} (HTTP input)` });
184
- }
185
- }
186
- if (taintedParams.length === 0)
187
- continue;
188
- // Step 2: AST-based taint propagation through the function body
189
- const body = fn.getBody();
190
- if (!body)
191
- continue;
192
- const taintedNames = new Set(taintedParams.map(p => p.name));
193
- const taintedVars = new Map();
194
- for (const p of taintedParams)
195
- taintedVars.set(p.name, p);
196
- // Walk ALL variable declarations including nested scopes (if/for/while)
197
- // forEachDescendant visits in document order = parent-before-child
198
- const varDecls = [];
199
- body.forEachDescendant((node) => {
200
- if (node.getKind() === SyntaxKind.VariableDeclaration) {
201
- varDecls.push(node);
202
- }
203
- });
204
- // Multiple passes to handle forward dependencies (max 3 hops)
205
- for (let hop = 0; hop < 3; hop++) {
206
- for (const decl of varDecls) {
207
- const nameNode = decl.getNameNode();
208
- const nameKind = nameNode.getKindName();
209
- // Simple name binding: const id = parseInt(req.body.id)
210
- if (nameKind === 'Identifier') {
211
- const declName = nameNode.getText();
212
- if (taintedNames.has(declName))
213
- continue;
214
- const init = decl.getInitializer();
215
- if (!init)
216
- continue;
217
- if (astExprRefersToTainted(init, taintedNames)) {
218
- taintedNames.add(declName);
219
- const srcName = findTaintedIdentifier(init, taintedNames);
220
- const srcVar = srcName ? taintedVars.get(srcName) : undefined;
221
- const srcOrigin = srcVar?.origin;
222
- taintedVars.set(declName, { name: declName, origin: srcOrigin || 'derived' });
223
- }
224
- }
225
- // Object destructuring: const { x, y } = taintedObj
226
- if (nameKind === 'ObjectBindingPattern') {
227
- const init = decl.getInitializer();
228
- if (!init || !astExprRefersToTainted(init, taintedNames))
229
- continue;
230
- const srcName = findTaintedIdentifier(init, taintedNames);
231
- const srcVar2 = srcName ? taintedVars.get(srcName) : undefined;
232
- const srcOrigin = srcVar2?.origin;
233
- for (const element of nameNode.getElements()) {
234
- const elName = element.getName();
235
- if (!taintedNames.has(elName)) {
236
- taintedNames.add(elName);
237
- taintedVars.set(elName, { name: elName, origin: srcOrigin || 'destructured' });
238
- }
239
- }
240
- }
241
- // Array destructuring: const [a, b] = taintedArr
242
- if (nameKind === 'ArrayBindingPattern') {
243
- const init = decl.getInitializer();
244
- if (!init || !astExprRefersToTainted(init, taintedNames))
245
- continue;
246
- const srcName = findTaintedIdentifier(init, taintedNames);
247
- const srcVar3 = srcName ? taintedVars.get(srcName) : undefined;
248
- const srcOrigin = srcVar3?.origin;
249
- for (const element of nameNode.getElements()) {
250
- if (element.getKindName() === 'BindingElement') {
251
- const elName = element.getName();
252
- if (!taintedNames.has(elName)) {
253
- taintedNames.add(elName);
254
- taintedVars.set(elName, { name: elName, origin: srcOrigin || 'destructured' });
255
- }
256
- }
257
- }
258
- }
259
- }
260
- }
261
- // Step 3: Find sinks via AST CallExpression walk
262
- const sinks = [];
263
- const calls = [];
264
- body.forEachDescendant(n => { if (n.getKindName() === 'CallExpression')
265
- calls.push(n); });
266
- for (const call of calls) {
267
- const calleeName = getCalleeBaseName(call);
268
- const sinkDef = SINK_NAMES.get(calleeName);
269
- if (!sinkDef)
270
- continue;
271
- // Check if any argument references a tainted variable
272
- for (const arg of call.getArguments()) {
273
- const taintedArg = findTaintedIdentifier(arg, taintedNames);
274
- if (taintedArg) {
275
- sinks.push({
276
- name: calleeName,
277
- category: sinkDef,
278
- taintedArg,
279
- line: call.getStartLineNumber(),
280
- });
281
- break;
282
- }
283
- }
284
- // Also check template literal arguments
285
- const templateArgs = call.getArguments().filter(a => {
286
- const k = a.getKindName();
287
- return k === 'TemplateExpression' || k === 'NoSubstitutionTemplateLiteral';
288
- });
289
- for (const tpl of templateArgs) {
290
- if (tpl.getKindName() === 'TemplateExpression') {
291
- for (const span of tpl.getTemplateSpans()) {
292
- const expr = span.getExpression();
293
- const taintedArg = findTaintedIdentifier(expr, taintedNames);
294
- if (taintedArg) {
295
- sinks.push({
296
- name: `${calleeName} (template)`,
297
- category: sinkDef,
298
- taintedArg,
299
- line: call.getStartLineNumber(),
300
- });
301
- }
302
- }
303
- }
304
- }
305
- }
306
- if (sinks.length === 0)
307
- continue;
308
- // Step 4: Check for sanitizers (AST-based)
309
- const foundSanitizers = findSanitizersAST(body, taintedNames);
310
- // Build paths
311
- const paths = [];
312
- for (const sink of sinks) {
313
- const source = taintedVars.get(sink.taintedArg) || taintedParams[0];
314
- // Subtree matching: sanitize(req.query) covers req.query.id but not req.body.cmd
315
- // parseInt(req.query.id) does NOT cover exec(req) — only the specific property is safe
316
- const sanitizer = foundSanitizers.find(s => {
317
- for (const sv of s.sanitizedVars) {
318
- if (sv === sink.taintedArg)
319
- return true;
320
- // Sanitized path is a prefix → covers all sub-properties
321
- if (sink.taintedArg.startsWith(sv + '.'))
322
- return true;
323
- }
324
- return false;
325
- });
326
- const hasSanitizer = sanitizer != null;
327
- const sufficient = sanitizer != null ? isSanitizerSufficient(sanitizer.name, sink.category) : false;
328
- paths.push({
329
- source,
330
- sink,
331
- sanitized: hasSanitizer && sufficient,
332
- sanitizer: sanitizer?.name,
333
- insufficientSanitizer: hasSanitizer && !sufficient ? sanitizer.name : undefined,
334
- });
335
- }
336
- if (paths.length > 0) {
337
- results.push({ fnName, filePath, startLine, paths });
338
- }
339
- }
340
- return results;
341
- }
342
- /** Check if an expression references any tainted variable name */
343
- function astExprRefersToTainted(expr, taintedNames) {
344
- const k = expr.getKindName();
345
- if (k === 'Identifier' && taintedNames.has(expr.getText()))
346
- return true;
347
- if (k === 'PropertyAccessExpression') {
348
- return astExprRefersToTainted(expr.getExpression(), taintedNames);
349
- }
350
- if (k === 'ElementAccessExpression') {
351
- return astExprRefersToTainted(expr.getExpression(), taintedNames);
352
- }
353
- if (k === 'CallExpression') {
354
- if (astExprRefersToTainted(expr.getExpression(), taintedNames))
355
- return true;
356
- for (const arg of expr.getArguments()) {
357
- if (astExprRefersToTainted(arg, taintedNames))
358
- return true;
359
- }
360
- return false;
361
- }
362
- if (k === 'AwaitExpression') {
363
- return astExprRefersToTainted(expr.getExpression(), taintedNames);
364
- }
365
- // Check all children for complex expressions
366
- for (const child of expr.getChildren()) {
367
- if (astExprRefersToTainted(child, taintedNames))
368
- return true;
369
- }
370
- return false;
371
- }
372
- /** Get the base name of a callee (e.g., exec from child_process.exec, or db.query) */
373
- function getCalleeBaseName(call) {
374
- const expr = call.getExpression();
375
- const k = expr.getKindName();
376
- if (k === 'Identifier')
377
- return expr.getText();
378
- if (k === 'PropertyAccessExpression')
379
- return expr.getName();
380
- return '';
381
- }
382
- /** Find the first tainted identifier in an expression tree */
383
- /** Get the full static access path (e.g., req.query.id). Returns undefined for dynamic access. */
384
- function getStaticAccessPath(expr) {
385
- const k = expr.getKindName();
386
- if (k === 'Identifier')
387
- return expr.getText();
388
- if (k === 'PropertyAccessExpression') {
389
- const obj = getStaticAccessPath(expr.getExpression());
390
- if (obj)
391
- return `${obj}.${expr.getName()}`;
392
- }
393
- return undefined;
394
- }
395
- function findTaintedIdentifier(expr, taintedNames) {
396
- const k = expr.getKindName();
397
- if (k === 'Identifier' && taintedNames.has(expr.getText()))
398
- return expr.getText();
399
- if (k === 'PropertyAccessExpression') {
400
- return findTaintedIdentifier(expr.getExpression(), taintedNames);
401
- }
402
- // Check binary expressions (string concatenation: 'cmd ' + userInput)
403
- if (k === 'BinaryExpression') {
404
- return findTaintedIdentifier(expr.getLeft(), taintedNames) || findTaintedIdentifier(expr.getRight(), taintedNames);
405
- }
406
- for (const child of expr.getChildren()) {
407
- const found = findTaintedIdentifier(child, taintedNames);
408
- if (found)
409
- return found;
410
- }
411
- return undefined;
412
- }
413
- /** AST-based sanitizer detection */
414
- function findSanitizersAST(body, taintedNames) {
415
- const sanitizers = [];
416
- const allCalls = [];
417
- body.forEachDescendant(n => { if (n.getKindName() === 'CallExpression')
418
- allCalls.push(n); });
419
- for (const call of allCalls) {
420
- const calleeName = getCalleeBaseName(call);
421
- const matchedSanitizer = SANITIZER_PATTERN_NAMES.find(s => calleeName.includes(s));
422
- if (!matchedSanitizer)
423
- continue;
424
- // Track which tainted vars are sanitized by this call
425
- const sanitizedVars = new Set();
426
- for (const arg of call.getArguments()) {
427
- // Track the FULL access path so parseInt(req.query.id) sanitizes 'req.query.id', not 'req'
428
- const fullPath = getStaticAccessPath(arg);
429
- if (fullPath && findTaintedIdentifier(arg, taintedNames)) {
430
- sanitizedVars.add(fullPath);
431
- }
432
- else {
433
- const tainted = findTaintedIdentifier(arg, taintedNames);
434
- if (tainted)
435
- sanitizedVars.add(tainted);
436
- }
437
- }
438
- // Also check if the result is assigned to a variable (replacing the tainted value)
439
- const parent = call.getParent();
440
- if (parent && parent.getKindName() === 'VariableDeclaration') {
441
- const declName = parent.getName();
442
- sanitizedVars.add(declName);
443
- }
444
- if (sanitizedVars.size > 0) {
445
- sanitizers.push({ name: matchedSanitizer, sanitizedVars });
446
- }
447
- }
448
- return sanitizers;
449
- }
450
- // Sink name → category lookup (flat map from SINK_PATTERNS)
451
- const SINK_NAMES = new Map([
452
- ['exec', 'command'], ['execSync', 'command'], ['spawn', 'command'],
453
- ['spawnSync', 'command'], ['execFile', 'command'], ['execFileSync', 'command'],
454
- ['writeFile', 'fs'], ['writeFileSync', 'fs'], ['createWriteStream', 'fs'],
455
- ['unlink', 'fs'], ['unlinkSync', 'fs'],
456
- ['query', 'sql'], ['$execute', 'sql'], ['raw', 'sql'],
457
- ['$queryRaw', 'sql'], ['$queryRawUnsafe', 'sql'],
458
- ['redirect', 'redirect'],
459
- ['eval', 'eval'], ['Function', 'eval'],
460
- ]);
461
- // Sanitizer names to detect (from SANITIZER_PATTERNS)
462
- const SANITIZER_PATTERN_NAMES = [
463
- 'parseInt', 'parseFloat', 'Number', 'Boolean', 'String',
464
- 'encodeURI', 'encodeURIComponent', 'escape',
465
- 'sanitize', 'DOMPurify', 'purify', 'xss',
466
- 'escapeHtml', 'sqlstring', 'parameterized',
467
- 'parse', 'safeParse', 'validate',
468
- ];
469
- /**
470
- * Regex-based taint analysis — legacy fallback for when no SourceFile is available.
471
- */
472
- function analyzeTaintRegex(inferred, filePath) {
473
- const results = [];
474
- for (const r of inferred) {
475
- if (r.node.type !== 'fn')
476
- continue;
477
- const fnName = r.node.props?.name || 'anonymous';
478
- const paramsStr = r.node.props?.params || '';
479
- // Get handler body
480
- const handler = r.node.children?.find(c => c.type === 'handler');
481
- const code = handler?.props?.code || '';
482
- if (!code)
483
- continue;
484
- // Step 1: Classify params as tainted
485
- const taintedParams = classifyParams(paramsStr);
486
- if (taintedParams.length === 0)
487
- continue;
488
- // Step 2: Propagate taint through assignments
489
- const taintedVars = propagateTaint(code, taintedParams);
490
- // Step 3: Find sinks that use tainted variables
491
- const sinks = findTaintedSinks(code, taintedVars);
492
- if (sinks.length === 0)
493
- continue;
494
- // Step 4: Check for sanitizers
495
- const paths = buildPaths(code, taintedVars, sinks);
496
- if (paths.length > 0) {
497
- results.push({
498
- fnName,
499
- filePath,
500
- startLine: r.startLine,
501
- paths,
502
- });
503
- }
504
- }
505
- return results;
506
- }
507
- /**
508
- * Classify function parameters as tainted or safe.
509
- */
510
- function classifyParams(paramsStr) {
511
- const sources = [];
512
- if (!paramsStr)
513
- return sources;
514
- const params = paramsStr.split(',').map(p => {
515
- const parts = p.trim().split(':');
516
- return { name: parts[0]?.trim(), type: parts[1]?.trim() || '' };
517
- });
518
- for (const p of params) {
519
- if (!p.name)
520
- continue;
521
- if (HTTP_PARAM_NAMES.test(p.name) || HTTP_PARAM_TYPES.test(p.type)) {
522
- sources.push({ name: p.name, origin: `${p.name} (HTTP input)` });
523
- }
524
- }
525
- return sources;
526
- }
527
- /**
528
- * Multi-hop taint propagation using worklist algorithm.
529
- * Propagates until fixed point or configurable depth limit.
530
- *
531
- * Handles all assignment patterns:
532
- * - const b = a
533
- * - const b = a.trim()
534
- * - const {x} = obj
535
- * - let b; b = a
536
- *
537
- * @param code - Handler code string
538
- * @param initialTainted - Set of initially tainted variable names
539
- * @param maxDepth - Maximum propagation depth (default: 3)
540
- * @returns Set of all tainted variable names after fixed point or depth limit
541
- */
542
- export function propagateTaintMultiHop(code, initialTainted, maxDepth = 3) {
543
- const tainted = new Set(initialTainted);
544
- const worklist = [];
545
- const visitedAssignments = new Set();
546
- const assignmentDepths = new Map();
547
- for (const v of initialTainted) {
548
- worklist.push({ varName: v, depth: 0 });
549
- }
550
- const allAssignments = extractAllAssignments(code);
551
- while (worklist.length > 0) {
552
- const { varName: currentVar, depth } = worklist.shift();
553
- if (depth >= maxDepth)
554
- continue;
555
- for (const assignment of allAssignments) {
556
- const { lhs, rhs, assignId } = assignment;
557
- if (visitedAssignments.has(`${assignId}:${depth}`))
558
- continue;
559
- const rhsDeps = extractDependencies(rhs);
560
- if (rhsDeps.has(currentVar)) {
561
- visitedAssignments.add(`${assignId}:${depth}`);
562
- const existingDepth = assignmentDepths.get(lhs);
563
- if (existingDepth !== undefined && existingDepth <= depth + 1) {
564
- continue;
565
- }
566
- assignmentDepths.set(lhs, depth + 1);
567
- if (!tainted.has(lhs)) {
568
- tainted.add(lhs);
569
- worklist.push({ varName: lhs, depth: depth + 1 });
570
- }
571
- if (isCircularAssignment(lhs, rhs, allAssignments)) {
572
- continue;
573
- }
574
- for (const dep of rhsDeps) {
575
- if (dep !== currentVar && tainted.has(dep)) {
576
- const depDepth = assignmentDepths.get(dep) ?? 0;
577
- if (depth + 1 > depDepth) {
578
- if (!tainted.has(lhs)) {
579
- tainted.add(lhs);
580
- worklist.push({ varName: lhs, depth: depth + 1 });
581
- }
582
- }
583
- }
584
- }
585
- }
586
- }
587
- }
588
- return tainted;
589
- }
590
- function extractAllAssignments(code) {
591
- const assignments = [];
592
- const lines = code.split('\n');
593
- let assignCounter = 0;
594
- for (const line of lines) {
595
- const trimmed = line.trim();
596
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
597
- continue;
598
- for (const assign of parseLineAssignments(trimmed, assignCounter)) {
599
- assignments.push(assign);
600
- assignCounter++;
601
- }
602
- }
603
- return assignments;
604
- }
605
- function parseLineAssignments(line, lineNum) {
606
- const assignments = [];
607
- const constLetVarRegex = /^(?:const|let|var)\s+/;
608
- const declMatch = line.match(constLetVarRegex);
609
- if (!declMatch) {
610
- const reassignRegex = /^(\w+)\s*=\s*(.+)$/;
611
- const reassign = line.match(reassignRegex);
612
- if (reassign) {
613
- assignments.push({
614
- lhs: reassign[1],
615
- rhs: reassign[2],
616
- assignId: `${lineNum}:reassign`,
617
- });
618
- }
619
- return assignments;
620
- }
621
- const rest = line.slice(declMatch[0].length);
622
- const destructRegex = /^\{\s*([^}]+)\}\s*=\s*(.+)$/;
623
- const destructMatch = rest.match(destructRegex);
624
- if (destructMatch) {
625
- const vars = destructMatch[1].split(',').map(v => {
626
- const name = v.trim().split(':')[0].split('=')[0].trim();
627
- return name;
628
- }).filter(v => v && !v.startsWith('...'));
629
- const rhs = destructMatch[2];
630
- for (let i = 0; i < vars.length; i++) {
631
- assignments.push({
632
- lhs: vars[i],
633
- rhs: `${rhs}[${i}]`,
634
- assignId: `${lineNum}:destructure:${i}`,
635
- });
636
- }
637
- return assignments;
638
- }
639
- const simpleAssignRegex = /^(\w+)\s*=\s*(.+)$/;
640
- const simpleMatch = rest.match(simpleAssignRegex);
641
- if (simpleMatch) {
642
- assignments.push({
643
- lhs: simpleMatch[1],
644
- rhs: simpleMatch[2],
645
- assignId: `${lineNum}:simple`,
646
- });
647
- }
648
- return assignments;
649
- }
650
- function extractDependencies(rhs) {
651
- const deps = new Set();
652
- const RESERVED = new Set(['undefined', 'null', 'true', 'false', 'const', 'let', 'var', 'new', 'typeof', 'instanceof', 'return', 'await', 'async', 'function', 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'throw', 'try', 'catch', 'finally']);
653
- // Match all identifier chains: foo, foo.bar, foo.bar.baz, foo[0].bar
654
- const chainRegex = /\b([a-zA-Z_$]\w*)(?:\.\w+|\[[^\]]*\])*/g;
655
- let match;
656
- while ((match = chainRegex.exec(rhs)) !== null) {
657
- const base = match[1];
658
- if (!RESERVED.has(base) && !/^\d/.test(base)) {
659
- deps.add(base);
660
- }
661
- }
662
- return deps;
663
- }
664
- function isCircularAssignment(lhs, rhs, allAssignments) {
665
- const rhsDeps = extractDependencies(rhs);
666
- const visited = new Set();
667
- const stack = [...rhsDeps];
668
- while (stack.length > 0) {
669
- const current = stack.pop();
670
- if (current === lhs)
671
- return true;
672
- if (visited.has(current))
673
- continue;
674
- visited.add(current);
675
- for (const assign of allAssignments) {
676
- if (assign.lhs === current) {
677
- const deps = extractDependencies(assign.rhs);
678
- for (const dep of deps) {
679
- if (!visited.has(dep)) {
680
- stack.push(dep);
681
- }
682
- }
683
- }
684
- }
685
- }
686
- return false;
687
- }
688
- /**
689
- * Propagate taint through variable assignments in handler body.
690
- * Tracks: const x = req.body.foo → x is tainted.
691
- * Returns all tainted variable names with their origins.
692
- */
693
- function propagateTaint(code, params) {
694
- const tainted = new Map();
695
- for (const p of params) {
696
- tainted.set(p.name, p);
697
- }
698
- const initialTainted = new Set(tainted.keys());
699
- const propagated = propagateTaintMultiHop(code, initialTainted);
700
- for (const v of propagated) {
701
- if (!tainted.has(v)) {
702
- tainted.set(v, { name: v, origin: `derived` });
703
- }
704
- }
705
- return Array.from(tainted.values());
706
- }
707
- /**
708
- * Find sink calls that use tainted variables.
709
- */
710
- function findTaintedSinks(code, taintedVars) {
711
- const sinks = [];
712
- const taintedNames = new Set(taintedVars.map(v => v.name));
713
- for (const { pattern, name, category } of SINK_PATTERNS) {
714
- // Scan ALL matches using a global copy (original patterns are non-global)
715
- const globalPattern = new RegExp(pattern.source, 'g');
716
- let match;
717
- while ((match = globalPattern.exec(code)) !== null) {
718
- // Extract the argument region after the match
719
- const callStart = match.index + match[0].length;
720
- const parenDepth = findClosingParen(code, callStart);
721
- const argText = code.slice(callStart, parenDepth);
722
- // Check if any tainted variable is used in the arguments
723
- for (const tName of taintedNames) {
724
- if (new RegExp(`\\b${tName}\\b`).test(argText)) {
725
- sinks.push({ name, category, taintedArg: tName });
726
- break;
727
- }
728
- }
729
- // Also check for template literals with tainted vars in any sink category
730
- const templateMatch = argText.match(/`[^`]*\$\{(\w+)\}[^`]*`/);
731
- if (templateMatch && taintedNames.has(templateMatch[1])) {
732
- sinks.push({ name: `${name} (template)`, category, taintedArg: templateMatch[1] });
733
- }
734
- }
735
- }
736
- // Check template literals used in exec/spawn-like contexts
737
- const templateExecRegex = /`[^`]*\$\{(\w+)\}[^`]*`/g;
738
- let tm;
739
- while ((tm = templateExecRegex.exec(code)) !== null) {
740
- if (taintedNames.has(tm[1])) {
741
- // Check if this template is used as argument to a command-like function
742
- const before = code.slice(Math.max(0, tm.index - 50), tm.index);
743
- if (/exec\s*\(|spawn\s*\(|execSync\s*\(/.test(before)) {
744
- // Already caught by SINK_PATTERNS, skip duplicate
745
- continue;
746
- }
747
- }
748
- }
749
- return sinks;
750
- }
751
- /**
752
- * Build taint paths and check for sanitizers between source and sink.
753
- */
754
- function buildPaths(code, taintedVars, sinks) {
755
- const paths = [];
756
- const foundSanitizers = detectSanitizers(code);
757
- for (const sink of sinks) {
758
- // Find the source that produced this tainted arg
759
- const source = taintedVars.find(v => v.name === sink.taintedArg);
760
- if (!source)
761
- continue;
762
- // Check if any sanitizer was applied to this specific variable
763
- const sanitizer = foundSanitizers.find(s => new RegExp(`\\b${sink.taintedArg}\\b`).test(s.context) ||
764
- new RegExp(`${s.name}\\s*\\([^)]*\\b${sink.taintedArg}\\b`).test(code));
765
- // Check sanitizer sufficiency — is this the RIGHT sanitizer for this sink?
766
- const hasSanitizer = sanitizer != null;
767
- const sufficient = sanitizer != null ? isSanitizerSufficient(sanitizer.name, sink.category) : false;
768
- paths.push({
769
- source,
770
- sink,
771
- sanitized: hasSanitizer && sufficient,
772
- sanitizer: sanitizer?.name,
773
- insufficientSanitizer: hasSanitizer && !sufficient ? sanitizer.name : undefined,
774
- });
775
- }
776
- return paths;
777
- }
778
- function detectSanitizers(code) {
779
- const found = [];
780
- for (const { pattern, name } of SANITIZER_PATTERNS) {
781
- const globalPattern = new RegExp(pattern.source, 'g');
782
- let match;
783
- while ((match = globalPattern.exec(code)) !== null) {
784
- const start = Math.max(0, match.index - 50);
785
- const end = Math.min(code.length, match.index + match[0].length + 50);
786
- found.push({ name, context: code.slice(start, end) });
787
- }
788
- }
789
- return found;
790
- }
791
- function findClosingParen(code, start) {
792
- let depth = 1;
793
- for (let i = start; i < code.length; i++) {
794
- if (code[i] === '(')
795
- depth++;
796
- if (code[i] === ')') {
797
- depth--;
798
- if (depth === 0)
799
- return i;
800
- }
801
- }
802
- return Math.min(start + 500, code.length); // fallback
803
- }
804
- // ── Finding Generator ────────────────────────────────────────────────────
805
- /**
806
- * Convert taint results into ReviewFinding[] for the unified pipeline.
807
- */
808
- export function taintToFindings(results) {
809
- const findings = [];
810
- const categoryLabels = {
811
- command: 'command injection',
812
- fs: 'path traversal / file write',
813
- sql: 'SQL injection',
814
- redirect: 'open redirect',
815
- eval: 'code injection',
816
- template: 'template injection',
817
- };
818
- for (const r of results) {
819
- // Report unsanitized paths AND insufficient sanitizer paths
820
- const reportable = r.paths.filter(p => !p.sanitized);
821
- if (reportable.length === 0)
822
- continue;
823
- for (const path of reportable) {
824
- const severity = path.sink.category === 'command' || path.sink.category === 'eval'
825
- ? 'error'
826
- : 'warning';
827
- const primarySpan = {
828
- file: r.filePath,
829
- startLine: r.startLine,
830
- startCol: 1,
831
- endLine: r.startLine,
832
- endCol: 1,
833
- };
834
- if (path.insufficientSanitizer) {
835
- // Sanitizer present but wrong for this sink type
836
- findings.push({
837
- source: 'kern',
838
- ruleId: `taint-insufficient-sanitizer`,
839
- severity,
840
- category: 'bug',
841
- message: `Insufficient sanitizer: '${path.insufficientSanitizer}' does not protect against ${categoryLabels[path.sink.category]}. ` +
842
- `${path.source.origin} → ${path.sink.name}() is still exploitable.`,
843
- primarySpan,
844
- suggestion: `${path.insufficientSanitizer} is not sufficient for ${path.sink.category} sinks. ${getSuggestion(path.sink.category)}`,
845
- fingerprint: createFingerprint(`taint-insufficient`, r.startLine, 1),
846
- });
847
- }
848
- else {
849
- // No sanitizer at all
850
- findings.push({
851
- source: 'kern',
852
- ruleId: `taint-${path.sink.category}`,
853
- severity,
854
- category: 'bug',
855
- message: `Taint flow: ${path.source.origin} → ${path.sink.name}() — potential ${categoryLabels[path.sink.category]}. ` +
856
- `Variable '${path.sink.taintedArg}' reaches dangerous sink without sanitization.`,
857
- primarySpan,
858
- suggestion: getSuggestion(path.sink.category),
859
- fingerprint: createFingerprint(`taint-${path.sink.category}`, r.startLine, 1),
860
- });
861
- }
862
- }
863
- }
864
- return findings;
865
- }
866
- function getSuggestion(category) {
867
- switch (category) {
868
- case 'command': return 'Use spawn() with array arguments, or validate/escape input before passing to exec()';
869
- case 'fs': return 'Use path.resolve() + path.normalize() and verify the result stays within allowed directory';
870
- case 'sql': return 'Use parameterized queries ($1, ?) instead of string interpolation';
871
- case 'redirect': return 'Validate redirect URL against an allowlist of safe destinations';
872
- case 'eval': return 'Never pass user input to eval() or new Function() — use safe alternatives';
873
- case 'template': return 'Sanitize user input before embedding in templates';
874
- }
875
- }
876
- // ── Cross-File Taint Analysis ────────────────────────────────────────────
877
- /**
878
- * Build a map of exported functions across all files.
879
- * Maps "filePath::fnName" → ExportedFunction with sink info.
880
- */
881
- export function buildExportMap(inferredPerFile) {
882
- const exportMap = new Map();
883
- for (const [filePath, inferred] of inferredPerFile) {
884
- for (const r of inferred) {
885
- if (r.node.type !== 'fn')
886
- continue;
887
- const fnName = r.node.props?.name || '';
888
- if (!fnName)
889
- continue;
890
- // Check if function is exported (absence of export='false' means exported)
891
- const isExported = r.node.props?.export !== 'false';
892
- if (!isExported)
893
- continue;
894
- const params = r.node.props?.params || '';
895
- const handler = r.node.children?.find(c => c.type === 'handler');
896
- const code = handler?.props?.code || '';
897
- // Check if the function body contains dangerous sinks
898
- const sinks = [];
899
- if (code) {
900
- const dummyTaint = [];
901
- // Parse params to get variable names for sink detection
902
- const paramNames = params.split(',').map(p => p.trim().split(':')[0]?.trim()).filter(Boolean);
903
- for (const name of paramNames) {
904
- dummyTaint.push({ name, origin: `param:${name}` });
905
- }
906
- if (dummyTaint.length > 0) {
907
- sinks.push(...findTaintedSinks(code, dummyTaint));
908
- }
909
- }
910
- exportMap.set(`${filePath}::${fnName}`, {
911
- filePath,
912
- fnName,
913
- params,
914
- hasSink: sinks.length > 0,
915
- sinks,
916
- });
917
- }
918
- }
919
- return exportMap;
920
- }
921
- /**
922
- * Build import→function resolution map.
923
- * Maps "importingFile::importedName" → absolute file path of the definition.
924
- */
925
- export function buildImportMap(inferredPerFile, graphImports) {
926
- const importMap = new Map();
927
- for (const [filePath, inferred] of inferredPerFile) {
928
- const resolvedImports = graphImports.get(filePath) || [];
929
- for (const r of inferred) {
930
- if (r.node.type !== 'import')
931
- continue;
932
- const from = r.node.props?.from || '';
933
- const names = r.node.props?.names || '';
934
- const defaultImport = r.node.props?.default || '';
935
- if (!from)
936
- continue;
937
- // Find the resolved path for this import specifier
938
- const resolvedPath = resolvedImports.find(p => p.includes(from.replace(/^\.\//, '').replace(/\.(js|ts|tsx)$/, '')));
939
- if (!resolvedPath)
940
- continue;
941
- // Map each imported name to its resolved file
942
- if (names) {
943
- for (const name of names.split(',').map(n => n.trim())) {
944
- if (name)
945
- importMap.set(`${filePath}::${name}`, resolvedPath);
946
- }
947
- }
948
- if (defaultImport) {
949
- importMap.set(`${filePath}::${defaultImport}`, resolvedPath);
950
- }
951
- }
952
- }
953
- return importMap;
954
- }
955
- /**
956
- * Cross-file taint analysis.
957
- *
958
- * For each handler function with tainted params:
959
- * 1. Find calls to imported functions in the handler body
960
- * 2. Check if tainted data is passed as an argument
961
- * 3. Look up the target function — does it have a dangerous sink?
962
- * 4. If yes and no sanitizer in between → cross-file taint path
963
- */
964
- export function analyzeTaintCrossFile(inferredPerFile, graphImports) {
965
- const exportMap = buildExportMap(inferredPerFile);
966
- const importMap = buildImportMap(inferredPerFile, graphImports);
967
- const results = [];
968
- for (const [filePath, inferred] of inferredPerFile) {
969
- for (const r of inferred) {
970
- if (r.node.type !== 'fn')
971
- continue;
972
- const fnName = r.node.props?.name || 'anonymous';
973
- const paramsStr = r.node.props?.params || '';
974
- const handler = r.node.children?.find(c => c.type === 'handler');
975
- const code = handler?.props?.code || '';
976
- if (!code)
977
- continue;
978
- // Only analyze functions with tainted params
979
- const taintedParams = classifyParams(paramsStr);
980
- if (taintedParams.length === 0)
981
- continue;
982
- const taintedVars = propagateTaint(code, taintedParams);
983
- const taintedNames = new Set(taintedVars.map(v => v.name));
984
- // Find calls to imported functions: importedFn(taintedVar)
985
- const callRegex = /\b(\w+)\s*\(/g;
986
- let callMatch;
987
- while ((callMatch = callRegex.exec(code)) !== null) {
988
- const calledFn = callMatch[0].replace(/\s*\($/, '');
989
- // Is this an imported function?
990
- const resolvedFile = importMap.get(`${filePath}::${calledFn}`);
991
- if (!resolvedFile)
992
- continue;
993
- // Does the target have dangerous sinks?
994
- const targetFn = exportMap.get(`${resolvedFile}::${calledFn}`);
995
- if (!targetFn || !targetFn.hasSink)
996
- continue;
997
- // Extract arguments passed to this call
998
- const callStart = callMatch.index + callMatch[0].length;
999
- const parenEnd = findClosingParen(code, callStart);
1000
- const argText = code.slice(callStart, parenEnd);
1001
- // Check if any tainted variable is passed as argument
1002
- const taintedArgs = [];
1003
- for (const tName of taintedNames) {
1004
- if (new RegExp(`\\b${tName}\\b`).test(argText)) {
1005
- taintedArgs.push(tName);
1006
- }
1007
- }
1008
- if (taintedArgs.length === 0)
1009
- continue;
1010
- // Check for sanitizers between the taint and the call
1011
- const beforeCall = code.slice(0, callMatch.index);
1012
- const foundSanitizers = detectSanitizers(beforeCall);
1013
- const hasSanitizer = taintedArgs.some(arg => foundSanitizers.some(s => new RegExp(`\\b${arg}\\b`).test(s.context)));
1014
- if (hasSanitizer)
1015
- continue; // Sanitized before passing to callee
1016
- // Found cross-file taint path
1017
- for (const sink of targetFn.sinks) {
1018
- const source = taintedVars.find(v => taintedArgs.includes(v.name));
1019
- if (!source)
1020
- continue;
1021
- results.push({
1022
- callerFile: filePath,
1023
- callerFn: fnName,
1024
- callerLine: r.startLine,
1025
- calleeFile: resolvedFile,
1026
- calleeFn: calledFn,
1027
- taintedArgs,
1028
- sinkInCallee: sink,
1029
- source,
1030
- });
1031
- }
1032
- }
1033
- }
1034
- }
1035
- return results;
1036
- }
1037
- /**
1038
- * Convert cross-file taint results into ReviewFinding[].
1039
- */
1040
- export function crossFileTaintToFindings(results) {
1041
- const findings = [];
1042
- const categoryLabels = {
1043
- command: 'command injection',
1044
- fs: 'path traversal / file write',
1045
- sql: 'SQL injection',
1046
- redirect: 'open redirect',
1047
- eval: 'code injection',
1048
- template: 'template injection',
1049
- };
1050
- for (const r of results) {
1051
- const severity = r.sinkInCallee.category === 'command' || r.sinkInCallee.category === 'eval'
1052
- ? 'error'
1053
- : 'warning';
1054
- findings.push({
1055
- source: 'kern',
1056
- ruleId: `taint-crossfile-${r.sinkInCallee.category}`,
1057
- severity,
1058
- category: 'bug',
1059
- message: `Cross-file taint: ${r.source.origin} in ${r.callerFn}() → ${r.calleeFn}() → ${r.sinkInCallee.name}(). ` +
1060
- `Tainted data crosses file boundary to reach ${categoryLabels[r.sinkInCallee.category]} sink.`,
1061
- primarySpan: {
1062
- file: r.callerFile,
1063
- startLine: r.callerLine,
1064
- startCol: 1,
1065
- endLine: r.callerLine,
1066
- endCol: 1,
1067
- },
1068
- relatedSpans: [{
1069
- file: r.calleeFile,
1070
- startLine: 1,
1071
- startCol: 1,
1072
- endLine: 1,
1073
- endCol: 1,
1074
- }],
1075
- suggestion: `Validate '${r.taintedArgs.join(', ')}' before passing to ${r.calleeFn}(). ${getSuggestion(r.sinkInCallee.category)}`,
1076
- fingerprint: createFingerprint(`taint-xfile-${r.sinkInCallee.category}`, r.callerLine, 1),
1077
- });
1078
- }
1079
- return findings;
1080
- }
1081
30
  //# sourceMappingURL=taint.js.map