Skip to content

Commit 563daa1

Browse files
committed
fix(security): remediate CodeQL ReDoS, SQL injection, and CLI-injection alerts
Addresses the remaining open code-scanning alerts on main. The 138 alerts were only 5 rules; 118 were a single mechanical pattern. polynomial-redos (118): almost all were s.matches(".*RE.*"). String.matches already anchors, so the wrapping .* exists only to undo that anchoring, and .* plus an alternation is what backtracks. Rewritten to PatternUtil.containsPattern (find() over a cached Pattern) at 174 sites in 17 files -- more than the 118 flagged, since CodeQL only reports where taint reaches, and the untainted ones are the same hazard. Equivalence was checked by differential test over all 175 literals rather than by inspection. This changes behavior on multi-line input: `.` does not cross a newline, so the anchored form failed to match a keyword after a line break and find() matches it. That is a fix for intent classifiers, and only affects callers that do not pre-normalize. The rest were compiled Pattern constants with ambiguous quantifiers, fixed individually with possessive quantifiers or bounded gaps. PlanPatternLibrary also had a latent bug: [^from]+ is a character class, so any column with f, r, o or m in it (order_id) defeated the collapse. sql-injection (15): three different cases. CardinalityEstimationService was a real hole -- quoteIdentifier wrapped in quotes without doubling an embedded quote, so a table named `x" ; DROP TABLE users; --` escaped it. Four of the five other quoteIdentifier implementations here already escape correctly. It now delegates to the dialect's SamplingProvider (removing an if/else on dbType) and resolves both identifiers against information_schema first. MySQLPrivilegeCheckProvider concatenated a database name into a literal; now bound. QueryExecutorService and the EXPLAIN providers execute user SQL by design -- that is the Editor, guarded by the policy layer, not parameterizable. spring-disabled-csrf-protection: correct as-is; every route is STATELESS with header-carried tokens, so there is no cookie session to forge. Documented in place rather than changed. command-line-injection: spawn already passed array args, but authorize_url comes from a server response and the win32 branch goes through cmd. Now scheme-validated to http/https. Verified: full backend suite shows the same 13 failures / 4 errors as the untouched baseline (confirmed by stashing) -- no new regressions. 25 new/ touched unit tests and 259 MCP tests pass.
1 parent 0662187 commit 563daa1

33 files changed

Lines changed: 456 additions & 214 deletions

CLAUDE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,63 @@ points are covered by that single call site (`establishTunnel` and
351351
via `deepsql.ssh.host-guard.allowed-hosts` (exact host, or a leading-dot suffix like
352352
`.corp.internal`).
353353

354+
### CodeQL Remediation (code scanning, 138 alerts on main)
355+
356+
The 138 open alerts were only **5 rules**, and the counter badly overstates the
357+
work: 118 were one mechanical pattern. What was fixed and what was not:
358+
359+
- **`java/polynomial-redos` (118).** Nearly all were `s.matches(".*RE.*")`.
360+
`String.matches` anchors the whole input, so the wrapping `.*` exists only to
361+
undo that anchoring — and `.*` + alternation is the backtracking. Rewritten to
362+
`PatternUtil.containsPattern(s, "RE")` (`find()` over a cached compiled
363+
Pattern) at **174 sites in 17 files** — more than the 118 flagged, since
364+
CodeQL only reports where taint reaches. Equivalence was verified by
365+
differential test over all 175 literals, not by inspection.
366+
- **This is a behavior change on multi-line input.** `.` does not cross a
367+
newline, so the old form *failed* to match a keyword after a line break;
368+
`find()` matches it. That is a bug fix for intent classifiers, and it only
369+
affects callers that do not pre-normalize — `PromptIntentSignals.normalize`
370+
already collapses newlines, `ChatContextAssembler` does not.
371+
- The remainder were compiled `Pattern` constants with genuinely ambiguous
372+
quantifiers, fixed individually with possessive quantifiers / bounded gaps
373+
(`PostgresSlowLogPatterns`, `QueryNormalizer`, `OptdOptimizationService`,
374+
`SqlUsageService`, `QueryPlanCacheService`, `ChatHistoryService`,
375+
`CompanyKnowledgeService`, `QueryExecutionPolicyService`).
376+
- `PlanPatternLibraryService` also carried a real latent bug: `[^from]+` is a
377+
character class ("not f/r/o/m"), so any column containing those letters
378+
(`order_id`, `from_date`) defeated the collapse. Now a bounded lazy scan.
379+
- **`java/sql-injection` (15).** Not one bug — three distinct cases:
380+
- `CardinalityEstimationService` (6) was a **real vulnerability**:
381+
`quoteIdentifier` wrapped in quotes but never doubled an embedded quote, so
382+
a table named `x" ; DROP TABLE users; --` escaped the quoting. Four of the
383+
other five `quoteIdentifier` implementations in this repo already escape
384+
correctly — this one was the outlier. It now delegates to the dialect's
385+
`SamplingProvider` (also removing an if/else on `dbType`), and both
386+
identifiers are resolved against `information_schema` first, so only
387+
catalog-returned names ever reach interpolated SQL.
388+
- `MySQLPrivilegeCheckProvider` (1) concatenated a database name into a
389+
string literal; now a bind parameter.
390+
- `QueryExecutorService` (3) and the EXPLAIN providers (4) execute
391+
user-authored SQL **by design** — that is the Editor feature. They are not
392+
parameterizable; their protection is the guard layer in the SQL Editor Guard
393+
Rules above. Do not "fix" these by mangling the SQL.
394+
- **`java/spring-disabled-csrf-protection` (1).** Correct as-is and documented
395+
in `SecurityConfig`: every route is `STATELESS` with header-carried JWT/MCP
396+
tokens, so there is no ambient cookie session to forge. Re-enable CSRF the
397+
moment any cookie-based auth appears.
398+
- **`js/command-line-injection` (1).** `spawn` already used array args (no
399+
shell), but `authorize_url` comes from a server response and the win32 branch
400+
routes through `cmd`. Now scheme-validated to http/https before opening.
401+
402+
**Scan-flapping, confirmed.** Analyses on `main` report 137 results
403+
consistently — except commit `8b47c67`, which reported **3**. That is the commit
404+
GitHub labelled "Fixed in branch main"; the next healthy scan re-found
405+
everything and it showed as "Reappeared". Nothing was fixed or reverted. Before
406+
concluding an alert is resolved, check `results_count` on the analysis
407+
(`gh api repos/.../code-scanning/analyses?ref=...`) — a partial scan reads as a
408+
clean one. Note also that PR-triggered scans are diff-scoped and legitimately
409+
report 0 for untouched files.
410+
354411
### Data Model Rules
355412

356413
- **`mcp_tokens.user_id` is a non-null FK with no cascade.** Deleting a user who holds

backend/src/main/java/com/dbaagent/config/SecurityConfig.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ public class SecurityConfig {
5858
@Bean
5959
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
6060
http
61+
// Deliberate, not an oversight (CodeQL java/spring-disabled-csrf-protection):
62+
// every authenticated route is SessionCreationPolicy.STATELESS and carries
63+
// its credential in an Authorization / MCP token header, which a browser
64+
// does not attach automatically. With no ambient cookie session there is no
65+
// CSRF to forge. Re-enable the moment any cookie-based auth is introduced.
6166
.csrf(csrf -> csrf.disable())
6267
.cors(cors -> cors.configurationSource(corsConfigurationSource()));
6368

backend/src/main/java/com/dbaagent/provider/mysql/MySQLPrivilegeCheckProvider.java

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.springframework.stereotype.Component;
77

88
import java.sql.Connection;
9+
import java.sql.PreparedStatement;
910
import java.sql.ResultSet;
1011
import java.sql.SQLException;
1112
import java.sql.Statement;
@@ -50,22 +51,28 @@ private ConnectionTestResult.PrivilegeCheck checkSelectOnTables(
5051
Connection connection,
5152
String database
5253
) {
53-
try (Statement stmt = connection.createStatement()) {
54-
stmt.setQueryTimeout(10);
54+
boolean scopedToDatabase = database != null && !database.isEmpty();
55+
String listTablesQuery = scopedToDatabase
56+
? "SELECT table_name FROM information_schema.tables "
57+
+ "WHERE table_schema = ? AND table_type = 'BASE TABLE' LIMIT 5"
58+
: "SELECT table_name FROM information_schema.tables "
59+
+ "WHERE table_type = 'BASE TABLE' LIMIT 5";
5560

56-
String listTablesQuery = database != null && !database.isEmpty()
57-
? "SELECT table_name FROM information_schema.tables " +
58-
"WHERE table_schema = '" + database + "' " +
59-
"AND table_type = 'BASE TABLE' LIMIT 5"
60-
: "SELECT table_name FROM information_schema.tables " +
61-
"WHERE table_type = 'BASE TABLE' LIMIT 5";
61+
// Bound rather than concatenated: a database name containing an
62+
// apostrophe closed the literal and appended arbitrary SQL
63+
// (java/sql-injection).
64+
try (PreparedStatement stmt = connection.prepareStatement(listTablesQuery)) {
65+
stmt.setQueryTimeout(10);
66+
if (scopedToDatabase) {
67+
stmt.setString(1, database);
68+
}
6269

63-
ResultSet tablesRs = stmt.executeQuery(listTablesQuery);
6470
List<String> tables = new ArrayList<>();
65-
while (tablesRs.next()) {
66-
tables.add(tablesRs.getString(1));
71+
try (ResultSet tablesRs = stmt.executeQuery()) {
72+
while (tablesRs.next()) {
73+
tables.add(tablesRs.getString(1));
74+
}
6775
}
68-
tablesRs.close();
6976

7077
if (tables.isEmpty()) {
7178
return ConnectionTestResult.PrivilegeCheck.builder()

backend/src/main/java/com/dbaagent/service/ChatContextAssembler.java

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.dbaagent.repository.ColumnValueCacheRepository;
3636
import com.dbaagent.repository.SchemaDocumentationRepository;
3737
import com.dbaagent.service.brain.classification.SchemaClassificationService;
38+
import com.dbaagent.util.PatternUtil;
3839
import com.dbaagent.util.TokenEstimator;
3940
import com.dbaagent.service.SchemaObjectNameUtil;
4041
import com.dbaagent.service.SchemaTableMatchUtil;
@@ -146,10 +147,10 @@ public Set<ContextType> determineNeededContext(String message) {
146147
needed.add(ContextType.RELATIONSHIPS);
147148

148149
// Simple schema/structure questions - minimal context needed
149-
boolean isSimpleSchemaQuestion = lowerMessage.matches(".*(show|list|what).*(tables?|columns?|schema|views?).*") ||
150-
lowerMessage.matches(".*(how many|count).*(tables?|rows?|records?).*") ||
151-
lowerMessage.matches(".*(describe|structure|definition).*") ||
152-
lowerMessage.matches(".*(largest|biggest|smallest|size).*table.*");
150+
boolean isSimpleSchemaQuestion = PatternUtil.containsPattern(lowerMessage, "(show|list|what).*(tables?|columns?|schema|views?)") ||
151+
PatternUtil.containsPattern(lowerMessage, "(how many|count).*(tables?|rows?|records?)") ||
152+
PatternUtil.containsPattern(lowerMessage, "(describe|structure|definition)") ||
153+
PatternUtil.containsPattern(lowerMessage, "(largest|biggest|smallest|size).*table");
153154

154155
if (isSimpleSchemaQuestion) {
155156
// For simple questions, only add relationships (minimal context)
@@ -160,9 +161,9 @@ public Set<ContextType> determineNeededContext(String message) {
160161
needed.add(ContextType.SEMANTIC_MODEL);
161162

162163
// Performance-related questions (tight patterns to avoid false positives on data queries)
163-
if (lowerMessage.matches(".*(slow quer|performance|optimize|speed up|latency|execution time|response time).*") ||
164-
lowerMessage.matches(".*(why.{0,20}(slow|taking|long)|taking too long|how long.{0,10}(quer|execut)).*") ||
165-
lowerMessage.matches(".*(query.{0,10}(slow|fast|quick|seconds|minutes)|timeout|timed? out).*")) {
164+
if (PatternUtil.containsPattern(lowerMessage, "(slow quer|performance|optimize|speed up|latency|execution time|response time)") ||
165+
PatternUtil.containsPattern(lowerMessage, "(why.{0,20}(slow|taking|long)|taking too long|how long.{0,10}(quer|execut))") ||
166+
PatternUtil.containsPattern(lowerMessage, "(query.{0,10}(slow|fast|quick|seconds|minutes)|timeout|timed? out)")) {
166167
needed.add(ContextType.SLOW_QUERIES);
167168
needed.add(ContextType.REGRESSIONS);
168169
needed.add(ContextType.INDEX_RECOMMENDATIONS);
@@ -171,46 +172,46 @@ public Set<ContextType> determineNeededContext(String message) {
171172
}
172173

173174
// Tuning/configuration questions - Brain ML insights
174-
if (lowerMessage.matches(".*(tun(e|ing)|config|parameter|knob|setting|memory|buffer|cache).*") ||
175-
lowerMessage.matches(".*(workload|oltp|olap|batch|throughput|qps).*") ||
176-
lowerMessage.matches(".*(cardinality|selectivity|statistic|estimate|plan|cost).*") ||
177-
lowerMessage.matches(".*(recommend|suggestion|improve|better).*")) {
175+
if (PatternUtil.containsPattern(lowerMessage, "(tun(e|ing)|config|parameter|knob|setting|memory|buffer|cache)") ||
176+
PatternUtil.containsPattern(lowerMessage, "(workload|oltp|olap|batch|throughput|qps)") ||
177+
PatternUtil.containsPattern(lowerMessage, "(cardinality|selectivity|statistic|estimate|plan|cost)") ||
178+
PatternUtil.containsPattern(lowerMessage, "(recommend|suggestion|improve|better)")) {
178179
needed.add(ContextType.BRAIN_INSIGHTS);
179180
}
180181

181182
// Index-related questions
182-
if (lowerMessage.matches(".*(index|indexes|indexed|indexing).*")) {
183+
if (PatternUtil.containsPattern(lowerMessage, "(index|indexes|indexed|indexing)")) {
183184
needed.add(ContextType.INDEX_RECOMMENDATIONS);
184185
needed.add(ContextType.KEY_COLUMNS);
185186
}
186187

187188
// Value dictionary / enum / filter-value questions
188-
if (lowerMessage.matches(".*(valid values|allowed values|possible values|status values|enum|picklist|dropdown).*") ||
189-
lowerMessage.matches(".*(what values|which values|acceptable values).*")) {
189+
if (PatternUtil.containsPattern(lowerMessage, "(valid values|allowed values|possible values|status values|enum|picklist|dropdown)") ||
190+
PatternUtil.containsPattern(lowerMessage, "(what values|which values|acceptable values)")) {
190191
needed.add(ContextType.KEY_COLUMNS);
191192
needed.add(ContextType.CLASSIFICATION);
192193
}
193194

194195
// Join-specific questions also get classification context
195-
if (lowerMessage.matches(".*(join|relationship|foreign key|fk|reference|connect|link).*")) {
196+
if (PatternUtil.containsPattern(lowerMessage, "(join|relationship|foreign key|fk|reference|connect|link)")) {
196197
needed.add(ContextType.CLASSIFICATION);
197198
}
198199

199200
// Growth/scaling questions
200-
if (lowerMessage.matches(".*(grow|growth|scale|scaling|storage|disk|bloat|archive).*") ||
201-
lowerMessage.matches(".*(partition|shard).*")) {
201+
if (PatternUtil.containsPattern(lowerMessage, "(grow|growth|scale|scaling|storage|disk|bloat|archive)") ||
202+
PatternUtil.containsPattern(lowerMessage, "(partition|shard)")) {
202203
needed.add(ContextType.GROWTH);
203204
needed.add(ContextType.CLASSIFICATION);
204205
}
205206

206207
// Analysis/review/audit questions - full context
207-
if (lowerMessage.matches(".*(analyze|analysis|review|audit|health|diagnose|assessment).*") ||
208-
lowerMessage.matches(".*(what.*wrong|issue|problem|bottleneck).*")) {
208+
if (PatternUtil.containsPattern(lowerMessage, "(analyze|analysis|review|audit|health|diagnose|assessment)") ||
209+
PatternUtil.containsPattern(lowerMessage, "(what.*wrong|issue|problem|bottleneck)")) {
209210
needed.addAll(EnumSet.allOf(ContextType.class));
210211
}
211212

212213
// Complex SQL generation - add helpful context
213-
if (lowerMessage.matches(".*(select|insert|update|delete|query).*") && lowerMessage.length() > 50) {
214+
if (PatternUtil.containsPattern(lowerMessage, "(select|insert|update|delete|query)") && lowerMessage.length() > 50) {
214215
needed.add(ContextType.KEY_COLUMNS);
215216
needed.add(ContextType.RELATIONSHIPS);
216217
needed.add(ContextType.CLASSIFICATION);

backend/src/main/java/com/dbaagent/service/ChatHistoryService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ private String summarizeTitleFromMessage(String message) {
254254
return "New chat";
255255
}
256256

257-
String punctuationTrimmed = normalized.replaceAll("[\\s?.!,;:]+$", "");
257+
String punctuationTrimmed = normalized.replaceAll("[\\s?.!,;:]++$", "");
258258
String candidate = punctuationTrimmed.isBlank() ? normalized : punctuationTrimmed;
259259
if (candidate.length() <= AUTO_TITLE_MAX_LENGTH) {
260260
return candidate;

backend/src/main/java/com/dbaagent/service/ChatQuestionRoutingService.java

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dbaagent.service;
22

3+
import com.dbaagent.util.PatternUtil;
34
import org.springframework.stereotype.Service;
45

56
import java.util.ArrayList;
@@ -226,7 +227,7 @@ private boolean looksLikeBusinessPerformancePrompt(String normalized) {
226227
if (normalized == null || normalized.isBlank() || !normalized.contains("performance")) {
227228
return false;
228229
}
229-
return normalized.matches(".*\\b(payment|payments|gateway|refund|refunds|revenue|booking|bookings|customer|customers|customer|customers|account|accounts|billing|transaction|transactions)\\b.*");
230+
return PatternUtil.containsPattern(normalized, "\\b(payment|payments|gateway|refund|refunds|revenue|booking|bookings|customer|customers|customer|customers|account|accounts|billing|transaction|transactions)\\b");
230231
}
231232

232233
private boolean looksLikeExactSchemaQuestion(String normalized) {
@@ -238,20 +239,20 @@ private boolean looksLikeExactSchemaQuestion(String normalized) {
238239
|| SchemaQuestionUtil.looksLikeExactTableColumnQuestion(normalized)) {
239240
return true;
240241
}
241-
if (!normalized.matches(".*\\b(table|tables|view|views|column|columns|fields|schema|structure|describe|definition)\\b.*")) {
242+
if (!PatternUtil.containsPattern(normalized, "\\b(table|tables|view|views|column|columns|fields|schema|structure|describe|definition)\\b")) {
242243
return false;
243244
}
244245
// Superlatives and rankings are not exact-schema lookups; neither is design advice.
245246
// "Which tables should I use to build an accounts module?" names tables but wants
246247
// reasoning over the schema, not a listing of it — answering it from cached
247248
// metadata drops exactly the part the user asked for.
248-
if (normalized.matches(".*\\b(least|most|best|worst|top|bottom|largest|smallest|used|unused|slow|growth|performance|fact|dimension|pattern|relationship|join)\\b.*")
249-
|| normalized.matches(".*\\b(should|build|design|model|recommend|suggest|architect)\\b.*")) {
249+
if (PatternUtil.containsPattern(normalized, "\\b(least|most|best|worst|top|bottom|largest|smallest|used|unused|slow|growth|performance|fact|dimension|pattern|relationship|join)\\b")
250+
|| PatternUtil.containsPattern(normalized, "\\b(should|build|design|model|recommend|suggest|architect)\\b")) {
250251
return false;
251252
}
252-
return normalized.matches(".*\\b(what|which|show|list|display|describe|structure|schema)\\b.*\\b(columns?|fields?|tables?|views?)\\b.*")
253-
|| normalized.matches(".*\\b(columns?|fields?)\\b.*\\b(in|for|of|on)\\b.*")
254-
|| normalized.matches(".*\\bdescribe\\b.*\\b(table|view)\\b.*")
255-
|| normalized.matches(".*\\b(schema|structure|definition)\\b.*\\b(of|for)\\b.*\\b(table|view)\\b.*");
253+
return PatternUtil.containsPattern(normalized, "\\b(what|which|show|list|display|describe|structure|schema)\\b.*\\b(columns?|fields?|tables?|views?)\\b")
254+
|| PatternUtil.containsPattern(normalized, "\\b(columns?|fields?)\\b.*\\b(in|for|of|on)\\b")
255+
|| PatternUtil.containsPattern(normalized, "\\bdescribe\\b.*\\b(table|view)\\b")
256+
|| PatternUtil.containsPattern(normalized, "\\b(schema|structure|definition)\\b.*\\b(of|for)\\b.*\\b(table|view)\\b");
256257
}
257258
}

0 commit comments

Comments
 (0)