code-auditor-mcp 3.4.12 → 3.4.13

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 (245) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/analyzers/__verify_self_probe.d.ts +2 -0
  3. package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
  4. package/dist/analyzers/__verify_self_probe.js +5 -0
  5. package/dist/analyzers/__verify_self_probe.js.map +1 -0
  6. package/dist/analyzers/analyzerUtils.d.ts +34 -1
  7. package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
  8. package/dist/analyzers/analyzerUtils.js +61 -22
  9. package/dist/analyzers/analyzerUtils.js.map +1 -1
  10. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
  11. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
  12. package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
  13. package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
  14. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
  15. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
  16. package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
  17. package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
  18. package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
  19. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  20. package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
  21. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  22. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
  23. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
  24. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
  25. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
  26. package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
  27. package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
  28. package/dist/analyzers/documentationAnalyzer.js +259 -198
  29. package/dist/analyzers/documentationAnalyzer.js.map +1 -1
  30. package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
  31. package/dist/analyzers/invariantsAnalyzer.js +57 -50
  32. package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
  33. package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
  34. package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
  35. package/dist/analyzers/orm/adapterRegistry.js +10 -0
  36. package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
  37. package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
  38. package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
  39. package/dist/analyzers/orm/drizzleAdapter.js +42 -44
  40. package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
  41. package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
  42. package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
  43. package/dist/analyzers/orm/prismaAdapter.js +30 -0
  44. package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
  45. package/dist/analyzers/provenance.d.ts +62 -2
  46. package/dist/analyzers/provenance.d.ts.map +1 -1
  47. package/dist/analyzers/provenance.js +443 -349
  48. package/dist/analyzers/provenance.js.map +1 -1
  49. package/dist/analyzers/reactAnalyzer.d.ts +11 -0
  50. package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
  51. package/dist/analyzers/reactAnalyzer.js +188 -125
  52. package/dist/analyzers/reactAnalyzer.js.map +1 -1
  53. package/dist/analyzers/ruleRegistry.d.ts +110 -1
  54. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  55. package/dist/analyzers/ruleRegistry.js +1714 -124
  56. package/dist/analyzers/ruleRegistry.js.map +1 -1
  57. package/dist/analyzers/ruleTiming.d.ts +54 -0
  58. package/dist/analyzers/ruleTiming.d.ts.map +1 -0
  59. package/dist/analyzers/ruleTiming.js +71 -0
  60. package/dist/analyzers/ruleTiming.js.map +1 -0
  61. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
  62. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
  63. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
  64. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
  65. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
  66. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
  67. package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
  68. package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
  69. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +17 -202
  70. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  71. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1073 -1121
  72. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  73. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
  74. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
  75. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
  76. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
  77. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
  78. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
  79. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
  80. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  81. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -214
  82. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
  83. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1521
  84. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
  85. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
  86. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  87. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -480
  88. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  89. package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
  90. package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
  91. package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
  92. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
  93. package/dist/analyzers/universal/schema/config.d.ts +31 -0
  94. package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
  95. package/dist/analyzers/universal/schema/config.js +57 -0
  96. package/dist/analyzers/universal/schema/config.js.map +1 -0
  97. package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
  98. package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
  99. package/dist/analyzers/universal/schema/discovery.js +616 -0
  100. package/dist/analyzers/universal/schema/discovery.js.map +1 -0
  101. package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
  102. package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
  103. package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
  104. package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
  105. package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
  106. package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
  107. package/dist/analyzers/universal/schema/migrations.js +238 -0
  108. package/dist/analyzers/universal/schema/migrations.js.map +1 -0
  109. package/dist/analyzers/universal/schema/types.d.ts +131 -0
  110. package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
  111. package/dist/analyzers/universal/schema/types.js +10 -0
  112. package/dist/analyzers/universal/schema/types.js.map +1 -0
  113. package/dist/analyzers/universal/schema/violations.d.ts +44 -0
  114. package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
  115. package/dist/analyzers/universal/schema/violations.js +54 -0
  116. package/dist/analyzers/universal/schema/violations.js.map +1 -0
  117. package/dist/auditRunner.d.ts.map +1 -1
  118. package/dist/auditRunner.js +89 -13
  119. package/dist/auditRunner.js.map +1 -1
  120. package/dist/cli.js +315 -40
  121. package/dist/cli.js.map +1 -1
  122. package/dist/config/configLoader.d.ts.map +1 -1
  123. package/dist/config/configLoader.js +9 -7
  124. package/dist/config/configLoader.js.map +1 -1
  125. package/dist/config/defaults.d.ts +12 -3
  126. package/dist/config/defaults.d.ts.map +1 -1
  127. package/dist/config/defaults.js +15 -5
  128. package/dist/config/defaults.js.map +1 -1
  129. package/dist/config/effectiveConfig.d.ts +75 -0
  130. package/dist/config/effectiveConfig.d.ts.map +1 -0
  131. package/dist/config/effectiveConfig.js +213 -0
  132. package/dist/config/effectiveConfig.js.map +1 -0
  133. package/dist/config/pathProfiles.d.ts +7 -4
  134. package/dist/config/pathProfiles.d.ts.map +1 -1
  135. package/dist/config/pathProfiles.js +8 -10
  136. package/dist/config/pathProfiles.js.map +1 -1
  137. package/dist/config/thresholdRationales.d.ts +45 -0
  138. package/dist/config/thresholdRationales.d.ts.map +1 -0
  139. package/dist/config/thresholdRationales.js +67 -0
  140. package/dist/config/thresholdRationales.js.map +1 -0
  141. package/dist/conventions/conventionMiner.d.ts.map +1 -1
  142. package/dist/conventions/conventionMiner.js +6 -2
  143. package/dist/conventions/conventionMiner.js.map +1 -1
  144. package/dist/enforcement/diffGate.d.ts +55 -0
  145. package/dist/enforcement/diffGate.d.ts.map +1 -0
  146. package/dist/enforcement/diffGate.js +136 -0
  147. package/dist/enforcement/diffGate.js.map +1 -0
  148. package/dist/enforcement/gate.d.ts +36 -0
  149. package/dist/enforcement/gate.d.ts.map +1 -0
  150. package/dist/enforcement/gate.js +72 -0
  151. package/dist/enforcement/gate.js.map +1 -0
  152. package/dist/enforcement/suppressions.d.ts +41 -0
  153. package/dist/enforcement/suppressions.d.ts.map +1 -0
  154. package/dist/enforcement/suppressions.js +170 -0
  155. package/dist/enforcement/suppressions.js.map +1 -0
  156. package/dist/fingerprint.d.ts.map +1 -1
  157. package/dist/fingerprint.js +4 -1
  158. package/dist/fingerprint.js.map +1 -1
  159. package/dist/invariants/ruleEngine.d.ts +3 -4
  160. package/dist/invariants/ruleEngine.d.ts.map +1 -1
  161. package/dist/invariants/ruleEngine.js +2 -2
  162. package/dist/invariants/ruleEngine.js.map +1 -1
  163. package/dist/languages/LanguageOrchestrator.d.ts +14 -0
  164. package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
  165. package/dist/languages/LanguageOrchestrator.js +93 -55
  166. package/dist/languages/LanguageOrchestrator.js.map +1 -1
  167. package/dist/languages/LanguageRegistry.d.ts +8 -0
  168. package/dist/languages/LanguageRegistry.d.ts.map +1 -1
  169. package/dist/languages/LanguageRegistry.js +8 -0
  170. package/dist/languages/LanguageRegistry.js.map +1 -1
  171. package/dist/languages/RuntimeManager.d.ts +82 -60
  172. package/dist/languages/RuntimeManager.d.ts.map +1 -1
  173. package/dist/languages/RuntimeManager.js +404 -349
  174. package/dist/languages/RuntimeManager.js.map +1 -1
  175. package/dist/languages/UniversalAnalyzer.d.ts +51 -6
  176. package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
  177. package/dist/languages/UniversalAnalyzer.js +127 -88
  178. package/dist/languages/UniversalAnalyzer.js.map +1 -1
  179. package/dist/languages/adapterBridge.d.ts +36 -0
  180. package/dist/languages/adapterBridge.d.ts.map +1 -1
  181. package/dist/languages/adapterBridge.js +89 -35
  182. package/dist/languages/adapterBridge.js.map +1 -1
  183. package/dist/languages/go/GoAdapter.d.ts +66 -23
  184. package/dist/languages/go/GoAdapter.d.ts.map +1 -1
  185. package/dist/languages/go/GoAdapter.js +553 -474
  186. package/dist/languages/go/GoAdapter.js.map +1 -1
  187. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +33 -4
  188. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
  189. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +101 -66
  190. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
  191. package/dist/languages/tree-sitter/converter.d.ts +18 -0
  192. package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
  193. package/dist/languages/tree-sitter/converter.js +18 -0
  194. package/dist/languages/tree-sitter/converter.js.map +1 -1
  195. package/dist/languages/tree-sitter/parser.d.ts +39 -2
  196. package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
  197. package/dist/languages/tree-sitter/parser.js +93 -12
  198. package/dist/languages/tree-sitter/parser.js.map +1 -1
  199. package/dist/languages/types.d.ts +67 -2
  200. package/dist/languages/types.d.ts.map +1 -1
  201. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -72
  202. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
  203. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1419 -923
  204. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
  205. package/dist/mcp-tools-shared.d.ts.map +1 -1
  206. package/dist/mcp-tools-shared.js.map +1 -1
  207. package/dist/pipeline.d.ts +13 -5
  208. package/dist/pipeline.d.ts.map +1 -1
  209. package/dist/pipeline.js +381 -200
  210. package/dist/pipeline.js.map +1 -1
  211. package/dist/pipelineAdapters.d.ts +3 -8
  212. package/dist/pipelineAdapters.d.ts.map +1 -1
  213. package/dist/pipelineAdapters.js +149 -94
  214. package/dist/pipelineAdapters.js.map +1 -1
  215. package/dist/presets/presets.d.ts +54 -0
  216. package/dist/presets/presets.d.ts.map +1 -0
  217. package/dist/presets/presets.js +181 -0
  218. package/dist/presets/presets.js.map +1 -0
  219. package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
  220. package/dist/reporting/sarifReportGenerator.js +0 -2
  221. package/dist/reporting/sarifReportGenerator.js.map +1 -1
  222. package/dist/ruleAliases.d.ts +67 -0
  223. package/dist/ruleAliases.d.ts.map +1 -0
  224. package/dist/ruleAliases.js +79 -0
  225. package/dist/ruleAliases.js.map +1 -0
  226. package/dist/styles/cssAstExtractor.d.ts.map +1 -1
  227. package/dist/styles/cssAstExtractor.js +78 -18
  228. package/dist/styles/cssAstExtractor.js.map +1 -1
  229. package/dist/styles/styleIndexer.js +10 -1
  230. package/dist/styles/styleIndexer.js.map +1 -1
  231. package/dist/styles/tailwindConfigLoader.d.ts +8 -6
  232. package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
  233. package/dist/styles/tailwindConfigLoader.js +61 -15
  234. package/dist/styles/tailwindConfigLoader.js.map +1 -1
  235. package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
  236. package/dist/styles/tailwindUtilityExpander.js +13 -1
  237. package/dist/styles/tailwindUtilityExpander.js.map +1 -1
  238. package/dist/types.d.ts +181 -1
  239. package/dist/types.d.ts.map +1 -1
  240. package/dist/types.js +8 -0
  241. package/dist/types.js.map +1 -1
  242. package/package.json +5 -3
  243. package/plugin/hooks/hooks.json +1 -1
  244. package/plugin/scripts/hook-audit.sh +25 -7
  245. package/plugin/skills/code-auditor/SKILL.md +5 -5
@@ -4,8 +4,22 @@
4
4
  * Analyzes database access patterns and data layer interactions
5
5
  */
6
6
  import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
7
+ import { withRuleTiming } from '../ruleTiming.js';
7
8
  import { buildProvenanceContext, isDBProvenanced, DB_CALL_METHODS, } from '../provenance.js';
8
- import { DEFAULT_SCHEMA_CONFIG } from './UniversalSchemaAnalyzer.js';
9
+ import { DB_RECEIVER_NAMES, DB_CALL_METHOD_NAMES, DB_BINDING_NAMES, DB_WRAPPER_NAMES, } from './UniversalSchemaAnalyzer.js';
10
+ /**
11
+ * SQL keywords recognized as evidence that a string is a SQL query.
12
+ * Includes DML verbs (SELECT/INSERT/UPDATE/DELETE) and DDL verbs
13
+ * (CREATE/DROP/ALTER/TRUNCATE) so DDL injection — e.g. a raw schema
14
+ * migration built by concatenating an interpolated identifier — is
15
+ * recognized as SQL rather than silently passing the keyword gate.
16
+ * Single source of truth for both containsSQLKeywords and
17
+ * containsSQLStructure to prevent drift.
18
+ */
19
+ const SQL_KEYWORDS = [
20
+ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'JOIN',
21
+ 'CREATE', 'DROP', 'ALTER', 'TRUNCATE',
22
+ ];
9
23
  export const DEFAULT_DATA_ACCESS_CONFIG = {
10
24
  checkOrgFilters: true,
11
25
  checkSQLInjection: true,
@@ -56,1203 +70,1141 @@ export const DEFAULT_DATA_ACCESS_CONFIG = {
56
70
  // DB wrapper functions that accept (sql, params) — same as .prepare().bind()
57
71
  // but expressed as a simple function call. Must match the provenance system's
58
72
  // dbWrapperNames so the FP guards see the same capability list the detector does.
59
- dbWrapperNames: ['d1Query', 'd1Exec'],
73
+ dbWrapperNames: [...DB_WRAPPER_NAMES],
74
+ // DB detection patterns — values shared with the schema analyzer, but owned by
75
+ // THIS analyzer's namespace (no cross-analyzer fallback in analyzeAST).
76
+ dbReceiverNames: [...DB_RECEIVER_NAMES],
77
+ dbCallMethods: [...DB_CALL_METHOD_NAMES],
78
+ dbBindingNames: [...DB_BINDING_NAMES],
79
+ detection: { mode: 'hybrid' },
60
80
  // SQL sanitizer functions — interpolation via escapeSql(x) is not raw.
61
81
  sanitizerNames: ['escapeSql'],
62
82
  };
63
- // ── Diagnostic infrastructure (one-time v3.4.12 adjuciation) ──────────────
64
- export class UniversalDataAccessAnalyzer extends UniversalAnalyzer {
65
- name = 'data-access';
66
- description = 'Analyzes database access patterns and data layer interactions';
67
- category = 'security';
68
- async analyzeAST(ast, adapter, config, sourceCode) {
69
- const violations = [];
70
- const finalConfig = { ...DEFAULT_DATA_ACCESS_CONFIG, ...config };
71
- // Spec 21: Build provenance context for this file (R1 — provenance-primary detection)
72
- const detectionMode = config.detection?.mode ?? 'hybrid';
73
- const schemaConfig = config;
74
- const p0 = performance.now();
75
- const provenanceContext = buildProvenanceContext(ast, adapter, sourceCode, {
76
- mode: detectionMode,
77
- // Single source of truth for DB detection patterns — see DEFAULT_SCHEMA_CONFIG.
78
- dbReceiverNames: schemaConfig.dbReceiverNames ?? DEFAULT_SCHEMA_CONFIG.dbReceiverNames,
79
- dbBindingNames: schemaConfig.dbBindingNames ?? DEFAULT_SCHEMA_CONFIG.dbBindingNames,
80
- dbCallMethods: schemaConfig.dbCallMethods ?? DEFAULT_SCHEMA_CONFIG.dbCallMethods,
81
- dbWrapperNames: schemaConfig.dbWrapperNames ?? DEFAULT_SCHEMA_CONFIG.dbWrapperNames,
83
+ /**
84
+ * Build a violation attributed to this analyzer's fixed name. Replaces the
85
+ * base-class `createViolation` so every helper can be a module-level free
86
+ * function (which keeps the analyzer class a thin orchestrator and collapses
87
+ * its class-size / cyclomatic-complexity findings).
88
+ */
89
+ function makeViolation(file, location, message, classification) {
90
+ const v = {
91
+ file,
92
+ line: location.line,
93
+ column: location.column,
94
+ severity: classification.severity,
95
+ message,
96
+ rule: classification.rule,
97
+ analyzer: 'data-access'
98
+ };
99
+ if (classification.symbol)
100
+ v.functionName = classification.symbol;
101
+ if (classification.resolution)
102
+ v.resolution = classification.resolution;
103
+ return v;
104
+ }
105
+ /**
106
+ * Map imports to database types
107
+ */
108
+ function mapDatabaseImports(imports, config) {
109
+ const dbImports = new Map();
110
+ Object.entries(config.databases || {}).forEach(([dbType, dbConfig]) => {
111
+ const hasImports = imports.some(imp => dbConfig.importPatterns.some(pattern => imp.source.includes(pattern)));
112
+ dbImports.set(dbType, {
113
+ hasImports,
114
+ patterns: [...dbConfig.queryPatterns, ...(dbConfig.ormPatterns || [])]
82
115
  });
83
- const timingAcc = schemaConfig._provenanceTiming;
84
- if (timingAcc)
85
- timingAcc.totalMs += performance.now() - p0;
86
- // Check imports for database libraries
87
- const imports = adapter.extractImports(ast);
88
- const dbImports = this.mapDatabaseImports(imports, finalConfig);
89
- // Find database calls (Spec 21: uses provenance context)
90
- const calls = this.extractDatabaseCalls(ast, adapter, sourceCode, dbImports, finalConfig, provenanceContext);
91
- // Analyze each call — track symbol ordinals for fingerprint stability
92
- const symbolOrdinals = new Map();
93
- for (const call of calls) {
94
- const analysis = this.analyzeQuery(call, sourceCode, finalConfig);
95
- // Check for violations
96
- violations.push(...this.checkViolations(call, analysis, ast.filePath, finalConfig, symbolOrdinals));
116
+ });
117
+ return dbImports;
118
+ }
119
+ /**
120
+ * Predicate for the node-discovery pass of extractDatabaseCalls. A node is a
121
+ * candidate when it is a DB-provenanced function call, a template literal in a
122
+ * DB-provenanced call's arguments, or a variable assignment holding SQL-shaped
123
+ * text (Spec 17 R2 content scanning is removed in favour of provenance).
124
+ */
125
+ function isDbCallCandidate(node, adapter, sourceCode, provenanceContext) {
126
+ const nodeText = adapter.getNodeText(node, sourceCode);
127
+ // Check if it's a function call whose callee is DB-related
128
+ if (isFunctionCall(node, adapter)) {
129
+ // Spec 21: Use provenance when available, fall back to name-based check
130
+ if (provenanceContext) {
131
+ if (isDBProvenanced(node, { adapter, sourceCode, context: provenanceContext, methods: DB_CALL_METHODS })) {
132
+ return true;
133
+ }
97
134
  }
98
- // R4.1: Check for database queries inside loops (N+1 detection, Spec 21: provenance-gated)
99
- violations.push(...this.checkLoopQueries(ast, adapter, sourceCode, finalConfig, provenanceContext));
100
- // Check for general data access patterns
101
- violations.push(...this.checkGeneralPatterns(ast, adapter, sourceCode, finalConfig));
102
- return violations;
103
135
  }
104
- /**
105
- * Map imports to database types
106
- */
107
- mapDatabaseImports(imports, config) {
108
- const dbImports = new Map();
109
- Object.entries(config.databases || {}).forEach(([dbType, dbConfig]) => {
110
- const hasImports = imports.some(imp => dbConfig.importPatterns.some(pattern => imp.source.includes(pattern)));
111
- dbImports.set(dbType, {
112
- hasImports,
113
- patterns: [...dbConfig.queryPatterns, ...(dbConfig.ormPatterns || [])]
114
- });
115
- });
116
- return dbImports;
136
+ // Spec 17 R2: Template literals are SQL candidates because of where they sit
137
+ // (DB-provenanced call arguments), not what their body contains.
138
+ if (isTemplateLiteral(node, adapter)) {
139
+ return isTemplateInDBProvenancedCall(node, adapter, sourceCode, provenanceContext);
117
140
  }
118
- /**
119
- * Extract database calls from AST
120
- */
121
- extractDatabaseCalls(ast, adapter, sourceCode, dbImports, config, provenanceContext) {
122
- const calls = [];
123
- // Find all relevant nodes
124
- const allNodes = adapter.findNodes(ast, {
125
- custom: (node) => {
126
- const nodeText = adapter.getNodeText(node, sourceCode);
127
- // Check if it's a function call whose callee is DB-related
128
- if (this.isFunctionCall(node, adapter)) {
129
- // Spec 21: Use provenance when available, fall back to name-based check
130
- if (provenanceContext) {
131
- if (isDBProvenanced(node, adapter, sourceCode, provenanceContext, DB_CALL_METHODS)) {
132
- return true;
133
- }
134
- }
135
- }
136
- // Spec 17 R2: Template literals are SQL candidates because of
137
- // where they sit (DB-provenanced call arguments), not what their
138
- // body contains. Content scanning with substring matching is removed.
139
- if (this.isTemplateLiteral(node, adapter)) {
140
- const parent = adapter.getParent(node);
141
- // Only capture template literals in call arguments — those are
142
- // the ones the provenance gate decides on.
143
- if (parent && adapter.getNodeType(parent) === 'arguments') {
144
- return this.isTemplateInDBProvenancedCall(node, adapter, sourceCode, provenanceContext);
145
- }
146
- return this.isTemplateInDBProvenancedCall(node, adapter, sourceCode, provenanceContext);
147
- }
148
- // Variable assignment with SQL structure — no child template check:
149
- // the template-literal path is now provenance-only (Spec 17 R2), so
150
- // there is no overlap risk from child template detection.
151
- if (this.isVariableAssignment(node, adapter)) {
152
- return this.containsSQLStructure(nodeText);
153
- }
154
- return false;
155
- }
156
- });
157
- // Deduplicate by line, preferring the most specific node
158
- const nodesByLine = new Map();
159
- allNodes.forEach(node => {
160
- const line = node.location.start.line;
161
- if (!nodesByLine.has(line)) {
162
- nodesByLine.set(line, []);
163
- }
164
- nodesByLine.get(line).push(node);
165
- });
166
- // For each line, pick the most specific node
167
- const uniqueNodes = [];
168
- nodesByLine.forEach(nodes => {
169
- if (nodes.length === 1) {
170
- uniqueNodes.push(nodes[0]);
141
+ // Variable assignment with SQL structure — no child template check: the
142
+ // template-literal path is now provenance-only (Spec 17 R2), so there is no
143
+ // overlap risk from child template detection.
144
+ if (isVariableAssignment(node, adapter)) {
145
+ return containsSQLStructure(nodeText);
146
+ }
147
+ return false;
148
+ }
149
+ /**
150
+ * Deduplicate discovered candidate nodes by line, preferring the most specific
151
+ * node (a template literal over a variable declaration) when several share a line.
152
+ */
153
+ function dedupeCandidateNodes(nodes, adapter) {
154
+ const nodesByLine = new Map();
155
+ nodes.forEach(node => {
156
+ const line = node.location.start.line;
157
+ if (!nodesByLine.has(line)) {
158
+ nodesByLine.set(line, []);
159
+ }
160
+ nodesByLine.get(line).push(node);
161
+ });
162
+ const uniqueNodes = [];
163
+ nodesByLine.forEach(nodesOnLine => {
164
+ if (nodesOnLine.length === 1) {
165
+ uniqueNodes.push(nodesOnLine[0]);
166
+ }
167
+ else {
168
+ // Prefer template literals over variable declarations
169
+ const templateLiteral = nodesOnLine.find(n => isTemplateLiteral(n, adapter));
170
+ if (templateLiteral) {
171
+ uniqueNodes.push(templateLiteral);
171
172
  }
172
173
  else {
173
- // Prefer template literals over variable declarations
174
- const templateLiteral = nodes.find(n => this.isTemplateLiteral(n, adapter));
175
- if (templateLiteral) {
176
- uniqueNodes.push(templateLiteral);
177
- }
178
- else {
179
- // Otherwise take the first one
180
- uniqueNodes.push(nodes[0]);
181
- }
182
- }
183
- });
184
- for (const node of uniqueNodes) {
185
- const nodeText = adapter.getNodeText(node, sourceCode);
186
- // Skip if the node text is too short or doesn't contain meaningful content
187
- if (!nodeText || nodeText.trim().length < 10)
188
- continue;
189
- // When a call_expression like db.prepare(\`...\`) spans multiple lines,
190
- // findNodes discovers both the call_expression (via path 1) and the
191
- // template_string inside its arguments (via path 2). The template string
192
- // is the more precise target for injection checks, and isDynamicString-
193
- // Construction on a call_expression delegates to its template arguments
194
- // anyway. Skip the call_expression here so we don't double-report the
195
- // same injection risk.
196
- if (this.isFunctionCall(node, adapter)) {
197
- const args = adapter.getChildren(node).find(c => adapter.getNodeType(c) === 'arguments');
198
- if (args) {
199
- const hasTemplate = adapter.getChildren(args).some(c => this.isTemplateLiteral(c, adapter));
200
- if (hasTemplate)
201
- continue;
202
- }
203
- }
204
- // Determine if this is a database-related call
205
- const isSqlQuery = this.containsSQLKeywords(nodeText);
206
- const isOrmCall = this.isOrmPattern(nodeText);
207
- if (isSqlQuery || isOrmCall) {
208
- const tables = this.extractTables(nodeText, config);
209
- const hasOrgFilter = this.hasOrganizationFilter(nodeText, config);
210
- const security = this.checkQuerySecurity(node, nodeText, ast, adapter, sourceCode, config);
211
- // Determine the type based on imports or patterns
212
- let callType = 'unknown';
213
- if (isSqlQuery) {
214
- callType = 'sql';
215
- }
216
- else if (isOrmCall) {
217
- // Check which ORM based on imports
218
- for (const [dbType, importInfo] of dbImports) {
219
- if (importInfo.hasImports) {
220
- callType = dbType;
221
- break;
222
- }
223
- }
224
- }
225
- calls.push({
226
- type: callType,
227
- method: this.extractMethodName(node, adapter, sourceCode),
228
- file: ast.filePath,
229
- line: node.location.start.line,
230
- column: node.location.start.column,
231
- tables,
232
- hasOrganizationFilter: hasOrgFilter,
233
- hasParameterizedQuery: security.parameterized,
234
- hasSqlInjectionRisk: security.injectionRisk,
235
- enclosingFunction: this.findEnclosingFunctionName(node, adapter),
236
- });
174
+ uniqueNodes.push(nodesOnLine[0]);
237
175
  }
238
176
  }
239
- return calls;
177
+ });
178
+ return uniqueNodes;
179
+ }
180
+ /**
181
+ * Classify a single candidate node into a DatabaseCall, or null when it does not
182
+ * look like a DB-related query.
183
+ */
184
+ function buildDatabaseCall(node, ast, scan) {
185
+ const { adapter, sourceCode, dbImports, config } = scan;
186
+ const nodeText = adapter.getNodeText(node, sourceCode);
187
+ if (!nodeText || nodeText.trim().length < 10)
188
+ return null;
189
+ // Skip when a call_expression like db.prepare(`...`) is rediscovered via
190
+ // its template argument (path 2) — the template string is the precise target.
191
+ if (shouldSkipCallForTemplateArg(node, adapter))
192
+ return null;
193
+ const isSqlQuery = containsSQLKeywords(nodeText);
194
+ const isOrmCall = isOrmPattern(nodeText);
195
+ if (!isSqlQuery && !isOrmCall)
196
+ return null;
197
+ const tables = extractTables(nodeText, config);
198
+ const hasOrgFilter = hasOrganizationFilter(nodeText, config);
199
+ const security = withRuleTiming('sql-injection-risk', () => checkQuerySecurity(node, nodeText, ast, scan));
200
+ let callType = 'unknown';
201
+ if (isSqlQuery) {
202
+ callType = 'sql';
240
203
  }
241
- /**
242
- * Analyze a database query
243
- */
244
- analyzeQuery(call, sourceCode, config) {
245
- const hasJoins = call.tables.length > 1;
246
- const hasSubquery = sourceCode.includes('SELECT') && sourceCode.includes('FROM') &&
247
- sourceCode.lastIndexOf('SELECT') !== sourceCode.indexOf('SELECT');
248
- let complexity = 'simple';
249
- if (hasSubquery || call.tables.length > 3) {
250
- complexity = 'complex';
251
- }
252
- else if (hasJoins || call.tables.length > 1) {
253
- complexity = 'moderate';
254
- }
255
- let performanceRisk = 'low';
256
- if (call.tables.length > (config.performanceThresholds?.joinedTableCount || 4)) {
257
- performanceRisk = 'high';
258
- }
259
- else if (!call.hasOrganizationFilter && call.tables.length > 0) {
260
- performanceRisk = 'medium';
204
+ else if (isOrmCall) {
205
+ for (const [dbType, importInfo] of dbImports) {
206
+ if (importInfo.hasImports) {
207
+ callType = dbType;
208
+ break;
209
+ }
261
210
  }
262
- return {
263
- complexity,
264
- tables: call.tables,
265
- hasJoins,
266
- hasSubquery,
267
- hasOrganizationFilter: call.hasOrganizationFilter,
268
- performanceRisk
269
- };
270
211
  }
271
- /**
272
- * Check for violations in a database call
273
- */
274
- checkViolations(call, analysis, filePath, config, symbolOrdinals) {
275
- const violations = [];
276
- // Build a stable base symbol key — enclosing function + callee method.
277
- // Ordinal added for genuine repeats within the same function.
278
- const fnName = call.enclosingFunction ?? 'top-level';
279
- const baseSymbol = `${fnName}:${call.method}`;
280
- const ordinal = (symbolOrdinals.get(baseSymbol) ?? 0) + 1;
281
- symbolOrdinals.set(baseSymbol, ordinal);
282
- const symbol = ordinal > 1 ? `${baseSymbol}:${ordinal}` : baseSymbol;
283
- // Security: SQL Injection Risk
284
- // Spec 11 (calibration): Detection uses AST-level heuristics (string
285
- // concatenation in query construction) without type information. Findings
286
- // are high-signal but not proof of exploitable injection. Severity demoted
287
- // from critical → warning in Spec 17. Spec 11 will measure true-positive
288
- // rate on ExcAlDraw and Gin corpora to decide whether heuristics should be
289
- // tightened or severity re-escalated.
290
- if (config.checkSQLInjection && call.hasSqlInjectionRisk) {
291
- violations.push(this.createViolation(filePath, { line: call.line, column: call.column }, `Potential SQL injection risk in ${call.method}. Use parameterized queries.`, 'suggestion', 'sql-injection-risk', undefined, symbol));
292
- }
293
- // Security: Missing Organization Filter
294
- if (config.checkOrgFilters && !call.hasOrganizationFilter && call.tables.length > 0 && this.requiresOrgFilter(call.tables, config)) {
295
- violations.push(this.createViolation(filePath, { line: call.line, column: call.column }, `Query on ${call.tables.join(', ')} missing organization/tenant filter`, 'warning', 'missing-org-filter', undefined, symbol));
296
- }
297
- // Performance: Complex Query
298
- if (analysis.performanceRisk === 'high') {
299
- violations.push(this.createViolation(filePath, { line: call.line, column: call.column }, `Complex query with ${call.tables.length} tables may have performance issues`, 'warning', 'complex-query', undefined, symbol));
300
- }
301
- // Performance: Unfiltered Query
302
- if (!call.hasOrganizationFilter && analysis.performanceRisk === 'medium') {
303
- violations.push(this.createViolation(filePath, { line: call.line, column: call.column }, `Unfiltered query on ${call.tables.join(', ')} may cause performance issues`, 'suggestion', 'unfiltered-query', undefined, symbol));
304
- }
305
- return violations;
212
+ return {
213
+ type: callType,
214
+ method: extractMethodName(node, adapter, sourceCode),
215
+ file: ast.filePath,
216
+ line: node.location.start.line,
217
+ column: node.location.start.column,
218
+ tables,
219
+ hasOrganizationFilter: hasOrgFilter,
220
+ hasParameterizedQuery: security.parameterized,
221
+ hasSqlInjectionRisk: security.injectionRisk,
222
+ enclosingFunction: findEnclosingFunctionName(node, adapter),
223
+ };
224
+ }
225
+ /**
226
+ * True when a call_expression node carries a template-literal argument and so
227
+ * should be skipped in favour of the template string (path 2) itself.
228
+ */
229
+ function shouldSkipCallForTemplateArg(node, adapter) {
230
+ if (!isFunctionCall(node, adapter))
231
+ return false;
232
+ const args = adapter.getChildren(node).find(c => adapter.getNodeType(c) === 'arguments');
233
+ if (!args)
234
+ return false;
235
+ return adapter.getChildren(args).some(c => isTemplateLiteral(c, adapter));
236
+ }
237
+ /**
238
+ * Extract database calls from AST
239
+ */
240
+ function extractDatabaseCalls(ast, scan) {
241
+ const { adapter, sourceCode, provenanceContext } = scan;
242
+ const allNodes = adapter.findNodes(ast, {
243
+ custom: (node) => isDbCallCandidate(node, adapter, sourceCode, provenanceContext),
244
+ });
245
+ const uniqueNodes = dedupeCandidateNodes(allNodes, adapter);
246
+ const calls = [];
247
+ for (const node of uniqueNodes) {
248
+ const call = buildDatabaseCall(node, ast, scan);
249
+ if (call)
250
+ calls.push(call);
306
251
  }
307
- /**
308
- * Check general data access patterns
309
- */
310
- checkGeneralPatterns(ast, adapter, sourceCode, config) {
311
- const violations = [];
312
- // Check for hardcoded connection strings
313
- const stringNodes = adapter.findNodes(ast, {
314
- custom: (node) => this.isStringLiteral(node, adapter)
315
- });
316
- const hardcodedOrdinals = new Map();
317
- for (const node of stringNodes) {
318
- const text = adapter.getNodeText(node, sourceCode);
319
- if (this.isConnectionString(text)) {
320
- const fnName = this.findEnclosingFunctionName(node, adapter);
321
- const baseSym = `${fnName}:hardcoded-connection`;
322
- const count = (hardcodedOrdinals.get(baseSym) ?? 0) + 1;
323
- hardcodedOrdinals.set(baseSym, count);
324
- const sym = count > 1 ? `${baseSym}:${count}` : baseSym;
325
- violations.push(this.createViolation(ast.filePath, node.location.start, 'Hardcoded database connection string detected. Use environment variables. (On Cloudflare Workers/D1, connection strings are injected via bindings.)', 'suggestion', // R7: direct-access → suggestion
326
- 'hardcoded-connection', undefined, sym));
327
- }
328
- }
329
- return violations;
252
+ return calls;
253
+ }
254
+ /**
255
+ * Analyze a database query
256
+ */
257
+ function analyzeQuery(call, sourceCode, config) {
258
+ const hasJoins = call.tables.length > 1;
259
+ const hasSubquery = sourceCode.includes('SELECT') && sourceCode.includes('FROM') &&
260
+ sourceCode.lastIndexOf('SELECT') !== sourceCode.indexOf('SELECT');
261
+ let complexity = 'simple';
262
+ if (hasSubquery || call.tables.length > 3) {
263
+ complexity = 'complex';
330
264
  }
331
- /**
332
- * Helper methods
333
- */
334
- isFunctionCall(node, adapter) {
335
- return node.type === 'call_expression' ||
336
- node.type === 'new_expression';
265
+ else if (hasJoins || call.tables.length > 1) {
266
+ complexity = 'moderate';
337
267
  }
338
- isTemplateLiteral(node, adapter) {
339
- return node.type === 'template_string';
268
+ let performanceRisk = 'low';
269
+ if (call.tables.length > (config.performanceThresholds?.joinedTableCount || 4)) {
270
+ performanceRisk = 'high';
340
271
  }
341
- isVariableAssignment(node, adapter) {
342
- // Only get the actual variable declaration, not the statement
343
- return node.type === 'variable_declaration' ||
344
- node.type === 'binary_expression' && (node.children?.some(child => adapter.getNodeText(child, '').includes('=')) ?? false);
272
+ else if (!call.hasOrganizationFilter && call.tables.length > 0) {
273
+ performanceRisk = 'medium';
345
274
  }
346
- containsSQLKeywords(text) {
347
- const sqlKeywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'JOIN'];
348
- const upperText = text.toUpperCase();
349
- return sqlKeywords.some(keyword => upperText.includes(keyword));
275
+ return {
276
+ complexity,
277
+ tables: call.tables,
278
+ hasJoins,
279
+ hasSubquery,
280
+ hasOrganizationFilter: call.hasOrganizationFilter,
281
+ performanceRisk
282
+ };
283
+ }
284
+ /**
285
+ * Check for violations in a database call
286
+ */
287
+ function checkViolations(call, analysis, ctx) {
288
+ const { filePath, config, symbolOrdinals } = ctx;
289
+ const violations = [];
290
+ const symbol = nextSymbol(call.enclosingFunction ?? 'top-level', call.method, symbolOrdinals);
291
+ const push = (message, opts) => violations.push(makeViolation(filePath, { line: call.line, column: call.column }, message, { ...opts, symbol }));
292
+ // Security: SQL injection — AST heuristics, high-signal not proof, so
293
+ // `warning` not `critical`; manual quote-escaping is not sanitization.
294
+ if (config.checkSQLInjection && call.hasSqlInjectionRisk) {
295
+ push(`Potential SQL injection risk in ${call.method}. Use parameterized queries.`, {
296
+ severity: 'warning',
297
+ rule: 'sql-injection-risk',
298
+ resolution: {
299
+ action: 'parameterize',
300
+ summary: `Replace the string-interpolated SQL in ${call.method} with a parameterized query — bind values via the driver's placeholder form (\`?\`, \`$1\`, or \`:name\`) instead of concatenating them into the statement.`,
301
+ symbols: [call.method],
302
+ files: [filePath],
303
+ lines: [call.line],
304
+ },
305
+ });
350
306
  }
351
- /**
352
- * Spec 22 R4.2: Requires ≥2 SQL keywords for variable-assignment detection.
353
- *
354
- * Single-keyword substring matches (e.g. "FROM" inside "Array.from") produce
355
- * ~120 false positives on the recall corpus. Genuine SQL in variable
356
- * assignments (string literals, ORM chains) almost always has ≥2 keywords
357
- * (SELECT+FROM, INSERT+INTO, DELETE+FROM, etc.).
358
- *
359
- * This is only used for the variable-assignment fallback path — template
360
- * literals and function calls use separate, context-aware gating.
361
- */
362
- containsSQLStructure(text) {
363
- const sqlKeywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'JOIN'];
364
- const upperText = text.toUpperCase();
365
- const found = sqlKeywords.filter(keyword => upperText.includes(keyword));
366
- return found.length >= 2;
307
+ // Security: Missing Organization Filter
308
+ if (config.checkOrgFilters && !call.hasOrganizationFilter && call.tables.length > 0 && requiresOrgFilter(call.tables, config)) {
309
+ push(`Query on ${call.tables.join(', ')} missing organization/tenant filter`, { severity: 'warning', rule: 'missing-org-filter' });
367
310
  }
368
- /**
369
- * Spec 17 R2 provenance gate: a template literal is a SQL candidate
370
- * because of where it sits (inside a DB-provenanced call's arguments),
371
- * NOT because its body contains SQL-shaped substrings.
372
- *
373
- * Content scanning with substring matching is removed — template bodies
374
- * containing natural-language words like "from" or "select" are no longer
375
- * misclassified. The cost: template literals assigned to variables whose
376
- * values eventually flow to DB calls are not detected (requires dataflow
377
- * analysis, which is outside the product's stated scope per Spec 15 R3).
378
- */
379
- isTemplateInDBProvenancedCall(node, adapter, sourceCode, provenanceContext) {
380
- const parent = adapter.getParent(node);
381
- if (!parent)
382
- return false;
383
- const parentType = adapter.getNodeType(parent);
384
- // Only the arguments-of-DB-call path survives the Spec 17 R2 cut.
385
- // Variable-assignment and statement-level conservative passes are
386
- // removed — they were the primary source of false positives.
387
- if (parentType === 'arguments') {
388
- const callExpr = adapter.getParent(parent);
389
- if (!callExpr || adapter.getNodeType(callExpr) !== 'call_expression')
390
- return false;
391
- if (provenanceContext) {
392
- return isDBProvenanced(callExpr, adapter, sourceCode, provenanceContext, DB_CALL_METHODS);
393
- }
394
- // Without provenance context, can't determine DB association —
395
- // do not speculate.
396
- return false;
397
- }
398
- return false;
311
+ // Performance: Complex Query
312
+ if (analysis.performanceRisk === 'high') {
313
+ push(`Complex query with ${call.tables.length} tables may have performance issues`, { severity: 'warning', rule: 'complex-query' });
399
314
  }
400
- /**
401
- * Detect that a node sits inside a D1 .prepare() call — the standard safe
402
- * pattern for SQL in Cloudflare Workers.
403
- *
404
- * D1's parameterized API is `db.prepare(sql).bind(a, b, c).first()`.
405
- * This method also recognises `.prepare()` WITHOUT a subsequent `.bind()`
406
- * as safe: a prepared statement with no bind step has zero runtime
407
- * parameters, so template interpolation in the SQL text is query
408
- * composition with compile-time constants, not user input.
409
- *
410
- * Handles these patterns:
411
- * • Direct chain: `db.prepare(sql).bind(a).all()`
412
- * • Two-statement: `const stmt = db.prepare(sql); stmt.bind(a).all();`
413
- * • No-param: `db.prepare(sql).first()` (no bind needed)
414
- *
415
- * Entry points:
416
- * - a `template_string` node inside prepare()'s arguments
417
- * - the prepare() call_expression itself
418
- */
419
- isInPrepareBindChain(node, adapter, sourceCode) {
420
- // Step 1: Find the prepare call_expression from the node.
421
- let prepareCall;
422
- if (adapter.getNodeType(node) === 'template_string') {
423
- // Walk up through arguments → call_expression
424
- const args = adapter.getParent(node);
425
- if (!args || adapter.getNodeType(args) !== 'arguments')
426
- return false;
427
- prepareCall = adapter.getParent(args);
428
- }
429
- else if (adapter.getNodeType(node) === 'call_expression') {
430
- // Node is the call_expression itself — check if it's a prepare() call.
431
- prepareCall = node;
432
- }
433
- else {
434
- return false;
435
- }
436
- if (!prepareCall || adapter.getNodeType(prepareCall) !== 'call_expression') {
437
- return false;
438
- }
439
- // Step 2: Verify the call_expression is .prepare() by inspecting its
440
- // callee — the first child that is a member_expression, which may be
441
- // wrapped inside an await_expression (await db.prepare(sql)).
442
- let prepareCallee = adapter.getChildren(prepareCall).find(c => adapter.getNodeType(c) === 'member_expression');
443
- if (!prepareCallee) {
444
- const awaitExpr = adapter.getChildren(prepareCall).find(c => adapter.getNodeType(c) === 'await_expression');
445
- if (awaitExpr) {
446
- prepareCallee = adapter.getChildren(awaitExpr).find(c => adapter.getNodeType(c) === 'member_expression');
447
- }
448
- }
449
- if (!prepareCallee)
450
- return false;
451
- const hasPrepareProp = adapter.getChildren(prepareCallee).some(c => adapter.getNodeType(c) === 'property_identifier' &&
452
- adapter.getNodeText(c, sourceCode) === 'prepare');
453
- if (!hasPrepareProp)
454
- return false;
455
- // Step 3: Walk up from the prepare call_expression to find .bind()
456
- // chained onto it. The parent of prepareCall should be a
457
- // member_expression whose property is "bind".
458
- const memberExpr = adapter.getParent(prepareCall);
459
- if (memberExpr && adapter.getNodeType(memberExpr) === 'member_expression') {
460
- const hasBindProp = adapter.getChildren(memberExpr).some(c => adapter.getNodeType(c) === 'property_identifier' &&
461
- adapter.getNodeText(c, sourceCode) === 'bind');
462
- if (hasBindProp) {
463
- // Step 4: The member_expression's parent must be a call_expression
464
- // (the actual .bind() invocation).
465
- const bindCall = adapter.getParent(memberExpr);
466
- if (bindCall && adapter.getNodeType(bindCall) === 'call_expression') {
467
- return true;
468
- }
469
- }
470
- }
471
- // Step 5 (two-statement pattern): The direct-chain check failed. Check
472
- // whether the prepare() result is assigned to a variable that is later
473
- // .bind()'ed in the same function scope.
474
- // Pattern: const stmt = db.prepare(sql);
475
- // stmt.bind(x).all();
476
- if (this.isPrepareAssignedToVariable(prepareCall, adapter, sourceCode)) {
477
- return true;
478
- }
479
- // Step 6 (no-param prepare): Node is inside .prepare() with no .bind()
480
- // found in the direct chain or local scope. Without .bind() the query
481
- // has no runtime parameterisation at the statement level. Fall through
482
- // to let checkQuerySecurity determine whether template interpolation
483
- // makes the query dynamic.
484
- return false;
315
+ // Performance: Unfiltered Query
316
+ if (!call.hasOrganizationFilter && analysis.performanceRisk === 'medium') {
317
+ push(`Unfiltered query on ${call.tables.join(', ')} may cause performance issues`, { severity: 'suggestion', rule: 'unfiltered-query' });
485
318
  }
486
- /**
487
- * Detect that a node sits inside a Durable Object .exec() call —
488
- * Cloudflare's internal SQLite interface for Durable Objects.
489
- *
490
- * `storage.sql.exec(query, ...bindings)` accepts *spread* bind parameters
491
- * after the query string. A call WITH spread binds is fully parameterized
492
- * and safe. A call WITHOUT spread binds has no runtime parameters, so
493
- * template interpolation is query-composition-time.
494
- *
495
- * Entry points: same as isInPrepareBindChain — a template_string inside
496
- * the arguments, or the call_expression itself.
497
- */
498
- isInExecChain(node, adapter, sourceCode) {
499
- // Step 1: Find the exec call_expression from the node.
500
- let execCall;
501
- if (adapter.getNodeType(node) === 'template_string') {
502
- const args = adapter.getParent(node);
503
- if (!args || adapter.getNodeType(args) !== 'arguments')
504
- return false;
505
- execCall = adapter.getParent(args);
506
- }
507
- else if (adapter.getNodeType(node) === 'call_expression') {
508
- execCall = node;
509
- }
510
- else {
511
- return false;
512
- }
513
- if (!execCall || adapter.getNodeType(execCall) !== 'call_expression') {
514
- return false;
515
- }
516
- // Step 2: Verify the call_expression is .exec() — the member_expression
517
- // may be inside an await_expression wrapper (await sql.exec(query)).
518
- let execCallee = adapter.getChildren(execCall).find(c => adapter.getNodeType(c) === 'member_expression');
519
- if (!execCallee) {
520
- const awaitExpr = adapter.getChildren(execCall).find(c => adapter.getNodeType(c) === 'await_expression');
521
- if (awaitExpr) {
522
- execCallee = adapter.getChildren(awaitExpr).find(c => adapter.getNodeType(c) === 'member_expression');
523
- }
524
- }
525
- if (!execCallee)
526
- return false;
527
- const hasExecProp = adapter.getChildren(execCallee).some(c => adapter.getNodeType(c) === 'property_identifier' &&
528
- adapter.getNodeText(c, sourceCode) === 'exec');
529
- if (!hasExecProp)
530
- return false;
531
- // Step 3: Check for spread bind parameters after the template.
532
- // `sql.exec(template, ...binds)` — the spread element in arguments
533
- // means values are parameterized.
534
- const args = adapter.getChildren(execCall).find(c => adapter.getNodeType(c) === 'arguments');
535
- if (args) {
536
- const argChildren = adapter.getChildren(args);
537
- const hasSpread = argChildren.some(c => adapter.getNodeType(c) === 'spread_element');
538
- if (hasSpread) {
539
- return true; // Parameterized via spread binds
540
- }
319
+ return violations;
320
+ }
321
+ /** Compute the next stable violation symbol key for a (function, method) pair. */
322
+ function nextSymbol(fnName, method, symbolOrdinals) {
323
+ const baseSymbol = `${fnName}:${method}`;
324
+ const ordinal = (symbolOrdinals.get(baseSymbol) ?? 0) + 1;
325
+ symbolOrdinals.set(baseSymbol, ordinal);
326
+ return ordinal > 1 ? `${baseSymbol}:${ordinal}` : baseSymbol;
327
+ }
328
+ /**
329
+ * Check general data access patterns
330
+ */
331
+ function checkGeneralPatterns(ast, adapter, sourceCode, config) {
332
+ const violations = [];
333
+ // Check for hardcoded connection strings
334
+ const stringNodes = adapter.findNodes(ast, {
335
+ custom: (node) => isStringLiteral(node, adapter)
336
+ });
337
+ const hardcodedOrdinals = new Map();
338
+ for (const node of stringNodes) {
339
+ const text = adapter.getNodeText(node, sourceCode);
340
+ if (isConnectionString(text)) {
341
+ const fnName = findEnclosingFunctionName(node, adapter);
342
+ const baseSym = `${fnName}:hardcoded-connection`;
343
+ const count = (hardcodedOrdinals.get(baseSym) ?? 0) + 1;
344
+ hardcodedOrdinals.set(baseSym, count);
345
+ const sym = count > 1 ? `${baseSym}:${count}` : baseSym;
346
+ violations.push(makeViolation(ast.filePath, node.location.start, 'Hardcoded database connection string detected. Use environment variables. (On Cloudflare Workers/D1, connection strings are injected via bindings.)', { severity: 'suggestion', rule: 'hardcoded-connection', symbol: sym } // R7: direct-access → suggestion
347
+ ));
541
348
  }
542
- // Step 4: No spread binds — the query has no runtime bind parameters.
543
- // Fall through so checkQuerySecurity determines whether template
544
- // interpolation makes the query dynamic. The "in-process SQLite"
545
- // argument does not make dynamic interpolation safe — if user-supplied
546
- // values reach the SQL text they are still injectable regardless of
547
- // whether the DB is remote or in-process.
548
- return false;
549
349
  }
550
- /**
551
- * Detect D1's convenience SQL methods — .all(), .first(), .run() —
552
- * called with bind parameters as a second argument.
553
- *
554
- * `db.all(query, ...params)` is shorthand for
555
- * `db.prepare(query).bind(...params).all()`. If there's a second argument
556
- * (the bind params), the call is fully parameterized and safe.
557
- */
558
- isD1ConvenienceCall(node, adapter, sourceCode) {
559
- const D1_CONVENIENCE = new Set(['all', 'first', 'run']);
560
- // Find the call_expression.
561
- let call;
562
- if (adapter.getNodeType(node) === 'template_string') {
563
- const args = adapter.getParent(node);
564
- if (!args || adapter.getNodeType(args) !== 'arguments')
565
- return false;
566
- call = adapter.getParent(args);
567
- }
568
- else if (adapter.getNodeType(node) === 'call_expression') {
569
- call = node;
570
- }
571
- else {
572
- return false;
573
- }
574
- if (!call || adapter.getNodeType(call) !== 'call_expression')
350
+ return violations;
351
+ }
352
+ /**
353
+ * Helper methods
354
+ */
355
+ function isFunctionCall(node, adapter) {
356
+ return node.type === 'call_expression' ||
357
+ node.type === 'new_expression';
358
+ }
359
+ function isTemplateLiteral(node, adapter) {
360
+ return node.type === 'template_string';
361
+ }
362
+ function isVariableAssignment(node, adapter) {
363
+ // Only get the actual variable declaration, not the statement
364
+ return node.type === 'variable_declaration' ||
365
+ node.type === 'binary_expression' && (node.children?.some(child => adapter.getNodeText(child, '').includes('=')) ?? false);
366
+ }
367
+ function containsSQLKeywords(text) {
368
+ const upperText = text.toUpperCase();
369
+ return SQL_KEYWORDS.some(keyword => upperText.includes(keyword));
370
+ }
371
+ /**
372
+ * Spec 22 R4.2: Requires ≥2 SQL keywords for variable-assignment detection.
373
+ *
374
+ * Single-keyword substring matches (e.g. "FROM" inside "Array.from") produce
375
+ * ~120 false positives on the recall corpus. Genuine SQL in variable
376
+ * assignments (string literals, ORM chains) almost always has ≥2 keywords
377
+ * (SELECT+FROM, INSERT+INTO, DELETE+FROM, etc.).
378
+ *
379
+ * This is only used for the variable-assignment fallback path — template
380
+ * literals and function calls use separate, context-aware gating.
381
+ */
382
+ function containsSQLStructure(text) {
383
+ const upperText = text.toUpperCase();
384
+ const found = SQL_KEYWORDS.filter(keyword => upperText.includes(keyword));
385
+ return found.length >= 2;
386
+ }
387
+ /**
388
+ * Spec 17 R2 provenance gate: a template literal is a SQL candidate
389
+ * because of where it sits (inside a DB-provenanced call's arguments),
390
+ * NOT because its body contains SQL-shaped substrings.
391
+ *
392
+ * Content scanning with substring matching is removed — template bodies
393
+ * containing natural-language words like "from" or "select" are no longer
394
+ * misclassified. The cost: template literals assigned to variables whose
395
+ * values eventually flow to DB calls are not detected (requires dataflow
396
+ * analysis, which is outside the product's stated scope per Spec 15 R3).
397
+ */
398
+ function isTemplateInDBProvenancedCall(node, adapter, sourceCode, provenanceContext) {
399
+ const parent = adapter.getParent(node);
400
+ if (!parent)
401
+ return false;
402
+ const parentType = adapter.getNodeType(parent);
403
+ // Only the arguments-of-DB-call path survives the Spec 17 R2 cut.
404
+ // Variable-assignment and statement-level conservative passes are
405
+ // removed — they were the primary source of false positives.
406
+ if (parentType === 'arguments') {
407
+ const callExpr = adapter.getParent(parent);
408
+ if (!callExpr || adapter.getNodeType(callExpr) !== 'call_expression')
575
409
  return false;
576
- // Verify the callee is one of .all / .first / .run — the
577
- // member_expression may be inside an await_expression wrapper
578
- // (await db.all(query, params)).
579
- let callee = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'member_expression');
580
- if (!callee) {
581
- const awaitExpr = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'await_expression');
582
- if (awaitExpr) {
583
- callee = adapter.getChildren(awaitExpr).find(c => adapter.getNodeType(c) === 'member_expression');
584
- }
410
+ if (provenanceContext) {
411
+ return isDBProvenanced(callExpr, { adapter, sourceCode, context: provenanceContext, methods: DB_CALL_METHODS });
585
412
  }
586
- if (!callee)
587
- return false;
588
- const methodName = adapter.getChildren(callee).find(c => adapter.getNodeType(c) === 'property_identifier' &&
589
- D1_CONVENIENCE.has(adapter.getNodeText(c, sourceCode)));
590
- if (!methodName)
591
- return false;
592
- // Check for bind parameters — must have more than one argument.
593
- // The first argument is the query text; any subsequent argument
594
- // carries bind values for the ? placeholders.
595
- const args = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'arguments');
596
- if (!args)
597
- return false;
598
- const argChildren = adapter.getChildren(args);
599
- // Filter out commas and whitespace; count real argument nodes.
600
- const realArgs = argChildren.filter(c => !['(', ')', ',', 'comment'].includes(adapter.getNodeType(c)));
601
- return realArgs.length >= 2;
413
+ // Without provenance context, can't determine DB association —
414
+ // do not speculate.
415
+ return false;
602
416
  }
603
- /**
604
- * Detect simple-function-call DB wrappers with bind parameters.
605
- *
606
- * dbWrapperNames (d1Query, d1Exec) are function wrappers that accept
607
- * (sqlTemplate, bindParams) the same pattern as .prepare().bind() but
608
- * expressed as a direct function call rather than a method chain.
609
- *
610
- * `d1Query(\`SELECT ... WHERE x = ?\`, [value])` bind params as second arg
611
- * means the call is fully parameterized, even though the template literal
612
- * text contains `${}` interpolation for table/column names.
613
- *
614
- * Entry point: a template_string inside the wrapper's arguments.
615
- */
616
- isWrapperFunctionWithBindParams(node, adapter, sourceCode, wrapperNames) {
617
- if (wrapperNames.length === 0)
618
- return false;
619
- if (adapter.getNodeType(node) !== 'template_string')
620
- return false;
621
- // Walk up from template_string arguments call_expression
417
+ return false;
418
+ }
419
+ /**
420
+ * Detect that a node sits inside a D1 .prepare() call the standard safe
421
+ * pattern for SQL in Cloudflare Workers.
422
+ *
423
+ * D1's parameterized API is `db.prepare(sql).bind(a, b, c).first()`.
424
+ * This method also recognises `.prepare()` WITHOUT a subsequent `.bind()`
425
+ * as safe: a prepared statement with no bind step has zero runtime
426
+ * parameters, so template interpolation in the SQL text is query
427
+ * composition with compile-time constants, not user input.
428
+ *
429
+ * Handles these patterns:
430
+ * • Direct chain: `db.prepare(sql).bind(a).all()`
431
+ * • Two-statement: `const stmt = db.prepare(sql); stmt.bind(a).all();`
432
+ * • No-param: `db.prepare(sql).first()` (no bind needed)
433
+ *
434
+ * Entry points:
435
+ * - a `template_string` node inside prepare()'s arguments
436
+ * - the prepare() call_expression itself
437
+ */
438
+ function isInPrepareBindChain(node, adapter, sourceCode) {
439
+ const prepareCall = findCallFromEntry(node, adapter);
440
+ if (!prepareCall)
441
+ return false;
442
+ const callee = findMemberCallee(prepareCall, adapter);
443
+ if (!callee || !hasProperty(callee, 'prepare', adapter, sourceCode))
444
+ return false;
445
+ // Direct chain: prepareCall.parent is a .bind member whose parent is a call.
446
+ if (isDirectBindChain(prepareCall, adapter, sourceCode))
447
+ return true;
448
+ // Two-statement pattern: const stmt = db.prepare(sql); stmt.bind(x).all();
449
+ if (isPrepareAssignedToVariable(prepareCall, adapter, sourceCode))
450
+ return true;
451
+ return false;
452
+ }
453
+ /** Locate the call_expression a node belongs to, if any. */
454
+ function findCallFromEntry(node, adapter) {
455
+ const type = adapter.getNodeType(node);
456
+ if (type === 'template_string') {
622
457
  const args = adapter.getParent(node);
623
458
  if (!args || adapter.getNodeType(args) !== 'arguments')
624
- return false;
459
+ return null;
625
460
  const call = adapter.getParent(args);
626
- if (!call || adapter.getNodeType(call) !== 'call_expression')
627
- return false;
628
- // Check if the callee is a simple identifier (not member expression)
629
- // matching one of the wrapper names.
630
- const children = adapter.getChildren(call);
631
- const callee = children.find(c => adapter.getNodeType(c) === 'identifier');
632
- if (!callee)
633
- return false;
634
- const calleeName = adapter.getNodeText(callee, sourceCode);
635
- if (!wrapperNames.includes(calleeName))
636
- return false;
637
- // Check for a second argument — the bind params. A single-arg call
638
- // like d1Query(sql) without params has no runtime parameterization.
639
- const realArgs = adapter.getChildren(args).filter(c => !['(', ')', ','].includes(adapter.getNodeType(c)));
640
- return realArgs.length >= 2;
461
+ return call && adapter.getNodeType(call) === 'call_expression' ? call : null;
641
462
  }
642
- /**
643
- * Check the two-statement prepare→bind pattern: db.prepare() is assigned to
644
- * a variable whose value is later .bind()'ed in the same function scope.
645
- *
646
- * const stmt = db.prepare(sql);
647
- * const result = stmt.bind(x).all();
648
- *
649
- * The direct-chain check (isInPrepareBindChain Step 3) only catches the
650
- * single-expression form `db.prepare(sql).bind(x).all()`. This method
651
- * catches the common idiom where the prepared statement is stored in a local
652
- * before being bound.
653
- */
654
- isPrepareAssignedToVariable(prepareCall, adapter, sourceCode) {
655
- // The parent of the prepare call_expression reveals how the result is used.
656
- const parent = adapter.getParent(prepareCall);
657
- if (!parent)
658
- return false;
659
- const parentType = adapter.getNodeType(parent);
660
- let varName = null;
661
- if (parentType === 'variable_declarator') {
662
- // const stmt = db.prepare(sql)
663
- const children = adapter.getChildren(parent);
664
- const nameChild = children.find(c => adapter.getNodeType(c) === 'identifier');
665
- if (nameChild) {
666
- varName = adapter.getNodeText(nameChild, sourceCode);
667
- }
668
- }
669
- else if (parentType === 'assignment_expression') {
670
- // stmt = db.prepare(sql)
671
- const children = adapter.getChildren(parent);
672
- const left = children.find(c => adapter.getNodeType(c) === 'identifier' ||
673
- adapter.getNodeType(c) === 'member_expression');
674
- if (left) {
675
- varName = adapter.getNodeText(left, sourceCode);
676
- }
463
+ if (type === 'call_expression')
464
+ return node;
465
+ return null;
466
+ }
467
+ /** Find a call's member_expression callee, unwrapping await_expression if present. */
468
+ function findMemberCallee(call, adapter) {
469
+ const direct = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'member_expression');
470
+ if (direct)
471
+ return direct;
472
+ const awaitExpr = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'await_expression');
473
+ if (!awaitExpr)
474
+ return null;
475
+ return adapter.getChildren(awaitExpr).find(c => adapter.getNodeType(c) === 'member_expression') ?? null;
476
+ }
477
+ /** True when `node`'s property_identifier equals `prop`. */
478
+ function hasProperty(node, prop, adapter, sourceCode) {
479
+ return memberPropertyName(node, adapter, sourceCode) === prop;
480
+ }
481
+ /** The property_identifier text of a member_expression, or null. */
482
+ function memberPropertyName(node, adapter, sourceCode) {
483
+ const prop = adapter.getChildren(node).find(c => adapter.getNodeType(c) === 'property_identifier');
484
+ return prop ? adapter.getNodeText(prop, sourceCode) : null;
485
+ }
486
+ /** True when a call's arguments contain a spread_element (…binds). */
487
+ function hasSpreadArgument(call, adapter) {
488
+ const args = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'arguments');
489
+ if (!args)
490
+ return false;
491
+ return adapter.getChildren(args).some(c => adapter.getNodeType(c) === 'spread_element');
492
+ }
493
+ /** Count real (non-punctuation) arguments in a call. */
494
+ function countArgs(call, adapter) {
495
+ const args = adapter.getChildren(call).find(c => adapter.getNodeType(c) === 'arguments');
496
+ if (!args)
497
+ return 0;
498
+ return adapter.getChildren(args).filter(c => !['(', ')', ',', 'comment'].includes(adapter.getNodeType(c))).length;
499
+ }
500
+ /** True when a prepare() call is directly followed by a `.bind()` invocation. */
501
+ function isDirectBindChain(prepareCall, adapter, sourceCode) {
502
+ const memberExpr = adapter.getParent(prepareCall);
503
+ if (!memberExpr || adapter.getNodeType(memberExpr) !== 'member_expression')
504
+ return false;
505
+ if (!hasProperty(memberExpr, 'bind', adapter, sourceCode))
506
+ return false;
507
+ const bindCall = adapter.getParent(memberExpr);
508
+ return !!bindCall && adapter.getNodeType(bindCall) === 'call_expression';
509
+ }
510
+ /**
511
+ * Detect that a node sits inside a Durable Object .exec() call —
512
+ * Cloudflare's internal SQLite interface for Durable Objects.
513
+ *
514
+ * `storage.sql.exec(query, ...bindings)` accepts *spread* bind parameters
515
+ * after the query string. A call WITH spread binds is fully parameterized
516
+ * and safe. A call WITHOUT spread binds has no runtime parameters, so
517
+ * template interpolation is query-composition-time.
518
+ *
519
+ * Entry points: same as isInPrepareBindChain — a template_string inside
520
+ * the arguments, or the call_expression itself.
521
+ */
522
+ function isInExecChain(node, adapter, sourceCode) {
523
+ const execCall = findCallFromEntry(node, adapter);
524
+ if (!execCall)
525
+ return false;
526
+ const callee = findMemberCallee(execCall, adapter);
527
+ if (!callee || !hasProperty(callee, 'exec', adapter, sourceCode))
528
+ return false;
529
+ // `sql.exec(template, ...binds)` — spread element means parameterized.
530
+ // Without it, there are no runtime binds: template interpolation is
531
+ // query-composition-time and checkQuerySecurity decides if it's dynamic.
532
+ return hasSpreadArgument(execCall, adapter);
533
+ }
534
+ /**
535
+ * Detect D1's convenience SQL methods — .all(), .first(), .run() —
536
+ * called with bind parameters as a second argument.
537
+ *
538
+ * `db.all(query, ...params)` is shorthand for
539
+ * `db.prepare(query).bind(...params).all()`. If there's a second argument
540
+ * (the bind params), the call is fully parameterized and safe.
541
+ */
542
+ function isD1ConvenienceCall(node, adapter, sourceCode) {
543
+ const D1_CONVENIENCE = new Set(['all', 'first', 'run']);
544
+ const call = findCallFromEntry(node, adapter);
545
+ if (!call)
546
+ return false;
547
+ const callee = findMemberCallee(call, adapter);
548
+ const method = callee ? memberPropertyName(callee, adapter, sourceCode) : null;
549
+ if (!method || !D1_CONVENIENCE.has(method))
550
+ return false;
551
+ // Bind params as a second argument → fully parameterized and safe.
552
+ return countArgs(call, adapter) >= 2;
553
+ }
554
+ /**
555
+ * Detect simple-function-call DB wrappers with bind parameters.
556
+ *
557
+ * dbWrapperNames (d1Query, d1Exec) are function wrappers that accept
558
+ * (sqlTemplate, bindParams) — the same pattern as .prepare().bind() but
559
+ * expressed as a direct function call rather than a method chain.
560
+ *
561
+ * `d1Query(\`SELECT ... WHERE x = ?\`, [value])` — bind params as second arg
562
+ * means the call is fully parameterized, even though the template literal
563
+ * text contains `${}` interpolation for table/column names.
564
+ *
565
+ * Entry point: a template_string inside the wrapper's arguments.
566
+ */
567
+ function isWrapperFunctionWithBindParams(node, adapter, sourceCode, wrapperNames) {
568
+ if (wrapperNames.length === 0)
569
+ return false;
570
+ if (adapter.getNodeType(node) !== 'template_string')
571
+ return false;
572
+ // Walk up from template_string → arguments → call_expression
573
+ const args = adapter.getParent(node);
574
+ if (!args || adapter.getNodeType(args) !== 'arguments')
575
+ return false;
576
+ const call = adapter.getParent(args);
577
+ if (!call || adapter.getNodeType(call) !== 'call_expression')
578
+ return false;
579
+ // Check if the callee is a simple identifier (not member expression)
580
+ // matching one of the wrapper names.
581
+ const children = adapter.getChildren(call);
582
+ const callee = children.find(c => adapter.getNodeType(c) === 'identifier');
583
+ if (!callee)
584
+ return false;
585
+ const calleeName = adapter.getNodeText(callee, sourceCode);
586
+ if (!wrapperNames.includes(calleeName))
587
+ return false;
588
+ // Check for a second argument — the bind params. A single-arg call
589
+ // like d1Query(sql) without params has no runtime parameterization.
590
+ const realArgs = adapter.getChildren(args).filter(c => !['(', ')', ','].includes(adapter.getNodeType(c)));
591
+ return realArgs.length >= 2;
592
+ }
593
+ /**
594
+ * Check the two-statement prepare→bind pattern: db.prepare() is assigned to
595
+ * a variable whose value is later .bind()'ed in the same function scope.
596
+ *
597
+ * const stmt = db.prepare(sql);
598
+ * const result = stmt.bind(x).all();
599
+ *
600
+ * The direct-chain check (isInPrepareBindChain Step 3) only catches the
601
+ * single-expression form `db.prepare(sql).bind(x).all()`. This method
602
+ * catches the common idiom where the prepared statement is stored in a local
603
+ * before being bound.
604
+ */
605
+ function isPrepareAssignedToVariable(prepareCall, adapter, sourceCode) {
606
+ // The parent of the prepare call_expression reveals how the result is used.
607
+ const parent = adapter.getParent(prepareCall);
608
+ if (!parent)
609
+ return false;
610
+ const parentType = adapter.getNodeType(parent);
611
+ let varName = null;
612
+ if (parentType === 'variable_declarator') {
613
+ // const stmt = db.prepare(sql)
614
+ const children = adapter.getChildren(parent);
615
+ const nameChild = children.find(c => adapter.getNodeType(c) === 'identifier');
616
+ if (nameChild) {
617
+ varName = adapter.getNodeText(nameChild, sourceCode);
677
618
  }
678
- if (!varName)
679
- return false;
680
- // Scan the enclosing function scope for `.bind()` on this variable.
681
- const fnNode = this.findEnclosingFunctionNode(prepareCall, adapter);
682
- if (!fnNode)
683
- return false;
684
- const fnText = adapter.getNodeText(fnNode, sourceCode);
685
- const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
686
- const bindPattern = new RegExp(String.raw `\b${escaped}\.bind\s*\(`, 'u');
687
- return bindPattern.test(fnText);
688
619
  }
689
- /**
690
- * Walk up the AST to find the enclosing function node (function_declaration,
691
- * arrow_function, or method_definition). Returns null if we reach the
692
- * program root.
693
- */
694
- findEnclosingFunctionNode(node, adapter) {
695
- let current = node;
696
- while (current) {
697
- const type = adapter.getNodeType(current);
698
- if (type === 'function_declaration' ||
699
- type === 'function_expression' ||
700
- type === 'arrow_function' ||
701
- type === 'method_definition') {
702
- return current;
703
- }
704
- current = adapter.getParent(current);
620
+ else if (parentType === 'assignment_expression') {
621
+ // stmt = db.prepare(sql)
622
+ const children = adapter.getChildren(parent);
623
+ const left = children.find(c => adapter.getNodeType(c) === 'identifier' ||
624
+ adapter.getNodeType(c) === 'member_expression');
625
+ if (left) {
626
+ varName = adapter.getNodeText(left, sourceCode);
705
627
  }
706
- return null;
707
- }
708
- isOrmPattern(text) {
709
- // Common ORM method patterns
710
- const ormPatterns = [
711
- /\.find\s*\(/,
712
- /\.findOne\s*\(/,
713
- /\.findMany\s*\(/,
714
- /\.findFirst\s*\(/,
715
- /\.findUnique\s*\(/,
716
- /\.select\s*\(/,
717
- /\.insert\s*\(/,
718
- /\.update\s*\(/,
719
- /\.updateOne\s*\(/,
720
- /\.updateMany\s*\(/,
721
- /\.delete\s*\(/,
722
- /\.deleteOne\s*\(/,
723
- /\.deleteMany\s*\(/,
724
- /\.from\s*\(/,
725
- /\.where\s*\(/,
726
- /\.join\s*\(/,
727
- /\.leftJoin\s*\(/,
728
- /\.rightJoin\s*\(/,
729
- /\.innerJoin\s*\(/,
730
- /\.create\s*\(/,
731
- /\.createMany\s*\(/,
732
- /\.aggregate\s*\(/,
733
- /\.count\s*\(/,
734
- /\.distinct\s*\(/
735
- ];
736
- return ormPatterns.some(pattern => pattern.test(text));
737
628
  }
738
- isDatabaseCall(text, patterns) {
739
- return patterns.some(pattern => text.toLowerCase().includes(pattern.toLowerCase()));
629
+ if (!varName)
630
+ return false;
631
+ // Scan the enclosing function scope for `.bind()` on this variable.
632
+ const fnNode = findEnclosingFunctionNode(prepareCall, adapter);
633
+ if (!fnNode)
634
+ return false;
635
+ const fnText = adapter.getNodeText(fnNode, sourceCode);
636
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
637
+ const bindPattern = new RegExp(String.raw `\b${escaped}\.bind\s*\(`, 'u');
638
+ return bindPattern.test(fnText);
639
+ }
640
+ /**
641
+ * Walk up the AST to find the enclosing function node (function_declaration,
642
+ * arrow_function, or method_definition). Returns null if we reach the
643
+ * program root.
644
+ */
645
+ function findEnclosingFunctionNode(node, adapter) {
646
+ let current = node;
647
+ while (current) {
648
+ const type = adapter.getNodeType(current);
649
+ if (type === 'function_declaration' ||
650
+ type === 'function_expression' ||
651
+ type === 'arrow_function' ||
652
+ type === 'method_definition') {
653
+ return current;
654
+ }
655
+ current = adapter.getParent(current);
740
656
  }
741
- extractTables(text, config) {
742
- const tables = new Set();
743
- // Check ORM patterns
744
- config.tablePatterns?.orm?.forEach(pattern => {
745
- const matches = text.matchAll(pattern);
746
- for (const match of matches) {
747
- if (match[1])
748
- tables.add(match[1]);
749
- }
750
- });
751
- // Check SQL patterns
752
- config.tablePatterns?.sql?.forEach(pattern => {
753
- const matches = text.matchAll(pattern);
754
- for (const match of matches) {
755
- if (match[1])
756
- tables.add(match[1]);
757
- }
758
- });
759
- // Additional check for common ORM patterns that might be missed
760
- // Handle patterns like db.select().from(users) where 'users' is a variable
761
- const ormVariablePattern = /\.from\s*\(\s*([\p{L}_][\p{L}\p{N}_]*)\s*\)/gu;
762
- const ormMatches = text.matchAll(ormVariablePattern);
763
- for (const match of ormMatches) {
764
- if (match[1] && !match[1].includes('"') && !match[1].includes("'")) {
657
+ return null;
658
+ }
659
+ function isOrmPattern(text) {
660
+ // Common ORM method patterns
661
+ const ormPatterns = [
662
+ /\.find\s*\(/,
663
+ /\.findOne\s*\(/,
664
+ /\.findMany\s*\(/,
665
+ /\.findFirst\s*\(/,
666
+ /\.findUnique\s*\(/,
667
+ /\.select\s*\(/,
668
+ /\.insert\s*\(/,
669
+ /\.update\s*\(/,
670
+ /\.updateOne\s*\(/,
671
+ /\.updateMany\s*\(/,
672
+ /\.delete\s*\(/,
673
+ /\.deleteOne\s*\(/,
674
+ /\.deleteMany\s*\(/,
675
+ /\.from\s*\(/,
676
+ /\.where\s*\(/,
677
+ /\.join\s*\(/,
678
+ /\.leftJoin\s*\(/,
679
+ /\.rightJoin\s*\(/,
680
+ /\.innerJoin\s*\(/,
681
+ /\.create\s*\(/,
682
+ /\.createMany\s*\(/,
683
+ /\.aggregate\s*\(/,
684
+ /\.count\s*\(/,
685
+ /\.distinct\s*\(/
686
+ ];
687
+ return ormPatterns.some(pattern => pattern.test(text));
688
+ }
689
+ function extractTables(text, config) {
690
+ const tables = new Set();
691
+ // Check ORM patterns
692
+ config.tablePatterns?.orm?.forEach(pattern => {
693
+ const matches = text.matchAll(pattern);
694
+ for (const match of matches) {
695
+ if (match[1])
765
696
  tables.add(match[1]);
766
- }
767
697
  }
768
- // Handle patterns like db.users.find() or db.orders.findOne()
769
- const dbTablePattern = /db\.([\p{L}_][\p{L}\p{N}_]*)\.\p{L}[\p{L}\p{N}_]*\s*\(/gu;
770
- const dbMatches = text.matchAll(dbTablePattern);
771
- for (const match of dbMatches) {
772
- if (match[1]) {
698
+ });
699
+ // Check SQL patterns
700
+ config.tablePatterns?.sql?.forEach(pattern => {
701
+ const matches = text.matchAll(pattern);
702
+ for (const match of matches) {
703
+ if (match[1])
773
704
  tables.add(match[1]);
774
- }
775
705
  }
776
- return Array.from(tables);
777
- }
778
- hasOrganizationFilter(text, config) {
779
- const patterns = config.organizationPatterns || [];
780
- const lowerText = text.toLowerCase();
781
- // If no patterns provided, check for hardcoded common patterns as fallback
782
- const fallbackPatterns = patterns.length === 0 ? [
783
- 'organizationid', 'organization_id', 'orgid', 'org_id',
784
- 'tenantid', 'tenant_id', 'companyid', 'company_id'
785
- ] : patterns;
786
- // Check for simple pattern matches first
787
- const hasSimpleMatch = fallbackPatterns.some(pattern => {
788
- const match = lowerText.includes(pattern.toLowerCase());
789
- return match;
790
- });
791
- if (hasSimpleMatch) {
792
- return true;
706
+ });
707
+ // Additional check for common ORM patterns that might be missed
708
+ // Handle patterns like db.select().from(users) where 'users' is a variable
709
+ const ormVariablePattern = /\.from\s*\(\s*([\p{L}_][\p{L}\p{N}_]*)\s*\)/gu;
710
+ const ormMatches = text.matchAll(ormVariablePattern);
711
+ for (const match of ormMatches) {
712
+ if (match[1] && !match[1].includes('"') && !match[1].includes("'")) {
713
+ tables.add(match[1]);
793
714
  }
794
- // Enhanced pattern matching for object properties and SQL WHERE clauses
795
- for (const pattern of fallbackPatterns) {
796
- const lowerPattern = pattern.toLowerCase();
797
- // Check for object property patterns: { organizationId: ... }
798
- if (lowerText.includes(`${lowerPattern}:`)) {
799
- return true;
800
- }
801
- // Check for object property patterns with quotes: { "organizationId": ... }
802
- if (lowerText.includes(`"${lowerPattern}"`)) {
803
- return true;
804
- }
805
- // Check for object property patterns with single quotes: { 'organizationId': ... }
806
- if (lowerText.includes(`'${lowerPattern}'`)) {
807
- return true;
808
- }
809
- // Check for SQL WHERE clause patterns: WHERE organizationId =
810
- if (lowerText.includes(`where ${lowerPattern} =`) ||
811
- lowerText.includes(`where ${lowerPattern}=`)) {
812
- return true;
813
- }
814
- // Check for SQL AND clause patterns: AND organizationId =
815
- if (lowerText.includes(`and ${lowerPattern} =`) ||
816
- lowerText.includes(`and ${lowerPattern}=`)) {
817
- return true;
818
- }
715
+ }
716
+ // Handle patterns like db.users.find() or db.orders.findOne()
717
+ const dbTablePattern = /db\.([\p{L}_][\p{L}\p{N}_]*)\.\p{L}[\p{L}\p{N}_]*\s*\(/gu;
718
+ const dbMatches = text.matchAll(dbTablePattern);
719
+ for (const match of dbMatches) {
720
+ if (match[1]) {
721
+ tables.add(match[1]);
819
722
  }
723
+ }
724
+ return Array.from(tables);
725
+ }
726
+ function hasOrganizationFilter(text, config) {
727
+ const patterns = config.organizationPatterns ?? [];
728
+ const lowerText = text.toLowerCase();
729
+ // No patterns → hardcoded common fallback set.
730
+ const candidates = patterns.length
731
+ ? patterns
732
+ : ['organizationid', 'organization_id', 'orgid', 'org_id',
733
+ 'tenantid', 'tenant_id', 'companyid', 'company_id'];
734
+ return candidates.some(p => matchesOrganizationPattern(lowerText, p.toLowerCase()));
735
+ }
736
+ /** True when `p` appears in `lowerText` as a bare token, object property, or SQL clause. */
737
+ function matchesOrganizationPattern(lowerText, p) {
738
+ return (lowerText.includes(p) ||
739
+ lowerText.includes(`${p}:`) ||
740
+ lowerText.includes(`"${p}"`) ||
741
+ lowerText.includes(`'${p}'`) ||
742
+ lowerText.includes(`where ${p} =`) || lowerText.includes(`where ${p}=`) ||
743
+ lowerText.includes(`and ${p} =`) || lowerText.includes(`and ${p}=`));
744
+ }
745
+ /**
746
+ * True when the query is already parameterized by one of the four chain
747
+ * shapes (.prepare().bind(), .exec() spread, D1 convenience call, or a DB
748
+ * wrapper function with bind params). All four short-circuit checkQuerySecurity
749
+ * to a "safe" result.
750
+ */
751
+ function isParameterizedByChain(node, adapter, sourceCode, config) {
752
+ if (isInPrepareBindChain(node, adapter, sourceCode))
753
+ return true;
754
+ if (isInExecChain(node, adapter, sourceCode))
755
+ return true;
756
+ if (isD1ConvenienceCall(node, adapter, sourceCode))
757
+ return true;
758
+ if (isWrapperFunctionWithBindParams(node, adapter, sourceCode, config.dbWrapperNames ?? []))
759
+ return true;
760
+ return false;
761
+ }
762
+ /**
763
+ * True when a single dynamic interpolation part is provably safe — via a
764
+ * config-driven sanitizer allowlist, the adapter's cross-function safety
765
+ * analysis, or a static-constant resolution for bare identifiers. Otherwise
766
+ * the part counts as unresolved (a candidate injection).
767
+ */
768
+ function isSafeDynamicPart(part, ast, scan) {
769
+ const { adapter, sourceCode, config } = scan;
770
+ // Config-driven sanitizer allowlist (escapeSql(x), …) applies to
771
+ // non-identifier expressions — an interpolation wrapped in a known
772
+ // sanitizer isn't raw (same pattern as dbWrapperNames for provenance).
773
+ if (!part.isIdentifier) {
774
+ const normalized = part.text.trim();
775
+ const sanitized = (config.sanitizerNames ?? []).some(name => normalized.startsWith(name + '(') || normalized.startsWith(name + ' ('));
776
+ if (sanitized)
777
+ return true;
778
+ }
779
+ // Prefer the adapter's cross-function safety analysis when available. It
780
+ // supersedes (and, for identifiers, subsumes) the static-constant check:
781
+ // it clears quote-escape sanitizers, safe ternaries, static-array
782
+ // `.map().join()` chains, safe local helper calls, and guard-validated /
783
+ // call-site-provenanced parameters — without weakening raw-input detection.
784
+ if (part.node && adapter.isSafeInterpolation) {
785
+ if (adapter.isSafeInterpolation(part.node, ast, sourceCode))
786
+ return true;
820
787
  return false;
821
788
  }
822
- checkQuerySecurity(node, text, ast, adapter, sourceCode, config) {
823
- // Check for D1's .prepare().bind() chain — queries parameterized via
824
- // .bind() are safe even when the template literal text doesn't contain
825
- // '?' placeholders (the bind args carry the values). Walk up the AST
826
- // from the template-string node to detect the enclosing chain.
827
- if (this.isInPrepareBindChain(node, adapter, sourceCode)) {
828
- return { parameterized: true, injectionRisk: false };
829
- }
830
- // Check for Durable Objects' .exec() — the internal SQLite interface.
831
- // Spread binds (sql.exec(template, ...binds)) mean parameterized; without
832
- // binds, template interpolation is query-composition-time, not runtime.
833
- if (this.isInExecChain(node, adapter, sourceCode)) {
834
- return { parameterized: true, injectionRisk: false };
835
- }
836
- // Check for D1 convenience methods — .all(), .first(), .run() with bind
837
- // parameters. db.all(query, ...params) is shorthand for the full
838
- // prepare().bind().all() chain.
839
- if (this.isD1ConvenienceCall(node, adapter, sourceCode)) {
840
- return { parameterized: true, injectionRisk: false };
841
- }
842
- // Check for DB wrapper functions with bind params — d1Query(sql, params)
843
- // sends params as separate JSON field to the D1 REST API, which is true
844
- // parameterization (not string interpolation). Same security guarantee
845
- // as .prepare().bind() but expressed as a direct function call.
846
- if (this.isWrapperFunctionWithBindParams(node, adapter, sourceCode, config.dbWrapperNames ?? [])) {
847
- return { parameterized: true, injectionRisk: false };
848
- }
849
- const parameterized = (config.securityPatterns?.parameterizedQueries || []).some(pattern => text.includes(pattern));
850
- if (parameterized) {
851
- return { parameterized: true, injectionRisk: false };
852
- }
853
- // If the adapter doesn't implement the dynamic-string capability, we can't
854
- // determine whether the query text was constructed unsafely. Err on the
855
- // quiet side no capability means no injection-risk finding.
856
- if (!adapter.isDynamicStringConstruction || !adapter.getDynamicParts) {
857
- return { parameterized: false, injectionRisk: false };
858
- }
859
- // Only a dynamically-constructed string (template literal, binary +, etc.)
860
- // can be an injection risk. Plain string literals are always safe.
861
- if (!adapter.isDynamicStringConstruction(node)) {
862
- return { parameterized: false, injectionRisk: false };
789
+ // Fallback for adapters without isSafeInterpolation: resolve identifiers
790
+ // to compile-time constants only.
791
+ if (part.isIdentifier) {
792
+ const resolved = part.node && adapter.resolveLocalConstant
793
+ ? adapter.resolveLocalConstant(part.node, ast, sourceCode)
794
+ : null;
795
+ if (resolved && resolved.isStatic)
796
+ return true;
797
+ return false;
798
+ }
799
+ // A non-identifier expression with no safety analysis available — can't
800
+ // prove it safe, so treat it as unresolved.
801
+ return false;
802
+ }
803
+ function checkQuerySecurity(node, text, ast, scan) {
804
+ const { adapter, sourceCode, config } = scan;
805
+ // Parameterized chains (.prepare().bind(), .exec() spread, D1 convenience,
806
+ // DB wrappers) and explicit parameterization are always safe.
807
+ if (isParameterizedByChain(node, adapter, sourceCode, config)) {
808
+ return { parameterized: true, injectionRisk: false };
809
+ }
810
+ if ((config.securityPatterns?.parameterizedQueries || []).some(p => text.includes(p))) {
811
+ return { parameterized: true, injectionRisk: false };
812
+ }
813
+ // No dynamic-string capability can't prove unsafe; err quiet.
814
+ if (!adapter.isDynamicStringConstruction || !adapter.getDynamicParts) {
815
+ return { parameterized: false, injectionRisk: false };
816
+ }
817
+ // Only a dynamically-constructed string carrying SQL keywords can inject.
818
+ if (!adapter.isDynamicStringConstruction(node) || !containsSQLKeywords(text)) {
819
+ return { parameterized: false, injectionRisk: false };
820
+ }
821
+ const unresolved = adapter.getDynamicParts(node, sourceCode)
822
+ .filter(part => !isSafeDynamicPart(part, ast, scan))
823
+ .map(part => part.text);
824
+ if (unresolved.length === 0) {
825
+ return { parameterized: false, injectionRisk: false };
826
+ }
827
+ return {
828
+ parameterized: false,
829
+ injectionRisk: true,
830
+ message: `Cannot protect interpolated content: ${unresolved.map(id => '${' + id + '}').join(', ')}`,
831
+ };
832
+ }
833
+ function extractCallExpressionMethod(callExpr, adapter, sourceCode) {
834
+ // Extract the method name from the callee of a call expression.
835
+ // For `sql.exec(...)` the callee is a member_expression whose
836
+ // property_identifier is "exec". Walking the AST avoids picking up SQL
837
+ // keywords (COUNT, JOIN, WHERE, ...) that appear inside template literals
838
+ // in the call arguments, which a regex scan of the full call-expression
839
+ // text would incorrectly match.
840
+ const children = adapter.getChildren(callExpr);
841
+ const callee = children.find(c => {
842
+ const t = adapter.getNodeType(c);
843
+ return t === 'member_expression' || t === 'identifier';
844
+ });
845
+ if (callee) {
846
+ const calleeType = adapter.getNodeType(callee);
847
+ if (calleeType === 'member_expression') {
848
+ const mc = adapter.getChildren(callee);
849
+ const prop = mc.find(c => adapter.getNodeType(c) === 'property_identifier');
850
+ if (prop)
851
+ return adapter.getNodeText(prop, sourceCode);
863
852
  }
864
- // Still require SQL keywords in the text — a dynamic string without them
865
- // isn't a SQL injection.
866
- if (!this.containsSQLKeywords(text)) {
867
- return { parameterized: false, injectionRisk: false };
853
+ else {
854
+ // Bare identifier call (e.g. `exec(...)`)
855
+ return adapter.getNodeText(callee, sourceCode);
868
856
  }
869
- // Extract the dynamic sub-parts and check whether they're resolvable.
870
- const dynamicParts = adapter.getDynamicParts(node, sourceCode);
871
- const unresolved = [];
872
- for (const part of dynamicParts) {
873
- if (!part.isIdentifier) {
874
- // A non-identifier expression — check for known sanitizer wrappers.
875
- // escapeSql(x) wraps user input in single-quote escaping and is a
876
- // config-driven allowlist. An interpolation wrapped in a known
877
- // sanitizer isn't raw same pattern as dbWrapperNames for provenance.
878
- const normalized = part.text.trim();
879
- const sanitized = (config.sanitizerNames ?? []).some(name => normalized.startsWith(name + '(') || normalized.startsWith(name + ' ('));
880
- if (sanitized)
881
- continue; // Sanitizer-wrapped not raw interpolation
882
- // A non-identifier expression embedded in the string — definitely dynamic.
883
- unresolved.push(part.text);
884
- continue;
885
- }
886
- // Try to resolve the identifier via the adapter. If the part carries
887
- // its own AST node (populated by getDynamicParts), use it directly.
888
- // Otherwise we can't resolve — treat as unresolved.
889
- const resolved = part.node && adapter.resolveLocalConstant
890
- ? adapter.resolveLocalConstant(part.node, ast, sourceCode)
891
- : null;
892
- if (!resolved) {
893
- unresolved.push(part.text);
894
- continue;
857
+ }
858
+ // Fallback: regex on just the callee portion of the text
859
+ const callText = adapter.getNodeText(callExpr, sourceCode);
860
+ const m = callText.match(/\.(\w+)\s*[<(]/);
861
+ return m ? m[1] : 'unknown';
862
+ }
863
+ function extractMethodName(node, adapter, sourceCode) {
864
+ const nodeType = adapter.getNodeType(node);
865
+ // For template literals inside a DB-provenanced call, extract the method
866
+ // name from the enclosing call expression instead of the template body.
867
+ if (nodeType === 'template_string' || nodeType === 'template_literal') {
868
+ const parent = adapter.getParent(node);
869
+ if (parent && adapter.getNodeType(parent) === 'arguments') {
870
+ const callExpr = adapter.getParent(parent);
871
+ if (callExpr && adapter.getNodeType(callExpr) === 'call_expression') {
872
+ return extractCallExpressionMethod(callExpr, adapter, sourceCode);
895
873
  }
896
- if (!resolved.isStatic) {
897
- unresolved.push(part.text);
898
- continue;
874
+ }
875
+ }
876
+ // For call expressions themselves, extract from the callee child. The
877
+ // full node text includes the arguments (which may contain template
878
+ // literals with SQL keywords), so a naive regex scan of the whole text
879
+ // can match COUNT, JOIN, WHERE, etc. instead of the real method name.
880
+ if (nodeType === 'call_expression') {
881
+ return extractCallExpressionMethod(node, adapter, sourceCode);
882
+ }
883
+ const text = adapter.getNodeText(node, sourceCode);
884
+ const match = text.match(/([\p{L}\p{N}_]+)\s*\(/u);
885
+ return match ? match[1] : 'unknown';
886
+ }
887
+ /**
888
+ * Spec 21 R6.2: Three-tier org-filter detection.
889
+ *
890
+ * Tier 1 (config-primary): `orgFilterTables` — the user's explicit declaration
891
+ * of which tables are multi-tenant. Tenancy is policy; this is the
892
+ * declaration of record.
893
+ *
894
+ * Tier 2 (usage-inference secondary): A table requires the filter if the
895
+ * project's own corpus shows it scoped — i.e., a column matching
896
+ * `orgFilterColumns` (default: org_id/tenant_id/organization_id/workspace_id)
897
+ * exists on it in the schema definitions in config.
898
+ * This is what makes non-English table names (e.g., 注文) detectable
899
+ * with zero explicit orgFilterTables declaration.
900
+ *
901
+ * Tier 3 (fallback): English table list retained as defaults, evidence-tagged
902
+ * `fallback` like every other name list in Spec 21.
903
+ */
904
+ function requiresOrgFilter(tables, config) {
905
+ const orgFilterTables = config.orgFilterTables ?? [];
906
+ const orgFilterColumns = config.orgFilterColumns ?? ['org_id', 'tenant_id', 'organization_id', 'workspace_id'];
907
+ const schemas = config.schemas ?? [];
908
+ const fallbackOrgTables = ['users', 'projects', 'orders', 'customers', 'accounts', 'teams'];
909
+ // Build a lookup set of tables from schema definitions that have an
910
+ // org-filter column — this is the usage-inference tier.
911
+ const schemaOrgTables = new Set();
912
+ for (const schema of schemas) {
913
+ for (const table of schema.tables) {
914
+ if (table.columns.some(c => orgFilterColumns.includes(c.name.toLowerCase()))) {
915
+ schemaOrgTables.add(table.name.toLowerCase());
899
916
  }
900
917
  }
901
- if (unresolved.length === 0) {
902
- // All dynamic parts resolved to static content — safe.
903
- return { parameterized: false, injectionRisk: false };
918
+ }
919
+ return tables.some(table => {
920
+ const tableLower = table.toLowerCase();
921
+ // Tier 1: config-primary — explicit user declaration
922
+ if (orgFilterTables.some(t => t.toLowerCase() === tableLower)) {
923
+ return true;
904
924
  }
905
- const names = unresolved.map(id => '${' + id + '}').join(', ');
906
- return {
907
- parameterized: false,
908
- injectionRisk: true,
909
- message: `Cannot protect interpolated content: ${names}`,
910
- };
925
+ // Tier 2: schema-based inference table has an org-filter column
926
+ if (schemaOrgTables.has(tableLower)) {
927
+ return true;
928
+ }
929
+ // Tier 3: English fallback
930
+ return fallbackOrgTables.includes(tableLower);
931
+ });
932
+ }
933
+ function isStringLiteral(node, adapter) {
934
+ return node.type === 'string' || node.type === 'template_string';
935
+ }
936
+ function isConnectionString(text) {
937
+ const patterns = [
938
+ /mongodb:\/\//i,
939
+ /postgres:\/\//i,
940
+ /mysql:\/\//i,
941
+ /Server=.*;Database=/i,
942
+ /Data Source=.*;Initial Catalog=/i
943
+ ];
944
+ return patterns.some(pattern => pattern.test(text));
945
+ }
946
+ // ── R4.1: Loop-query detection ──────────────────────────────────────
947
+ /**
948
+ * R4.1: Find database queries inside loops and flag them as N+1 risks.
949
+ * Each finding carries the query call location (never line 1).
950
+ */
951
+ function checkLoopQueries(ast, scan) {
952
+ const { adapter, sourceCode, provenanceContext } = scan;
953
+ const violations = [];
954
+ // Spec 21: provenance-gated detection of database calls.
955
+ const dbNodes = adapter.findNodes(ast, {
956
+ custom: (node) => isDbCallNode(node, adapter, sourceCode, provenanceContext),
957
+ });
958
+ const reported = new Set();
959
+ const loopOrdinals = new Map();
960
+ for (const node of dbNodes) {
961
+ const nodeText = adapter.getNodeText(node, sourceCode);
962
+ if (!nodeText || nodeText.trim().length < 10)
963
+ continue;
964
+ const loopInfo = findEnclosingLoop(node, adapter);
965
+ if (!loopInfo)
966
+ continue;
967
+ // R4.1: query node's actual location (never line 1) + runtime dedup.
968
+ const queryLine = node.location.start.line;
969
+ const dedupKey = `${queryLine}:${loopInfo.loopNode.location.start.line}`;
970
+ if (reported.has(dedupKey))
971
+ continue;
972
+ reported.add(dedupKey);
973
+ const sym = nextSymbol(findEnclosingFunctionName(node, adapter), 'loop-query', loopOrdinals);
974
+ // R4.2: Nested-loop attribution.
975
+ const depthMsg = loopInfo.depth > 1 ? ` (nested ${loopInfo.depth} levels deep)` : '';
976
+ violations.push(makeViolation(ast.filePath, node.location.start, `Database query inside loop${depthMsg} ` +
977
+ `(loop at line ${loopInfo.loopNode.location.start.line}). ` +
978
+ `This may cause N+1 performance issues. Consider batching queries or using a join.`, { severity: 'warning', rule: 'loop-query', symbol: sym }));
911
979
  }
912
- extractCallExpressionMethod(callExpr, adapter, sourceCode) {
913
- // Extract the method name from the callee of a call expression.
914
- // For `sql.exec(...)` the callee is a member_expression whose
915
- // property_identifier is "exec". Walking the AST avoids picking up SQL
916
- // keywords (COUNT, JOIN, WHERE, ...) that appear inside template literals
917
- // in the call arguments, which a regex scan of the full call-expression
918
- // text would incorrectly match.
919
- const children = adapter.getChildren(callExpr);
920
- const callee = children.find(c => {
921
- const t = adapter.getNodeType(c);
922
- return t === 'member_expression' || t === 'identifier';
923
- });
924
- if (callee) {
925
- const calleeType = adapter.getNodeType(callee);
926
- if (calleeType === 'member_expression') {
927
- const mc = adapter.getChildren(callee);
928
- const prop = mc.find(c => adapter.getNodeType(c) === 'property_identifier');
929
- if (prop)
930
- return adapter.getNodeText(prop, sourceCode);
931
- }
932
- else {
933
- // Bare identifier call (e.g. `exec(...)`)
934
- return adapter.getNodeText(callee, sourceCode);
980
+ return violations;
981
+ }
982
+ /**
983
+ * R4.1: Determine if a node is a database call expression.
984
+ * Spec 21: When provenance context is available, uses provenance-based detection
985
+ * (conjunctive guard never name alone). In names mode or without context,
986
+ * falls back to the legacy dbPatterns text match.
987
+ */
988
+ function isDbCallNode(node, adapter, sourceCode, provenanceContext) {
989
+ // Spec 21: Provenance-first detection when context is available
990
+ if (provenanceContext && provenanceContext.mode !== 'names') {
991
+ // In provenance or hybrid mode, use provenance check
992
+ if (isFunctionCall(node, adapter)) {
993
+ if (isDBProvenanced(node, { adapter, sourceCode, context: provenanceContext, methods: DB_CALL_METHODS })) {
994
+ return true;
935
995
  }
936
996
  }
937
- // Fallback: regex on just the callee portion of the text
938
- const callText = adapter.getNodeText(callExpr, sourceCode);
939
- const m = callText.match(/\.(\w+)\s*[<(]/);
940
- return m ? m[1] : 'unknown';
941
- }
942
- extractMethodName(node, adapter, sourceCode) {
943
- const nodeType = adapter.getNodeType(node);
944
- // For template literals inside a DB-provenanced call, extract the method
945
- // name from the enclosing call expression instead of the template body.
946
- if (nodeType === 'template_string' || nodeType === 'template_literal') {
997
+ // Spec 17 R2: Template literal is a DB node only when it sits
998
+ // inside a DB-provenanced call's arguments — no content scan.
999
+ if (isTemplateLiteral(node, adapter)) {
947
1000
  const parent = adapter.getParent(node);
948
1001
  if (parent && adapter.getNodeType(parent) === 'arguments') {
949
1002
  const callExpr = adapter.getParent(parent);
950
1003
  if (callExpr && adapter.getNodeType(callExpr) === 'call_expression') {
951
- return this.extractCallExpressionMethod(callExpr, adapter, sourceCode);
952
- }
953
- }
954
- }
955
- // For call expressions themselves, extract from the callee child. The
956
- // full node text includes the arguments (which may contain template
957
- // literals with SQL keywords), so a naive regex scan of the whole text
958
- // can match COUNT, JOIN, WHERE, etc. instead of the real method name.
959
- if (nodeType === 'call_expression') {
960
- return this.extractCallExpressionMethod(node, adapter, sourceCode);
961
- }
962
- const text = adapter.getNodeText(node, sourceCode);
963
- const match = text.match(/([\p{L}\p{N}_]+)\s*\(/u);
964
- return match ? match[1] : 'unknown';
965
- }
966
- /**
967
- * Spec 21 R6.2: Three-tier org-filter detection.
968
- *
969
- * Tier 1 (config-primary): `orgFilterTables` — the user's explicit declaration
970
- * of which tables are multi-tenant. Tenancy is policy; this is the
971
- * declaration of record.
972
- *
973
- * Tier 2 (usage-inference secondary): A table requires the filter if the
974
- * project's own corpus shows it scoped — i.e., a column matching
975
- * `orgFilterColumns` (default: org_id/tenant_id/organization_id/workspace_id)
976
- * exists on it in the schema definitions in config.
977
- * This is what makes non-English table names (e.g., 注文) detectable
978
- * with zero explicit orgFilterTables declaration.
979
- *
980
- * Tier 3 (fallback): English table list retained as defaults, evidence-tagged
981
- * `fallback` like every other name list in Spec 21.
982
- */
983
- requiresOrgFilter(tables, config) {
984
- const orgFilterTables = config.orgFilterTables ?? [];
985
- const orgFilterColumns = config.orgFilterColumns ?? ['org_id', 'tenant_id', 'organization_id', 'workspace_id'];
986
- const schemas = config.schemas ?? [];
987
- const fallbackOrgTables = ['users', 'projects', 'orders', 'customers', 'accounts', 'teams'];
988
- // Build a lookup set of tables from schema definitions that have an
989
- // org-filter column — this is the usage-inference tier.
990
- const schemaOrgTables = new Set();
991
- for (const schema of schemas) {
992
- for (const table of schema.tables) {
993
- if (table.columns.some(c => orgFilterColumns.includes(c.name.toLowerCase()))) {
994
- schemaOrgTables.add(table.name.toLowerCase());
1004
+ return isDBProvenanced(callExpr, { adapter, sourceCode, context: provenanceContext, methods: DB_CALL_METHODS });
995
1005
  }
996
1006
  }
1007
+ return false;
997
1008
  }
998
- return tables.some(table => {
999
- const tableLower = table.toLowerCase();
1000
- // Tier 1: config-primary — explicit user declaration
1001
- if (orgFilterTables.some(t => t.toLowerCase() === tableLower)) {
1002
- return true;
1003
- }
1004
- // Tier 2: schema-based inference — table has an org-filter column
1005
- if (schemaOrgTables.has(tableLower)) {
1006
- return true;
1007
- }
1008
- // Tier 3: English fallback
1009
- return fallbackOrgTables.includes(tableLower);
1010
- });
1011
- }
1012
- isStringLiteral(node, adapter) {
1013
- return node.type === 'string' || node.type === 'template_string';
1014
- }
1015
- isConnectionString(text) {
1016
- const patterns = [
1017
- /mongodb:\/\//i,
1018
- /postgres:\/\//i,
1019
- /mysql:\/\//i,
1020
- /Server=.*;Database=/i,
1021
- /Data Source=.*;Initial Catalog=/i
1022
- ];
1023
- return patterns.some(pattern => pattern.test(text));
1009
+ return false;
1024
1010
  }
1025
- // ── R4.1: Loop-query detection ──────────────────────────────────────
1026
- /**
1027
- * R4.1: Find database queries inside loops and flag them as N+1 risks.
1028
- * Each finding carries the query call location (never line 1).
1029
- */
1030
- checkLoopQueries(ast, adapter, sourceCode, config, provenanceContext) {
1031
- const violations = [];
1032
- // Find all nodes that look like database calls (Spec 21: provenance-gated)
1033
- const dbNodes = adapter.findNodes(ast, {
1034
- custom: (node) => this.isDbCallNode(node, adapter, sourceCode, provenanceContext),
1035
- });
1036
- // Track reported line+loop combos to avoid duplicates (runtime dedup only)
1037
- const reported = new Set();
1038
- // Track symbol ordinals per enclosing function for fingerprint stability
1039
- const loopOrdinals = new Map();
1040
- for (const node of dbNodes) {
1041
- const nodeText = adapter.getNodeText(node, sourceCode);
1042
- if (!nodeText || nodeText.trim().length < 10)
1043
- continue;
1044
- const loopInfo = this.findEnclosingLoop(node, adapter);
1045
- if (!loopInfo)
1046
- continue;
1047
- // R4.1: Use query node's actual location (never line 1)
1048
- const queryLine = node.location.start.line;
1049
- // Deduplicate: same line + same loop line = already reported
1050
- const dedupKey = `${queryLine}:${loopInfo.loopNode.location.start.line}`;
1051
- if (reported.has(dedupKey))
1052
- continue;
1053
- reported.add(dedupKey);
1054
- // Stable fingerprint symbol: enclosing function + ordinal
1055
- const enclosingFn = this.findEnclosingFunctionName(node, adapter);
1056
- const baseSym = `${enclosingFn}:loop-query`;
1057
- const count = (loopOrdinals.get(baseSym) ?? 0) + 1;
1058
- loopOrdinals.set(baseSym, count);
1059
- const sym = count > 1 ? `${baseSym}:${count}` : baseSym;
1060
- // R4.2: Nested-loop attribution
1061
- const depthMsg = loopInfo.depth > 1
1062
- ? ` (nested ${loopInfo.depth} levels deep)`
1063
- : '';
1064
- violations.push(this.createViolation(ast.filePath, node.location.start, // query-call line, never line 1
1065
- `Database query inside loop${depthMsg} ` +
1066
- `(loop at line ${loopInfo.loopNode.location.start.line}). ` +
1067
- `This may cause N+1 performance issues. Consider batching queries or using a join.`, 'warning', // R7: loop-query → warning
1068
- 'loop-query', undefined, sym));
1011
+ // Legacy fallback: name-based matching for names mode / no context.
1012
+ // Template-literal content scanning is removed (Spec 17 R2) — without
1013
+ // provenance context, we can't determine if a template literal is SQL;
1014
+ // function-call-based SQL detection still works via dbPatterns match.
1015
+ const nodeText = adapter.getNodeText(node, sourceCode);
1016
+ if (isFunctionCall(node, adapter)) {
1017
+ const dbPatterns = ['select', 'insert', 'update', 'delete', 'from', 'where', 'execute', 'query', 'find', 'aggregate', 'count', 'distinct'];
1018
+ if (dbPatterns.some(pattern => nodeText.toLowerCase().includes(pattern))) {
1019
+ return true;
1069
1020
  }
1070
- return violations;
1071
1021
  }
1072
- /**
1073
- * R4.1: Determine if a node is a database call expression.
1074
- * Lightweight check — reused from extractDatabaseCalls logic.
1075
- */
1076
- /**
1077
- * R4.1: Determine if a node is a database call expression.
1078
- * Spec 21: When provenance context is available, uses provenance-based detection
1079
- * (conjunctive guard never name alone). In names mode or without context,
1080
- * falls back to the legacy dbPatterns text match.
1081
- */
1082
- isDbCallNode(node, adapter, sourceCode, provenanceContext) {
1083
- // Spec 21: Provenance-first detection when context is available
1084
- if (provenanceContext && provenanceContext.mode !== 'names') {
1085
- // In provenance or hybrid mode, use provenance check
1086
- if (this.isFunctionCall(node, adapter)) {
1087
- if (isDBProvenanced(node, adapter, sourceCode, provenanceContext, DB_CALL_METHODS)) {
1088
- return true;
1089
- }
1090
- }
1091
- // Spec 17 R2: Template literal is a DB node only when it sits
1092
- // inside a DB-provenanced call's arguments — no content scan.
1093
- if (this.isTemplateLiteral(node, adapter)) {
1094
- const parent = adapter.getParent(node);
1095
- if (parent && adapter.getNodeType(parent) === 'arguments') {
1096
- const callExpr = adapter.getParent(parent);
1097
- if (callExpr && adapter.getNodeType(callExpr) === 'call_expression') {
1098
- return isDBProvenanced(callExpr, adapter, sourceCode, provenanceContext, DB_CALL_METHODS);
1099
- }
1100
- }
1101
- return false;
1102
- }
1103
- return false;
1022
+ return false;
1023
+ }
1024
+ /**
1025
+ * R4.1/R4.2: Walk the parent chain to find the innermost enclosing loop.
1026
+ * Returns the loop node and nesting depth.
1027
+ *
1028
+ * Detects:
1029
+ * - for / while / do loops via adapter.isLoop()
1030
+ * - .forEach / .map / .filter callbacks via AST pattern matching
1031
+ */
1032
+ function findEnclosingLoop(node, adapter) {
1033
+ let current = node;
1034
+ let depth = 0;
1035
+ const foundLoops = [];
1036
+ while (current) {
1037
+ const parent = adapter.getParent(current);
1038
+ if (!parent)
1039
+ break;
1040
+ // Check for language-level loops (for, while, do)
1041
+ if (adapter.isLoop(parent)) {
1042
+ foundLoops.push(parent);
1104
1043
  }
1105
- // Legacy fallback: name-based matching for names mode / no context.
1106
- // Template-literal content scanning is removed (Spec 17 R2) — without
1107
- // provenance context, we can't determine if a template literal is SQL;
1108
- // function-call-based SQL detection still works via dbPatterns match.
1109
- const nodeText = adapter.getNodeText(node, sourceCode);
1110
- if (this.isFunctionCall(node, adapter)) {
1111
- const dbPatterns = ['select', 'insert', 'update', 'delete', 'from', 'where', 'execute', 'query', 'find', 'aggregate', 'count', 'distinct'];
1112
- if (dbPatterns.some(pattern => nodeText.toLowerCase().includes(pattern))) {
1113
- return true;
1114
- }
1044
+ // Check for iterator callbacks (.forEach, .map, .filter, etc.)
1045
+ if (isIteratorCallback(parent, adapter)) {
1046
+ foundLoops.push(parent);
1115
1047
  }
1048
+ current = parent;
1049
+ }
1050
+ if (foundLoops.length === 0)
1051
+ return null;
1052
+ // R4.1: Innermost is the first one we found (closest to node)
1053
+ // R4.2: Total count is the nesting depth
1054
+ return {
1055
+ loopNode: foundLoops[0],
1056
+ depth: foundLoops.length,
1057
+ };
1058
+ }
1059
+ /**
1060
+ * R4.1: Check if a node is a call_expression invoking an iterator method
1061
+ * (.forEach, .map, .filter, .reduce, .some, .every) — these create
1062
+ * implicit loops where a DB query inside the callback is an N+1 risk.
1063
+ */
1064
+ function isIteratorCallback(node, adapter) {
1065
+ // Must be a call_expression
1066
+ if (node.type !== 'call_expression')
1067
+ return false;
1068
+ // Callee must be a member_expression whose property matches iterator method names
1069
+ const children = adapter.getChildren(node);
1070
+ const callee = children.find(c => c.type === 'member_expression');
1071
+ if (!callee)
1116
1072
  return false;
1073
+ const calleeChildren = adapter.getChildren(callee);
1074
+ const propertyNode = calleeChildren.find(c => c.type === 'property_identifier' || c.type === 'string');
1075
+ if (!propertyNode)
1076
+ return false;
1077
+ const methodName = adapter.getNodeType(propertyNode) === 'property_identifier'
1078
+ ? propertyNode.text ?? adapter.getNodeText(propertyNode, '')
1079
+ : '';
1080
+ // Normalize: the method name might come from the node type or need text extraction
1081
+ const iteratorMethods = ['forEach', 'map', 'filter', 'reduce', 'some', 'every', 'find', 'findIndex', 'flatMap'];
1082
+ // Try multiple ways to get the method name
1083
+ const propText = methodName || getPropertyName(propertyNode, adapter);
1084
+ return iteratorMethods.includes(propText);
1085
+ }
1086
+ /**
1087
+ * Extract the property name from a property_identifier node.
1088
+ */
1089
+ function getPropertyName(node, adapter) {
1090
+ // Try named children
1091
+ if (node.name)
1092
+ return node.name;
1093
+ if (node.text)
1094
+ return node.text;
1095
+ // Try to get it from children
1096
+ const children = adapter.getChildren(node);
1097
+ for (const child of children) {
1098
+ if (child.name)
1099
+ return child.name;
1100
+ if (child.text)
1101
+ return child.text;
1117
1102
  }
1118
- /**
1119
- * R4.1/R4.2: Walk the parent chain to find the innermost enclosing loop.
1120
- * Returns the loop node and nesting depth.
1121
- *
1122
- * Detects:
1123
- * - for / while / do loops via adapter.isLoop()
1124
- * - .forEach / .map / .filter callbacks via AST pattern matching
1125
- */
1126
- findEnclosingLoop(node, adapter) {
1127
- let current = node;
1128
- let depth = 0;
1129
- const foundLoops = [];
1130
- while (current) {
1131
- const parent = adapter.getParent(current);
1132
- if (!parent)
1133
- break;
1134
- // Check for language-level loops (for, while, do)
1135
- if (adapter.isLoop(parent)) {
1136
- foundLoops.push(parent);
1137
- }
1138
- // Check for iterator callbacks (.forEach, .map, .filter, etc.)
1139
- if (this.isIteratorCallback(parent, adapter)) {
1140
- foundLoops.push(parent);
1141
- }
1142
- current = parent;
1103
+ return '';
1104
+ }
1105
+ /**
1106
+ * Walk the parent chain to find the enclosing function/method/arrow name.
1107
+ * Returns 'top-level' if no enclosing function is found.
1108
+ *
1109
+ * Used for stable fingerprint symbols the enclosing function name is
1110
+ * immune to line drift (Spec 18 Gap 2).
1111
+ */
1112
+ function findEnclosingFunctionName(node, adapter) {
1113
+ let current = adapter.getParent(node);
1114
+ while (current) {
1115
+ const type = adapter.getNodeType(current);
1116
+ // Arrow functions, function declarations, function expressions
1117
+ if (type === 'arrow_function' ||
1118
+ type === 'function_declaration' ||
1119
+ type === 'function_expression' ||
1120
+ type === 'generator_function_declaration' ||
1121
+ type === 'generator_function_expression') {
1122
+ const name = getNodeName(current, adapter);
1123
+ if (name)
1124
+ return name;
1143
1125
  }
1144
- if (foundLoops.length === 0)
1145
- return null;
1146
- // R4.1: Innermost is the first one we found (closest to node)
1147
- // R4.2: Total count is the nesting depth
1148
- return {
1149
- loopNode: foundLoops[0],
1150
- depth: foundLoops.length,
1151
- };
1152
- }
1153
- /**
1154
- * R4.1: Check if a node is a call_expression invoking an iterator method
1155
- * (.forEach, .map, .filter, .reduce, .some, .every) — these create
1156
- * implicit loops where a DB query inside the callback is an N+1 risk.
1157
- */
1158
- isIteratorCallback(node, adapter) {
1159
- // Must be a call_expression
1160
- if (node.type !== 'call_expression')
1161
- return false;
1162
- // Callee must be a member_expression whose property matches iterator method names
1163
- const children = adapter.getChildren(node);
1164
- const callee = children.find(c => c.type === 'member_expression');
1165
- if (!callee)
1166
- return false;
1167
- const calleeChildren = adapter.getChildren(callee);
1168
- const propertyNode = calleeChildren.find(c => c.type === 'property_identifier' || c.type === 'string');
1169
- if (!propertyNode)
1170
- return false;
1171
- const methodName = adapter.getNodeType(propertyNode) === 'property_identifier'
1172
- ? propertyNode.text ?? adapter.getNodeText(propertyNode, '')
1173
- : '';
1174
- // Normalize: the method name might come from the node type or need text extraction
1175
- const iteratorMethods = ['forEach', 'map', 'filter', 'reduce', 'some', 'every', 'find', 'findIndex', 'flatMap'];
1176
- // Try multiple ways to get the method name
1177
- const propText = methodName || this.getPropertyName(propertyNode, adapter);
1178
- return iteratorMethods.includes(propText);
1179
- }
1180
- /**
1181
- * Extract the property name from a property_identifier node.
1182
- */
1183
- getPropertyName(node, adapter) {
1184
- // Try named children
1185
- if (node.name)
1186
- return node.name;
1187
- if (node.text)
1188
- return node.text;
1189
- // Try to get it from children
1190
- const children = adapter.getChildren(node);
1191
- for (const child of children) {
1192
- if (child.name)
1193
- return child.name;
1194
- if (child.text)
1195
- return child.text;
1126
+ // Method definitions on classes/objects
1127
+ if (type === 'method_definition' || adapter.isMethod(current)) {
1128
+ const name = getNodeName(current, adapter);
1129
+ if (name)
1130
+ return name;
1196
1131
  }
1197
- return '';
1132
+ current = adapter.getParent(current);
1198
1133
  }
1199
- /**
1200
- * Walk the parent chain to find the enclosing function/method/arrow name.
1201
- * Returns 'top-level' if no enclosing function is found.
1202
- *
1203
- * Used for stable fingerprint symbols — the enclosing function name is
1204
- * immune to line drift (Spec 18 Gap 2).
1205
- */
1206
- findEnclosingFunctionName(node, adapter) {
1207
- let current = adapter.getParent(node);
1208
- while (current) {
1209
- const type = adapter.getNodeType(current);
1210
- // Arrow functions, function declarations, function expressions
1211
- if (type === 'arrow_function' ||
1212
- type === 'function_declaration' ||
1213
- type === 'function_expression' ||
1214
- type === 'generator_function_declaration' ||
1215
- type === 'generator_function_expression') {
1216
- const name = this.getNodeName(current, adapter);
1217
- if (name)
1218
- return name;
1219
- }
1220
- // Method definitions on classes/objects
1221
- if (type === 'method_definition' || adapter.isMethod(current)) {
1222
- const name = this.getNodeName(current, adapter);
1134
+ return 'top-level';
1135
+ }
1136
+ /**
1137
+ * Extract the name/identifier from a function or method AST node.
1138
+ */
1139
+ function getNodeName(node, adapter) {
1140
+ // Try named children first
1141
+ if (node.name && typeof node.name === 'string')
1142
+ return node.name;
1143
+ if (node.text && typeof node.text === 'string')
1144
+ return node.text;
1145
+ // Fall back to the raw tree-sitter node's text content (leaf identifiers etc.)
1146
+ const rawText = node.raw?.text;
1147
+ if (typeof rawText === 'string' && rawText.length > 0)
1148
+ return rawText;
1149
+ // Walk children for identifier / property_identifier
1150
+ if (node.children) {
1151
+ for (const child of node.children) {
1152
+ const childType = adapter.getNodeType(child);
1153
+ if (childType === 'identifier' ||
1154
+ childType === 'property_identifier') {
1155
+ const name = getNodeName(child, adapter);
1223
1156
  if (name)
1224
1157
  return name;
1225
1158
  }
1226
- current = adapter.getParent(current);
1227
1159
  }
1228
- return 'top-level';
1229
1160
  }
1230
- /**
1231
- * Extract the name/identifier from a function or method AST node.
1232
- */
1233
- getNodeName(node, adapter) {
1234
- // Try named children first
1235
- if (node.name && typeof node.name === 'string')
1236
- return node.name;
1237
- if (node.text && typeof node.text === 'string')
1238
- return node.text;
1239
- // Fall back to the raw tree-sitter node's text content (leaf identifiers etc.)
1240
- const rawText = node.raw?.text;
1241
- if (typeof rawText === 'string' && rawText.length > 0)
1242
- return rawText;
1243
- // Walk children for identifier / property_identifier
1244
- if (node.children) {
1245
- for (const child of node.children) {
1246
- const childType = adapter.getNodeType(child);
1247
- if (childType === 'identifier' ||
1248
- childType === 'property_identifier') {
1249
- const name = this.getNodeName(child, adapter);
1250
- if (name)
1251
- return name;
1252
- }
1253
- }
1161
+ return '';
1162
+ }
1163
+ /**
1164
+ * Universal data access analyzer.
1165
+ */
1166
+ export class UniversalDataAccessAnalyzer extends UniversalAnalyzer {
1167
+ name = 'data-access';
1168
+ description = 'Analyzes database access patterns and data layer interactions';
1169
+ category = 'security';
1170
+ async analyzeAST(ast, adapter, config, sourceCode) {
1171
+ const violations = [];
1172
+ const finalConfig = { ...DEFAULT_DATA_ACCESS_CONFIG, ...config };
1173
+ // Spec 21 R1: provenance-primary detection (names owned by THIS analyzer).
1174
+ const detectionMode = finalConfig.detection?.mode ?? 'hybrid';
1175
+ const p0 = performance.now();
1176
+ const provenanceContext = buildProvenanceContext(ast, adapter, sourceCode, {
1177
+ mode: detectionMode,
1178
+ dbReceiverNames: finalConfig.dbReceiverNames,
1179
+ dbBindingNames: finalConfig.dbBindingNames,
1180
+ dbCallMethods: finalConfig.dbCallMethods,
1181
+ dbWrapperNames: finalConfig.dbWrapperNames,
1182
+ });
1183
+ const timingAcc = config._provenanceTiming;
1184
+ if (timingAcc)
1185
+ timingAcc.totalMs += performance.now() - p0;
1186
+ // Spec 34: bundle per-file context to stay under the 4-parameter gate.
1187
+ const scan = {
1188
+ adapter,
1189
+ sourceCode,
1190
+ dbImports: mapDatabaseImports(adapter.extractImports(ast), finalConfig),
1191
+ config: finalConfig,
1192
+ provenanceContext,
1193
+ };
1194
+ // Analyze each database call, tracking symbol ordinals for stable fingerprints.
1195
+ const symbolOrdinals = new Map();
1196
+ for (const call of extractDatabaseCalls(ast, scan)) {
1197
+ const analysis = analyzeQuery(call, sourceCode, finalConfig);
1198
+ violations.push(...checkViolations(call, analysis, {
1199
+ filePath: ast.filePath,
1200
+ config: finalConfig,
1201
+ symbolOrdinals,
1202
+ }));
1254
1203
  }
1255
- return '';
1204
+ // R4.1: loop-query (N+1) detection + general patterns.
1205
+ violations.push(...checkLoopQueries(ast, scan));
1206
+ violations.push(...checkGeneralPatterns(ast, adapter, sourceCode, finalConfig));
1207
+ return violations;
1256
1208
  }
1257
1209
  }
1258
1210
  //# sourceMappingURL=UniversalDataAccessAnalyzer.js.map