Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ returns a number).
### Backend Rules
1. **Database Provider Registry**: Use `DatabaseProviderRegistry` for all DB-specific operations. Do NOT add if/else or switch for database types.
2. **LLM Provider Registry**: Use `LlmProviderRegistry` for all provider-specific LLM behavior. Do NOT add if/else or switch on provider type. Chat and embedding providers are registered and resolved independently — some providers offer only one. Providers are *factories* over credentials, not `ChatModel`s, so credentials stay resolvable per call and key rotation needs no restart.
3. **SSH-Aware Access**: Always use `ConnectionService.getJdbcTemplate(connectionId, request)` — handles SSH tunneling transparently.
3. **SSH-Aware Access**: Always use `ConnectionService.getJdbcTemplate(connectionId, request)` — handles SSH tunneling transparently. The bastion host is screened by `SshHostGuard` before any session is created (see SSRF guard below).
4. **SQL Rule**: All generated SQL MUST use table-qualified column names (`table.column_name`).
5. **RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
6. **Virtual Threads**: Enabled for concurrency (JDK 25).
Expand Down Expand Up @@ -321,6 +321,111 @@ it against a real database — not a theoretical hardening pass.
constructs a real `MySQLQueryExecutionProvider`. Do not reintroduce a stubbed
dialect here; the mock is what let the blocker ship.

### SSH Tunnel SSRF Guard

`SshHostGuard` screens `request.getSshHost()` before `jsch.getSession(...)` in
`SshTunnelService.createSession`, closing CodeQL `java/ssrf` alert #138. Both entry
points are covered by that single call site (`establishTunnel` and
`testSshConnection`).

- **It resolves the host and checks every returned address**, not just the literal
string. A public hostname whose A record points at `169.254.169.254` or `10.x` is
still refused — a string-only check is defeated by one DNS record.
- Blocked: loopback, wildcard, link-local (cloud metadata), RFC1918, CGNAT
(`100.64/10`), multicast, IPv6 ULA (`fc00::/7`), and IPv4-mapped/compatible IPv6
forms that smuggle a blocked v4 address through a v6 literal.
- **`testSshConnection` calls the guard *outside* its try block.** That method catches
broad `Exception` and returns `false`, so a guard rejection inside it would render a
blocked host as an ordinary auth failure — the silent-failure anti-pattern above.
It propagates `IllegalArgumentException` instead, matching how a missing SSH password
already surfaces.
- **It ships disabled** (`deepsql.ssh.host-guard.enabled=false`). Bastions legitimately
live on RFC1918 networks, so enabling it by default would break existing self-hosted
installs on upgrade. The trade-off is explicit: **on a default install the SSRF
surface is open** — an authenticated user who can create connections can still point
the tunnel at `169.254.169.254` or internal hosts. The CodeQL alert closes either way
(the sanitizer is on the call path regardless of the flag), so a closed alert here
does **not** mean deployments are protected. Do not read #138 going green as
"SSRF handled".
- Operators who want the protection set `enabled=true` and allowlist their own bastion
via `deepsql.ssh.host-guard.allowed-hosts` (exact host, or a leading-dot suffix like
`.corp.internal`).
- **Two sibling guards cover the other two `java/ssrf` alerts.** Address
classification is shared in `OutboundHostGuard` (resolve the host, check every
returned address, block loopback/link-local/RFC1918/CGNAT/ULA/IPv4-mapped-IPv6);
the three call sites differ only in policy and message.
- `DatabaseHostGuard` (alert #136, `ConnectionService`) screens the **JDBC** host.
The SSH guard never covered this — a direct, non-tunnelled connection does not
pass through `SshTunnelService` at all. Applied in `buildJdbcUrl` *and* the
Hikari pool path, and skipped when `tunnelPort != null` since a tunnelled
connection targets the local forwarded port. Also ships disabled
(`deepsql.database.host-guard.enabled`) — databases sit on RFC1918 even more
often than bastions do.
- `S3LogFetchService.assertFetchableUrl` (alert #137) screens the presigned log
URL. The real hazard was `setInstanceFollowRedirects(true)`: the JDK chases a
302 with no chance to inspect the target, so a presigned URL on a public host
could hand off to the metadata endpoint. Redirects are now followed manually
(max 5), with **every hop** re-checked for https + a public address. Unlike the
other two this is always on — there is no legitimate reason to fetch a slow
query log from a private address.

### CodeQL Remediation (code scanning, 138 alerts on main)

The 138 open alerts were only **5 rules**, and the counter badly overstates the
work: 118 were one mechanical pattern. What was fixed and what was not:

- **`java/polynomial-redos` (118).** Nearly all were `s.matches(".*RE.*")`.
`String.matches` anchors the whole input, so the wrapping `.*` exists only to
undo that anchoring — and `.*` + alternation is the backtracking. Rewritten to
`PatternUtil.containsPattern(s, "RE")` (`find()` over a cached compiled
Pattern) at **174 sites in 17 files** — more than the 118 flagged, since
CodeQL only reports where taint reaches. Equivalence was verified by
differential test over all 175 literals, not by inspection.
- **This is a behavior change on multi-line input.** `.` does not cross a
newline, so the old form *failed* to match a keyword after a line break;
`find()` matches it. That is a bug fix for intent classifiers, and it only
affects callers that do not pre-normalize — `PromptIntentSignals.normalize`
already collapses newlines, `ChatContextAssembler` does not.
- The remainder were compiled `Pattern` constants with genuinely ambiguous
quantifiers, fixed individually with possessive quantifiers / bounded gaps
(`PostgresSlowLogPatterns`, `QueryNormalizer`, `OptdOptimizationService`,
`SqlUsageService`, `QueryPlanCacheService`, `ChatHistoryService`,
`CompanyKnowledgeService`, `QueryExecutionPolicyService`).
- `PlanPatternLibraryService` also carried a real latent bug: `[^from]+` is a
character class ("not f/r/o/m"), so any column containing those letters
(`order_id`, `from_date`) defeated the collapse. Now a bounded lazy scan.
- **`java/sql-injection` (15).** Not one bug — three distinct cases:
- `CardinalityEstimationService` (6) was a **real vulnerability**:
`quoteIdentifier` wrapped in quotes but never doubled an embedded quote, so
a table named `x" ; DROP TABLE users; --` escaped the quoting. Four of the
other five `quoteIdentifier` implementations in this repo already escape
correctly — this one was the outlier. It now delegates to the dialect's
`SamplingProvider` (also removing an if/else on `dbType`), and both
identifiers are resolved against `information_schema` first, so only
catalog-returned names ever reach interpolated SQL.
- `MySQLPrivilegeCheckProvider` (1) concatenated a database name into a
string literal; now a bind parameter.
- `QueryExecutorService` (3) and the EXPLAIN providers (4) execute
user-authored SQL **by design** — that is the Editor feature. They are not
parameterizable; their protection is the guard layer in the SQL Editor Guard
Rules above. Do not "fix" these by mangling the SQL.
- **`java/spring-disabled-csrf-protection` (1).** Correct as-is and documented
in `SecurityConfig`: every route is `STATELESS` with header-carried JWT/MCP
tokens, so there is no ambient cookie session to forge. Re-enable CSRF the
moment any cookie-based auth appears.
- **`js/command-line-injection` (1).** `spawn` already used array args (no
shell), but `authorize_url` comes from a server response and the win32 branch
routes through `cmd`. Now scheme-validated to http/https before opening.

**Scan-flapping, confirmed.** Analyses on `main` report 137 results
consistently — except commit `8b47c67`, which reported **3**. That is the commit
GitHub labelled "Fixed in branch main"; the next healthy scan re-found
everything and it showed as "Reappeared". Nothing was fixed or reverted. Before
concluding an alert is resolved, check `results_count` on the analysis
(`gh api repos/.../code-scanning/analyses?ref=...`) — a partial scan reads as a
clean one. Note also that PR-triggered scans are diff-scoped and legitimately
report 0 for untouched files.

### Data Model Rules

- **`mcp_tokens.user_id` is a non-null FK with no cascade.** Deleting a user who holds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Deliberate, not an oversight (CodeQL java/spring-disabled-csrf-protection):
// every authenticated route is SessionCreationPolicy.STATELESS and carries
// its credential in an Authorization / MCP token header, which a browser
// does not attach automatically. With no ambient cookie session there is no
// CSRF to forge. Re-enable the moment any cookie-based auth is introduced.
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.stereotype.Component;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
Expand Down Expand Up @@ -50,22 +51,28 @@ private ConnectionTestResult.PrivilegeCheck checkSelectOnTables(
Connection connection,
String database
) {
try (Statement stmt = connection.createStatement()) {
stmt.setQueryTimeout(10);
boolean scopedToDatabase = database != null && !database.isEmpty();
String listTablesQuery = scopedToDatabase
? "SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema = ? AND table_type = 'BASE TABLE' LIMIT 5"
: "SELECT table_name FROM information_schema.tables "
+ "WHERE table_type = 'BASE TABLE' LIMIT 5";

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

ResultSet tablesRs = stmt.executeQuery(listTablesQuery);
List<String> tables = new ArrayList<>();
while (tablesRs.next()) {
tables.add(tablesRs.getString(1));
try (ResultSet tablesRs = stmt.executeQuery()) {
while (tablesRs.next()) {
tables.add(tablesRs.getString(1));
}
}
tablesRs.close();

if (tables.isEmpty()) {
return ConnectionTestResult.PrivilegeCheck.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.dbaagent.repository.ColumnValueCacheRepository;
import com.dbaagent.repository.SchemaDocumentationRepository;
import com.dbaagent.service.brain.classification.SchemaClassificationService;
import com.dbaagent.util.PatternUtil;
import com.dbaagent.util.TokenEstimator;
import com.dbaagent.service.SchemaObjectNameUtil;
import com.dbaagent.service.SchemaTableMatchUtil;
Expand Down Expand Up @@ -146,10 +147,10 @@ public Set<ContextType> determineNeededContext(String message) {
needed.add(ContextType.RELATIONSHIPS);

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

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

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

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

// Index-related questions
if (lowerMessage.matches(".*(index|indexes|indexed|indexing).*")) {
if (PatternUtil.containsPattern(lowerMessage, "(index|indexes|indexed|indexing)")) {
needed.add(ContextType.INDEX_RECOMMENDATIONS);
needed.add(ContextType.KEY_COLUMNS);
}

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

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

// Growth/scaling questions
if (lowerMessage.matches(".*(grow|growth|scale|scaling|storage|disk|bloat|archive).*") ||
lowerMessage.matches(".*(partition|shard).*")) {
if (PatternUtil.containsPattern(lowerMessage, "(grow|growth|scale|scaling|storage|disk|bloat|archive)") ||
PatternUtil.containsPattern(lowerMessage, "(partition|shard)")) {
needed.add(ContextType.GROWTH);
needed.add(ContextType.CLASSIFICATION);
}

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

// Complex SQL generation - add helpful context
if (lowerMessage.matches(".*(select|insert|update|delete|query).*") && lowerMessage.length() > 50) {
if (PatternUtil.containsPattern(lowerMessage, "(select|insert|update|delete|query)") && lowerMessage.length() > 50) {
needed.add(ContextType.KEY_COLUMNS);
needed.add(ContextType.RELATIONSHIPS);
needed.add(ContextType.CLASSIFICATION);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private String summarizeTitleFromMessage(String message) {
return "New chat";
}

String punctuationTrimmed = normalized.replaceAll("[\\s?.!,;:]+$", "");
String punctuationTrimmed = normalized.replaceAll("[\\s?.!,;:]++$", "");
String candidate = punctuationTrimmed.isBlank() ? normalized : punctuationTrimmed;
if (candidate.length() <= AUTO_TITLE_MAX_LENGTH) {
return candidate;
Expand Down
Loading
Loading