rails-ai-context 5.35.0 → 5.35.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- metadata +14 -3
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "strscan"
|
|
4
|
-
|
|
5
3
|
module RailsAiContext
|
|
6
4
|
module Tools
|
|
7
5
|
class Query < BaseTool
|
|
@@ -30,13 +28,17 @@ module RailsAiContext
|
|
|
30
28
|
explain: {
|
|
31
29
|
type: "boolean",
|
|
32
30
|
description: "Run EXPLAIN on the query. Returns execution plan analysis instead of data. SELECT only."
|
|
31
|
+
},
|
|
32
|
+
database: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "The app database to query, by its config/database.yml name (for example queue). Default: the primary."
|
|
33
35
|
}
|
|
34
36
|
},
|
|
35
37
|
required: [ "sql" ]
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
guide_row(
|
|
39
|
-
order:
|
|
41
|
+
order: 27,
|
|
40
42
|
mcp: "rails_query(sql:\"X\")",
|
|
41
43
|
cli_args: "sql=X",
|
|
42
44
|
summary: "Safe read-only SQL queries with timeout, row limit, column redaction"
|
|
@@ -49,189 +51,10 @@ module RailsAiContext
|
|
|
49
51
|
open_world_hint: false
|
|
50
52
|
)
|
|
51
53
|
|
|
52
|
-
# ── Layer 1: SQL validation ─────────────────────────────────────
|
|
53
|
-
# REPLACE is the MySQL statement; `replace(` is the string function every
|
|
54
|
-
# database has, so a REPLACE followed by an opening parenthesis is let be.
|
|
55
|
-
BLOCKED_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|SET|COPY|MERGE|REPLACE(?!\s*\())\b/i
|
|
56
|
-
# SHOW CREATE TABLE / VIEW print a definition; every other SHOW CREATE is
|
|
57
|
-
# refused by the CREATE keyword - SHOW CREATE USER prints credential hashes.
|
|
58
|
-
SHOW_CREATE_DEFINITION = /\A\s*SHOW\s+CREATE\s+(?:TABLE|VIEW)\b/i
|
|
59
|
-
BLOCKED_CLAUSES = /\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE)\b/i
|
|
60
|
-
BLOCKED_SHOWS = /\bSHOW\s+(GRANTS|PROCESSLIST|BINLOG|SLAVE|MASTER|REPLICAS)\b/i
|
|
61
|
-
SELECT_INTO = /\bSELECT\b[^;]*\bINTO\b/i
|
|
62
|
-
MULTI_STATEMENT = /;\s*\S/
|
|
63
|
-
ALLOWED_PREFIX = /\A\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
|
|
64
|
-
|
|
65
|
-
# SELECT-callable functions that give the caller a filesystem / network
|
|
66
|
-
# exfiltration primitive even though the query is technically a SELECT.
|
|
67
|
-
# These pass `SET TRANSACTION READ ONLY` because they're reads from the
|
|
68
|
-
# DB engine's perspective, but they bypass the gem's `sensitive_patterns`
|
|
69
|
-
# file allowlist entirely by pivoting through the database process.
|
|
70
|
-
#
|
|
71
|
-
# Postgres: pg_read_file / pg_read_binary_file / pg_ls_dir / pg_stat_file
|
|
72
|
-
# (file read), lo_import / lo_export (large-object I/O),
|
|
73
|
-
# dblink* (cross-db exfiltration).
|
|
74
|
-
# MySQL: LOAD_FILE (scalar file read outside SELECT INTO contexts).
|
|
75
|
-
# SQLite: load_extension (shared-library load - disabled by default
|
|
76
|
-
# but harden in defense).
|
|
77
|
-
#
|
|
78
|
-
# COPY ... TO PROGRAM is not one of these: COPY is not a SELECT, so the
|
|
79
|
-
# statement keyword blocker rejects it before this runs.
|
|
80
|
-
BLOCKED_FUNCTIONS = /\b(
|
|
81
|
-
pg_read_binary_file | pg_read_file |
|
|
82
|
-
pg_ls_dir | pg_ls_logdir | pg_ls_tmpdir | pg_ls_waldir | pg_ls_archive_statusdir |
|
|
83
|
-
pg_stat_file | pg_file_settings | pg_current_logfile |
|
|
84
|
-
lo_import | lo_export |
|
|
85
|
-
dblink[a-z_]* |
|
|
86
|
-
LOAD\s+DATA |
|
|
87
|
-
load_file | load_extension
|
|
88
|
-
)\b/ix
|
|
89
|
-
|
|
90
|
-
# Checked before SELECT_INTO, which also matches, so the refusal names the disk write.
|
|
91
|
-
BLOCKED_OUTPUT = /\bINTO\s+(OUTFILE|DUMPFILE)\b/i
|
|
92
|
-
|
|
93
|
-
# Session-effecting / administrative PostgreSQL functions. A query is a
|
|
94
|
-
# read from the engine's point of view, so SET TRANSACTION READ ONLY lets
|
|
95
|
-
# these run and keep their effect on the pooled connection: a backend is
|
|
96
|
-
# terminated, a config rotated, a stat reset, a WAL/restore/replication
|
|
97
|
-
# control fired, a logical message emitted, a session advisory lock left
|
|
98
|
-
# held. This is the FIRST layer, a fast name blocklist; the PostgreSQL
|
|
99
|
-
# plan's VOLATILE-function check (pg_proc.provolatile) is the robust layer
|
|
100
|
-
# that closes the rest of the family without an ever-growing list.
|
|
101
|
-
BLOCKED_PG_ADMIN = /\b(
|
|
102
|
-
pg_terminate_backend | pg_cancel_backend |
|
|
103
|
-
pg_reload_conf | pg_rotate_logfile[a-z_]* |
|
|
104
|
-
pg_stat_reset[a-z_]* |
|
|
105
|
-
pg_switch_wal | pg_switch_xlog | pg_create_restore_point |
|
|
106
|
-
pg_create_(?:physical|logical)_replication_slot | pg_drop_replication_slot |
|
|
107
|
-
pg_replication_origin_[a-z_]* | pg_logical_emit_message |
|
|
108
|
-
pg_advisory_lock[a-z_]* | pg_advisory_unlock[a-z_]* |
|
|
109
|
-
pg_advisory_xact_lock[a-z_]* | pg_try_advisory_lock[a-z_]* |
|
|
110
|
-
pg_promote | pg_wal_replay_pause | pg_wal_replay_resume |
|
|
111
|
-
pg_xlog_replay_pause | pg_xlog_replay_resume |
|
|
112
|
-
pg_notify | set_config | txid_current[a-z_]* | pg_current_xact_id[a-z_]* |
|
|
113
|
-
pg_import_system_collations
|
|
114
|
-
)\s*\(/ix
|
|
115
|
-
|
|
116
|
-
# Session-effecting MySQL/MariaDB functions. GET_LOCK leaves a named lock
|
|
117
|
-
# held on the pooled connection after the query returns, which a later
|
|
118
|
-
# caller inherits; RELEASE_* and the IS_* probes drive the same session
|
|
119
|
-
# lock table. source/master_pos_wait block on replication. MySQL has no
|
|
120
|
-
# cheap volatility catalogue like pg_proc, so these are a name blocklist.
|
|
121
|
-
BLOCKED_MYSQL_SESSION = /\b(
|
|
122
|
-
get_lock | release_lock | release_all_locks | is_free_lock | is_used_lock |
|
|
123
|
-
master_pos_wait | source_pos_wait
|
|
124
|
-
)\s*\(/ix
|
|
125
|
-
|
|
126
|
-
# VOLATILE functions known to only read. pg_proc marks a function VOLATILE
|
|
127
|
-
# when its answer may change within one statement, which is as true of a
|
|
128
|
-
# reader of live server state as of a writer, so VOLATILE alone does not
|
|
129
|
-
# mean "changes state". Each one here is a built-in C function that writes
|
|
130
|
-
# nothing (every one is also proparallel 's', parallel safe):
|
|
131
|
-
# * values: random, clock_timestamp, gen_random_uuid, timeofday,
|
|
132
|
-
# uuid_generate_v4; pg_sleep* only waits, and the statement timeout
|
|
133
|
-
# bounds it.
|
|
134
|
-
# * sizes (dbsize.c): stat() the relation, database or tablespace files;
|
|
135
|
-
# the most they take is a transaction-scoped AccessShareLock, released
|
|
136
|
-
# when the read-only transaction rolls back.
|
|
137
|
-
# * lock status (lockfuncs.c): a snapshot of the shared lock table -
|
|
138
|
-
# pg_lock_status is what the pg_locks view reads.
|
|
139
|
-
# * recovery and WAL position (xlogfuncs.c): read shared-memory
|
|
140
|
-
# positions and flags; pg_current_wal_* only reports where WAL is.
|
|
141
|
-
# pg_stat_activity is not here: pg_stat_get_activity is STABLE, so it was
|
|
142
|
-
# never refused. Everything else the plan reports as VOLATILE is refused.
|
|
143
|
-
PG_VOLATILE_ALLOWLIST = %w[
|
|
144
|
-
random clock_timestamp gen_random_uuid timeofday uuid_generate_v4
|
|
145
|
-
pg_sleep pg_sleep_for pg_sleep_until
|
|
146
|
-
pg_total_relation_size pg_relation_size pg_table_size pg_indexes_size
|
|
147
|
-
pg_database_size pg_tablespace_size
|
|
148
|
-
pg_lock_status pg_blocking_pids pg_safe_snapshot_blocking_pids
|
|
149
|
-
pg_is_in_recovery pg_is_wal_replay_paused pg_get_wal_replay_pause_state
|
|
150
|
-
pg_current_wal_lsn pg_current_wal_insert_lsn pg_current_wal_flush_lsn
|
|
151
|
-
pg_last_wal_receive_lsn pg_last_wal_replay_lsn pg_last_xact_replay_timestamp
|
|
152
|
-
].freeze
|
|
153
|
-
|
|
154
|
-
# A column-alias list renames the columns a wildcard produced, so the
|
|
155
|
-
# names redaction reads no longer name the sensitive columns underneath.
|
|
156
|
-
# These refuse one on a CTE or a FROM item (derived table or table alias).
|
|
157
|
-
# The list is BARE identifiers only: a type modifier (numeric(10,2),
|
|
158
|
-
# varchar(255)) carries digits, and a record-returning function's list
|
|
159
|
-
# (json_to_recordset(...) AS t(a int, b text)) carries types, so neither
|
|
160
|
-
# matches; a single-column alias (generate_series(...) AS g(n)) is not a
|
|
161
|
-
# rename of several columns and is left alone. A list on a VALUES body
|
|
162
|
-
# (`(VALUES (1,'a')) AS v(id, name)`, `WITH v(id, name) AS (VALUES ...)`)
|
|
163
|
-
# renames no table column - VALUES has none - so it is left alone too;
|
|
164
|
-
# see renaming_alias_list?.
|
|
165
|
-
ALIAS_IDENT = /[A-Za-z_]\w*/
|
|
166
|
-
ALIAS_IDENT_LIST = /#{ALIAS_IDENT}(?:\s*,\s*#{ALIAS_IDENT})+/
|
|
167
|
-
# CTE column list: name(a, b, ...) AS (
|
|
168
|
-
CTE_ALIAS_LIST = /#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)\s*AS\s*\(/i
|
|
169
|
-
# Derived-table / explicit alias: ... AS alias(a, b, ...)
|
|
170
|
-
DERIVED_ALIAS_LIST = /\bAS\s+#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)/i
|
|
171
|
-
# Table alias without AS: FROM/JOIN table alias(a, b, ...)
|
|
172
|
-
TABLE_ALIAS_LIST = /\b(?:FROM|JOIN)\s+#{ALIAS_IDENT}(?:\.#{ALIAS_IDENT})?\s+#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)/i
|
|
173
|
-
COLUMN_ALIAS_LIST = Regexp.union(CTE_ALIAS_LIST, DERIVED_ALIAS_LIST, TABLE_ALIAS_LIST)
|
|
174
|
-
|
|
175
|
-
# Defense against the column-aliasing redaction bypass:
|
|
176
|
-
#
|
|
177
|
-
# SELECT password_digest AS x FROM users -- bypasses result.columns redaction
|
|
178
|
-
# SELECT substring(password_digest, 1, 60) ... -- column name becomes "substring"
|
|
179
|
-
# SELECT md5(session_data) FROM sessions -- column name becomes "md5"
|
|
180
|
-
#
|
|
181
|
-
# Post-execution redaction operates on the column names the DB returns,
|
|
182
|
-
# which the caller controls via aliases and expressions. The only
|
|
183
|
-
# defense that works is to reject queries that TEXTUALLY reference
|
|
184
|
-
# any sensitive column, before execution. This list is frozen, so an app
|
|
185
|
-
# whose own column merely looks sensitive exempts it by name through
|
|
186
|
-
# `config.query_allowed_columns`.
|
|
187
|
-
SENSITIVE_COLUMN_SUFFIXES = %w[
|
|
188
|
-
password_digest password_hash encrypted_password
|
|
189
|
-
password_reset_token confirmation_token unlock_token
|
|
190
|
-
remember_token reset_password_token api_key api_secret
|
|
191
|
-
access_token refresh_token jti otp_secret session_data
|
|
192
|
-
secret_key secret private_key
|
|
193
|
-
].freeze
|
|
194
|
-
|
|
195
|
-
# SQL injection tautology patterns: OR 1=1, OR true, OR ''='', etc.
|
|
196
|
-
TAUTOLOGY_PATTERNS = [
|
|
197
|
-
/\bOR\s+1\s*=\s*1\b/i,
|
|
198
|
-
/\bOR\s+true\b/i,
|
|
199
|
-
/\bOR\s+'[^']*'\s*=\s*'[^']*'/i,
|
|
200
|
-
/\bOR\s+"[^"]*"\s*=\s*"[^"]*"/i,
|
|
201
|
-
/\bOR\s+\d+\s*=\s*\d+/i
|
|
202
|
-
].freeze
|
|
203
|
-
|
|
204
|
-
# A UNION takes its column names from its first SELECT, so rows of
|
|
205
|
-
# another table - a wildcard's columns included - come out under those
|
|
206
|
-
# names, past the redaction that reads them. Any UNION is refused, in
|
|
207
|
-
# every spelling: `UNION ALL`, `UNION DISTINCT`, and the parenthesised
|
|
208
|
-
# `UNION (SELECT ...)` MySQL 8 and MariaDB take, which the narrower
|
|
209
|
-
# pattern this replaces let through. INTERSECT and EXCEPT return only
|
|
210
|
-
# rows of their first SELECT, so they stay.
|
|
211
|
-
SET_UNION = /\bUNION\b/i
|
|
212
|
-
|
|
213
54
|
HARD_ROW_CAP = 1000
|
|
214
55
|
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
# name to apply the MySQL safety/parsing path must match both, or
|
|
218
|
-
# Trilogy apps silently fall through to the "unknown adapter" path
|
|
219
|
-
# with no READ ONLY transaction and no statement timeout.
|
|
220
|
-
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
221
|
-
|
|
222
|
-
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
223
|
-
POSTGRES_ADAPTER = /postg/i
|
|
224
|
-
|
|
225
|
-
# SHOW/DESCRIBE/EXPLAIN return schema metadata (a table's column list,
|
|
226
|
-
# an EXPLAIN plan), never application data rows. Two consequences:
|
|
227
|
-
# 1. They're inherently bounded - appending `LIMIT n` to them is
|
|
228
|
-
# invalid syntax on MySQL/Postgres and the query fails outright,
|
|
229
|
-
# even though these statements are explicitly advertised as allowed.
|
|
230
|
-
# 2. Column-name-based redaction doesn't apply to them - MySQL's
|
|
231
|
-
# DESCRIBE returns a literal column named "Key" (PRI/UNI/MUL/empty)
|
|
232
|
-
# that isn't actual secret data but matches the generic
|
|
233
|
-
# sensitive-suffix heuristic (ends_with "key") used for real result
|
|
234
|
-
# sets, so it would otherwise be redacted into uselessness.
|
|
56
|
+
# SHOW/DESCRIBE/EXPLAIN return schema metadata: a LIMIT appended to them is a syntax error,
|
|
57
|
+
# and name redaction would filter the "Key" column of MySQL's DESCRIBE.
|
|
235
58
|
SCHEMA_METADATA_PREFIX = /\A\s*(SHOW|DESCRIBE|DESC|EXPLAIN)\b/i
|
|
236
59
|
|
|
237
60
|
# The session record is echoed back to the model; a full query body can
|
|
@@ -242,25 +65,20 @@ module RailsAiContext
|
|
|
242
65
|
params.key?(:sql) ? params.merge(sql: params[:sql].to_s.truncate(60)) : params
|
|
243
66
|
end
|
|
244
67
|
|
|
245
|
-
def self.call(sql: nil, limit: nil, format: "table", explain: false, server_context: nil, **_extra)
|
|
68
|
+
def self.call(sql: nil, limit: nil, format: "table", explain: false, database: nil, server_context: nil, **_extra)
|
|
246
69
|
if (refusal = static_tier_refusal("Running SQL queries"))
|
|
247
70
|
return refusal
|
|
248
71
|
end
|
|
249
72
|
|
|
250
|
-
# ── Environment guard ───────────────────────────────────────
|
|
251
73
|
unless config.allow_query_in_production || rails_env_name != "production"
|
|
252
|
-
return
|
|
74
|
+
return error_response(
|
|
253
75
|
"rails_query is disabled in production for data privacy. " \
|
|
254
76
|
"Set config.allow_query_in_production = true to override."
|
|
255
77
|
)
|
|
256
78
|
end
|
|
257
79
|
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
# resolves rescue class constants at raise time, and `rescue
|
|
261
|
-
# ActiveRecord::ConnectionNotEstablished` crashes with NameError
|
|
262
|
-
# on apps where ActiveRecord is not loaded (e.g.
|
|
263
|
-
# `rails new --api --skip-active-record`).
|
|
80
|
+
# Before anything that rescues ActiveRecord::*: a rescue clause names
|
|
81
|
+
# its class at raise time, a NameError on an app without ActiveRecord.
|
|
264
82
|
unless defined?(ActiveRecord::Base)
|
|
265
83
|
return text_response(
|
|
266
84
|
"Database queries are unavailable: ActiveRecord is not loaded in this app. " \
|
|
@@ -269,1094 +87,177 @@ module RailsAiContext
|
|
|
269
87
|
)
|
|
270
88
|
end
|
|
271
89
|
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
valid, error = validate_sql(sql, mysql: mysql, postgres: postgres)
|
|
276
|
-
return error_response(error) unless valid
|
|
277
|
-
|
|
278
|
-
# Run the text the validator read, never the raw input: whatever the
|
|
279
|
-
# scanner and the database disagree on, nothing unread reaches the database.
|
|
280
|
-
sql = comment_free(sql, mysql: mysql)
|
|
281
|
-
|
|
282
|
-
# ── Layer 1b: PostgreSQL semantic check (planning only) ─────
|
|
283
|
-
# EXPLAIN (VERBOSE, FORMAT JSON) in a read-only transaction plans the
|
|
284
|
-
# query without running it, so whole-row serialisation, an expanded
|
|
285
|
-
# sensitive column and a VOLATILE admin function are caught before any
|
|
286
|
-
# execution - the normal path and the EXPLAIN path alike.
|
|
287
|
-
if (refusal = postgresql_plan_refusal(sql, config.query_timeout))
|
|
90
|
+
# Before a connection is leased or anything reads the statement: a call with none needs no database,
|
|
91
|
+
# and every scan of a statement takes time that grows with its length.
|
|
92
|
+
if (refusal = SqlGate.blank_refusal(sql) || SqlGate.length_refusal(sql))
|
|
288
93
|
return error_response(refusal)
|
|
289
94
|
end
|
|
290
95
|
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
# ── Layers 2-3: Execute with DB-level safety + row limit ────
|
|
310
|
-
result = execute_safely(sql, row_limit, timeout_seconds)
|
|
311
|
-
|
|
312
|
-
# ── Layer 4: Redact sensitive columns ───────────────────────
|
|
313
|
-
# Skip for SHOW/DESCRIBE/EXPLAIN - see SCHEMA_METADATA_PREFIX.
|
|
314
|
-
schema_metadata = sql.match?(SCHEMA_METADATA_PREFIX)
|
|
315
|
-
provenance = schema_metadata ? [] : provenance_sensitive_indices(sql) | plan_output_sensitive_indices(sql, result)
|
|
316
|
-
redacted = if sql.match?(SHOW_CREATE_DEFINITION)
|
|
317
|
-
redact_definition(result)
|
|
318
|
-
elsif schema_metadata
|
|
319
|
-
result
|
|
320
|
-
else
|
|
321
|
-
redact_results(result, provenance)
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# ── Format output ───────────────────────────────────────────
|
|
325
|
-
# The truncation note rides every format (for CSV, after a blank line so
|
|
326
|
-
# a parser still reads the block); the unbounded-SQLite note stays off
|
|
327
|
-
# CSV, which is meant to be plain data.
|
|
328
|
-
cut = !schema_metadata && result.respond_to?(:truncated) && result.truncated
|
|
329
|
-
truncation = cut ? truncation_note([ row_limit, HARD_ROW_CAP ].min, format) : ""
|
|
330
|
-
output = case format
|
|
331
|
-
when "csv"
|
|
332
|
-
format_csv(redacted) + truncation
|
|
333
|
-
else
|
|
334
|
-
body = sql.match?(SHOW_CREATE_DEFINITION) ? format_definition(redacted) : format_table(redacted)
|
|
335
|
-
body + truncation + unbounded_note(result)
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
text_response(output)
|
|
339
|
-
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => e
|
|
340
|
-
text_response("Database unavailable: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Check `config/database.yml` for correct host/port/credentials\n- Try `RAILS_ENV=test` if the development DB is remote\n- Run `bin/rails db:create` if the database doesn't exist yet")
|
|
96
|
+
# Decided from the configuration, so an adapter with no family is refused before its server is pinged.
|
|
97
|
+
family = configured_family(database)
|
|
98
|
+
return no_family_response(family) if family && !family.known?
|
|
99
|
+
|
|
100
|
+
AppDatabases.lease(rails_app.root, database) { |connection| answer(connection, sql, limit, format, explain, database) }
|
|
101
|
+
rescue AppDatabases::Unreachable => e
|
|
102
|
+
# A lock met while the connection opens is the locked database a statement meets, so it answers the same.
|
|
103
|
+
error_response([ e, e.cause ].compact.any? { |error| DatabaseFamily.busy?(error) } ? DatabaseFamily::BUSY : e.message)
|
|
104
|
+
rescue AppDatabases::Error, SecretColumns::Unreadable, DatabaseFamily::Busy => e
|
|
105
|
+
error_response(e.message)
|
|
106
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
107
|
+
# The lease checked the connection answers, so this one was lost while the statement ran.
|
|
108
|
+
stopped_answering_response(database, e)
|
|
109
|
+
rescue DatabaseFamily::TimedOut => e
|
|
110
|
+
return stopped_answering_response(database, e.cause) if e.cause && DatabaseFamily.lost_connection?(e.cause)
|
|
111
|
+
return error_response(e.message) if e.is_a?(ReadTimedOut)
|
|
112
|
+
|
|
113
|
+
error_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
|
|
341
114
|
rescue ActiveRecord::StatementInvalid => e
|
|
342
|
-
if
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# missing table the same way ("... does not exist"), and matching
|
|
346
|
-
# that shape answered a real SQL error with `db:create` advice and
|
|
347
|
-
# exit 0. `ActiveRecord::NoDatabaseError` is rescued above, so this
|
|
348
|
-
# only catches adapters that raise the plain StatementInvalid.
|
|
349
|
-
elsif e.message.match?(/database "[^"]*" does not exist|could not find (?:your )?database|Unknown database/i)
|
|
350
|
-
text_response("Database not found: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Run `bin/rails db:create` to create the database\n- Check `config/database.yml` for the correct database name\n- Try `RAILS_ENV=test` if the development DB is remote")
|
|
351
|
-
else
|
|
352
|
-
# Genuine execution failure (unknown column, bad table, syntax
|
|
353
|
-
# error) - flag as an error result so MCP clients and the CLI
|
|
354
|
-
# (exit 1) treat it as failed. Guidance about a database that is
|
|
355
|
-
# not there stays informational above: the question is answerable,
|
|
356
|
-
# just not now.
|
|
357
|
-
error_response("SQL error: #{clean_error_message(e.message)}")
|
|
358
|
-
end
|
|
115
|
+
return stopped_answering_response(database, e) if DatabaseFamily.lost_connection?(e)
|
|
116
|
+
|
|
117
|
+
error_response("SQL error: #{clean_error_message(e.message)}#{held_elsewhere(e.message, database)}")
|
|
359
118
|
rescue => e
|
|
360
119
|
error_response("Query failed: #{clean_error_message(e.message)}")
|
|
361
120
|
end
|
|
362
121
|
|
|
363
|
-
#
|
|
364
|
-
def self.
|
|
365
|
-
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
QUOTED = /'[^']*'?|"[^"]*"?|`[^`]*`?/m
|
|
369
|
-
# A backslash escapes the next character: every MySQL string, and a PostgreSQL E'...' one.
|
|
370
|
-
ESCAPED_STRING = /'(?:\\.|[^'\\])*'?/m
|
|
371
|
-
MYSQL_QUOTED = /#{ESCAPED_STRING}|"(?:\\.|[^"\\])*"?|`[^`]*`?/m
|
|
372
|
-
DOLLAR_QUOTED = /(?<![\p{Word}$])\$(\p{Word}*)\$.*?\$\1\$/m
|
|
373
|
-
UNQUOTED = /[^'"`$\/\-#*]+/
|
|
374
|
-
|
|
375
|
-
# Comments out, everything else as written: this is the text that runs.
|
|
376
|
-
# A quoted span is copied whole, so a comment marker inside one is data.
|
|
377
|
-
# Only MySQL has a hash comment, a `--` that needs a space after it, and
|
|
378
|
-
# `/*! ... */` content it runs. PostgreSQL has dollar quoting.
|
|
379
|
-
def self.comment_free(sql, mysql: false)
|
|
380
|
-
# fixed_anchor, so the dollar quote's lookbehind sees the text before the pointer.
|
|
381
|
-
scanner = StringScanner.new(sql, fixed_anchor: true)
|
|
382
|
-
line_comment = mysql ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
|
|
383
|
-
out = +""
|
|
384
|
-
executable = false
|
|
385
|
-
|
|
386
|
-
until scanner.eos?
|
|
387
|
-
quoted = if mysql then MYSQL_QUOTED
|
|
388
|
-
elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
|
|
389
|
-
else QUOTED
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)) || scanner.scan(UNQUOTED))
|
|
393
|
-
out << span
|
|
394
|
-
elsif mysql && scanner.scan(/\/\*!\d*/)
|
|
395
|
-
# Its content stays in the text for the checks to read, e.g. the
|
|
396
|
-
# `LOAD_FILE` in `SELECT /*!50000 LOAD_FILE('/etc/passwd') */`.
|
|
397
|
-
executable = true
|
|
398
|
-
out << " "
|
|
399
|
-
elsif executable && scanner.scan(/\*\//)
|
|
400
|
-
executable = false
|
|
401
|
-
out << " "
|
|
402
|
-
elsif scanner.scan(/\/\*.*?(?:\*\/|\z)/m) || scanner.scan(line_comment)
|
|
403
|
-
out << " "
|
|
404
|
-
else
|
|
405
|
-
out << scanner.getch
|
|
406
|
-
end
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
out.strip
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Comment-free text with every quoted and dollar-quoted span blanked to
|
|
413
|
-
# spaces of the same length, so a check reads structure only. A `;` or a
|
|
414
|
-
# keyword inside a string literal or a `$$ ... $$` body is data, not a
|
|
415
|
-
# second statement: it is the `;` in a `DO $$ ... ; ... $$` block that
|
|
416
|
-
# made the multi-statement check refuse one for the wrong reason.
|
|
417
|
-
def self.mask_quoted(text, mysql: false)
|
|
418
|
-
scanner = StringScanner.new(text, fixed_anchor: true)
|
|
419
|
-
out = +""
|
|
420
|
-
|
|
421
|
-
until scanner.eos?
|
|
422
|
-
quoted = if mysql then MYSQL_QUOTED
|
|
423
|
-
elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
|
|
424
|
-
else QUOTED
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)))
|
|
428
|
-
out << (" " * span.length)
|
|
429
|
-
elsif (span = scanner.scan(UNQUOTED))
|
|
430
|
-
out << span
|
|
431
|
-
else
|
|
432
|
-
out << scanner.getch
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
out
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
# Whether the SQL carries a column-alias list that renames a table's
|
|
440
|
-
# columns. A list on a VALUES body renames nothing a table holds, so it is
|
|
441
|
-
# left alone; a sensitive name inside the VALUES rows is still refused by
|
|
442
|
-
# the textual check. The lists are matched on the text as written, as
|
|
443
|
-
# before; parentheses are balanced on quote-masked text (same length, same
|
|
444
|
-
# positions), so one inside a literal does not move a body's bounds.
|
|
445
|
-
private_class_method def self.renaming_alias_list?(cleaned, mysql: false)
|
|
446
|
-
return true if cleaned.match?(TABLE_ALIAS_LIST)
|
|
447
|
-
|
|
448
|
-
masked = mask_quoted(cleaned, mysql: mysql)
|
|
449
|
-
# A CTE's list: the pattern ends on the "(" that opens its body.
|
|
450
|
-
cleaned.scan(CTE_ALIAS_LIST) do
|
|
451
|
-
return true unless values_body_at?(masked, Regexp.last_match.end(0))
|
|
452
|
-
end
|
|
453
|
-
# A derived table's list: its body is the parenthesis before AS.
|
|
454
|
-
cleaned.scan(DERIVED_ALIAS_LIST) do
|
|
455
|
-
open = derived_table_open(masked, Regexp.last_match.begin(0))
|
|
456
|
-
return true unless open && values_body_at?(masked, open + 1)
|
|
457
|
-
end
|
|
458
|
-
false
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
private_class_method def self.values_body_at?(masked, position)
|
|
462
|
-
masked[position..].to_s.match?(/\A\s*VALUES\b/i)
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
# The index of the "(" that opens the parenthesised FROM item ending just
|
|
466
|
-
# before `as_position`, or nil when no ")" stands there.
|
|
467
|
-
private_class_method def self.derived_table_open(masked, as_position)
|
|
468
|
-
close = as_position - 1
|
|
469
|
-
close -= 1 while close >= 0 && masked[close].match?(/\s/)
|
|
470
|
-
return nil unless close >= 0 && masked[close] == ")"
|
|
471
|
-
|
|
472
|
-
depth = 0
|
|
473
|
-
close.downto(0) do |i|
|
|
474
|
-
depth += 1 if masked[i] == ")"
|
|
475
|
-
depth -= 1 if masked[i] == "("
|
|
476
|
-
return i if depth.zero?
|
|
477
|
-
end
|
|
478
|
-
nil
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
private_class_method def self.mysql_dialect?
|
|
482
|
-
ActiveRecord::Base.connection_db_config.adapter.to_s.match?(MYSQL_ADAPTER)
|
|
483
|
-
rescue ActiveRecord::ActiveRecordError
|
|
484
|
-
false
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# ── SQL validation (Layer 1) ────────────────────────────────────
|
|
488
|
-
# `postgres:` relaxes the sensitive-column check so a name inside a string
|
|
489
|
-
# literal ('%secret%', a JSON key) is read as data, not a column - safe on
|
|
490
|
-
# PostgreSQL because its plan layer (Layer 1b) re-checks and must vouch.
|
|
491
|
-
# MySQL, SQLite and unknown adapters keep raw-text matching, with no
|
|
492
|
-
# masking: MySQL's NO_BACKSLASH_ESCAPES and ANSI_QUOTES modes change what a
|
|
493
|
-
# quote means, and SQLite reads a single-quoted name after a dot (t.'col')
|
|
494
|
-
# as an identifier, so a masker there could be fooled into hiding one.
|
|
495
|
-
def self.validate_sql(sql, mysql: false, postgres: false)
|
|
496
|
-
return [ false, "SQL query is required." ] if sql.nil? || sql.strip.empty?
|
|
497
|
-
|
|
498
|
-
# Belt-and-suspenders: run BLOCKED_FUNCTIONS against the RAW sql before
|
|
499
|
-
# stripping comments. If the unwrap logic in comment_free is ever
|
|
500
|
-
# defeated by a novel MySQL comment variant, this still catches the
|
|
501
|
-
# dangerous primitives (pg_read_file, LOAD_FILE, dblink, ...).
|
|
502
|
-
if (m = sql.match(BLOCKED_FUNCTIONS))
|
|
503
|
-
return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
cleaned = strip_sql_comments(sql, mysql: mysql)
|
|
507
|
-
|
|
508
|
-
# Check multi-statement and clause patterns first - they provide more
|
|
509
|
-
# specific error messages than the generic keyword blocker. The
|
|
510
|
-
# semicolon is read on quote-masked text, so one inside a string literal
|
|
511
|
-
# or a `$$ ... $$` block body is not mistaken for a second statement.
|
|
512
|
-
#
|
|
513
|
-
# The statement-keyword checks (SELECT INTO, the write keywords) read the
|
|
514
|
-
# same quote-masked text, so `LIKE '%update%'` or `'put into box'` is
|
|
515
|
-
# data. That is safe: ALLOWED_PREFIX still requires a read statement, and
|
|
516
|
-
# the read-only transaction (PostgreSQL, MySQL) or read-only connection
|
|
517
|
-
# (SQLite) stops any write a mis-masked literal could hide. A disk write
|
|
518
|
-
# (INTO OUTFILE/DUMPFILE) and the file/network functions stay on the raw
|
|
519
|
-
# text: no read-only transaction stops a server-side file write.
|
|
520
|
-
masked = mask_quoted(cleaned, mysql: mysql)
|
|
521
|
-
return [ false, "Blocked: multiple statements (no semicolons)" ] if masked.match?(MULTI_STATEMENT)
|
|
522
|
-
return [ false, "Blocked: FOR UPDATE/SHARE clause" ] if cleaned.match?(BLOCKED_CLAUSES)
|
|
523
|
-
return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
|
|
524
|
-
return [ false, "Blocked: SELECT INTO OUTFILE / DUMPFILE writes to disk" ] if cleaned.match?(BLOCKED_OUTPUT)
|
|
525
|
-
return [ false, "Blocked: SELECT INTO creates a table" ] if masked.match?(SELECT_INTO)
|
|
526
|
-
|
|
527
|
-
# Block database functions that give a filesystem/network primitive.
|
|
528
|
-
# pg_read_file, lo_import, dblink, LOAD_FILE, load_extension, etc.
|
|
529
|
-
# These pass SET TRANSACTION READ ONLY but bypass sensitive_patterns.
|
|
530
|
-
if (m = cleaned.match(BLOCKED_FUNCTIONS))
|
|
531
|
-
return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# Session-effecting admin / lock functions run under READ ONLY and keep
|
|
535
|
-
# their effect on the pooled connection. The name blocklist is the first
|
|
536
|
-
# layer; PostgreSQL's plan VOLATILE-function check is the robust one.
|
|
537
|
-
if (m = cleaned.match(BLOCKED_PG_ADMIN))
|
|
538
|
-
fn = m[0].sub(/\s*\(\z/, "")
|
|
539
|
-
return [ false,
|
|
540
|
-
"Blocked: administrative function #{fn} has a session or server-wide " \
|
|
541
|
-
"effect that a read-only transaction does not prevent. rails_query runs " \
|
|
542
|
-
"read-only queries for inspection; it will not run server-control functions." ]
|
|
543
|
-
end
|
|
544
|
-
if (m = cleaned.match(BLOCKED_MYSQL_SESSION))
|
|
545
|
-
fn = m[0].sub(/\s*\(\z/, "")
|
|
546
|
-
return [ false,
|
|
547
|
-
"Blocked: session function #{fn} leaves state (a named lock) on the pooled " \
|
|
548
|
-
"connection after the query returns. rails_query will not run it." ]
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
# A CTE or FROM-item column-alias list renames a wildcard's columns,
|
|
552
|
-
# which would carry the sensitive values out under harmless names. One
|
|
553
|
-
# on a VALUES body renames no table column and is left alone.
|
|
554
|
-
if renaming_alias_list?(cleaned, mysql: mysql)
|
|
555
|
-
return [ false,
|
|
556
|
-
"Blocked: a column-alias list (e.g. `t(a, b, c)`) renames the columns a " \
|
|
557
|
-
"wildcard returns, so sensitive columns would leave under harmless names. " \
|
|
558
|
-
"Name the columns you want in the SELECT list instead." ]
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
# On PostgreSQL a "union" inside a string literal is data; its plan
|
|
562
|
-
# layer reads the query itself. Elsewhere the raw text decides.
|
|
563
|
-
if (postgres ? mask_sql_literals(cleaned) : cleaned).match?(SET_UNION)
|
|
564
|
-
return [ false,
|
|
565
|
-
"Blocked: UNION is not run. A UNION takes its column names from its first SELECT, " \
|
|
566
|
-
"so rows of another table would come out under those names, past the redaction that " \
|
|
567
|
-
"reads them. Query each table on its own; a recursive CTE needs UNION, so it is not run either." ]
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Check for SQL injection tautology patterns (OR 1=1, OR true, etc.)
|
|
571
|
-
tautology = TAUTOLOGY_PATTERNS.find { |p| cleaned.match?(p) }
|
|
572
|
-
return [ false, "Blocked: SQL injection pattern detected (#{cleaned[tautology]})" ] if tautology
|
|
573
|
-
|
|
574
|
-
# Check blocked keywords before the allowed-prefix fallback so that
|
|
575
|
-
# INSERT/UPDATE/DELETE/DROP etc. get a specific "Blocked" error
|
|
576
|
-
# rather than the generic "Only SELECT... allowed" message. Read on the
|
|
577
|
-
# quote-masked text; SHOW CREATE TABLE / VIEW drop their CREATE first.
|
|
578
|
-
if (m = masked.sub(SHOW_CREATE_DEFINITION, "SHOW").match(BLOCKED_KEYWORDS))
|
|
579
|
-
return [ false, "Blocked: contains #{m[0]}" ]
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
return [ false, "Only SELECT, WITH, SHOW, EXPLAIN, DESCRIBE allowed" ] unless cleaned.match?(ALLOWED_PREFIX)
|
|
583
|
-
|
|
584
|
-
# Column-aliasing redaction bypass defense: reject any query that
|
|
585
|
-
# textually references a sensitive column name. Post-execution redaction
|
|
586
|
-
# reads the output column names, which an alias or an expression renames,
|
|
587
|
-
# so it cannot survive `SELECT password_digest AS x` or `upper(api_token)`.
|
|
588
|
-
# On PostgreSQL the match runs on literal-masked text; `.call` then makes
|
|
589
|
-
# the plan vouch for anything that passed only because of that masking.
|
|
590
|
-
if (offending = references_sensitive_column?(postgres ? mask_sql_literals(cleaned) : cleaned))
|
|
591
|
-
return [ false,
|
|
592
|
-
"Blocked: query references sensitive column `#{offending}`. " \
|
|
593
|
-
"Name the columns you need; `#{offending}` is sensitive and never " \
|
|
594
|
-
"returned, and an alias or expression over it cannot be redacted after " \
|
|
595
|
-
"the query runs, so the whole query is refused. If `#{offending}` is not " \
|
|
596
|
-
"sensitive in your app, add \"#{offending}\" to config.query_allowed_columns " \
|
|
597
|
-
"in an initializer." ]
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
[ true, nil ]
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# PostgreSQL string literals masked to an empty pair, so a sensitive word
|
|
604
|
-
# inside one (a `'%secret%'` pattern, a JSON key `->>'token'`) is read as
|
|
605
|
-
# data, not a column name. Dollar quotes first, then single quotes with any
|
|
606
|
-
# PG prefix (`E` escapes a backslash; `U&`, `B`, `X`). A double-quoted
|
|
607
|
-
# identifier is never touched. This is good enough for the textual layer;
|
|
608
|
-
# the plan layer, whose deparser output does not depend on
|
|
609
|
-
# standard_conforming_strings, is the final authority (see .call).
|
|
610
|
-
# A double-quoted identifier is matched so it is skipped whole: a quote
|
|
611
|
-
# inside one (`AS "a'b"`) would otherwise open a literal that swallows
|
|
612
|
-
# the SQL after it.
|
|
613
|
-
PG_TEXT_LITERAL = /
|
|
614
|
-
(?<ident>"(?:""|[^"])*")
|
|
615
|
-
| \$(?<tag>(?:[A-Za-z_]\w*)?)\$.*?\$\k<tag>\$
|
|
616
|
-
| [eE]'(?:\\.|''|[^'])*'
|
|
617
|
-
| (?:[uU]&|[bBxX])?'(?:''|[^'])*'
|
|
618
|
-
/mx
|
|
619
|
-
def self.mask_sql_literals(text)
|
|
620
|
-
text.gsub(PG_TEXT_LITERAL) { Regexp.last_match[:ident] || "''" }
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
# Returns the first sensitive column name the SQL names, or nil. Two rules,
|
|
624
|
-
# one predicate (sensitive_column?):
|
|
625
|
-
# * a fixed name list - config.query_redacted_columns and
|
|
626
|
-
# SENSITIVE_COLUMN_SUFFIXES - matched on every adapter whether or not
|
|
627
|
-
# the name is a real column, so the oracle holds with no connection;
|
|
628
|
-
# * every REAL column of the app's schema that the predicate flags
|
|
629
|
-
# (an `api_token` the heuristic catches, an `encrypts` column), so an
|
|
630
|
-
# app-specific sensitive column is caught through an alias or an
|
|
631
|
-
# expression too.
|
|
632
|
-
# The match is case-insensitive and word-bounded, so a table or alias that
|
|
633
|
-
# merely contains "token" does not trip it - only a real column name does.
|
|
634
|
-
def self.references_sensitive_column?(cleaned_sql)
|
|
635
|
-
down = cleaned_sql.downcase
|
|
636
|
-
blocked_column_names.each do |col|
|
|
637
|
-
next if col.empty?
|
|
638
|
-
return col if down.match?(/\b#{Regexp.escape(col)}\b/)
|
|
639
|
-
end
|
|
640
|
-
nil
|
|
641
|
-
end
|
|
122
|
+
# nil where the lease has its own refusal to give: a database this environment does not configure.
|
|
123
|
+
private_class_method def self.configured_family(database)
|
|
124
|
+
return DatabaseFamily.of(ActiveRecord::Base.connection_db_config) if database.to_s.empty?
|
|
642
125
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
private_class_method def self.blocked_column_names
|
|
646
|
-
fixed = Array(config.query_redacted_columns).map { |c| c.to_s.downcase } +
|
|
647
|
-
SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)
|
|
648
|
-
(fixed + schema_sensitive_columns.to_a).uniq - allowed_columns.to_a
|
|
126
|
+
named = AppDatabases.for(rails_app.root)[database]
|
|
127
|
+
named.family if named&.here
|
|
649
128
|
end
|
|
650
129
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
real_column_names.select { |name| sensitive_column?(name) }
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
# Rails' own bookkeeping tables, on every adapter. Their columns
|
|
660
|
-
# (schema_migrations.version, ar_internal_metadata.key/value) are never
|
|
661
|
-
# secrets, and ar_internal_metadata.key ends in "key", so leaving them in
|
|
662
|
-
# the sensitive-column set refused every query whose text held the word
|
|
663
|
-
# "key" - a column, a JSON key, a `LIKE '%key%'`. A `SELECT *` from one
|
|
664
|
-
# still goes through output-name redaction, as before.
|
|
665
|
-
BOOKKEEPING_TABLES = %w[ar_internal_metadata schema_migrations].freeze
|
|
666
|
-
|
|
667
|
-
private_class_method def self.bookkeeping_table?(name)
|
|
668
|
-
BOOKKEEPING_TABLES.include?(name.to_s.downcase)
|
|
669
|
-
end
|
|
670
|
-
|
|
671
|
-
# Every real column name of the app's schema, downcased. The live
|
|
672
|
-
# connection first (its own schema cache), the cached introspection as a
|
|
673
|
-
# fallback. Memoised for the run; nothing here raising fails the query.
|
|
674
|
-
private_class_method def self.real_column_names
|
|
675
|
-
RailsAiContext::RunCache.fetch([ :query_real_columns ]) do
|
|
676
|
-
columns_from_connection || columns_from_context || Set.new
|
|
677
|
-
end
|
|
130
|
+
private_class_method def self.no_family_response(family)
|
|
131
|
+
error_response(
|
|
132
|
+
"rails_query refuses the #{family.name} adapter: it runs SQL only on a database whose read-only " \
|
|
133
|
+
"transaction and statement timeout it knows (PostgreSQL, MySQL with MariaDB, SQLite). No statement was sent."
|
|
134
|
+
)
|
|
678
135
|
end
|
|
679
136
|
|
|
680
|
-
private_class_method def self.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
conn.tables.each do |t|
|
|
684
|
-
next if bookkeeping_table?(t)
|
|
685
|
-
conn.columns(t).each { |c| names << c.name.to_s.downcase }
|
|
686
|
-
end
|
|
687
|
-
names
|
|
688
|
-
rescue StandardError
|
|
689
|
-
nil
|
|
137
|
+
private_class_method def self.stopped_answering_response(database, error)
|
|
138
|
+
name = database.to_s.empty? ? ActiveRecord::Base.connection_db_config.name : database
|
|
139
|
+
error_response("The #{name} database stopped answering during the query: #{clean_error_message(error.message)}")
|
|
690
140
|
end
|
|
691
141
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
return nil unless tables.is_a?(Hash)
|
|
142
|
+
# A read beside the statement ran out of the time those reads share. Its message is the answer.
|
|
143
|
+
class ReadTimedOut < DatabaseFamily::TimedOut; end
|
|
695
144
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
names << name.to_s.downcase if name
|
|
702
|
-
end
|
|
703
|
-
end
|
|
704
|
-
names
|
|
705
|
-
rescue StandardError
|
|
706
|
-
nil
|
|
707
|
-
end
|
|
145
|
+
# Runs reads beside the statement, and says which side of it they were on when their time runs out.
|
|
146
|
+
private_class_method def self.beside_statement(outcome)
|
|
147
|
+
yield
|
|
148
|
+
rescue DatabaseFamily::TimedOut => e
|
|
149
|
+
raise if e.cause && DatabaseFamily.lost_connection?(e.cause)
|
|
708
150
|
|
|
709
|
-
|
|
710
|
-
# check and result redaction alike: a column is sensitive when it is a
|
|
711
|
-
# configured or built-in redacted name, an `encrypts` column, or matches
|
|
712
|
-
# the result heuristic - unless the app allows it by name.
|
|
713
|
-
def self.sensitive_column?(name)
|
|
714
|
-
down = name.to_s.downcase
|
|
715
|
-
return false if down.empty? || allowed_columns.include?(down)
|
|
716
|
-
|
|
717
|
-
redacted_name_set.include?(down) ||
|
|
718
|
-
encrypted_column_set.include?(down) ||
|
|
719
|
-
sensitive_by_heuristic?(down)
|
|
151
|
+
raise ReadTimedOut, outcome
|
|
720
152
|
end
|
|
721
153
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
private_class_method def self.sensitive_by_heuristic?(down)
|
|
726
|
-
down.end_with?(*SENSITIVE_SUFFIXES) || down.match?(/password|secret|token/)
|
|
727
|
-
end
|
|
154
|
+
private_class_method def self.answer(connection, sql, limit, format, explain, database)
|
|
155
|
+
family = DatabaseFamily.of(connection)
|
|
156
|
+
return no_family_response(family) unless family.known?
|
|
728
157
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
158
|
+
# Built before any clock starts: a cold context is the app's whole introspection.
|
|
159
|
+
context = cached_context
|
|
160
|
+
# The statement has query_timeout to itself. Every other read the call sends shares a second one.
|
|
161
|
+
timeout = config.query_timeout
|
|
162
|
+
reads = SqlGate::Budget.new(timeout)
|
|
163
|
+
secrets, verdict = beside_statement(
|
|
164
|
+
"The reads rails_query makes before a statement (the columns of the schema, and on PostgreSQL the statement's plan) " \
|
|
165
|
+
"took the whole #{timeout} second timeout, so the statement was not run."
|
|
166
|
+
) do
|
|
167
|
+
secrets = reads.spend { |left| family.bounded(connection, timeout: left) { SecretColumns.for(connection, context: context, statement: sql) } }
|
|
168
|
+
verdict = SqlGate.new(family: family, secrets: secrets, connection: connection, budget: reads).check(sql)
|
|
169
|
+
# A read no statement limit holds can run past its share, and then nothing is left for the reads after the statement.
|
|
170
|
+
raise DatabaseFamily::TimedOut, "the reads before the statement used the whole timeout" if reads.spent? && !verdict.refused?
|
|
733
171
|
|
|
734
|
-
|
|
735
|
-
private_class_method def self.encrypted_column_set
|
|
736
|
-
set = Set.new
|
|
737
|
-
models = cached_context&.dig(:models)
|
|
738
|
-
if models.is_a?(Hash)
|
|
739
|
-
models.each_value do |data|
|
|
740
|
-
next unless data.is_a?(Hash)
|
|
741
|
-
Array(data[:encrypts]).each { |col| set << col.to_s.downcase }
|
|
742
|
-
end
|
|
172
|
+
[ secrets, verdict ]
|
|
743
173
|
end
|
|
744
|
-
|
|
745
|
-
end
|
|
746
|
-
|
|
747
|
-
# An app whose own column merely looks sensitive (oauth_applications.secret)
|
|
748
|
-
# exempts it by name, from the pre-query check and from result redaction alike.
|
|
749
|
-
private_class_method def self.allowed_columns
|
|
750
|
-
Array(config.query_allowed_columns).to_set { |c| c.to_s.downcase }
|
|
751
|
-
end
|
|
174
|
+
return error_response(verdict.refusal) if verdict.refused?
|
|
752
175
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
conn = ActiveRecord::Base.connection
|
|
756
|
-
adapter = conn.adapter_name.downcase
|
|
176
|
+
sql = verdict.sql
|
|
177
|
+
return explain_response(family, connection, sql, timeout, database) if explain
|
|
757
178
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
cap_rows(
|
|
761
|
-
end
|
|
179
|
+
row_limit = limit ? [ limit.to_i, HARD_ROW_CAP ].min : config.query_row_limit
|
|
180
|
+
row_limit = [ row_limit, 1 ].max
|
|
181
|
+
result = cap_rows(family.read_only(connection, apply_row_limit(sql, row_limit), timeout: timeout), sql, row_limit)
|
|
762
182
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
when MYSQL_ADAPTER
|
|
770
|
-
execute_mysql(conn, sql, timeout)
|
|
771
|
-
when /sqlite/
|
|
772
|
-
execute_sqlite(conn, sql, timeout)
|
|
183
|
+
schema_metadata = sql.match?(SCHEMA_METADATA_PREFIX)
|
|
184
|
+
definition = sql.match?(SqlGate::SHOW_CREATE_DEFINITION)
|
|
185
|
+
redacted = if definition
|
|
186
|
+
redact_definition(result)
|
|
187
|
+
elsif schema_metadata
|
|
188
|
+
result
|
|
773
189
|
else
|
|
774
|
-
|
|
775
|
-
|
|
190
|
+
sources = beside_statement(
|
|
191
|
+
"The statement ran, and the read that traces its result columns to their table columns took what was left of the " \
|
|
192
|
+
"#{timeout} second timeout the reads beside a statement share, so its rows are not shown."
|
|
193
|
+
) { reads.spend { |left| family.bounded(connection, timeout: left) { family.column_sources(connection, sql, plan: verdict.plan) } } }
|
|
194
|
+
redact_results(result, secrets, sources)
|
|
776
195
|
end
|
|
777
|
-
end
|
|
778
196
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
raise ActiveRecord::Rollback
|
|
786
|
-
end
|
|
787
|
-
result
|
|
788
|
-
end
|
|
789
|
-
|
|
790
|
-
private_class_method def self.execute_mysql(conn, sql, timeout)
|
|
791
|
-
# Per-query timeout. MySQL honours the MAX_EXECUTION_TIME optimizer hint;
|
|
792
|
-
# MariaDB ignores it (a long query would run unbounded, holding the
|
|
793
|
-
# connection), so there the statement is bounded with MariaDB's own
|
|
794
|
-
# `SET STATEMENT max_statement_time = <seconds> FOR <query>`, which scopes
|
|
795
|
-
# the limit to this one statement and needs no restore on the pool.
|
|
796
|
-
bounded_sql = if mariadb_server?(conn)
|
|
797
|
-
"SET STATEMENT max_statement_time=#{[ timeout.to_f, 0.001 ].max} FOR #{sql}"
|
|
798
|
-
elsif sql.match?(/\ASELECT/i)
|
|
799
|
-
sql.sub(/\ASELECT/i, "SELECT /*+ MAX_EXECUTION_TIME(#{(timeout * 1000).to_i}) */")
|
|
197
|
+
# The truncation note rides every format (for CSV after a blank line, so
|
|
198
|
+
# a parser still reads the block); the unbounded note stays off CSV.
|
|
199
|
+
cut = !schema_metadata && result.respond_to?(:truncated) && result.truncated
|
|
200
|
+
truncation = cut ? truncation_note([ row_limit, HARD_ROW_CAP ].min, format) : ""
|
|
201
|
+
output = if format == "csv"
|
|
202
|
+
format_csv(redacted) + truncation
|
|
800
203
|
else
|
|
801
|
-
|
|
204
|
+
(definition ? format_definition(redacted) : format_table(redacted)) + truncation + unbounded_note(result)
|
|
802
205
|
end
|
|
803
206
|
|
|
804
|
-
|
|
805
|
-
# the lazy BEGIN ahead of the first in-block statement, and MySQL
|
|
806
|
-
# rejects changing transaction characteristics mid-transaction
|
|
807
|
-
# (error 1568). Without GLOBAL/SESSION scope the SET applies only to
|
|
808
|
-
# the next transaction, which the block below immediately consumes,
|
|
809
|
-
# so the read-only characteristic cannot leak onto the pooled
|
|
810
|
-
# connection. (#89)
|
|
811
|
-
result = nil
|
|
812
|
-
conn.execute("SET TRANSACTION READ ONLY")
|
|
813
|
-
conn.transaction do
|
|
814
|
-
result = conn.select_all(bounded_sql)
|
|
815
|
-
raise ActiveRecord::Rollback
|
|
816
|
-
end
|
|
817
|
-
result
|
|
818
|
-
end
|
|
819
|
-
|
|
820
|
-
# MariaDB and MySQL share the mysql2/trilogy adapters but bound a query
|
|
821
|
-
# differently; the adapter answers `mariadb?` on Rails 7.1+, and the
|
|
822
|
-
# server version string is the fallback.
|
|
823
|
-
private_class_method def self.mariadb_server?(conn)
|
|
824
|
-
return conn.mariadb? if conn.respond_to?(:mariadb?)
|
|
825
|
-
|
|
826
|
-
conn.select_value("SELECT VERSION()").to_s.include?("MariaDB")
|
|
827
|
-
rescue StandardError
|
|
828
|
-
false
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
# sqlite3-ruby cannot interrupt a running statement, so a killable child runs it instead.
|
|
832
|
-
private_class_method def self.execute_sqlite(conn, sql, timeout)
|
|
833
|
-
if (path = sqlite_fork_path(conn))
|
|
834
|
-
begin
|
|
835
|
-
return execute_sqlite_in_child(conn, path, sql, timeout)
|
|
836
|
-
rescue ActiveRecord::StatementInvalid => e
|
|
837
|
-
raise unless e.message.match?(SQLITE_APP_CONNECTION_ONLY)
|
|
838
|
-
end
|
|
839
|
-
end
|
|
840
|
-
|
|
841
|
-
result = nil
|
|
842
|
-
begin
|
|
843
|
-
conn.execute("PRAGMA query_only = ON")
|
|
844
|
-
result = conn.select_all(sql)
|
|
845
|
-
ensure
|
|
846
|
-
conn.execute("PRAGMA query_only = OFF")
|
|
847
|
-
end
|
|
848
|
-
ResultProxy.new(result.columns, result.rows, true)
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
# Errors from state only the app's own connection has: registered functions,
|
|
852
|
-
# virtual table modules, collations, an encryption key.
|
|
853
|
-
SQLITE_APP_CONNECTION_ONLY = /no such (?:function|module|collation)|file is not a database/i
|
|
854
|
-
|
|
855
|
-
# A row-capped result is far below this; a larger prefix is a corrupt one.
|
|
856
|
-
SQLITE_MAX_RESULT_BYTES = 1 << 30
|
|
857
|
-
|
|
858
|
-
private_class_method def self.read_before(reader, size, deadline)
|
|
859
|
-
data = "".b
|
|
860
|
-
while data.bytesize < size
|
|
861
|
-
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
862
|
-
raise ActiveRecord::StatementInvalid, "SQLite query exceeded the statement timeout" unless left.positive? && IO.select([ reader ], nil, nil, left)
|
|
863
|
-
|
|
864
|
-
chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
|
|
865
|
-
raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
|
|
866
|
-
|
|
867
|
-
data << chunk unless chunk == :wait_readable
|
|
868
|
-
end
|
|
869
|
-
data
|
|
207
|
+
text_response(output)
|
|
870
208
|
end
|
|
871
209
|
|
|
872
|
-
|
|
873
|
-
private_class_method def self.sqlite_fork_path(conn)
|
|
874
|
-
return nil unless Process.respond_to?(:fork)
|
|
210
|
+
MISSING_TABLE = /no such table: (?:\w+\.)?(\w+)|relation "(?:\w+\.)?(\w+)" does not exist|Table '(?:[^'.]+\.)?(\w+)' doesn't exist/i
|
|
875
211
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
212
|
+
# Where a table the statement did not find lives, read from the schema
|
|
213
|
+
# section's tables for the primary and each other database's dump.
|
|
214
|
+
private_class_method def self.held_elsewhere(message, database)
|
|
215
|
+
table = message.match(MISSING_TABLE)&.captures&.compact&.first or return ""
|
|
216
|
+
databases = AppDatabases.for(rails_app.root)
|
|
217
|
+
asked = database.to_s.empty? ? databases.primary&.name : database.to_s
|
|
218
|
+
schema = cached_context[:schema]
|
|
219
|
+
return "" unless schema.is_a?(Hash)
|
|
879
220
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
# sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
|
|
884
|
-
SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
|
|
885
|
-
|
|
886
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
887
|
-
reader, writer = IO.pipe.each(&:binmode)
|
|
888
|
-
pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
|
|
889
|
-
writer.close
|
|
890
|
-
read_child_result(reader, deadline)
|
|
891
|
-
ensure
|
|
892
|
-
# Also reached in a child whose fork hooks raised: it must not carry on as a second server.
|
|
893
|
-
exit!(1) if parent && Process.pid != parent
|
|
894
|
-
reader&.close
|
|
895
|
-
writer&.close
|
|
896
|
-
reap_child(pid) if pid
|
|
897
|
-
end
|
|
221
|
+
held = { (databases.primary&.name || "primary") => schema[:tables] }
|
|
222
|
+
(schema[:secondary_databases] || {}).each { |name, dump| held[name.to_s] ||= dump[:tables] if dump.is_a?(Hash) }
|
|
223
|
+
holder = held.find { |name, tables| name != asked && tables.is_a?(Hash) && tables.key?(table) }&.first or return ""
|
|
898
224
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
reader.close
|
|
902
|
-
payload = begin
|
|
903
|
-
db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
|
|
904
|
-
db.busy_timeout = (timeout * 1000).to_i
|
|
905
|
-
stmt = db.prepare(sql)
|
|
906
|
-
[ :ok, stmt.columns, stmt.to_a ]
|
|
907
|
-
rescue => e
|
|
908
|
-
[ :error, "#{e.class}: #{e.message}" ]
|
|
909
|
-
end
|
|
910
|
-
bytes = Marshal.dump(payload)
|
|
911
|
-
writer.write([ bytes.bytesize ].pack("Q>"), bytes)
|
|
912
|
-
ensure
|
|
913
|
-
exit!(0)
|
|
914
|
-
end
|
|
225
|
+
found = databases[holder]
|
|
226
|
+
return ". The #{table} table is on the #{holder} database: run the query again with database: \"#{holder}\"." if found.nil? || found.here
|
|
915
227
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
status, *rest = begin
|
|
920
|
-
raise ArgumentError, "result length out of range" if size > SQLITE_MAX_RESULT_BYTES
|
|
921
|
-
|
|
922
|
-
Marshal.load(read_before(reader, size, deadline))
|
|
923
|
-
rescue ArgumentError, TypeError
|
|
924
|
-
raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
|
|
925
|
-
end
|
|
926
|
-
raise ActiveRecord::StatementInvalid, rest.first if status == :error
|
|
927
|
-
|
|
928
|
-
ActiveRecord::Result.new(*rest)
|
|
929
|
-
end
|
|
930
|
-
|
|
931
|
-
# KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
|
|
932
|
-
# Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
|
|
933
|
-
private_class_method def self.reap_child(pid)
|
|
934
|
-
Process.kill(:KILL, pid) rescue nil
|
|
935
|
-
Process.wait(pid) rescue nil
|
|
228
|
+
". The #{table} table is on the #{holder} database, which #{RailsAiContext.environment_name} does not configure."
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
RailsAiContext.debug_fail(e, "", label: "held_elsewhere")
|
|
936
231
|
end
|
|
937
232
|
|
|
938
233
|
private_class_method def self.unbounded_note(result)
|
|
939
|
-
result.
|
|
234
|
+
result.respond_to?(:unbounded) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
|
|
940
235
|
end
|
|
941
236
|
|
|
942
237
|
# Says, in every format, that the row cap held rows back, so a reader (or a
|
|
943
|
-
# parser) is not told a partial answer is the whole one.
|
|
944
|
-
# after a blank line, outside the comma block.
|
|
238
|
+
# parser) is not told a partial answer is the whole one.
|
|
945
239
|
private_class_method def self.truncation_note(cap, format)
|
|
946
240
|
detail = if cap >= HARD_ROW_CAP
|
|
947
241
|
"the 1000-row hard cap"
|
|
948
242
|
else
|
|
949
243
|
"the row limit of #{cap}; pass limit: up to 1000 to see more"
|
|
950
244
|
end
|
|
951
|
-
note = "#{cap}
|
|
245
|
+
note = "#{count_phrase(cap, "row")} shown; the query returned more (#{detail})."
|
|
952
246
|
format == "csv" ? "\n\n#{note}" : "\n\n_#{note}_"
|
|
953
247
|
end
|
|
954
248
|
|
|
955
|
-
|
|
956
|
-
# Plans the query inside a read-only transaction with EXPLAIN (VERBOSE,
|
|
957
|
-
# FORMAT JSON) - planning only, nothing runs, not even a VOLATILE admin
|
|
958
|
-
# function (the planner does not fold one). Refuses three things the
|
|
959
|
-
# textual layer cannot see: a whole-row reference (`alias.*`) to a relation
|
|
960
|
-
# that has a sensitive column; a sensitive column used anywhere but as a
|
|
961
|
-
# plain pass-through output (a ROW(tbl.*) the planner expanded, a WHERE
|
|
962
|
-
# oracle); and any VOLATILE function outside the harmless allowlist.
|
|
963
|
-
private_class_method def self.postgresql_plan_refusal(sql, timeout)
|
|
964
|
-
return nil unless postgres_adapter?
|
|
965
|
-
|
|
966
|
-
plan = pg_plan(sql, timeout)
|
|
967
|
-
return nil unless plan
|
|
968
|
-
|
|
969
|
-
nodes = []
|
|
970
|
-
collect_plan_nodes(plan, nodes)
|
|
971
|
-
aliases = plan_alias_map(nodes)
|
|
972
|
-
exprs = plan_expressions(nodes)
|
|
973
|
-
|
|
974
|
-
if (rel = whole_row_leak(exprs, aliases))
|
|
975
|
-
return "Blocked: the query serialises a whole row of `#{rel}`, which carries every " \
|
|
976
|
-
"column including the sensitive ones. Name the columns you need in the SELECT list."
|
|
977
|
-
end
|
|
978
|
-
if (col = sensitive_column_in_plan(exprs))
|
|
979
|
-
return "Blocked: the query's plan uses the sensitive column `#{col}` (expanded from a " \
|
|
980
|
-
"wildcard or a row reference). Name the non-sensitive columns you need; `#{col}` is " \
|
|
981
|
-
"never returned."
|
|
982
|
-
end
|
|
983
|
-
# A sensitive column the plan reads as a plain pass-through but hides
|
|
984
|
-
# behind an intermediate (a MATERIALIZED CTE, a subquery scan, a set
|
|
985
|
-
# operation), so its value reaches a result column the top node does
|
|
986
|
-
# not name. The top-Output redaction below cannot pin it, so refuse.
|
|
987
|
-
if (col = laundered_sensitive_column(plan, nodes, aliases))
|
|
988
|
-
return "Blocked: the query passes the sensitive column `#{col}` through a CTE, subquery " \
|
|
989
|
-
"or set operation whose output the tool cannot map to a column, so it could leave " \
|
|
990
|
-
"unredacted. Name the non-sensitive columns you need; `#{col}` is never returned."
|
|
991
|
-
end
|
|
992
|
-
if (fn = volatile_function_in_plan(exprs))
|
|
993
|
-
return "Blocked: function #{fn} is VOLATILE (pg_proc.provolatile = 'v') and not on " \
|
|
994
|
-
"rails_query's list of read-only VOLATILE functions, so it is not run. Some VOLATILE " \
|
|
995
|
-
"functions change server or session state in ways a read-only transaction does not " \
|
|
996
|
-
"stop; only those known to just read state (sizes, lock status, recovery and WAL " \
|
|
997
|
-
"position, random values) are allowed."
|
|
998
|
-
end
|
|
999
|
-
nil
|
|
1000
|
-
rescue StandardError
|
|
1001
|
-
# Planning failed (syntax the executor will also reject, a permission
|
|
1002
|
-
# error): let normal execution raise the real error. The name blocklist
|
|
1003
|
-
# in validate_sql already stopped the known admin functions.
|
|
1004
|
-
nil
|
|
1005
|
-
end
|
|
1006
|
-
|
|
1007
|
-
# The plan-vouch. On PostgreSQL a sensitive name that appears only inside a
|
|
1008
|
-
# string literal passed the textual check (masked), so the query may run
|
|
1009
|
-
# only if the plan was actually obtained - which means the plan layer above
|
|
1010
|
-
# read the query and raised nothing. If planning did not happen, the
|
|
1011
|
-
# literal masker is the only thing that let the name through, and it can be
|
|
1012
|
-
# fooled by a quote trick (standard_conforming_strings off, an E-string),
|
|
1013
|
-
# so the raw-text refusal stands.
|
|
1014
|
-
private_class_method def self.unvouched_literal_refusal(sql, postgres)
|
|
1015
|
-
return nil unless postgres
|
|
1016
|
-
|
|
1017
|
-
offending = references_sensitive_column?(sql) or return nil
|
|
1018
|
-
# A real identifier (still there after masking) is validate_sql's to
|
|
1019
|
-
# refuse; reaching here with one would mean masking let it slip - refuse.
|
|
1020
|
-
return nil if references_sensitive_column?(mask_sql_literals(sql)).nil? &&
|
|
1021
|
-
pg_plan(sql, config.query_timeout)
|
|
1022
|
-
|
|
1023
|
-
"Blocked: query references sensitive column `#{offending}`. It is read as data " \
|
|
1024
|
-
"only when the query plan confirms it, and the plan could not be obtained, so the " \
|
|
1025
|
-
"query is refused. Name the columns you need; `#{offending}` is never returned."
|
|
1026
|
-
end
|
|
1027
|
-
|
|
1028
|
-
# The query's JSON plan from EXPLAIN (VERBOSE) - run once per tool call,
|
|
1029
|
-
# shared by the refusal checks and the output-provenance redaction.
|
|
1030
|
-
private_class_method def self.pg_plan(sql, timeout)
|
|
1031
|
-
target = strip_leading_explain(sql)
|
|
1032
|
-
return nil unless target.match?(/\A\s*(SELECT|WITH)\b/i)
|
|
1033
|
-
|
|
1034
|
-
RailsAiContext::RunCache.fetch([ :pg_plan, target ]) { explain_plan(target, timeout) }
|
|
1035
|
-
end
|
|
1036
|
-
|
|
1037
|
-
# Run EXPLAIN in its own read-only transaction and parse the JSON plan.
|
|
1038
|
-
private_class_method def self.explain_plan(sql, timeout)
|
|
1039
|
-
conn = ActiveRecord::Base.connection
|
|
1040
|
-
json = nil
|
|
1041
|
-
conn.transaction do
|
|
1042
|
-
conn.execute("SET TRANSACTION READ ONLY")
|
|
1043
|
-
conn.execute("SET LOCAL statement_timeout = '#{(timeout * 1000).to_i}'")
|
|
1044
|
-
row = conn.select_all("EXPLAIN (VERBOSE, FORMAT JSON) #{sql}").rows.first
|
|
1045
|
-
json = row&.first
|
|
1046
|
-
raise ActiveRecord::Rollback
|
|
1047
|
-
end
|
|
1048
|
-
data = JSON.parse(json.to_s)
|
|
1049
|
-
data.is_a?(Array) ? data.first&.dig("Plan") : nil
|
|
1050
|
-
rescue ActiveRecord::StatementInvalid
|
|
1051
|
-
nil
|
|
1052
|
-
end
|
|
1053
|
-
|
|
1054
|
-
# Result-column indices whose top-node Output expression is a plain Var of
|
|
1055
|
-
# a sensitive base column - a view/CTE/subquery that renamed the column, so
|
|
1056
|
-
# redaction by output name would miss it. The top Output is positional: its
|
|
1057
|
-
# i-th expression is result column i, with any trailing sort/group key past
|
|
1058
|
-
# the result width. PostgreSQL only; nothing here raising fails the query.
|
|
1059
|
-
private_class_method def self.plan_output_sensitive_indices(sql, result)
|
|
1060
|
-
return [] unless postgres_adapter?
|
|
1061
|
-
|
|
1062
|
-
plan = pg_plan(sql, config.query_timeout)
|
|
1063
|
-
output = plan && plan["Output"]
|
|
1064
|
-
return [] unless output.is_a?(Array)
|
|
1065
|
-
|
|
1066
|
-
nodes = []
|
|
1067
|
-
collect_plan_nodes(plan, nodes)
|
|
1068
|
-
aliases = plan_alias_map(nodes)
|
|
1069
|
-
width = result.columns.length
|
|
1070
|
-
(0...[ width, output.length ].min).select do |i|
|
|
1071
|
-
rel, col = base_var(output[i], aliases)
|
|
1072
|
-
rel && relation_has_sensitive_column?(rel, col)
|
|
1073
|
-
end
|
|
1074
|
-
rescue StandardError
|
|
1075
|
-
[]
|
|
1076
|
-
end
|
|
1077
|
-
|
|
1078
|
-
# A plain, qualified Var `rel.col` resolved to its base relation, or nil.
|
|
1079
|
-
# A quoted or spaced alias (a set-operation's "*SELECT* 1") never matches,
|
|
1080
|
-
# so only a real relation's column is read as a base Var.
|
|
1081
|
-
BASE_VAR = /\A"?([A-Za-z_]\w*)"?\.([A-Za-z_]\w*)\z/
|
|
1082
|
-
private_class_method def self.base_var(expr, aliases)
|
|
1083
|
-
m = expr.to_s.strip.match(BASE_VAR) or return nil
|
|
1084
|
-
rel = aliases[m[1].downcase]
|
|
1085
|
-
rel ? [ rel, m[2].downcase ] : nil
|
|
1086
|
-
end
|
|
1087
|
-
|
|
1088
|
-
private_class_method def self.relation_has_sensitive_column?(relation, column)
|
|
1089
|
-
Array(relation_sensitive_columns[relation]).include?(column)
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
# The sensitive base column (if any) the plan reads as a plain pass-through
|
|
1093
|
-
# Var in some node but NOT in the top node's Output, so the top-Output
|
|
1094
|
-
# redaction cannot reach it. Returns the bare column name, or nil.
|
|
1095
|
-
private_class_method def self.laundered_sensitive_column(plan, nodes, aliases)
|
|
1096
|
-
top = sensitive_base_vars(Array(plan["Output"]), aliases)
|
|
1097
|
-
nodes.each do |node|
|
|
1098
|
-
sensitive_base_vars(Array(node["Output"]), aliases).each do |var|
|
|
1099
|
-
return var.split(".").last unless top.include?(var)
|
|
1100
|
-
end
|
|
1101
|
-
end
|
|
1102
|
-
nil
|
|
1103
|
-
end
|
|
1104
|
-
|
|
1105
|
-
# The "rel.col" of each Output entry that is a plain Var of a sensitive
|
|
1106
|
-
# base column.
|
|
1107
|
-
private_class_method def self.sensitive_base_vars(output, aliases)
|
|
1108
|
-
output.filter_map do |expr|
|
|
1109
|
-
rel, col = base_var(expr, aliases)
|
|
1110
|
-
"#{rel}.#{col}" if rel && relation_has_sensitive_column?(rel, col)
|
|
1111
|
-
end.to_set
|
|
1112
|
-
end
|
|
1113
|
-
|
|
1114
|
-
# Strip a leading EXPLAIN [ (opts) | ANALYZE | VERBOSE ... ] so the
|
|
1115
|
-
# semantic check reads the statement a raw EXPLAIN would run - an
|
|
1116
|
-
# EXPLAIN ANALYZE that would otherwise execute the body.
|
|
1117
|
-
private_class_method def self.strip_leading_explain(sql)
|
|
1118
|
-
sql.sub(/\A\s*EXPLAIN\s+(?:\([^)]*\)\s*|ANALYZE\s+|VERBOSE\s+)*/i, "")
|
|
1119
|
-
end
|
|
1120
|
-
|
|
1121
|
-
private_class_method def self.collect_plan_nodes(node, acc)
|
|
1122
|
-
return unless node.is_a?(Hash)
|
|
1123
|
-
acc << node
|
|
1124
|
-
Array(node["Plans"]).each { |child| collect_plan_nodes(child, acc) }
|
|
1125
|
-
end
|
|
1126
|
-
|
|
1127
|
-
# alias (downcased) => relation name (downcased). The alias defaults to the
|
|
1128
|
-
# relation name when the query gave none, which is how `users.*` resolves.
|
|
1129
|
-
private_class_method def self.plan_alias_map(nodes)
|
|
1130
|
-
map = {}
|
|
1131
|
-
nodes.each do |node|
|
|
1132
|
-
rel = node["Relation Name"]
|
|
1133
|
-
next unless rel
|
|
1134
|
-
name = (node["Alias"] || rel).to_s.downcase
|
|
1135
|
-
map[name] = rel.to_s.downcase
|
|
1136
|
-
end
|
|
1137
|
-
map
|
|
1138
|
-
end
|
|
1139
|
-
|
|
1140
|
-
# Every expression string a node carries: the projected Output entries
|
|
1141
|
-
# kept apart (they may be plain pass-throughs) from the conditions, keys
|
|
1142
|
-
# and function calls, which never are.
|
|
1143
|
-
PLAN_COND_KEYS = [
|
|
1144
|
-
"Filter", "Index Cond", "Recheck Cond", "Hash Cond", "Merge Cond",
|
|
1145
|
-
"Join Filter", "One-Time Filter", "TID Cond", "Function Call",
|
|
1146
|
-
"Sort Key", "Group Key", "Presorted Key", "Order By", "Cache Key"
|
|
1147
|
-
].freeze
|
|
1148
|
-
private_class_method def self.plan_expressions(nodes)
|
|
1149
|
-
outputs = []
|
|
1150
|
-
conds = []
|
|
1151
|
-
nodes.each do |node|
|
|
1152
|
-
outputs.concat(Array(node["Output"]).map { |e| mask_plan_literals(e) })
|
|
1153
|
-
PLAN_COND_KEYS.each { |k| conds.concat(Array(node[k]).map { |e| mask_plan_literals(e) }) }
|
|
1154
|
-
end
|
|
1155
|
-
{ outputs: outputs, conds: conds, all: outputs + conds }
|
|
1156
|
-
end
|
|
1157
|
-
|
|
1158
|
-
# A constant in a plan expression masked to an empty pair, so a sensitive
|
|
1159
|
-
# word inside one (`'%key%'::text` in a Filter, a `'...'` argument) is not
|
|
1160
|
-
# read as a column or a function name. PostgreSQL's deparser always doubles
|
|
1161
|
-
# quotes and never emits an E'' string, so this pattern matches a literal
|
|
1162
|
-
# exactly whatever standard_conforming_strings is; a double-quoted
|
|
1163
|
-
# identifier is never touched. The scanners (whole_row_leak,
|
|
1164
|
-
# sensitive_column_in_plan, volatile_function_in_plan) read the masked text.
|
|
1165
|
-
PLAN_LITERAL = /'(?:''|[^'])*'/
|
|
1166
|
-
private_class_method def self.mask_plan_literals(expr)
|
|
1167
|
-
expr.to_s.gsub(PLAN_LITERAL, "''")
|
|
1168
|
-
end
|
|
1169
|
-
|
|
1170
|
-
# A whole-row `alias.*` reference to a relation that holds a sensitive
|
|
1171
|
-
# column. An unresolved alias is refused only when some relation in the
|
|
1172
|
-
# plan is sensitive, so a whole-row read of a non-sensitive table is fine.
|
|
1173
|
-
private_class_method def self.whole_row_leak(exprs, aliases)
|
|
1174
|
-
any_sensitive = aliases.values.any? { |rel| sensitive_relation?(rel) }
|
|
1175
|
-
exprs[:all].each do |expr|
|
|
1176
|
-
expr.to_s.scan(/([A-Za-z_]\w*)\.\*/) do |(al)|
|
|
1177
|
-
rel = aliases[al.downcase]
|
|
1178
|
-
return rel if rel && sensitive_relation?(rel)
|
|
1179
|
-
return (rel || al) if rel.nil? && any_sensitive
|
|
1180
|
-
end
|
|
1181
|
-
end
|
|
1182
|
-
nil
|
|
1183
|
-
end
|
|
1184
|
-
|
|
1185
|
-
# A sensitive column name in the plan that is not a plain pass-through
|
|
1186
|
-
# output. The textual layer has already refused any query whose own text
|
|
1187
|
-
# names a sensitive column, so a name here came from a wildcard, a row
|
|
1188
|
-
# reference the planner expanded, or a view - never from a column the
|
|
1189
|
-
# caller asked for by name.
|
|
1190
|
-
private_class_method def self.sensitive_column_in_plan(exprs)
|
|
1191
|
-
sensitive = schema_sensitive_columns.to_a
|
|
1192
|
-
return nil if sensitive.empty?
|
|
1193
|
-
|
|
1194
|
-
# Conditions and keys are never a pass-through: any sensitive name is a leak (or an oracle).
|
|
1195
|
-
exprs[:conds].each do |expr|
|
|
1196
|
-
sensitive.each { |col| return col if expr.to_s.downcase.match?(/\b#{Regexp.escape(col)}\b/) }
|
|
1197
|
-
end
|
|
1198
|
-
# An Output entry is allowed when it is exactly a bare column reference
|
|
1199
|
-
# (a plain SELECT * column, redacted later by name/provenance).
|
|
1200
|
-
exprs[:outputs].each do |expr|
|
|
1201
|
-
next if passthrough_output?(expr)
|
|
1202
|
-
sensitive.each { |col| return col if expr.to_s.downcase.match?(/\b#{Regexp.escape(col)}\b/) }
|
|
1203
|
-
end
|
|
1204
|
-
nil
|
|
1205
|
-
end
|
|
1206
|
-
|
|
1207
|
-
PASSTHROUGH_OUTPUT = /\A\(?\s*(?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*\s*\)?\z/
|
|
1208
|
-
private_class_method def self.passthrough_output?(expr)
|
|
1209
|
-
expr.to_s.match?(PASSTHROUGH_OUTPUT)
|
|
1210
|
-
end
|
|
1211
|
-
|
|
1212
|
-
# A VOLATILE function (pg_proc.provolatile = 'v') named anywhere in the
|
|
1213
|
-
# plan, outside the harmless allowlist. This closes the admin-function
|
|
1214
|
-
# family - pg_terminate_backend, pg_advisory_lock, set_config and the rest -
|
|
1215
|
-
# without an ever-growing name list.
|
|
1216
|
-
private_class_method def self.volatile_function_in_plan(exprs)
|
|
1217
|
-
names = Set.new
|
|
1218
|
-
exprs[:all].each do |expr|
|
|
1219
|
-
expr.to_s.scan(/([A-Za-z_]\w*)\s*\(/) { |(fn)| names << fn.downcase }
|
|
1220
|
-
end
|
|
1221
|
-
names.subtract(PG_VOLATILE_ALLOWLIST)
|
|
1222
|
-
return nil if names.empty?
|
|
1223
|
-
|
|
1224
|
-
conn = ActiveRecord::Base.connection
|
|
1225
|
-
quoted = names.map { |n| conn.quote(n) }.join(", ")
|
|
1226
|
-
volatile = conn.select_values(
|
|
1227
|
-
"SELECT DISTINCT lower(proname) FROM pg_proc " \
|
|
1228
|
-
"WHERE lower(proname) IN (#{quoted}) AND provolatile = 'v'"
|
|
1229
|
-
)
|
|
1230
|
-
volatile.first
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
private_class_method def self.sensitive_relation?(relation)
|
|
1234
|
-
relation_sensitive_columns.key?(relation.to_s.downcase)
|
|
1235
|
-
end
|
|
1236
|
-
|
|
1237
|
-
# relation name (downcased) => its sensitive column names, for relations
|
|
1238
|
-
# that have at least one. Live connection first, cached context as a
|
|
1239
|
-
# fallback, memoised for the run.
|
|
1240
|
-
private_class_method def self.relation_sensitive_columns
|
|
1241
|
-
RailsAiContext::RunCache.fetch([ :query_relation_sensitive ]) do
|
|
1242
|
-
by_relation = relation_columns
|
|
1243
|
-
by_relation.each_with_object({}) do |(rel, cols), acc|
|
|
1244
|
-
flagged = cols.select { |c| sensitive_column?(c) }
|
|
1245
|
-
acc[rel] = flagged unless flagged.empty?
|
|
1246
|
-
end
|
|
1247
|
-
end
|
|
1248
|
-
end
|
|
1249
|
-
|
|
1250
|
-
private_class_method def self.relation_columns
|
|
1251
|
-
from_connection_relation_columns || from_context_relation_columns || {}
|
|
1252
|
-
end
|
|
1253
|
-
|
|
1254
|
-
private_class_method def self.from_connection_relation_columns
|
|
1255
|
-
conn = ActiveRecord::Base.connection
|
|
1256
|
-
conn.tables.each_with_object({}) do |t, acc|
|
|
1257
|
-
next if bookkeeping_table?(t)
|
|
1258
|
-
acc[t.to_s.downcase] = conn.columns(t).map { |c| c.name.to_s.downcase }
|
|
1259
|
-
end
|
|
1260
|
-
rescue StandardError
|
|
1261
|
-
nil
|
|
1262
|
-
end
|
|
1263
|
-
|
|
1264
|
-
private_class_method def self.from_context_relation_columns
|
|
1265
|
-
tables = cached_context&.dig(:schema, :tables)
|
|
1266
|
-
return nil unless tables.is_a?(Hash)
|
|
1267
|
-
|
|
1268
|
-
tables.each_with_object({}) do |(name, data), acc|
|
|
1269
|
-
next if bookkeeping_table?(name)
|
|
1270
|
-
cols = Array(data.is_a?(Hash) && data[:columns]).map do |col|
|
|
1271
|
-
(col.is_a?(Hash) ? col[:name] : col).to_s.downcase
|
|
1272
|
-
end
|
|
1273
|
-
acc[name.to_s.downcase] = cols
|
|
1274
|
-
end
|
|
1275
|
-
rescue StandardError
|
|
1276
|
-
nil
|
|
1277
|
-
end
|
|
1278
|
-
|
|
1279
|
-
private_class_method def self.postgres_adapter?
|
|
1280
|
-
ActiveRecord::Base.connection.adapter_name.to_s.match?(POSTGRES_ADAPTER)
|
|
1281
|
-
rescue StandardError
|
|
1282
|
-
false
|
|
1283
|
-
end
|
|
1284
|
-
|
|
1285
|
-
# Output columns whose provenance (PG::Result#ftable / #ftablecol) traces
|
|
1286
|
-
# to a sensitive base column, so a renamed sensitive column is redacted
|
|
1287
|
-
# whatever its output name. Describe-only (no execution); PostgreSQL only.
|
|
1288
|
-
private_class_method def self.provenance_sensitive_indices(sql)
|
|
1289
|
-
return [] unless postgres_adapter?
|
|
1290
|
-
|
|
1291
|
-
conn = ActiveRecord::Base.connection
|
|
1292
|
-
raw = conn.raw_connection
|
|
1293
|
-
stmt = "rac_prov_#{Process.pid}_#{rand(1 << 30)}"
|
|
1294
|
-
begin
|
|
1295
|
-
raw.prepare(stmt, sql)
|
|
1296
|
-
desc = raw.describe_prepared(stmt)
|
|
1297
|
-
ensure
|
|
1298
|
-
raw.exec("DEALLOCATE #{stmt}") rescue nil
|
|
1299
|
-
end
|
|
1300
|
-
|
|
1301
|
-
oid_columns = {}
|
|
1302
|
-
indices = []
|
|
1303
|
-
desc.nfields.times do |i|
|
|
1304
|
-
oid = desc.ftable(i)
|
|
1305
|
-
col = desc.ftablecol(i)
|
|
1306
|
-
next if oid.nil? || oid.to_i.zero? || col.to_i.zero?
|
|
1307
|
-
|
|
1308
|
-
names = (oid_columns[oid] ||= attribute_names_for(conn, oid))
|
|
1309
|
-
name = names[col.to_i]
|
|
1310
|
-
indices << i if name && sensitive_column?(name)
|
|
1311
|
-
end
|
|
1312
|
-
indices
|
|
1313
|
-
rescue StandardError
|
|
1314
|
-
[]
|
|
1315
|
-
end
|
|
1316
|
-
|
|
1317
|
-
# attnum => column name for one relation oid.
|
|
1318
|
-
private_class_method def self.attribute_names_for(conn, oid)
|
|
1319
|
-
rows = conn.select_rows(
|
|
1320
|
-
"SELECT attnum, attname FROM pg_attribute " \
|
|
1321
|
-
"WHERE attrelid = #{oid.to_i} AND attnum > 0 AND NOT attisdropped"
|
|
1322
|
-
)
|
|
1323
|
-
rows.each_with_object({}) { |(num, name), acc| acc[num.to_i] = name.to_s }
|
|
1324
|
-
rescue StandardError
|
|
1325
|
-
{}
|
|
1326
|
-
end
|
|
1327
|
-
|
|
1328
|
-
# ── EXPLAIN execution ────────────────────────────────────────────
|
|
1329
|
-
private_class_method def self.execute_explain(sql, timeout)
|
|
249
|
+
private_class_method def self.explain_response(family, connection, sql, timeout, database)
|
|
1330
250
|
unless sql.match?(/\A\s*(SELECT|WITH)\b/i)
|
|
1331
|
-
return
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
conn = ActiveRecord::Base.connection
|
|
1335
|
-
adapter = conn.adapter_name.downcase
|
|
1336
|
-
|
|
1337
|
-
explain_sql, parser = case adapter
|
|
1338
|
-
when POSTGRES_ADAPTER
|
|
1339
|
-
[ "EXPLAIN (FORMAT JSON, ANALYZE) #{sql}", :parse_pg_explain ]
|
|
1340
|
-
when MYSQL_ADAPTER
|
|
1341
|
-
# MySQL 8.3+ and 9.x default explain_format to TREE, which
|
|
1342
|
-
# parse_mysql_explain can't read (it expects the classic
|
|
1343
|
-
# table/type/key/rows/Extra columns). Force TRADITIONAL explicitly
|
|
1344
|
-
# so parsing works regardless of the server's default.
|
|
1345
|
-
[ "EXPLAIN FORMAT=TRADITIONAL #{sql}", :parse_mysql_explain ]
|
|
1346
|
-
when /sqlite/
|
|
1347
|
-
[ "EXPLAIN QUERY PLAN #{sql}", :parse_sqlite_explain ]
|
|
1348
|
-
else
|
|
1349
|
-
[ "EXPLAIN #{sql}", :parse_generic_explain ]
|
|
251
|
+
return error_response("#{"The statement is already an EXPLAIN. " if sql.match?(/\A\s*EXPLAIN\b/i)}" \
|
|
252
|
+
"`explain: true` takes a SELECT or WITH statement and explains it.")
|
|
1350
253
|
end
|
|
1351
254
|
|
|
1352
|
-
|
|
1353
|
-
parsed = send(parser, result)
|
|
255
|
+
parsed = family.explain(connection, sql, timeout: timeout)
|
|
1354
256
|
|
|
1355
257
|
lines = [ "# EXPLAIN Analysis", "" ]
|
|
1356
258
|
lines << "**Query:** `#{sql.truncate(120)}`"
|
|
1357
259
|
lines << ""
|
|
1358
260
|
|
|
1359
|
-
# Summary
|
|
1360
261
|
if parsed[:scan_types]&.any?
|
|
1361
262
|
lines << "## Scan Summary"
|
|
1362
263
|
parsed[:scan_types].each do |scan|
|
|
@@ -1365,130 +266,27 @@ module RailsAiContext
|
|
|
1365
266
|
lines << ""
|
|
1366
267
|
end
|
|
1367
268
|
|
|
1368
|
-
# Warnings
|
|
1369
269
|
if parsed[:warnings]&.any?
|
|
1370
270
|
lines << "## Warnings"
|
|
1371
271
|
parsed[:warnings].each { |w| lines << "- #{w}" }
|
|
1372
272
|
lines << ""
|
|
1373
273
|
end
|
|
1374
274
|
|
|
1375
|
-
# Raw plan
|
|
1376
275
|
lines << "## Raw Plan"
|
|
1377
276
|
lines << "```"
|
|
1378
277
|
lines << parsed[:raw]
|
|
1379
278
|
lines << "```"
|
|
1380
279
|
|
|
1381
|
-
text_response(lines.join("\n") +
|
|
280
|
+
text_response(lines.join("\n") + (parsed[:unbounded] ? UNBOUNDED_SQLITE_NOTE : ""))
|
|
1382
281
|
rescue ActiveRecord::StatementInvalid => e
|
|
1383
|
-
|
|
1384
|
-
end
|
|
1385
|
-
|
|
1386
|
-
private_class_method def self.parse_sqlite_explain(result)
|
|
1387
|
-
scans = []
|
|
1388
|
-
warnings = []
|
|
1389
|
-
raw_lines = []
|
|
1390
|
-
|
|
1391
|
-
result.rows.each do |row|
|
|
1392
|
-
# SQLite EXPLAIN QUERY PLAN columns: id, parent, notused, detail
|
|
1393
|
-
detail = row.last.to_s
|
|
1394
|
-
raw_lines << detail
|
|
1395
|
-
|
|
1396
|
-
if detail.match?(/SCAN\b/i)
|
|
1397
|
-
table = detail.match(/SCAN\s+(?:TABLE\s+)?(\w+)/i)&.captures&.first
|
|
1398
|
-
scan_type = detail.match?(/USING.*INDEX/i) ? "index scan" : "full table scan"
|
|
1399
|
-
index = detail.match(/USING.*INDEX\s+(\w+)/i)&.captures&.first
|
|
1400
|
-
scans << { table: table, type: scan_type, index: index }
|
|
1401
|
-
warnings << "Full table scan on #{table}" if scan_type == "full table scan" && table
|
|
1402
|
-
elsif detail.match?(/SEARCH\b/i)
|
|
1403
|
-
table = detail.match(/SEARCH\s+(?:TABLE\s+)?(\w+)/i)&.captures&.first
|
|
1404
|
-
index = detail.match(/USING.*INDEX\s+(\w+)/i)&.captures&.first
|
|
1405
|
-
scans << { table: table, type: "index search", index: index }
|
|
1406
|
-
end
|
|
1407
|
-
end
|
|
282
|
+
raise if DatabaseFamily.lost_connection?(e)
|
|
1408
283
|
|
|
1409
|
-
|
|
284
|
+
error_response("EXPLAIN failed: #{clean_error_message(e.message)}#{held_elsewhere(e.message, database)}")
|
|
1410
285
|
end
|
|
1411
286
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
raw = result.rows.map { |r| r.first.to_s }.join("\n")
|
|
1417
|
-
|
|
1418
|
-
# PostgreSQL JSON format returns plan as JSON
|
|
1419
|
-
begin
|
|
1420
|
-
plan_data = JSON.parse(raw)
|
|
1421
|
-
if plan_data.is_a?(Array) && plan_data.first.is_a?(Hash)
|
|
1422
|
-
plan = plan_data.first["Plan"]
|
|
1423
|
-
extract_pg_nodes(plan, scans, warnings) if plan
|
|
1424
|
-
end
|
|
1425
|
-
rescue JSON::ParserError
|
|
1426
|
-
# Non-JSON EXPLAIN format - fall through to raw output
|
|
1427
|
-
end
|
|
1428
|
-
|
|
1429
|
-
{ scan_types: scans, warnings: warnings, raw: raw }
|
|
1430
|
-
end
|
|
1431
|
-
|
|
1432
|
-
private_class_method def self.extract_pg_nodes(node, scans, warnings)
|
|
1433
|
-
return unless node.is_a?(Hash)
|
|
1434
|
-
|
|
1435
|
-
node_type = node["Node Type"].to_s
|
|
1436
|
-
table = node["Relation Name"]
|
|
1437
|
-
index = node["Index Name"]
|
|
1438
|
-
rows = node["Actual Rows"] || node["Plan Rows"]
|
|
1439
|
-
|
|
1440
|
-
if node_type.include?("Seq Scan")
|
|
1441
|
-
scans << { table: table, type: "sequential scan", index: nil }
|
|
1442
|
-
warnings << "Sequential scan on #{table} (#{rows} rows)" if rows.to_i > 1000
|
|
1443
|
-
elsif node_type.include?("Index")
|
|
1444
|
-
scans << { table: table, type: node_type.downcase, index: index }
|
|
1445
|
-
end
|
|
1446
|
-
|
|
1447
|
-
(node["Plans"] || []).each { |child| extract_pg_nodes(child, scans, warnings) }
|
|
1448
|
-
end
|
|
1449
|
-
|
|
1450
|
-
private_class_method def self.parse_mysql_explain(result)
|
|
1451
|
-
scans = []
|
|
1452
|
-
warnings = []
|
|
1453
|
-
raw_lines = []
|
|
1454
|
-
|
|
1455
|
-
result.rows.each do |row|
|
|
1456
|
-
cols = result.columns.zip(row).to_h
|
|
1457
|
-
raw_lines << cols.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
1458
|
-
|
|
1459
|
-
table = cols["table"]
|
|
1460
|
-
scan_type = cols["type"]
|
|
1461
|
-
key = cols["key"]
|
|
1462
|
-
rows = cols["rows"].to_i
|
|
1463
|
-
extra = cols["Extra"].to_s
|
|
1464
|
-
|
|
1465
|
-
type_label = case scan_type
|
|
1466
|
-
when "ALL" then "full table scan"
|
|
1467
|
-
when "index" then "full index scan"
|
|
1468
|
-
when "range" then "index range scan"
|
|
1469
|
-
when "ref", "eq_ref" then "index lookup"
|
|
1470
|
-
when "const", "system" then "constant lookup"
|
|
1471
|
-
else scan_type.to_s
|
|
1472
|
-
end
|
|
1473
|
-
|
|
1474
|
-
scans << { table: table, type: type_label, index: key }
|
|
1475
|
-
warnings << "Full table scan on #{table} (#{rows} rows)" if scan_type == "ALL" && rows > 1000
|
|
1476
|
-
warnings << "Using filesort on #{table}" if extra.include?("filesort")
|
|
1477
|
-
warnings << "Using temporary table on #{table}" if extra.include?("temporary")
|
|
1478
|
-
end
|
|
1479
|
-
|
|
1480
|
-
{ scan_types: scans, warnings: warnings, raw: raw_lines.join("\n") }
|
|
1481
|
-
end
|
|
1482
|
-
|
|
1483
|
-
private_class_method def self.parse_generic_explain(result)
|
|
1484
|
-
raw = result.rows.map { |r| r.join(" | ") }.join("\n")
|
|
1485
|
-
{ scan_types: [], warnings: [], raw: raw }
|
|
1486
|
-
end
|
|
1487
|
-
|
|
1488
|
-
# ── Row limit enforcement (Layer 3) ─────────────────────────────
|
|
1489
|
-
# The query's own limit ends the statement. A LIMIT anywhere else belongs
|
|
1490
|
-
# to a subquery or a string literal and is left as written.
|
|
1491
|
-
TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
|
|
287
|
+
# The query's own limit ends the statement; a LIMIT anywhere else belongs to a subquery or a literal.
|
|
288
|
+
# LIMIT ALL, LIMIT NULL (PostgreSQL) and a negative limit (SQLite) are no limit at all.
|
|
289
|
+
TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+|ALL|NULL|-\s*\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
|
|
1492
290
|
TRAILING_FETCH = /\bFETCH\s+(FIRST|NEXT)\s+(\d+)(\s+ROWS?\s+ONLY)\s*;?\s*\z/i
|
|
1493
291
|
|
|
1494
292
|
# The text asks for one row past the cap, so the answer can tell a result
|
|
@@ -1500,7 +298,7 @@ module RailsAiContext
|
|
|
1500
298
|
fetch = [ limit, HARD_ROW_CAP ].min + 1
|
|
1501
299
|
|
|
1502
300
|
if sql.match?(TRAILING_LIMIT)
|
|
1503
|
-
sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{[ $2.to_i, fetch ].min}#{$3}" }
|
|
301
|
+
sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{$2.match?(/\A\d+\z/) ? [ $2.to_i, fetch ].min : fetch}#{$3}" }
|
|
1504
302
|
elsif sql.match?(TRAILING_FETCH)
|
|
1505
303
|
sql.sub(TRAILING_FETCH) { "FETCH #{$1} #{[ $2.to_i, fetch ].min}#{$3}" }
|
|
1506
304
|
elsif own_limit?(sql)
|
|
@@ -1538,24 +336,19 @@ module RailsAiContext
|
|
|
1538
336
|
ResultProxy.new(result.columns, result.rows.first(cap), unbounded, true)
|
|
1539
337
|
end
|
|
1540
338
|
|
|
1541
|
-
#
|
|
1542
|
-
#
|
|
1543
|
-
|
|
1544
|
-
# to its base column, so a sensitive column reaches [FILTERED] whatever
|
|
1545
|
-
# name it was given). The name rule keeps the apps-only exemption via
|
|
1546
|
-
# sensitive_column?, which subtracts config.query_allowed_columns.
|
|
1547
|
-
private_class_method def self.redact_results(result, provenance_indices = [])
|
|
339
|
+
# A column is filtered when its output name or the table column it reads is secret. A source
|
|
340
|
+
# whose column could not be read is filtered when its relation, or any relation, holds a secret.
|
|
341
|
+
private_class_method def self.redact_results(result, secrets, sources)
|
|
1548
342
|
columns = result.columns
|
|
1549
343
|
rows = result.rows
|
|
344
|
+
provenance = sources.filter_map do |i, column, relation|
|
|
345
|
+
unread = column.nil? && (relation ? secrets.relation_secret?(relation) : secrets.in_schema.any?)
|
|
346
|
+
i if unread || secrets.secret?(column)
|
|
347
|
+
end
|
|
1550
348
|
|
|
1551
|
-
#
|
|
1552
|
-
# an encrypted-at-rest attribute is never meant to be read back raw.
|
|
1553
|
-
encrypted = encrypted_column_set
|
|
1554
|
-
|
|
349
|
+
# SQLite names a second result column of the same name `name:1`.
|
|
1555
350
|
redacted_indices = columns.each_index.select do |i|
|
|
1556
|
-
|
|
1557
|
-
encrypted.include?(columns[i].to_s.downcase) ||
|
|
1558
|
-
sensitive_column?(columns[i])
|
|
351
|
+
provenance.include?(i) || secrets.secret?(columns[i]) || secrets.secret?(columns[i].to_s.sub(/:\d+\z/, ""))
|
|
1559
352
|
end
|
|
1560
353
|
|
|
1561
354
|
return result if redacted_indices.empty?
|
|
@@ -1580,7 +373,6 @@ module RailsAiContext
|
|
|
1580
373
|
ResultProxy.new(result.columns, rows)
|
|
1581
374
|
end
|
|
1582
375
|
|
|
1583
|
-
# ── Output formatting ───────────────────────────────────────────
|
|
1584
376
|
private_class_method def self.format_table(result)
|
|
1585
377
|
columns = result.columns
|
|
1586
378
|
rows = result.rows
|
|
@@ -1635,14 +427,8 @@ module RailsAiContext
|
|
|
1635
427
|
end
|
|
1636
428
|
|
|
1637
429
|
private_class_method def self.format_csv(result)
|
|
1638
|
-
|
|
1639
|
-
rows
|
|
1640
|
-
|
|
1641
|
-
return "_Query returned 0 rows._" if rows.empty?
|
|
1642
|
-
|
|
1643
|
-
lines = []
|
|
1644
|
-
lines << columns.map { |col| csv_field(col.to_s) }.join(",")
|
|
1645
|
-
rows.each do |row|
|
|
430
|
+
lines = [ result.columns.map { |col| csv_field(col.to_s) }.join(",") ]
|
|
431
|
+
result.rows.each do |row|
|
|
1646
432
|
lines << row.map { |val| csv_field(csv_cell(val)) }.join(",")
|
|
1647
433
|
end
|
|
1648
434
|
|
|
@@ -1685,8 +471,8 @@ module RailsAiContext
|
|
|
1685
471
|
end
|
|
1686
472
|
|
|
1687
473
|
private_class_method def self.clean_error_message(message)
|
|
1688
|
-
#
|
|
1689
|
-
message.lines.first
|
|
474
|
+
# The first line only. Rails ends it with a colon where the statement follows on the next.
|
|
475
|
+
(message.lines.first || message).strip.delete_suffix(":")
|
|
1690
476
|
end
|
|
1691
477
|
|
|
1692
478
|
# Quacks like ActiveRecord::Result for redacted output, for a SQLite query
|