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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +119 -1
- package/dist/analyzers/__verify_self_probe.d.ts +2 -0
- package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
- package/dist/analyzers/__verify_self_probe.js +5 -0
- package/dist/analyzers/__verify_self_probe.js.map +1 -0
- package/dist/analyzers/analyzerUtils.d.ts +34 -1
- package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
- package/dist/analyzers/analyzerUtils.js +61 -22
- package/dist/analyzers/analyzerUtils.js.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
- package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
- package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
- package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
- package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
- package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/documentationAnalyzer.js +259 -198
- package/dist/analyzers/documentationAnalyzer.js.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.js +59 -46
- package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
- package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.js +10 -0
- package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
- package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.js +42 -44
- package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
- package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.js +30 -0
- package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
- package/dist/analyzers/provenance.d.ts +72 -2
- package/dist/analyzers/provenance.d.ts.map +1 -1
- package/dist/analyzers/provenance.js +462 -338
- package/dist/analyzers/provenance.js.map +1 -1
- package/dist/analyzers/reactAnalyzer.d.ts +11 -0
- package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/reactAnalyzer.js +188 -125
- package/dist/analyzers/reactAnalyzer.js.map +1 -1
- package/dist/analyzers/ruleRegistry.d.ts +118 -1
- package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
- package/dist/analyzers/ruleRegistry.js +1717 -104
- package/dist/analyzers/ruleRegistry.js.map +1 -1
- package/dist/analyzers/ruleTiming.d.ts +54 -0
- package/dist/analyzers/ruleTiming.d.ts.map +1 -0
- package/dist/analyzers/ruleTiming.js +71 -0
- package/dist/analyzers/ruleTiming.js.map +1 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +24 -186
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1081 -1061
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -212
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1518
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -472
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
- package/dist/analyzers/universal/schema/config.d.ts +31 -0
- package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/config.js +57 -0
- package/dist/analyzers/universal/schema/config.js.map +1 -0
- package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
- package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/discovery.js +616 -0
- package/dist/analyzers/universal/schema/discovery.js.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
- package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
- package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
- package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/migrations.js +238 -0
- package/dist/analyzers/universal/schema/migrations.js.map +1 -0
- package/dist/analyzers/universal/schema/types.d.ts +131 -0
- package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/types.js +10 -0
- package/dist/analyzers/universal/schema/types.js.map +1 -0
- package/dist/analyzers/universal/schema/violations.d.ts +44 -0
- package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/violations.js +54 -0
- package/dist/analyzers/universal/schema/violations.js.map +1 -0
- package/dist/auditRunner.d.ts.map +1 -1
- package/dist/auditRunner.js +101 -39
- package/dist/auditRunner.js.map +1 -1
- package/dist/cli.js +348 -40
- package/dist/cli.js.map +1 -1
- package/dist/config/configLoader.d.ts.map +1 -1
- package/dist/config/configLoader.js +9 -7
- package/dist/config/configLoader.js.map +1 -1
- package/dist/config/defaults.d.ts +12 -3
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +15 -5
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/effectiveConfig.d.ts +75 -0
- package/dist/config/effectiveConfig.d.ts.map +1 -0
- package/dist/config/effectiveConfig.js +213 -0
- package/dist/config/effectiveConfig.js.map +1 -0
- package/dist/config/pathProfiles.d.ts +7 -4
- package/dist/config/pathProfiles.d.ts.map +1 -1
- package/dist/config/pathProfiles.js +8 -10
- package/dist/config/pathProfiles.js.map +1 -1
- package/dist/config/thresholdRationales.d.ts +45 -0
- package/dist/config/thresholdRationales.d.ts.map +1 -0
- package/dist/config/thresholdRationales.js +67 -0
- package/dist/config/thresholdRationales.js.map +1 -0
- package/dist/conventions/conventionMiner.d.ts.map +1 -1
- package/dist/conventions/conventionMiner.js +6 -2
- package/dist/conventions/conventionMiner.js.map +1 -1
- package/dist/enforcement/diffGate.d.ts +55 -0
- package/dist/enforcement/diffGate.d.ts.map +1 -0
- package/dist/enforcement/diffGate.js +136 -0
- package/dist/enforcement/diffGate.js.map +1 -0
- package/dist/enforcement/gate.d.ts +36 -0
- package/dist/enforcement/gate.d.ts.map +1 -0
- package/dist/enforcement/gate.js +72 -0
- package/dist/enforcement/gate.js.map +1 -0
- package/dist/enforcement/suppressions.d.ts +41 -0
- package/dist/enforcement/suppressions.d.ts.map +1 -0
- package/dist/enforcement/suppressions.js +170 -0
- package/dist/enforcement/suppressions.js.map +1 -0
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -1
- package/dist/fingerprint.js.map +1 -1
- package/dist/grammars/tree-sitter-scss.wasm +0 -0
- package/dist/invariants/ruleEngine.d.ts +3 -4
- package/dist/invariants/ruleEngine.d.ts.map +1 -1
- package/dist/invariants/ruleEngine.js +2 -2
- package/dist/invariants/ruleEngine.js.map +1 -1
- package/dist/languages/LanguageOrchestrator.d.ts +14 -0
- package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
- package/dist/languages/LanguageOrchestrator.js +93 -55
- package/dist/languages/LanguageOrchestrator.js.map +1 -1
- package/dist/languages/LanguageRegistry.d.ts +8 -0
- package/dist/languages/LanguageRegistry.d.ts.map +1 -1
- package/dist/languages/LanguageRegistry.js +8 -0
- package/dist/languages/LanguageRegistry.js.map +1 -1
- package/dist/languages/RuntimeManager.d.ts +82 -60
- package/dist/languages/RuntimeManager.d.ts.map +1 -1
- package/dist/languages/RuntimeManager.js +404 -349
- package/dist/languages/RuntimeManager.js.map +1 -1
- package/dist/languages/UniversalAnalyzer.d.ts +51 -6
- package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
- package/dist/languages/UniversalAnalyzer.js +127 -88
- package/dist/languages/UniversalAnalyzer.js.map +1 -1
- package/dist/languages/adapterBridge.d.ts +36 -0
- package/dist/languages/adapterBridge.d.ts.map +1 -1
- package/dist/languages/adapterBridge.js +89 -35
- package/dist/languages/adapterBridge.js.map +1 -1
- package/dist/languages/go/GoAdapter.d.ts +66 -23
- package/dist/languages/go/GoAdapter.d.ts.map +1 -1
- package/dist/languages/go/GoAdapter.js +553 -474
- package/dist/languages/go/GoAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +38 -9
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +111 -74
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/converter.d.ts +18 -0
- package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/converter.js +18 -0
- package/dist/languages/tree-sitter/converter.js.map +1 -1
- package/dist/languages/tree-sitter/parser.d.ts +39 -2
- package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
- package/dist/languages/tree-sitter/parser.js +95 -12
- package/dist/languages/tree-sitter/parser.js.map +1 -1
- package/dist/languages/types.d.ts +67 -2
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -62
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1461 -791
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
- package/dist/mcp-tools/whitelistTools.d.ts +1 -1
- package/dist/mcp-tools-shared.d.ts.map +1 -1
- package/dist/mcp-tools-shared.js +1 -0
- package/dist/mcp-tools-shared.js.map +1 -1
- package/dist/mcpAuditJobs.d.ts.map +1 -1
- package/dist/mcpAuditJobs.js +1 -0
- package/dist/mcpAuditJobs.js.map +1 -1
- package/dist/pipeline.d.ts +30 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +527 -203
- package/dist/pipeline.js.map +1 -1
- package/dist/pipelineAdapters.d.ts +5 -12
- package/dist/pipelineAdapters.d.ts.map +1 -1
- package/dist/pipelineAdapters.js +175 -100
- package/dist/pipelineAdapters.js.map +1 -1
- package/dist/presets/presets.d.ts +54 -0
- package/dist/presets/presets.d.ts.map +1 -0
- package/dist/presets/presets.js +181 -0
- package/dist/presets/presets.js.map +1 -0
- package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
- package/dist/reporting/sarifReportGenerator.js +0 -2
- package/dist/reporting/sarifReportGenerator.js.map +1 -1
- package/dist/ruleAliases.d.ts +67 -0
- package/dist/ruleAliases.d.ts.map +1 -0
- package/dist/ruleAliases.js +79 -0
- package/dist/ruleAliases.js.map +1 -0
- package/dist/styles/cssAstExtractor.d.ts +20 -5
- package/dist/styles/cssAstExtractor.d.ts.map +1 -1
- package/dist/styles/cssAstExtractor.js +247 -9
- package/dist/styles/cssAstExtractor.js.map +1 -1
- package/dist/styles/styleIndexer.js +13 -4
- package/dist/styles/styleIndexer.js.map +1 -1
- package/dist/styles/tailwindConfigLoader.d.ts +8 -6
- package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
- package/dist/styles/tailwindConfigLoader.js +61 -15
- package/dist/styles/tailwindConfigLoader.js.map +1 -1
- package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
- package/dist/styles/tailwindUtilityExpander.js +13 -1
- package/dist/styles/tailwindUtilityExpander.js.map +1 -1
- package/dist/types.d.ts +201 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +7 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/hook-audit.sh +25 -7
- 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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
268
|
+
let performanceRisk = 'low';
|
|
269
|
+
if (call.tables.length > (config.performanceThresholds?.joinedTableCount || 4)) {
|
|
270
|
+
performanceRisk = 'high';
|
|
326
271
|
}
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (
|
|
712
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1132
|
+
current = adapter.getParent(current);
|
|
1130
1133
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
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
|