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