woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -5,22 +5,22 @@ require 'timeout'
|
|
|
5
5
|
require_relative 'audit_logger'
|
|
6
6
|
require_relative 'bridge_protocol'
|
|
7
7
|
require_relative 'confirmation'
|
|
8
|
+
require_relative 'credential_scanner'
|
|
8
9
|
require_relative 'eval_guard'
|
|
10
|
+
require_relative 'input_contract'
|
|
9
11
|
require_relative 'model_validator'
|
|
10
12
|
require_relative 'safe_context'
|
|
11
13
|
require_relative 'scope_predicate_parser'
|
|
14
|
+
require_relative 'sql_validator'
|
|
12
15
|
require_relative 'sql_noise_stripper'
|
|
13
16
|
require_relative 'table_gate'
|
|
17
|
+
require_relative 'tool_specs'
|
|
14
18
|
|
|
15
19
|
module Woods
|
|
16
20
|
module Console
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# Implements the same `send_request(Hash) -> Hash` interface as
|
|
22
|
-
# ConnectionManager, so all existing tool definitions in Server work
|
|
23
|
-
# unchanged — just pass this where `conn_mgr` goes.
|
|
21
|
+
# Executes supported Console requests directly through ActiveRecord in a
|
|
22
|
+
# booted Rails process. Server registration limits callers to the subset
|
|
23
|
+
# this executor can run with the configured safety controls.
|
|
24
24
|
#
|
|
25
25
|
# @example
|
|
26
26
|
# executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
|
|
@@ -28,8 +28,6 @@ module Woods
|
|
|
28
28
|
# # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
|
|
29
29
|
#
|
|
30
30
|
class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
|
|
31
|
-
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
|
|
32
|
-
|
|
33
31
|
TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
|
|
34
32
|
|
|
35
33
|
# Tools gated behind the read_tools_enabled flag.
|
|
@@ -37,11 +35,6 @@ module Woods
|
|
|
37
35
|
# but require explicit opt-in for embedded mode.
|
|
38
36
|
EMBEDDED_READ_TOOLS = %w[sql query].freeze
|
|
39
37
|
|
|
40
|
-
MAX_SQL_LIMIT = 10_000
|
|
41
|
-
MAX_QUERY_LIMIT = 10_000
|
|
42
|
-
|
|
43
|
-
MIN_EVAL_TIMEOUT = 1
|
|
44
|
-
MAX_EVAL_TIMEOUT = 30
|
|
45
38
|
DEFAULT_EVAL_TIMEOUT = 10
|
|
46
39
|
|
|
47
40
|
# @param model_validator [ModelValidator] Validates model/column names
|
|
@@ -97,8 +90,9 @@ module Woods
|
|
|
97
90
|
refusal = refusal_for(tool)
|
|
98
91
|
return refusal if refusal
|
|
99
92
|
|
|
93
|
+
normalize_params!(tool, params)
|
|
100
94
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
-
result = @safe_context.execute { dispatch(tool, params) }
|
|
95
|
+
result = @safe_context.execute { with_mysql_quote_modes { dispatch(tool, params) } }
|
|
102
96
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
103
97
|
|
|
104
98
|
{ 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
|
|
@@ -130,12 +124,30 @@ module Woods
|
|
|
130
124
|
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
131
125
|
|
|
132
126
|
Rails.logger.warn(
|
|
133
|
-
"[Woods::Console] execution error: #{error.class}: #{error.message}"
|
|
127
|
+
"[Woods::Console] execution error: #{error.class}: #{scan_log_text(error.message)}"
|
|
134
128
|
)
|
|
135
129
|
rescue StandardError
|
|
136
130
|
# Never let logging break the request path.
|
|
137
131
|
end
|
|
138
132
|
|
|
133
|
+
# Layer 2 applied to server-side log text (L11). The client response for
|
|
134
|
+
# this branch is already sanitized down to the class name, but the log
|
|
135
|
+
# line carries the adapter's own message — and PG/Mysql2 errors embed the
|
|
136
|
+
# rejected SQL and, for constraint violations, the offending literal. A
|
|
137
|
+
# secret pasted into a WHERE clause therefore landed unscanned in the
|
|
138
|
+
# server log while the response path was scanned. Failure falls back to a
|
|
139
|
+
# sentinel rather than raw text, mirroring {AuditLogger#redact}.
|
|
140
|
+
def scan_log_text(text)
|
|
141
|
+
scanned, = error_log_scanner.scan(text.to_s)
|
|
142
|
+
scanned
|
|
143
|
+
rescue StandardError
|
|
144
|
+
'[REDACTION_FAILED]'
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def error_log_scanner
|
|
148
|
+
@error_log_scanner ||= CredentialScanner.new
|
|
149
|
+
end
|
|
150
|
+
|
|
139
151
|
# Return a pre-dispatch refusal hash for tools the executor cannot or
|
|
140
152
|
# will not run, else nil to let dispatch proceed.
|
|
141
153
|
#
|
|
@@ -158,20 +170,26 @@ module Woods
|
|
|
158
170
|
|
|
159
171
|
# Self-describing error for tools the embedded executor cannot run.
|
|
160
172
|
#
|
|
161
|
-
# `sql`/`query` are gated behind `embedded_read_tools: true
|
|
162
|
-
#
|
|
163
|
-
#
|
|
173
|
+
# `sql`/`query` are gated behind `embedded_read_tools: true`. Everything
|
|
174
|
+
# else outside Tier 1 is unavailable through a supported server mode.
|
|
175
|
+
#
|
|
176
|
+
# The flag has two names depending on transport: `embedded_read_tools:`
|
|
177
|
+
# on `Woods::Console::RackMiddleware` (HTTP), or
|
|
178
|
+
# `config.console_embedded_read_tools` read by `exe/woods-console`
|
|
179
|
+
# (stdio). This executor runs under both, so the message names both —
|
|
180
|
+
# naming only one leaves an operator on the other transport with no
|
|
181
|
+
# idea what to set.
|
|
164
182
|
#
|
|
165
183
|
# @param tool [String] Tool name that was rejected
|
|
166
184
|
# @return [String] Actionable error message
|
|
167
185
|
def unsupported_message(tool)
|
|
168
186
|
if EMBEDDED_READ_TOOLS.include?(tool)
|
|
169
187
|
"Tool '#{tool}' requires embedded_read_tools: true on " \
|
|
170
|
-
'Woods::Console::RackMiddleware, or
|
|
188
|
+
'Woods::Console::RackMiddleware, or config.console_embedded_read_tools = true ' \
|
|
189
|
+
'for the stdio server (exe/woods-console). ' \
|
|
171
190
|
'See docs/CONSOLE_MCP_SETUP.md.'
|
|
172
191
|
else
|
|
173
|
-
"Tool '#{tool}' is not available in
|
|
174
|
-
'bridge architecture (Option D in docs/CONSOLE_MCP_SETUP.md).'
|
|
192
|
+
"Tool '#{tool}' is not available in a supported Console MCP mode."
|
|
175
193
|
end
|
|
176
194
|
end
|
|
177
195
|
|
|
@@ -190,16 +208,12 @@ module Woods
|
|
|
190
208
|
# @return [String] Multi-line actionable message.
|
|
191
209
|
def eval_disabled_message
|
|
192
210
|
<<~MSG.strip
|
|
193
|
-
console_eval is
|
|
211
|
+
console_eval is not available in a supported Console MCP mode.
|
|
194
212
|
Use console_query (model + select + joins/group_by/having/order) or console_sql
|
|
195
213
|
for anything you were about to run. Both already support aggregates and scoping.
|
|
196
214
|
If you believe eval is still necessary, SHOW your proposed Ruby snippet to the
|
|
197
215
|
user first and let them run it manually — do not retry console_eval automatically.
|
|
198
|
-
|
|
199
|
-
AND wire console_unsafe_eval_confirmation + console_unsafe_eval_audit_log_path.
|
|
200
|
-
The server refuses to boot with the flag on in Rails.env.production?, and refuses
|
|
201
|
-
to boot with the flag on but any collaborator missing (fail-closed).
|
|
202
|
-
See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" for the full checklist.
|
|
216
|
+
WOODS_CONSOLE_UNSAFE_EVAL and the legacy collaborator options fail closed at boot.
|
|
203
217
|
MSG
|
|
204
218
|
end
|
|
205
219
|
|
|
@@ -251,19 +265,8 @@ module Woods
|
|
|
251
265
|
raise
|
|
252
266
|
end
|
|
253
267
|
|
|
254
|
-
# Validate + clamp the user-supplied timeout. Accepts a positive
|
|
255
|
-
# Integer (or nil → default). Everything else is rejected so a
|
|
256
|
-
# caller passing `timeout: 0` or `timeout: "forever"` hears about
|
|
257
|
-
# it instead of silently getting MIN_EVAL_TIMEOUT.
|
|
258
268
|
def eval_timeout_from(raw)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
unless raw.is_a?(Integer) && raw.positive?
|
|
262
|
-
raise ValidationError,
|
|
263
|
-
"timeout must be a positive integer (#{MIN_EVAL_TIMEOUT}..#{MAX_EVAL_TIMEOUT})"
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
raw.clamp(MIN_EVAL_TIMEOUT, MAX_EVAL_TIMEOUT)
|
|
269
|
+
raw || DEFAULT_EVAL_TIMEOUT
|
|
267
270
|
end
|
|
268
271
|
|
|
269
272
|
def guard_check!(code, audit_params)
|
|
@@ -396,6 +399,28 @@ module Woods
|
|
|
396
399
|
end
|
|
397
400
|
end
|
|
398
401
|
|
|
402
|
+
def normalize_params!(tool, params)
|
|
403
|
+
spec = Server::TOOL_SPECS.find { |candidate| candidate.name == "console_#{tool}" }
|
|
404
|
+
return unless spec
|
|
405
|
+
|
|
406
|
+
registered = Server::EXECUTABLE_MODES.values.any? { |names| names.include?(spec.name) }
|
|
407
|
+
if registered
|
|
408
|
+
spec.validate_arguments!(params)
|
|
409
|
+
else
|
|
410
|
+
# Unregistered specs (console_eval today) never run the full public
|
|
411
|
+
# JSON Schema here, so without this check a well-formed numeric
|
|
412
|
+
# string (e.g. `timeout: "15"`) sails straight into normalize!'s
|
|
413
|
+
# coercion below — silently accepting input the declared schema's
|
|
414
|
+
# `type: integer` would reject outright. This closes that gap
|
|
415
|
+
# without changing normalize!'s own messages for malformed/
|
|
416
|
+
# out-of-bounds values, which are asserted verbatim elsewhere.
|
|
417
|
+
InputContract.reject_string_typed_integers!(params, spec.properties)
|
|
418
|
+
end
|
|
419
|
+
InputContract.normalize!(params, spec.properties)
|
|
420
|
+
rescue InputContract::ValidationError => e
|
|
421
|
+
raise ValidationError, e.message
|
|
422
|
+
end
|
|
423
|
+
|
|
399
424
|
# @param params [Hash] Must contain 'model' key
|
|
400
425
|
# @raise [ValidationError]
|
|
401
426
|
def validate_model!(params)
|
|
@@ -425,7 +450,7 @@ module Woods
|
|
|
425
450
|
return unless @table_gate
|
|
426
451
|
|
|
427
452
|
begin
|
|
428
|
-
@table_gate.check_sql!(sql)
|
|
453
|
+
@table_gate.check_sql!(sql, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
|
|
429
454
|
rescue TableGateError => e
|
|
430
455
|
raise ValidationError, e.message
|
|
431
456
|
end
|
|
@@ -460,7 +485,7 @@ module Woods
|
|
|
460
485
|
def handle_sample(params)
|
|
461
486
|
validate_select_columns!(params)
|
|
462
487
|
model = resolve_model(params['model'])
|
|
463
|
-
limit =
|
|
488
|
+
limit = params.fetch('limit', 5)
|
|
464
489
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
465
490
|
scope = apply_columns(scope, params['columns'])
|
|
466
491
|
records = scope.order(random_function).limit(limit)
|
|
@@ -468,20 +493,45 @@ module Woods
|
|
|
468
493
|
end
|
|
469
494
|
|
|
470
495
|
def handle_find(params)
|
|
496
|
+
validate_find_locator!(params)
|
|
497
|
+
if params['by']
|
|
498
|
+
by_columns = params['by'].keys.map(&:to_s)
|
|
499
|
+
@model_validator.validate_columns!(params['model'], by_columns)
|
|
500
|
+
by_columns.each { |column| refuse_protected_predicate_column!(column) }
|
|
501
|
+
end
|
|
502
|
+
validate_select_columns!(params)
|
|
471
503
|
model = resolve_model(params['model'])
|
|
472
|
-
record =
|
|
473
|
-
model.find_by(id: params['id'])
|
|
474
|
-
elsif params['by']
|
|
475
|
-
model.find_by(params['by'])
|
|
476
|
-
end
|
|
504
|
+
record = params['id'] ? model.find_by(id: params['id']) : model.find_by(params['by'])
|
|
477
505
|
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
478
506
|
end
|
|
479
507
|
|
|
508
|
+
# Require exactly one non-empty locator: `id` or a non-empty `by` hash.
|
|
509
|
+
# `find_by({})` returns an arbitrary row when neither is supplied, and
|
|
510
|
+
# `find_by` with an empty `by` hash is indistinguishable from that:
|
|
511
|
+
# both must be refused before any query runs. Defense-in-depth against
|
|
512
|
+
# the public schema's `oneOf`/`minProperties` constraint (ToolSpec
|
|
513
|
+
# for console_find), for callers that reach this handler directly.
|
|
514
|
+
#
|
|
515
|
+
# @param params [Hash]
|
|
516
|
+
# @raise [ValidationError] when zero or both locator forms are present
|
|
517
|
+
def validate_find_locator!(params)
|
|
518
|
+
has_id = !params['id'].nil?
|
|
519
|
+
has_by = params['by'].is_a?(Hash) && params['by'].any?
|
|
520
|
+
return if has_id ^ has_by
|
|
521
|
+
|
|
522
|
+
raise ValidationError, 'console_find requires exactly one non-empty locator: id or by' unless has_id && has_by
|
|
523
|
+
|
|
524
|
+
raise ValidationError, 'console_find accepts only one locator at a time: id or by, not both'
|
|
525
|
+
end
|
|
526
|
+
|
|
480
527
|
def handle_pluck(params)
|
|
481
528
|
columns = params['columns']
|
|
529
|
+
raise ValidationError, 'columns must contain at least one item' if columns && columns.empty?
|
|
530
|
+
|
|
482
531
|
@model_validator.validate_columns!(params['model'], columns) if columns
|
|
532
|
+
refuse_orphan_eav_value_selection!(Array(columns)) if columns
|
|
483
533
|
model = resolve_model(params['model'])
|
|
484
|
-
limit =
|
|
534
|
+
limit = params.fetch('limit', 100)
|
|
485
535
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
486
536
|
scope = scope.distinct if params['distinct']
|
|
487
537
|
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
@@ -491,12 +541,16 @@ module Woods
|
|
|
491
541
|
def handle_aggregate(params)
|
|
492
542
|
column = params['column']
|
|
493
543
|
function = params['function']
|
|
494
|
-
|
|
544
|
+
if column
|
|
545
|
+
@model_validator.validate_column!(params['model'], column)
|
|
546
|
+
refuse_redacted_aggregate_expression!(column)
|
|
547
|
+
end
|
|
495
548
|
|
|
496
|
-
unless AGGREGATE_FUNCTIONS.include?(function)
|
|
549
|
+
unless Server::AGGREGATE_FUNCTIONS.include?(function)
|
|
497
550
|
raise ValidationError, "Invalid aggregate function: #{function}. " \
|
|
498
|
-
"Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
|
|
551
|
+
"Allowed: #{Server::AGGREGATE_FUNCTIONS.join(', ')}"
|
|
499
552
|
end
|
|
553
|
+
raise ValidationError, "column is required for #{function} aggregate" if function != 'count' && column.nil?
|
|
500
554
|
|
|
501
555
|
model = resolve_model(params['model'])
|
|
502
556
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
@@ -511,12 +565,10 @@ module Woods
|
|
|
511
565
|
|
|
512
566
|
def handle_association_count(params)
|
|
513
567
|
model = resolve_model(params['model'])
|
|
514
|
-
record = model.find(params['id'])
|
|
515
568
|
association_name = params['association']
|
|
569
|
+
reflection = model.reflect_on_association(association_name.to_sym)
|
|
516
570
|
|
|
517
|
-
unless
|
|
518
|
-
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
|
|
519
|
-
end
|
|
571
|
+
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
|
|
520
572
|
|
|
521
573
|
# Defense-in-depth: the parent model passed validate_model!'s
|
|
522
574
|
# gate_model! check, but the association may target a different
|
|
@@ -525,11 +577,119 @@ module Woods
|
|
|
525
577
|
# explicitly before reading any rows from it.
|
|
526
578
|
gate_association!(params['model'], association_name)
|
|
527
579
|
|
|
580
|
+
# Validate every request-controlled scope column against the
|
|
581
|
+
# association's own model before any database I/O runs (not just
|
|
582
|
+
# before the association is read): `model.find` below is itself a
|
|
583
|
+
# query, and a request with a bad scope should never reach it.
|
|
584
|
+
validate_scope_columns!(params['scope'], reflection.klass.name) if params['scope']
|
|
585
|
+
|
|
586
|
+
record = model.find(params['id'])
|
|
528
587
|
scope = record.public_send(association_name)
|
|
529
|
-
scope = apply_scope(scope, params['scope'])
|
|
588
|
+
scope = apply_scope(scope, params['scope'], model_name: reflection.klass.name) if params['scope']
|
|
589
|
+
gate_association_sql!(scope)
|
|
530
590
|
{ 'count' => scope.count }
|
|
531
591
|
end
|
|
532
592
|
|
|
593
|
+
# Defense-in-depth: gate_association! (called by
|
|
594
|
+
# {#handle_association_count} before this) only proves the
|
|
595
|
+
# association's OWN target table isn't blocked. A through-association,
|
|
596
|
+
# default_scope, or an applied scope can still render SQL that reaches
|
|
597
|
+
# a blocked table via a less-obvious join — mirrors handle_query's
|
|
598
|
+
# re-check of the final rendered SQL. Only renders `to_sql` when a
|
|
599
|
+
# gate is actually configured — it is otherwise unnecessary AR work on
|
|
600
|
+
# every request.
|
|
601
|
+
def gate_association_sql!(scope)
|
|
602
|
+
gate_sql!(scope.to_sql) if @table_gate
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Pure column-name validation for a scope Hash, no relation is built
|
|
606
|
+
# and no query runs. Reuses ScopePredicateParser's own suffix grammar
|
|
607
|
+
# so the two never drift.
|
|
608
|
+
#
|
|
609
|
+
# @param scope [Hash, nil]
|
|
610
|
+
# @param model_name [String]
|
|
611
|
+
# @raise [ValidationError] on an unknown column
|
|
612
|
+
def validate_scope_columns!(scope, model_name)
|
|
613
|
+
return unless scope.is_a?(Hash)
|
|
614
|
+
|
|
615
|
+
scope.each_key do |raw_key|
|
|
616
|
+
column = scope_key_column(raw_key)
|
|
617
|
+
@model_validator.validate_column!(model_name, column)
|
|
618
|
+
refuse_protected_predicate_column!(column)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Strip a Ransack-style predicate suffix (e.g. `_matches`, `_gt`) from a
|
|
623
|
+
# scope Hash key, returning the bare column name underneath.
|
|
624
|
+
#
|
|
625
|
+
# @param raw_key [String, Symbol]
|
|
626
|
+
# @return [String]
|
|
627
|
+
def scope_key_column(raw_key)
|
|
628
|
+
key = raw_key.to_s
|
|
629
|
+
match = ScopePredicateParser::SUFFIX_PATTERN.match(key)
|
|
630
|
+
match ? key.delete_suffix(match[1]) : key
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Refuse a column that is configured as redacted
|
|
634
|
+
# (`console_redacted_columns`). {SafeContext#redact} only scrubs
|
|
635
|
+
# sensitive values from serialized *output* — accepting the same
|
|
636
|
+
# column as a scope/filter key, a find locator, an aggregate target,
|
|
637
|
+
# or an order_by column lets a caller read its plaintext value via a
|
|
638
|
+
# comparison, aggregate, or sort-order oracle before redaction ever
|
|
639
|
+
# runs.
|
|
640
|
+
#
|
|
641
|
+
# Matching is case-insensitive: unquoted SQL identifiers are
|
|
642
|
+
# case-insensitive, so a case variant of a redacted column (`AMOUNT`,
|
|
643
|
+
# `Password_Digest`) must get this typed refusal rather than fall
|
|
644
|
+
# through to an existence check.
|
|
645
|
+
#
|
|
646
|
+
# @param column [String] bare column name (no schema/table qualifier)
|
|
647
|
+
# @raise [ValidationError] if the column is on console_redacted_columns
|
|
648
|
+
def refuse_redacted_column!(column)
|
|
649
|
+
return unless @safe_context.redacted_columns.any? { |name| name.to_s.casecmp?(column.to_s) }
|
|
650
|
+
|
|
651
|
+
raise ValidationError,
|
|
652
|
+
"Rejected: column '#{column}' is redacted (console_redacted_columns) and cannot be used " \
|
|
653
|
+
'as a scope, filter, aggregate, find, or order key.'
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Refuse a predicate column protected by either redaction layer. This
|
|
657
|
+
# is narrower than EAV alias/aggregate protection: the key column is
|
|
658
|
+
# the selector used to identify sensitive rows and remains a valid
|
|
659
|
+
# predicate, while the value column is the secret-bearing field.
|
|
660
|
+
#
|
|
661
|
+
# Both layers match case-insensitively (see {#refuse_redacted_column!}):
|
|
662
|
+
# a case variant of a protected column must keep the typed refusal.
|
|
663
|
+
#
|
|
664
|
+
# @param column [String, Symbol] already-normalized bare or qualified column name
|
|
665
|
+
# @raise [ValidationError] when the column is protected for predicates
|
|
666
|
+
def refuse_protected_predicate_column!(column)
|
|
667
|
+
base = base_column_name(column.to_s)
|
|
668
|
+
return refuse_redacted_column!(base) if @safe_context.redacted_columns.any? { |name| name.to_s.casecmp?(base) }
|
|
669
|
+
return unless redacted_eav_value_columns.any? { |name| name.to_s.casecmp?(base) }
|
|
670
|
+
|
|
671
|
+
raise ValidationError,
|
|
672
|
+
"Rejected: EAV value column '#{base}' is redacted (console_redacted_key_values) and cannot " \
|
|
673
|
+
'be used as a scope, filter, or having predicate.'
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Refuse every column referenced by a scope Hash that is protected for
|
|
677
|
+
# predicates, honoring predicate suffixes (`email_matches`,
|
|
678
|
+
# `salary_gt`, etc.) the same way {#validate_scope_columns!} does.
|
|
679
|
+
#
|
|
680
|
+
# @param scope [Hash]
|
|
681
|
+
# @raise [ValidationError] on the first protected column found
|
|
682
|
+
def refuse_redacted_scope_keys!(scope)
|
|
683
|
+
scope.each_key { |raw_key| refuse_protected_predicate_column!(scope_key_column(raw_key)) }
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# The value columns of every configured EAV redaction pair.
|
|
687
|
+
#
|
|
688
|
+
# @return [Array<String>]
|
|
689
|
+
def redacted_eav_value_columns
|
|
690
|
+
@safe_context.redacted_key_values.filter_map { |pattern| pattern['value_column'] }
|
|
691
|
+
end
|
|
692
|
+
|
|
533
693
|
def gate_association!(model_name, association)
|
|
534
694
|
return unless @table_gate && association
|
|
535
695
|
|
|
@@ -568,10 +728,13 @@ module Woods
|
|
|
568
728
|
model = resolve_model(params['model'])
|
|
569
729
|
order_by = params.fetch('order_by', 'created_at')
|
|
570
730
|
direction = params.fetch('direction', 'desc')
|
|
571
|
-
limit =
|
|
731
|
+
limit = params.fetch('limit', 10)
|
|
572
732
|
|
|
573
733
|
@model_validator.validate_column!(params['model'], order_by)
|
|
574
|
-
|
|
734
|
+
refuse_redacted_column!(order_by)
|
|
735
|
+
unless %w[asc desc].include?(direction)
|
|
736
|
+
raise ValidationError, "direction must be asc or desc (got #{direction.inspect})"
|
|
737
|
+
end
|
|
575
738
|
|
|
576
739
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
577
740
|
scope = apply_columns(scope, params['columns'])
|
|
@@ -599,12 +762,23 @@ module Woods
|
|
|
599
762
|
raise ValidationError, 'Missing required parameter: sql' unless sql
|
|
600
763
|
|
|
601
764
|
require_relative 'sql_validator'
|
|
602
|
-
SqlValidator.new.validate!(sql)
|
|
765
|
+
SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
|
|
766
|
+
validate_protected_sql_usage!(sql)
|
|
603
767
|
# Post-validation, pre-execution TableGate — blocks every configured
|
|
604
768
|
# table even if the sql is otherwise well-formed.
|
|
605
769
|
gate_sql!(sql)
|
|
606
770
|
|
|
607
|
-
limit = params['limit']
|
|
771
|
+
limit = params['limit']
|
|
772
|
+
# EXPLAIN's output is plan rows, not the query's own row set: wrapping
|
|
773
|
+
# it as `SELECT * FROM (EXPLAIN ...) AS _limited LIMIT n` is invalid
|
|
774
|
+
# SQL that fails as a generic adapter error. Reject the combination
|
|
775
|
+
# with a typed error instead of advertising a limit EXPLAIN can't honor.
|
|
776
|
+
if limit && explain_statement?(sql)
|
|
777
|
+
raise ValidationError, 'limit is not supported with EXPLAIN (EXPLAIN output is plan rows, ' \
|
|
778
|
+
'not the query result set, so it cannot be wrapped and limited). ' \
|
|
779
|
+
'Resubmit without limit.'
|
|
780
|
+
end
|
|
781
|
+
|
|
608
782
|
query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
|
|
609
783
|
result = active_connection.select_all(query_sql)
|
|
610
784
|
|
|
@@ -613,6 +787,56 @@ module Woods
|
|
|
613
787
|
raise ValidationError, e.message
|
|
614
788
|
end
|
|
615
789
|
|
|
790
|
+
# @param sql [String] Validated SQL (already passed SqlValidator)
|
|
791
|
+
# @return [Boolean] true when the statement starts with EXPLAIN
|
|
792
|
+
def explain_statement?(sql)
|
|
793
|
+
sql.strip.match?(/\AEXPLAIN\b/i)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# The SQL dialect the active connection will parse the statement
|
|
797
|
+
# under, for {SqlValidator}'s dialect-aware lock-clause check. MySQL
|
|
798
|
+
# and PostgreSQL quote/comment grammars differ (`\'` escapes, `#`
|
|
799
|
+
# comments); validating with the matching dialect accepts dialect-valid
|
|
800
|
+
# literals and still rejects every known bypass form. Unknown adapters
|
|
801
|
+
# return nil and get the conservative both-dialect union.
|
|
802
|
+
#
|
|
803
|
+
# @return [Symbol, nil]
|
|
804
|
+
def sql_dialect
|
|
805
|
+
adapter = active_connection.adapter_name.to_s.downcase
|
|
806
|
+
return :mysql if adapter.include?('mysql')
|
|
807
|
+
return :postgres if adapter.include?('postgre')
|
|
808
|
+
|
|
809
|
+
nil
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Keep session reads local to this request and execution context. Restore
|
|
813
|
+
# the previous cache for nested requests, including when dispatch raises.
|
|
814
|
+
def with_mysql_quote_modes
|
|
815
|
+
previous = Thread.current[:woods_console_mysql_quote_modes]
|
|
816
|
+
Thread.current[:woods_console_mysql_quote_modes] = {}
|
|
817
|
+
yield
|
|
818
|
+
ensure
|
|
819
|
+
Thread.current[:woods_console_mysql_quote_modes] = previous
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Read the executing session, not adapter defaults or a cached boot value.
|
|
823
|
+
# The request runs inside SafeContext on active_connection throughout.
|
|
824
|
+
def mysql_quote_modes
|
|
825
|
+
return {} unless sql_dialect == :mysql
|
|
826
|
+
|
|
827
|
+
connection = active_connection
|
|
828
|
+
cache = Thread.current[:woods_console_mysql_quote_modes]
|
|
829
|
+
return cache[connection] if cache&.key?(connection)
|
|
830
|
+
|
|
831
|
+
modes = connection.select_value('SELECT @@SESSION.sql_mode').to_s.upcase.split(',')
|
|
832
|
+
result = {
|
|
833
|
+
ansi_quotes: modes.include?('ANSI_QUOTES'),
|
|
834
|
+
no_backslash_escapes: modes.include?('NO_BACKSLASH_ESCAPES')
|
|
835
|
+
}
|
|
836
|
+
cache[connection] = result if cache
|
|
837
|
+
result
|
|
838
|
+
end
|
|
839
|
+
|
|
616
840
|
# Build and execute a structured ActiveRecord query.
|
|
617
841
|
#
|
|
618
842
|
# @param params [Hash] Must contain 'model' and 'select'
|
|
@@ -638,23 +862,42 @@ module Woods
|
|
|
638
862
|
# @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
|
|
639
863
|
# @return [ActiveRecord::Relation]
|
|
640
864
|
def build_query_relation(model, params)
|
|
641
|
-
|
|
642
|
-
|
|
865
|
+
clauses = validated_query_clauses(model, params)
|
|
866
|
+
relation = apply_query_clauses(model, params, clauses)
|
|
867
|
+
limit = params.fetch('limit', 10_000)
|
|
643
868
|
relation.limit(limit)
|
|
644
869
|
end
|
|
645
870
|
|
|
871
|
+
# Validate every structured query clause before any ActiveRecord
|
|
872
|
+
# relation method can run. This keeps redaction-oracle refusals typed
|
|
873
|
+
# and prevents malformed predicates from reaching Arel/adapter code.
|
|
874
|
+
#
|
|
875
|
+
# @param model [Class] ActiveRecord model class
|
|
876
|
+
# @param params [Hash]
|
|
877
|
+
# @return [Hash] normalized, validated query clauses
|
|
878
|
+
def validated_query_clauses(model, params)
|
|
879
|
+
model_name = params['model']
|
|
880
|
+
{
|
|
881
|
+
select: params['select'] ? validated_select(params['select'], model_name) : nil,
|
|
882
|
+
joins: validated_query_joins(model, params['joins']),
|
|
883
|
+
scope: params.key?('scope') ? validated_query_scope(params['scope'], model_name, model) : nil,
|
|
884
|
+
group_by: params['group_by']&.any? ? validated_columns(params['group_by'], model_name) : nil,
|
|
885
|
+
having: params['having'] ? validated_having(params['having'], model_name) : nil,
|
|
886
|
+
order: params['order'] ? validated_order(params['order'], model_name) : nil
|
|
887
|
+
}
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def validated_query_joins(model, joins)
|
|
891
|
+
return nil unless joins&.any?
|
|
892
|
+
|
|
893
|
+
validate_joins!(model, joins)
|
|
894
|
+
joins.map(&:to_sym)
|
|
895
|
+
end
|
|
896
|
+
|
|
646
897
|
# Optional aggregate-expression wrappers accepted inside a `select`.
|
|
647
898
|
# Anything else must be a bare column name validated against the model.
|
|
648
899
|
# Matching is case-insensitive; the trailing `AS alias` is optional and
|
|
649
900
|
# the alias itself must be an identifier — it can't carry SQL.
|
|
650
|
-
SAFE_SELECT_EXPR = /
|
|
651
|
-
\A\s*
|
|
652
|
-
(?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
|
|
653
|
-
(?:\s+AS\s+(\w+))?
|
|
654
|
-
\s*\z
|
|
655
|
-
/ix
|
|
656
|
-
private_constant :SAFE_SELECT_EXPR
|
|
657
|
-
|
|
658
901
|
# Apply select/joins/scope/group/having/order clauses to a relation.
|
|
659
902
|
#
|
|
660
903
|
# Validates every user-supplied column/alias through the ModelValidator
|
|
@@ -666,42 +909,242 @@ module Woods
|
|
|
666
909
|
#
|
|
667
910
|
# @param model [Class] ActiveRecord model class
|
|
668
911
|
# @param params [Hash]
|
|
912
|
+
# @param clauses [Hash] prevalidated query clauses
|
|
669
913
|
# @return [ActiveRecord::Relation]
|
|
670
914
|
# @raise [ValidationError] on unsafe column/expression input
|
|
671
|
-
def apply_query_clauses(model, params
|
|
915
|
+
def apply_query_clauses(model, params, clauses)
|
|
672
916
|
model_name = params['model']
|
|
673
917
|
relation = model.all
|
|
674
918
|
|
|
675
|
-
relation = relation.select(*
|
|
676
|
-
relation = relation.joins(
|
|
677
|
-
relation = apply_scope(relation,
|
|
678
|
-
relation = relation.group(*
|
|
679
|
-
relation = relation.having(*
|
|
680
|
-
relation = relation.order(
|
|
919
|
+
relation = relation.select(*clauses[:select]) if clauses[:select]
|
|
920
|
+
relation = relation.joins(clauses[:joins]) if clauses[:joins]
|
|
921
|
+
relation = apply_scope(relation, clauses[:scope], model_name: model_name) if params.key?('scope')
|
|
922
|
+
relation = relation.group(*clauses[:group_by]) if clauses[:group_by]
|
|
923
|
+
relation = relation.having(*clauses[:having]) if clauses[:having]
|
|
924
|
+
relation = relation.order(clauses[:order]) if clauses[:order]
|
|
681
925
|
relation
|
|
682
926
|
end
|
|
683
927
|
|
|
684
928
|
# Normalize `select:` into an array of safe expressions. Each element
|
|
685
929
|
# must be a column name (optionally qualified and/or aliased) or a
|
|
686
|
-
# whitelisted aggregate call over a column.
|
|
930
|
+
# whitelisted aggregate call over a column. The list is then validated
|
|
931
|
+
# as a set: positional EAV redaction resolves key/value columns by
|
|
932
|
+
# header name and needs BOTH headers present, so a value column
|
|
933
|
+
# selected without its paired key column would return plaintext.
|
|
687
934
|
#
|
|
688
935
|
# @param select [String, Array<String>]
|
|
689
936
|
# @param model_name [String]
|
|
690
937
|
# @return [Array<String>]
|
|
691
938
|
def validated_select(select, model_name)
|
|
692
|
-
Array(select).
|
|
939
|
+
expressions = Array(select).map do |expr|
|
|
693
940
|
validate_select_expression!(expr.strip, model_name)
|
|
694
941
|
end
|
|
942
|
+
refuse_orphan_eav_value_selection!(expressions)
|
|
943
|
+
expressions
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Refuse an EAV value column whose paired key column is missing from
|
|
947
|
+
# the select set. The per-expression checks ({#validate_select_expression!})
|
|
948
|
+
# already refused aliases and aggregates over protected columns, so
|
|
949
|
+
# what reaches here unaliased can only be direct column selection —
|
|
950
|
+
# and the positional redactor masks the value cell only when the key
|
|
951
|
+
# column is in the same header. Requiring the key column keeps direct
|
|
952
|
+
# EAV reads working ({Woods::Console::Redactor} masks the value);
|
|
953
|
+
# refusing outright would break legitimate key/value reads.
|
|
954
|
+
#
|
|
955
|
+
# @param expressions [Array<String>] validated select expressions
|
|
956
|
+
# @raise [ValidationError] when a value column is selected without its key
|
|
957
|
+
def refuse_orphan_eav_value_selection!(expressions)
|
|
958
|
+
selected = directly_selected_columns(expressions)
|
|
959
|
+
|
|
960
|
+
@safe_context.redacted_key_values.each do |pattern|
|
|
961
|
+
next unless selected.include?(pattern['value_column'])
|
|
962
|
+
next if selected.include?(pattern['key_column'])
|
|
963
|
+
|
|
964
|
+
raise ValidationError,
|
|
965
|
+
"Rejected: selecting EAV value column '#{pattern['value_column']}' without its paired " \
|
|
966
|
+
"key column '#{pattern['key_column']}' bypasses redaction; select both columns so the " \
|
|
967
|
+
'value can be masked.'
|
|
968
|
+
end
|
|
695
969
|
end
|
|
696
970
|
|
|
971
|
+
# The bare, unaliased columns referenced by a validated select list.
|
|
972
|
+
# Aggregates and aliases cannot appear here — the per-expression
|
|
973
|
+
# validation refuses them over protected columns before this runs.
|
|
974
|
+
#
|
|
975
|
+
# @param expressions [Array<String>]
|
|
976
|
+
# @return [Array<String>]
|
|
977
|
+
def directly_selected_columns(expressions)
|
|
978
|
+
expressions.filter_map do |expr|
|
|
979
|
+
match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
|
|
980
|
+
next unless match
|
|
981
|
+
|
|
982
|
+
fn_arg, bare_col, alias_name = match.captures[1..]
|
|
983
|
+
next if fn_arg || alias_name
|
|
984
|
+
|
|
985
|
+
base_column_name(bare_col)
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Validate one select expression against the model and the redaction
|
|
990
|
+
# configuration.
|
|
991
|
+
#
|
|
992
|
+
# Exactly four shapes are refused (redaction stays positional by
|
|
993
|
+
# output header — {Woods::Console::Redactor} masks by column name, so
|
|
994
|
+
# any construct that renames or shadows an output header would carry
|
|
995
|
+
# plaintext past it):
|
|
996
|
+
# 1. an `AS` alias over a `console_redacted_columns` column
|
|
997
|
+
# (`password_digest AS note` returns plaintext under the `note`
|
|
998
|
+
# header, which no redaction rule matches);
|
|
999
|
+
# 2. an aggregate over a `console_redacted_columns` column, aliased
|
|
1000
|
+
# or bare (`SUM(salary)` leaks the aggregate of a column whose
|
|
1001
|
+
# individual values are masked — {#handle_aggregate} refuses the
|
|
1002
|
+
# same column via {#refuse_redacted_column!});
|
|
1003
|
+
# 3. an `AS` alias over either column of a
|
|
1004
|
+
# `console_redacted_key_values` pair (the positional EAV rule
|
|
1005
|
+
# resolves key/value columns by header name, so renaming either
|
|
1006
|
+
# header silently disarms it);
|
|
1007
|
+
# 4. any `AS` alias whose NAME collides with a protected header
|
|
1008
|
+
# (`id AS value` duplicates the EAV value header; positional
|
|
1009
|
+
# index resolution could then mask the shadow instead of the
|
|
1010
|
+
# secret — the aliased source being harmless is exactly what
|
|
1011
|
+
# makes the shape dangerous).
|
|
1012
|
+
#
|
|
1013
|
+
# Direct, unaliased selection of a redacted column REMAINS ALLOWED:
|
|
1014
|
+
# the output header keeps the column's real name, and the positional
|
|
1015
|
+
# redactor masks the value. Aliasing a non-redacted column stays
|
|
1016
|
+
# allowed.
|
|
1017
|
+
#
|
|
1018
|
+
# @param expr [String] a single select expression
|
|
1019
|
+
# @param model_name [String]
|
|
1020
|
+
# @return [String] the expression, unchanged, once validated
|
|
1021
|
+
# @raise [ValidationError] on an unsafe expression or a refused shape
|
|
697
1022
|
def validate_select_expression!(expr, model_name)
|
|
698
|
-
match =
|
|
1023
|
+
match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
|
|
699
1024
|
raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
|
|
700
1025
|
|
|
701
|
-
|
|
1026
|
+
refuse_redacted_select_shapes!(match.captures, model_name)
|
|
1027
|
+
expr
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# Column-existence check plus the three redaction-refusal shapes; see
|
|
1031
|
+
# {#validate_select_expression!} for the shape list.
|
|
1032
|
+
#
|
|
1033
|
+
# @param captures [Array] {Server::SELECT_EXPRESSION_REGEXP} captures
|
|
1034
|
+
# @param model_name [String]
|
|
1035
|
+
# @raise [ValidationError] on an unknown column or a refused shape
|
|
1036
|
+
def refuse_redacted_select_shapes!(captures, model_name)
|
|
1037
|
+
fn, fn_arg, bare_col, alias_name = captures
|
|
702
1038
|
column = bare_col || fn_arg
|
|
703
1039
|
validate_column_reference!(column, model_name) unless column == '*'
|
|
704
|
-
|
|
1040
|
+
|
|
1041
|
+
refuse_protected_alias_target!(alias_name) if alias_name
|
|
1042
|
+
refuse_redacted_select_alias!(bare_col, alias_name) if alias_name
|
|
1043
|
+
refuse_redacted_aggregate_expression!(fn_arg) if fn
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Refuse an `AS` alias whose NAME collides with a protected output
|
|
1047
|
+
# header (shape 4 in {#validate_select_expression!}). The positional
|
|
1048
|
+
# redactor resolves masks by header name; an alias naming a redacted
|
|
1049
|
+
# or EAV column duplicates that header, and the duplicate can steal
|
|
1050
|
+
# the mask from the real column's cell. Case-insensitive: unquoted
|
|
1051
|
+
# SQL identifiers fold, so a case-variant alias lands on the same
|
|
1052
|
+
# output header.
|
|
1053
|
+
#
|
|
1054
|
+
# @param alias_name [String] the `AS` alias
|
|
1055
|
+
# @raise [ValidationError] when the alias names a protected header
|
|
1056
|
+
def refuse_protected_alias_target!(alias_name)
|
|
1057
|
+
return unless protected_column_name?(alias_name)
|
|
1058
|
+
|
|
1059
|
+
raise ValidationError,
|
|
1060
|
+
"Rejected: alias '#{alias_name}' names a protected output header; an alias must not " \
|
|
1061
|
+
'collide with a redacted or EAV column name.'
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# @param name [String]
|
|
1065
|
+
# @return [Boolean] whether either redaction layer protects the name
|
|
1066
|
+
def protected_column_name?(name)
|
|
1067
|
+
casecmp_member?(@safe_context.redacted_columns, name) ||
|
|
1068
|
+
casecmp_member?(redacted_kv_columns, name)
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Case-insensitive membership, matching unquoted SQL identifier
|
|
1072
|
+
# semantics (the predicate refusals already compare this way).
|
|
1073
|
+
#
|
|
1074
|
+
# @param list [Array<String>]
|
|
1075
|
+
# @param name [String]
|
|
1076
|
+
# @return [Boolean]
|
|
1077
|
+
def casecmp_member?(list, name)
|
|
1078
|
+
list.any? { |entry| entry.to_s.casecmp?(name.to_s) }
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Refuse an `AS` alias over a column protected by either redaction
|
|
1082
|
+
# layer. A no-op when +alias_name+ is nil. See
|
|
1083
|
+
# {#validate_select_expression!} for why the alias is refused while
|
|
1084
|
+
# direct selection is not.
|
|
1085
|
+
#
|
|
1086
|
+
# @param column [String, nil] bare or qualified column reference
|
|
1087
|
+
# @param alias_name [String, nil] the `AS` alias
|
|
1088
|
+
# @raise [ValidationError] when the aliased column is protected
|
|
1089
|
+
def refuse_redacted_select_alias!(column, alias_name)
|
|
1090
|
+
return unless column
|
|
1091
|
+
|
|
1092
|
+
base = base_column_name(column)
|
|
1093
|
+
if casecmp_member?(@safe_context.redacted_columns, base)
|
|
1094
|
+
raise ValidationError,
|
|
1095
|
+
"Rejected: aliasing redacted column '#{base}' as '#{alias_name}' bypasses output " \
|
|
1096
|
+
'redaction. Select it unaliased; the value is masked.'
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
return unless casecmp_member?(redacted_kv_columns, base)
|
|
1100
|
+
|
|
1101
|
+
raise ValidationError,
|
|
1102
|
+
"Rejected: aliasing redacted key/value column '#{base}' as '#{alias_name}' bypasses " \
|
|
1103
|
+
'EAV output redaction. Select it unaliased.'
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# Refuse an aggregate call over a column protected by either redaction
|
|
1107
|
+
# layer, mirroring {#handle_aggregate}'s {#refuse_redacted_column!} for
|
|
1108
|
+
# the aggregate expressions accepted inside `select`. The EAV check
|
|
1109
|
+
# covers both columns of every `console_redacted_key_values` pair: an
|
|
1110
|
+
# aggregate over the value column (e.g. `MAX(amount)` over the rows a
|
|
1111
|
+
# sensitive key selects) reads the redacted value itself, and the key
|
|
1112
|
+
# column is refused for symmetry with the alias rule.
|
|
1113
|
+
#
|
|
1114
|
+
# @param column [String, nil] aggregate argument (`*` is never redacted)
|
|
1115
|
+
# @raise [ValidationError] when the aggregated column is protected
|
|
1116
|
+
def refuse_redacted_aggregate_expression!(column)
|
|
1117
|
+
return if column.nil? || column == '*'
|
|
1118
|
+
|
|
1119
|
+
base = base_column_name(column)
|
|
1120
|
+
if casecmp_member?(@safe_context.redacted_columns, base)
|
|
1121
|
+
raise ValidationError,
|
|
1122
|
+
"Rejected: aggregating redacted column '#{base}' reads its value; it cannot be used " \
|
|
1123
|
+
'as an aggregate input.'
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
return unless casecmp_member?(redacted_kv_columns, base)
|
|
1127
|
+
|
|
1128
|
+
raise ValidationError,
|
|
1129
|
+
"Rejected: aggregating redacted key/value column '#{base}' reads its value; it cannot " \
|
|
1130
|
+
'be used as an aggregate input.'
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
# The key and value columns of every configured EAV redaction pair.
|
|
1134
|
+
#
|
|
1135
|
+
# @return [Array<String>]
|
|
1136
|
+
def redacted_kv_columns
|
|
1137
|
+
@safe_context.redacted_key_values
|
|
1138
|
+
.flat_map { |pattern| [pattern['key_column'], pattern['value_column']] }
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Strip a `table.` qualifier, returning the bare column name that
|
|
1142
|
+
# redaction configuration is keyed on.
|
|
1143
|
+
#
|
|
1144
|
+
# @param column [String]
|
|
1145
|
+
# @return [String]
|
|
1146
|
+
def base_column_name(column)
|
|
1147
|
+
column.split('.').last
|
|
705
1148
|
end
|
|
706
1149
|
|
|
707
1150
|
# Validate group_by entries — bare columns only (no functions, no SQL).
|
|
@@ -713,6 +1156,7 @@ module Woods
|
|
|
713
1156
|
Array(columns).flat_map { |c| c.to_s.split(',') }.map do |col|
|
|
714
1157
|
col = col.strip
|
|
715
1158
|
validate_column_reference!(col, model_name)
|
|
1159
|
+
refuse_protected_predicate_column!(col)
|
|
716
1160
|
col
|
|
717
1161
|
end
|
|
718
1162
|
end
|
|
@@ -726,58 +1170,152 @@ module Woods
|
|
|
726
1170
|
# Anything else is rejected — raw strings (e.g. `"1=1 UNION SELECT
|
|
727
1171
|
# password_digest FROM users"`) used to flow straight through and
|
|
728
1172
|
# enable SELECT-based exfiltration despite the SafeContext rollback.
|
|
729
|
-
|
|
730
|
-
\A\s*
|
|
731
|
-
(?:
|
|
732
|
-
(?<col>\w+(?:\.\w+)?)
|
|
733
|
-
|
|
|
734
|
-
(?<agg>SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?<arg>\*|\w+(?:\.\w+)?)\s*\)
|
|
735
|
-
)
|
|
736
|
-
\s*(?<op>=|!=|<>|<=|>=|<|>)\s*\?\s*\z
|
|
737
|
-
/ix
|
|
738
|
-
private_constant :HAVING_AGG_TEMPLATE
|
|
739
|
-
|
|
740
|
-
def validated_having(having, model_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
1173
|
+
def validated_having(having, model_name)
|
|
741
1174
|
case having
|
|
742
1175
|
when Hash
|
|
743
1176
|
raise ValidationError, 'having: empty hash' if having.empty?
|
|
744
1177
|
|
|
745
|
-
having.each_key
|
|
1178
|
+
having.each_key do |k|
|
|
1179
|
+
validate_column_reference!(k.to_s, model_name)
|
|
1180
|
+
refuse_protected_predicate_column!(k)
|
|
1181
|
+
end
|
|
746
1182
|
[having]
|
|
747
1183
|
when Array
|
|
748
|
-
|
|
1184
|
+
validated_having_array!(having, model_name)
|
|
1185
|
+
else
|
|
1186
|
+
raise ValidationError, "having: unsupported type #{having.class}"
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
# Validate the `[template, bind]` array form of `having:`.
|
|
1191
|
+
#
|
|
1192
|
+
# @param having [Array] `[template, bind]`
|
|
1193
|
+
# @param model_name [String]
|
|
1194
|
+
# @return [Array] `having`, unchanged, once validated
|
|
1195
|
+
def validated_having_array!(having, model_name)
|
|
1196
|
+
unless having.length == 2 && having.first.is_a?(String)
|
|
1197
|
+
raise ValidationError, 'having must contain exactly one template and one bind value'
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
template = having.first
|
|
1201
|
+
match = Server::HAVING_TEMPLATE_REGEXP.match(template)
|
|
1202
|
+
raise ValidationError, "having: unsupported SQL template #{template.inspect}" unless match
|
|
1203
|
+
|
|
1204
|
+
# Validate any referenced columns through ModelValidator so
|
|
1205
|
+
# aggregate args can't reach the db without a column check.
|
|
1206
|
+
col = match[1] || match[3]
|
|
1207
|
+
validate_column_reference!(col, model_name) if col && col != '*'
|
|
1208
|
+
refuse_protected_having_reference!(match)
|
|
1209
|
+
validate_having_bind!(having.last)
|
|
1210
|
+
|
|
1211
|
+
having
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Redaction oracle refusal for a HAVING template. A predicate over a
|
|
1215
|
+
# protected column leaks its value through repeated guesses — the
|
|
1216
|
+
# response reveals whether any row satisfied the comparison.
|
|
1217
|
+
# Aggregates are refused over both redaction layers (the EAV pair
|
|
1218
|
+
# columns, matching {#refuse_redacted_aggregate_expression!} for
|
|
1219
|
+
# select); bare-column predicates are refused over protected
|
|
1220
|
+
# predicate columns: console_redacted_columns and EAV value columns.
|
|
1221
|
+
#
|
|
1222
|
+
# @param match [MatchData] {Server::HAVING_TEMPLATE_REGEXP} match
|
|
1223
|
+
# @raise [ValidationError] when the referenced column is protected
|
|
1224
|
+
def refuse_protected_having_reference!(match)
|
|
1225
|
+
return refuse_protected_predicate_column!(match[1]) if match[1]
|
|
1226
|
+
return if match[3].nil? || match[3] == '*'
|
|
1227
|
+
|
|
1228
|
+
refuse_redacted_aggregate_expression!(match[3])
|
|
1229
|
+
end
|
|
749
1230
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1231
|
+
# Defense-in-depth: the public schema already restricts the bind to a
|
|
1232
|
+
# scalar JSON type (see tool_specs.rb), but a container (Hash/Array)
|
|
1233
|
+
# bind that reaches AR's `?` placeholder fails as a generic adapter
|
|
1234
|
+
# error, not a typed one: reject it here too, mirroring
|
|
1235
|
+
# apply_query_scope's bind check.
|
|
1236
|
+
def validate_having_bind!(bind)
|
|
1237
|
+
case bind
|
|
1238
|
+
when String, Numeric, true, false, nil
|
|
1239
|
+
nil
|
|
1240
|
+
else
|
|
1241
|
+
raise ValidationError, 'having bind must be a string, number, boolean, or null'
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
753
1244
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1245
|
+
# Apply the public console_query scope contract. Query arrays are
|
|
1246
|
+
# intentionally narrower than the legacy Tier 1 executor form: exactly
|
|
1247
|
+
# one safe column comparison template and one bind value.
|
|
1248
|
+
def apply_query_scope(relation, scope, model_name)
|
|
1249
|
+
apply_scope(relation, validated_query_scope(scope, model_name, nil), model_name: model_name)
|
|
1250
|
+
end
|
|
758
1251
|
|
|
759
|
-
|
|
1252
|
+
# Validate a console_query scope. +model+ is the already-resolved
|
|
1253
|
+
# ActiveRecord class: its own table name lets a `table.column`
|
|
1254
|
+
# placeholder scope validate the column against the model's own
|
|
1255
|
+
# columns (public/executor parity) even when the ModelValidator
|
|
1256
|
+
# carries no table_names mapping. A nil +model+ falls back to the
|
|
1257
|
+
# strict qualified-reference resolution.
|
|
1258
|
+
def validated_query_scope(scope, model_name, model)
|
|
1259
|
+
if scope.is_a?(Hash)
|
|
1260
|
+
validate_scope_columns!(scope, model_name)
|
|
1261
|
+
return scope
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
unless scope.is_a?(Array) && scope.length == 2 && scope.first.is_a?(String)
|
|
1265
|
+
raise ValidationError, 'scope must be an object or exact ["column OP ?", bind] array'
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
case scope.last
|
|
1269
|
+
when String, Numeric, true, false, nil
|
|
1270
|
+
nil
|
|
760
1271
|
else
|
|
761
|
-
raise ValidationError,
|
|
1272
|
+
raise ValidationError, 'scope bind must be a string, number, boolean, or null'
|
|
762
1273
|
end
|
|
1274
|
+
|
|
1275
|
+
match = Server::QUERY_SCOPE_TEMPLATE_REGEXP.match(scope.first)
|
|
1276
|
+
raise ValidationError, "scope: unsupported SQL template #{scope.first.inspect}" unless match
|
|
1277
|
+
|
|
1278
|
+
# Redaction refusal runs BEFORE column resolution: a redacted column
|
|
1279
|
+
# referenced through any case variant (`Users.Password_Digest = ?`)
|
|
1280
|
+
# must get the typed redaction refusal, never a pass-through to the
|
|
1281
|
+
# existence check (whose case-sensitive column lookup would report a
|
|
1282
|
+
# generic "Unknown column" instead).
|
|
1283
|
+
refuse_protected_predicate_column!(match[1])
|
|
1284
|
+
validate_column_reference!(match[1], model_name, own_table: own_table_name(model))
|
|
1285
|
+
scope
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
# The queried model's own table name, when resolvable. Test doubles
|
|
1289
|
+
# may not implement `table_name`; a nil result keeps the strict
|
|
1290
|
+
# qualified-reference resolution instead of the own-table shortcut.
|
|
1291
|
+
#
|
|
1292
|
+
# @param model [Class, nil]
|
|
1293
|
+
# @return [String, nil]
|
|
1294
|
+
def own_table_name(model)
|
|
1295
|
+
return nil unless model.respond_to?(:table_name)
|
|
1296
|
+
|
|
1297
|
+
model.table_name.to_s
|
|
763
1298
|
end
|
|
764
1299
|
|
|
765
1300
|
# Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
|
|
766
1301
|
def validated_order(order, model_name)
|
|
767
1302
|
case order
|
|
768
1303
|
when Hash
|
|
769
|
-
order.each_key
|
|
1304
|
+
order.each_key do |key|
|
|
1305
|
+
validate_column_reference!(key.to_s, model_name)
|
|
1306
|
+
refuse_protected_predicate_column!(key)
|
|
1307
|
+
end
|
|
770
1308
|
order.transform_values do |dir|
|
|
771
|
-
|
|
772
|
-
unless %i[asc desc].include?(dir_sym)
|
|
1309
|
+
unless dir.to_s.match?(Server::ORDER_DIRECTION_REGEXP)
|
|
773
1310
|
raise ValidationError, "order direction must be :asc or :desc (got #{dir.inspect})"
|
|
774
1311
|
end
|
|
775
1312
|
|
|
776
|
-
|
|
1313
|
+
dir.to_s.downcase.to_sym
|
|
777
1314
|
end
|
|
778
1315
|
when String, Symbol
|
|
779
1316
|
col = order.to_s.strip
|
|
780
1317
|
validate_column_reference!(col, model_name)
|
|
1318
|
+
refuse_protected_predicate_column!(col)
|
|
781
1319
|
col
|
|
782
1320
|
else
|
|
783
1321
|
raise ValidationError, "order: unsupported type #{order.class}"
|
|
@@ -791,19 +1329,23 @@ module Woods
|
|
|
791
1329
|
# blocked-table reference into `select`/`order`/`having` via a
|
|
792
1330
|
# qualified column like `users.password_digest`. Bare columns validate
|
|
793
1331
|
# against the active model through ModelValidator.
|
|
794
|
-
|
|
1332
|
+
#
|
|
1333
|
+
# +own_table+ is the queried model's own table name, supplied by the
|
|
1334
|
+
# console_query scope path: a `table.column` reference whose table is
|
|
1335
|
+
# the model's own table validates the column against that same model,
|
|
1336
|
+
# exactly like the bare form. This cannot smuggle a foreign table
|
|
1337
|
+
# (the qualifier is checked for equality, not looked up), so redaction
|
|
1338
|
+
# and TableGate still apply to the reference as a whole. Any other
|
|
1339
|
+
# qualified reference keeps the strict cross-table resolution through
|
|
1340
|
+
# {ModelValidator#validate_table_column!}, which fails closed when the
|
|
1341
|
+
# table side cannot be proven.
|
|
1342
|
+
#
|
|
1343
|
+
# @param column [String] bare or `table.column` reference
|
|
1344
|
+
# @param model_name [String]
|
|
1345
|
+
# @param own_table [String, nil] the queried model's own table name
|
|
1346
|
+
def validate_column_reference!(column, model_name, own_table: nil)
|
|
795
1347
|
if column.include?('.')
|
|
796
|
-
|
|
797
|
-
unless safe_identifier?(table) && safe_identifier?(col)
|
|
798
|
-
raise ValidationError, "Rejected column reference: #{column.inspect}"
|
|
799
|
-
end
|
|
800
|
-
|
|
801
|
-
# Gate the table side through TableGate if one is configured.
|
|
802
|
-
begin
|
|
803
|
-
@table_gate&.check_table!(table)
|
|
804
|
-
rescue TableGateError => e
|
|
805
|
-
raise ValidationError, e.message
|
|
806
|
-
end
|
|
1348
|
+
validate_qualified_column_reference!(column, model_name, own_table)
|
|
807
1349
|
else
|
|
808
1350
|
raise ValidationError, "Rejected column reference: #{column.inspect}" unless safe_identifier?(column)
|
|
809
1351
|
|
|
@@ -811,8 +1353,55 @@ module Woods
|
|
|
811
1353
|
end
|
|
812
1354
|
end
|
|
813
1355
|
|
|
1356
|
+
# The qualified `table.column` half of {#validate_column_reference!}.
|
|
1357
|
+
# The table half must be a safe identifier and unblocked (TableGate)
|
|
1358
|
+
# before column ownership is resolved: +own_table+ (the queried
|
|
1359
|
+
# model's own table name) short-circuits resolution to that model's
|
|
1360
|
+
# own columns, exactly like the bare form; the qualifier is compared
|
|
1361
|
+
# case-insensitively (unquoted SQL identifiers are case-insensitive)
|
|
1362
|
+
# against that one table name, never looked up, so this cannot
|
|
1363
|
+
# smuggle a foreign table and redaction still applies to the
|
|
1364
|
+
# reference as a whole. The column half keeps ModelValidator's exact
|
|
1365
|
+
# existence check, identical to the bare-column form. Anything else
|
|
1366
|
+
# resolves through {ModelValidator#validate_table_column!}, which
|
|
1367
|
+
# fails closed when the table side cannot be proven.
|
|
1368
|
+
#
|
|
1369
|
+
# @param column [String] a `table.column` reference
|
|
1370
|
+
# @param model_name [String]
|
|
1371
|
+
# @param own_table [String, nil] the queried model's own table name
|
|
1372
|
+
def validate_qualified_column_reference!(column, model_name, own_table)
|
|
1373
|
+
table, col = column.split('.', 2)
|
|
1374
|
+
unless safe_identifier?(table) && safe_identifier?(col)
|
|
1375
|
+
raise ValidationError, "Rejected column reference: #{column.inspect}"
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
# Gate the table side through TableGate if one is configured.
|
|
1379
|
+
begin
|
|
1380
|
+
@table_gate&.check_table!(table)
|
|
1381
|
+
rescue TableGateError => e
|
|
1382
|
+
raise ValidationError, e.message
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
if own_table && table.casecmp?(own_table)
|
|
1386
|
+
# TableGate only proves `table` isn't *blocked* — it says nothing
|
|
1387
|
+
# about whether `col` actually exists there. Validate ownership
|
|
1388
|
+
# against the real schema before this reference can reach SQL.
|
|
1389
|
+
@model_validator.validate_column!(model_name, col)
|
|
1390
|
+
else
|
|
1391
|
+
@model_validator.validate_table_column!(table, col)
|
|
1392
|
+
end
|
|
1393
|
+
end
|
|
1394
|
+
|
|
814
1395
|
def safe_identifier?(name)
|
|
815
|
-
name.is_a?(String) && name.match?(
|
|
1396
|
+
name.is_a?(String) && name.match?(Server::SAFE_IDENTIFIER_REGEXP)
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
def validate_joins!(model, joins)
|
|
1400
|
+
joins.each do |association|
|
|
1401
|
+
next if model.reflect_on_association(association.to_sym)
|
|
1402
|
+
|
|
1403
|
+
raise ValidationError, "Unknown association '#{association}' on #{model.name}"
|
|
1404
|
+
end
|
|
816
1405
|
end
|
|
817
1406
|
|
|
818
1407
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
@@ -820,24 +1409,24 @@ module Woods
|
|
|
820
1409
|
# Apply scope conditions (WHERE clauses) to a relation.
|
|
821
1410
|
#
|
|
822
1411
|
# Accepts Hash form for equality or Ransack-style predicate suffixes
|
|
823
|
-
# (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`)
|
|
824
|
-
#
|
|
825
|
-
# ["
|
|
1412
|
+
# (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`). The
|
|
1413
|
+
# array branch is used only after console_query's narrower contract has
|
|
1414
|
+
# validated an exact `["column OP ?", bind]` pair.
|
|
826
1415
|
#
|
|
827
|
-
# When `model_name` is supplied
|
|
828
|
-
#
|
|
829
|
-
# safe Arel nodes. Plain equality hashes skip the parser entirely.
|
|
1416
|
+
# When `model_name` is supplied, ScopePredicateParser validates every
|
|
1417
|
+
# equality and predicate column before applying the scope.
|
|
830
1418
|
#
|
|
831
1419
|
# @param relation [ActiveRecord::Relation, Class] Model or relation
|
|
832
1420
|
# @param scope [Hash, Array, nil] Filter conditions
|
|
833
|
-
# @param model_name [String, nil] Model name for
|
|
1421
|
+
# @param model_name [String, nil] Model name for Hash key validation
|
|
834
1422
|
# @return [ActiveRecord::Relation]
|
|
835
1423
|
def apply_scope(relation, scope, model_name: nil)
|
|
836
1424
|
case scope
|
|
837
1425
|
when Hash
|
|
838
1426
|
return relation unless scope.any?
|
|
839
1427
|
|
|
840
|
-
|
|
1428
|
+
refuse_redacted_scope_keys!(scope)
|
|
1429
|
+
if model_name
|
|
841
1430
|
parser = ScopePredicateParser.new(model_name: model_name, model_validator: @model_validator)
|
|
842
1431
|
parser.parse(relation, scope)
|
|
843
1432
|
else
|
|
@@ -846,14 +1435,8 @@ module Woods
|
|
|
846
1435
|
when Array
|
|
847
1436
|
return relation unless scope.any?
|
|
848
1437
|
|
|
849
|
-
#
|
|
850
|
-
#
|
|
851
|
-
# `where(raw_sql_string)` arity — unbounded SQL injection. A
|
|
852
|
-
# caller could pass `["EXISTS (SELECT 1 FROM users WHERE
|
|
853
|
-
# password_digest LIKE 'a%')"]` and turn `console_count` /
|
|
854
|
-
# `console_pluck` into a boolean exfiltration oracle against
|
|
855
|
-
# any table the DB user can read (TableGate doesn't fire on
|
|
856
|
-
# the rendered Tier-1 SQL). Validate the template now.
|
|
1438
|
+
# Keep defense-in-depth validation here even though the registered
|
|
1439
|
+
# query schema and apply_query_scope enforce a narrower array form.
|
|
857
1440
|
validate_scope_array!(scope)
|
|
858
1441
|
relation.where(*scope)
|
|
859
1442
|
else
|
|
@@ -918,18 +1501,12 @@ module Woods
|
|
|
918
1501
|
|
|
919
1502
|
placeholder_count = template.scan('?').size
|
|
920
1503
|
bind_count = scope.length - 1
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
end
|
|
1504
|
+
unless placeholder_count == bind_count
|
|
1505
|
+
raise ValidationError,
|
|
1506
|
+
"scope template expects #{placeholder_count} bind(s), got #{bind_count}"
|
|
1507
|
+
end
|
|
926
1508
|
|
|
927
|
-
|
|
928
|
-
#
|
|
929
|
-
# @param scope [Hash]
|
|
930
|
-
# @return [Boolean]
|
|
931
|
-
def predicate_suffix?(scope)
|
|
932
|
-
scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
|
|
1509
|
+
refuse_protected_predicate_references!(template)
|
|
933
1510
|
end
|
|
934
1511
|
|
|
935
1512
|
# Validate that any requested +columns+ are real model columns before
|
|
@@ -944,6 +1521,86 @@ module Woods
|
|
|
944
1521
|
return unless params['columns']
|
|
945
1522
|
|
|
946
1523
|
@model_validator.validate_columns!(params['model'], params['columns'])
|
|
1524
|
+
refuse_orphan_eav_value_selection!(params['columns'])
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
# Raw SQL preserves redaction identity only for direct, unaliased
|
|
1528
|
+
# protected columns in the outer SELECT list. Aliases, aggregates,
|
|
1529
|
+
# predicates, CTEs, and other result shapes can rename a protected
|
|
1530
|
+
# value or turn it into an oracle, so they fail closed before execution.
|
|
1531
|
+
def validate_protected_sql_usage!(sql)
|
|
1532
|
+
protected = (@safe_context.redacted_columns + redacted_kv_columns).uniq
|
|
1533
|
+
referenced = protected.select { |column| sql_identifier_referenced?(sql, column) }
|
|
1534
|
+
return if referenced.empty?
|
|
1535
|
+
|
|
1536
|
+
stripped = SqlNoiseStripper.strip_noise(sql, dialect: sql_dialect || :postgres, **mysql_quote_modes)
|
|
1537
|
+
expressions, tail = protected_sql_projection(stripped)
|
|
1538
|
+
selected = expressions.filter_map { |expression| direct_sql_column_name(expression) }
|
|
1539
|
+
unsafe = unsafe_protected_sql_column(referenced, expressions, selected, tail)
|
|
1540
|
+
return unless unsafe
|
|
1541
|
+
|
|
1542
|
+
raise ValidationError,
|
|
1543
|
+
"Rejected: console_sql uses protected column '#{unsafe}' in an alias, aggregate, predicate, or " \
|
|
1544
|
+
'unpaired EAV shape that cannot preserve redaction identity. Select protected columns directly ' \
|
|
1545
|
+
'and unaliased, or use a structured Console tool.'
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1548
|
+
def unsafe_protected_sql_column(referenced, expressions, selected, tail)
|
|
1549
|
+
referenced.find do |column|
|
|
1550
|
+
unsafe_protected_sql_reference?(column, expressions, selected, tail)
|
|
1551
|
+
end || orphan_eav_sql_value(selected)
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
def protected_sql_projection(stripped)
|
|
1555
|
+
match = /\ASELECT\s+(.*?)\s+FROM\b/im.match(stripped)
|
|
1556
|
+
return [[], stripped] unless match
|
|
1557
|
+
|
|
1558
|
+
[sql_projection_expressions(match[1]), stripped[match.end(1)..]]
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
def unsafe_protected_sql_reference?(column, expressions, selected, tail)
|
|
1562
|
+
unsafe_projection = expressions.any? do |expression|
|
|
1563
|
+
sql_identifier_referenced?(expression, column) && direct_sql_column_name(expression) != column
|
|
1564
|
+
end
|
|
1565
|
+
unsafe_tail = protected_sql_predicate_column?(column) && sql_identifier_referenced?(tail, column)
|
|
1566
|
+
!selected.include?(column) || unsafe_projection || unsafe_tail
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
def protected_sql_predicate_column?(column)
|
|
1570
|
+
@safe_context.redacted_columns.include?(column) || redacted_eav_value_columns.include?(column)
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
def orphan_eav_sql_value(selected)
|
|
1574
|
+
pattern = @safe_context.redacted_key_values.find do |candidate|
|
|
1575
|
+
selected.include?(candidate['value_column']) && !selected.include?(candidate['key_column'])
|
|
1576
|
+
end
|
|
1577
|
+
pattern&.fetch('value_column')
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
def sql_projection_expressions(projection)
|
|
1581
|
+
projection.split(',').map(&:strip)
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
def direct_sql_column_name(expression)
|
|
1585
|
+
identifier = /(?:[A-Za-z_]\w*|"(?:""|[^"])+"|`(?:``|[^`])+`)/
|
|
1586
|
+
match = /\A(?:#{identifier}\.)?(#{identifier})\z/.match(expression)
|
|
1587
|
+
return unless match
|
|
1588
|
+
|
|
1589
|
+
match[1].sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
# Defense-in-depth for legacy Tier 1 scope arrays with more than one
|
|
1593
|
+
# bind. The public schema currently admits only Hash scopes, but direct
|
|
1594
|
+
# bridge callers still reach this validator.
|
|
1595
|
+
def refuse_protected_predicate_references!(template)
|
|
1596
|
+
protected = (@safe_context.redacted_columns + redacted_eav_value_columns).uniq
|
|
1597
|
+
referenced = protected.find { |column| sql_identifier_referenced?(template, column) }
|
|
1598
|
+
refuse_protected_predicate_column!(referenced) if referenced
|
|
1599
|
+
end
|
|
1600
|
+
|
|
1601
|
+
def sql_identifier_referenced?(sql, column)
|
|
1602
|
+
stripped = SqlNoiseStripper.strip_noise(sql, dialect: sql_dialect || :postgres, **mysql_quote_modes)
|
|
1603
|
+
stripped.match?(/(?<![A-Za-z0-9_$])#{Regexp.escape(column)}(?![A-Za-z0-9_$])/i)
|
|
947
1604
|
end
|
|
948
1605
|
|
|
949
1606
|
# Apply column selection to a relation.
|