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