woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- 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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- 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 +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- 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/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- 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 +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- 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 +2503 -192
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- 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 +108 -23
- 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 +907 -154
- 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/mcp/version_aware_tool_dispatch.rb +3 -9
- 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 +223 -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 +70 -38
- 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 +25 -6
- 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.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- 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 +53 -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 +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- 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/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- 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/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- 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,26 +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
|
-
require_relative 'redactor'
|
|
12
13
|
require_relative 'scope_predicate_parser'
|
|
13
|
-
require_relative 'sql_noise_stripper'
|
|
14
14
|
require_relative 'sql_validator'
|
|
15
|
+
require_relative 'sql_noise_stripper'
|
|
15
16
|
require_relative 'table_gate'
|
|
16
|
-
require_relative '
|
|
17
|
-
require_relative 'sql_output_policy'
|
|
17
|
+
require_relative 'tool_specs'
|
|
18
18
|
|
|
19
19
|
module Woods
|
|
20
20
|
module Console
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# Implements the same `send_request(Hash) -> Hash` interface as
|
|
26
|
-
# ConnectionManager, so all existing tool definitions in Server work
|
|
27
|
-
# 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.
|
|
28
24
|
#
|
|
29
25
|
# @example
|
|
30
26
|
# executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
|
|
@@ -32,10 +28,6 @@ module Woods
|
|
|
32
28
|
# # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
|
|
33
29
|
#
|
|
34
30
|
class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
|
|
35
|
-
include SqlOutputPolicy
|
|
36
|
-
|
|
37
|
-
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
|
|
38
|
-
|
|
39
31
|
TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
|
|
40
32
|
|
|
41
33
|
# Tools gated behind the read_tools_enabled flag.
|
|
@@ -43,16 +35,10 @@ module Woods
|
|
|
43
35
|
# but require explicit opt-in for embedded mode.
|
|
44
36
|
EMBEDDED_READ_TOOLS = %w[sql query].freeze
|
|
45
37
|
|
|
46
|
-
MAX_SQL_LIMIT = 10_000
|
|
47
|
-
MAX_QUERY_LIMIT = 10_000
|
|
48
|
-
|
|
49
|
-
MIN_EVAL_TIMEOUT = 1
|
|
50
|
-
MAX_EVAL_TIMEOUT = 30
|
|
51
38
|
DEFAULT_EVAL_TIMEOUT = 10
|
|
52
39
|
|
|
53
40
|
# @param model_validator [ModelValidator] Validates model/column names
|
|
54
41
|
# @param safe_context [SafeContext] Wraps execution in rolled-back transaction
|
|
55
|
-
# @param redaction_context [SafeContext, nil] Output policy supplied by the server renderer
|
|
56
42
|
# @param connection [Object, nil] Database connection for adapter detection
|
|
57
43
|
# @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
58
44
|
# @param table_gate [TableGate, nil] Enforces console_blocked_tables on every
|
|
@@ -75,10 +61,9 @@ module Woods
|
|
|
75
61
|
# refusal as before.
|
|
76
62
|
def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false, # rubocop:disable Metrics/ParameterLists
|
|
77
63
|
table_gate: nil, eval_guard: nil, confirmation: nil, audit_logger: nil,
|
|
78
|
-
unsafe_eval_enabled: false
|
|
64
|
+
unsafe_eval_enabled: false)
|
|
79
65
|
@model_validator = model_validator
|
|
80
66
|
@safe_context = safe_context
|
|
81
|
-
@redaction_context = redaction_context || safe_context
|
|
82
67
|
@connection = connection
|
|
83
68
|
@read_tools_enabled = read_tools_enabled
|
|
84
69
|
@table_gate = table_gate
|
|
@@ -105,8 +90,9 @@ module Woods
|
|
|
105
90
|
refusal = refusal_for(tool)
|
|
106
91
|
return refusal if refusal
|
|
107
92
|
|
|
93
|
+
normalize_params!(tool, params)
|
|
108
94
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
-
result = @safe_context.execute {
|
|
95
|
+
result = @safe_context.execute { with_mysql_quote_modes { dispatch(tool, params) } }
|
|
110
96
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
111
97
|
|
|
112
98
|
{ 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
|
|
@@ -126,60 +112,6 @@ module Woods
|
|
|
126
112
|
|
|
127
113
|
private
|
|
128
114
|
|
|
129
|
-
def dispatch_with_key_redaction(tool, params)
|
|
130
|
-
refuse_protected_scope!(params['scope'])
|
|
131
|
-
refuse_protected_scope!(params['by']) if tool == 'find'
|
|
132
|
-
output = dispatch(tool, params)
|
|
133
|
-
return output if redaction_key_values.empty?
|
|
134
|
-
|
|
135
|
-
Redactor.apply(output, typed_redaction_context(tool, params))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def typed_redaction_context(tool, params)
|
|
139
|
-
types = Hash.new { |hash, key| hash[key] = [] }
|
|
140
|
-
typed_redaction_models(tool, params).each do |name, model|
|
|
141
|
-
redaction_key_values.each do |pattern|
|
|
142
|
-
key = pattern['key_column']
|
|
143
|
-
types[key] << model.type_for_attribute(key) if @model_validator.columns_for(name).include?(key)
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
@redaction_context.with_key_value_types(types, raw: %w[sql query].include?(tool))
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def typed_redaction_models(tool, params)
|
|
150
|
-
tables = if tool == 'sql'
|
|
151
|
-
SqlTableScanner.identifiers_in(params['sql'], dialect: sql_dialect, mysql_modes: mysql_quote_modes)
|
|
152
|
-
else
|
|
153
|
-
selected_source_tables(params)
|
|
154
|
-
end
|
|
155
|
-
@model_validator.model_names.filter_map do |name|
|
|
156
|
-
model = resolve_model(name)
|
|
157
|
-
next unless model.respond_to?(:type_for_attribute)
|
|
158
|
-
next unless typed_model_source?(model, name, params, tables)
|
|
159
|
-
|
|
160
|
-
[name, model]
|
|
161
|
-
rescue NameError
|
|
162
|
-
# Injectable registries can describe models without loading Rails.
|
|
163
|
-
next
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def typed_model_source?(model, name, params, tables)
|
|
168
|
-
return true if name == params['model']
|
|
169
|
-
return false unless model.respond_to?(:table_name)
|
|
170
|
-
|
|
171
|
-
# Match the scanner's unquoted final segment, as the identifier gates
|
|
172
|
-
# do. Keep all matching models across schemas: every possible type
|
|
173
|
-
# contributes to masking rather than selecting one ambiguous source.
|
|
174
|
-
tables.any? { |table| table.split('.').last.casecmp?(model.table_name.split('.').last) }
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def selected_source_tables(params)
|
|
178
|
-
Array(params['select'] || params['columns']).filter_map do |column|
|
|
179
|
-
column.split('.')[0...-1].join('.') if column.include?('.')
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
115
|
def sanitize_execution_error(error)
|
|
184
116
|
klass = error.class.name
|
|
185
117
|
# Well-known AR wrappers that contain the adapter error as their cause —
|
|
@@ -192,12 +124,30 @@ module Woods
|
|
|
192
124
|
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
193
125
|
|
|
194
126
|
Rails.logger.warn(
|
|
195
|
-
"[Woods::Console] execution error: #{error.class}: #{error.message}"
|
|
127
|
+
"[Woods::Console] execution error: #{error.class}: #{scan_log_text(error.message)}"
|
|
196
128
|
)
|
|
197
129
|
rescue StandardError
|
|
198
130
|
# Never let logging break the request path.
|
|
199
131
|
end
|
|
200
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
|
+
|
|
201
151
|
# Return a pre-dispatch refusal hash for tools the executor cannot or
|
|
202
152
|
# will not run, else nil to let dispatch proceed.
|
|
203
153
|
#
|
|
@@ -220,20 +170,26 @@ module Woods
|
|
|
220
170
|
|
|
221
171
|
# Self-describing error for tools the embedded executor cannot run.
|
|
222
172
|
#
|
|
223
|
-
# `sql`/`query` are gated behind `embedded_read_tools: true
|
|
224
|
-
#
|
|
225
|
-
#
|
|
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.
|
|
226
182
|
#
|
|
227
183
|
# @param tool [String] Tool name that was rejected
|
|
228
184
|
# @return [String] Actionable error message
|
|
229
185
|
def unsupported_message(tool)
|
|
230
186
|
if EMBEDDED_READ_TOOLS.include?(tool)
|
|
231
187
|
"Tool '#{tool}' requires embedded_read_tools: true on " \
|
|
232
|
-
'Woods::Console::RackMiddleware, or
|
|
188
|
+
'Woods::Console::RackMiddleware, or config.console_embedded_read_tools = true ' \
|
|
189
|
+
'for the stdio server (exe/woods-console). ' \
|
|
233
190
|
'See docs/CONSOLE_MCP_SETUP.md.'
|
|
234
191
|
else
|
|
235
|
-
"Tool '#{tool}' is not available in
|
|
236
|
-
'bridge architecture (Option D in docs/CONSOLE_MCP_SETUP.md).'
|
|
192
|
+
"Tool '#{tool}' is not available in a supported Console MCP mode."
|
|
237
193
|
end
|
|
238
194
|
end
|
|
239
195
|
|
|
@@ -252,16 +208,12 @@ module Woods
|
|
|
252
208
|
# @return [String] Multi-line actionable message.
|
|
253
209
|
def eval_disabled_message
|
|
254
210
|
<<~MSG.strip
|
|
255
|
-
console_eval is
|
|
211
|
+
console_eval is not available in a supported Console MCP mode.
|
|
256
212
|
Use console_query (model + select + joins/group_by/having/order) or console_sql
|
|
257
213
|
for anything you were about to run. Both already support aggregates and scoping.
|
|
258
214
|
If you believe eval is still necessary, SHOW your proposed Ruby snippet to the
|
|
259
215
|
user first and let them run it manually — do not retry console_eval automatically.
|
|
260
|
-
|
|
261
|
-
AND wire console_unsafe_eval_confirmation + console_unsafe_eval_audit_log_path.
|
|
262
|
-
The server refuses to boot with the flag on in Rails.env.production?, and refuses
|
|
263
|
-
to boot with the flag on but any collaborator missing (fail-closed).
|
|
264
|
-
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.
|
|
265
217
|
MSG
|
|
266
218
|
end
|
|
267
219
|
|
|
@@ -313,19 +265,8 @@ module Woods
|
|
|
313
265
|
raise
|
|
314
266
|
end
|
|
315
267
|
|
|
316
|
-
# Validate + clamp the user-supplied timeout. Accepts a positive
|
|
317
|
-
# Integer (or nil → default). Everything else is rejected so a
|
|
318
|
-
# caller passing `timeout: 0` or `timeout: "forever"` hears about
|
|
319
|
-
# it instead of silently getting MIN_EVAL_TIMEOUT.
|
|
320
268
|
def eval_timeout_from(raw)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
unless raw.is_a?(Integer) && raw.positive?
|
|
324
|
-
raise ValidationError,
|
|
325
|
-
"timeout must be a positive integer (#{MIN_EVAL_TIMEOUT}..#{MAX_EVAL_TIMEOUT})"
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
raw.clamp(MIN_EVAL_TIMEOUT, MAX_EVAL_TIMEOUT)
|
|
269
|
+
raw || DEFAULT_EVAL_TIMEOUT
|
|
329
270
|
end
|
|
330
271
|
|
|
331
272
|
def guard_check!(code, audit_params)
|
|
@@ -458,6 +399,28 @@ module Woods
|
|
|
458
399
|
end
|
|
459
400
|
end
|
|
460
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
|
+
|
|
461
424
|
# @param params [Hash] Must contain 'model' key
|
|
462
425
|
# @raise [ValidationError]
|
|
463
426
|
def validate_model!(params)
|
|
@@ -487,9 +450,8 @@ module Woods
|
|
|
487
450
|
return unless @table_gate
|
|
488
451
|
|
|
489
452
|
begin
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
rescue TableGateError, SqlValidationError => e
|
|
453
|
+
@table_gate.check_sql!(sql, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
|
|
454
|
+
rescue TableGateError => e
|
|
493
455
|
raise ValidationError, e.message
|
|
494
456
|
end
|
|
495
457
|
end
|
|
@@ -517,57 +479,82 @@ module Woods
|
|
|
517
479
|
def handle_count(params)
|
|
518
480
|
model = resolve_model(params['model'])
|
|
519
481
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
520
|
-
{ 'count' =>
|
|
482
|
+
{ 'count' => scope.count }
|
|
521
483
|
end
|
|
522
484
|
|
|
523
485
|
def handle_sample(params)
|
|
524
486
|
validate_select_columns!(params)
|
|
525
487
|
model = resolve_model(params['model'])
|
|
526
|
-
limit =
|
|
488
|
+
limit = params.fetch('limit', 5)
|
|
527
489
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
528
490
|
scope = apply_columns(scope, params['columns'])
|
|
529
|
-
records =
|
|
491
|
+
records = scope.order(random_function).limit(limit)
|
|
530
492
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
531
493
|
end
|
|
532
494
|
|
|
533
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
|
|
534
502
|
validate_select_columns!(params)
|
|
535
503
|
model = resolve_model(params['model'])
|
|
536
|
-
|
|
537
|
-
record = if params['id']
|
|
538
|
-
scope.find_by(id: params['id'])
|
|
539
|
-
elsif params['by']
|
|
540
|
-
scope.find_by(params['by'])
|
|
541
|
-
end
|
|
504
|
+
record = params['id'] ? model.find_by(id: params['id']) : model.find_by(params['by'])
|
|
542
505
|
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
543
506
|
end
|
|
544
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
|
+
|
|
545
527
|
def handle_pluck(params)
|
|
546
528
|
columns = params['columns']
|
|
547
|
-
|
|
529
|
+
raise ValidationError, 'columns must contain at least one item' if columns && columns.empty?
|
|
530
|
+
|
|
531
|
+
@model_validator.validate_columns!(params['model'], columns) if columns
|
|
532
|
+
refuse_orphan_eav_value_selection!(Array(columns)) if columns
|
|
548
533
|
model = resolve_model(params['model'])
|
|
549
|
-
limit =
|
|
534
|
+
limit = params.fetch('limit', 100)
|
|
550
535
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
551
536
|
scope = scope.distinct if params['distinct']
|
|
552
|
-
values =
|
|
537
|
+
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
553
538
|
{ 'columns' => Array(columns), 'values' => values }
|
|
554
539
|
end
|
|
555
540
|
|
|
556
541
|
def handle_aggregate(params)
|
|
557
542
|
column = params['column']
|
|
558
543
|
function = params['function']
|
|
559
|
-
|
|
560
|
-
|
|
544
|
+
if column
|
|
545
|
+
@model_validator.validate_column!(params['model'], column)
|
|
546
|
+
refuse_redacted_aggregate_expression!(column)
|
|
547
|
+
end
|
|
561
548
|
|
|
562
|
-
unless AGGREGATE_FUNCTIONS.include?(function)
|
|
549
|
+
unless Server::AGGREGATE_FUNCTIONS.include?(function)
|
|
563
550
|
raise ValidationError, "Invalid aggregate function: #{function}. " \
|
|
564
|
-
"Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
|
|
551
|
+
"Allowed: #{Server::AGGREGATE_FUNCTIONS.join(', ')}"
|
|
565
552
|
end
|
|
553
|
+
raise ValidationError, "column is required for #{function} aggregate" if function != 'count' && column.nil?
|
|
566
554
|
|
|
567
555
|
model = resolve_model(params['model'])
|
|
568
556
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
569
557
|
|
|
570
|
-
scope = checked_relation(scope)
|
|
571
558
|
value = if function == 'count'
|
|
572
559
|
column ? scope.count(column.to_sym) : scope.count
|
|
573
560
|
else
|
|
@@ -578,12 +565,10 @@ module Woods
|
|
|
578
565
|
|
|
579
566
|
def handle_association_count(params)
|
|
580
567
|
model = resolve_model(params['model'])
|
|
581
|
-
record = checked_relation(model).find(params['id'])
|
|
582
568
|
association_name = params['association']
|
|
569
|
+
reflection = model.reflect_on_association(association_name.to_sym)
|
|
583
570
|
|
|
584
|
-
unless
|
|
585
|
-
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
|
|
586
|
-
end
|
|
571
|
+
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
|
|
587
572
|
|
|
588
573
|
# Defense-in-depth: the parent model passed validate_model!'s
|
|
589
574
|
# gate_model! check, but the association may target a different
|
|
@@ -592,24 +577,117 @@ module Woods
|
|
|
592
577
|
# explicitly before reading any rows from it.
|
|
593
578
|
gate_association!(params['model'], association_name)
|
|
594
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'])
|
|
595
587
|
scope = record.public_send(association_name)
|
|
596
|
-
scope = apply_scope(scope, params['scope'])
|
|
597
|
-
|
|
588
|
+
scope = apply_scope(scope, params['scope'], model_name: reflection.klass.name) if params['scope']
|
|
589
|
+
gate_association_sql!(scope)
|
|
590
|
+
{ 'count' => scope.count }
|
|
591
|
+
end
|
|
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
|
|
598
620
|
end
|
|
599
621
|
|
|
600
|
-
#
|
|
601
|
-
#
|
|
602
|
-
#
|
|
603
|
-
|
|
604
|
-
|
|
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) }
|
|
605
650
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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.'
|
|
609
654
|
end
|
|
610
655
|
|
|
611
|
-
|
|
612
|
-
|
|
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'] }
|
|
613
691
|
end
|
|
614
692
|
|
|
615
693
|
def gate_association!(model_name, association)
|
|
@@ -650,15 +728,17 @@ module Woods
|
|
|
650
728
|
model = resolve_model(params['model'])
|
|
651
729
|
order_by = params.fetch('order_by', 'created_at')
|
|
652
730
|
direction = params.fetch('direction', 'desc')
|
|
653
|
-
limit =
|
|
731
|
+
limit = params.fetch('limit', 10)
|
|
654
732
|
|
|
655
733
|
@model_validator.validate_column!(params['model'], order_by)
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
658
738
|
|
|
659
739
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
660
740
|
scope = apply_columns(scope, params['columns'])
|
|
661
|
-
records =
|
|
741
|
+
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
662
742
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
663
743
|
end
|
|
664
744
|
|
|
@@ -681,31 +761,80 @@ module Woods
|
|
|
681
761
|
sql = params['sql']
|
|
682
762
|
raise ValidationError, 'Missing required parameter: sql' unless sql
|
|
683
763
|
|
|
684
|
-
raise ValidationError, 'Rejected: console_sql requires a recognized database adapter family.' unless sql_dialect
|
|
685
|
-
|
|
686
764
|
require_relative 'sql_validator'
|
|
687
|
-
|
|
765
|
+
SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
|
|
766
|
+
validate_protected_sql_usage!(sql)
|
|
767
|
+
# Post-validation, pre-execution TableGate — blocks every configured
|
|
768
|
+
# table even if the sql is otherwise well-formed.
|
|
769
|
+
gate_sql!(sql)
|
|
770
|
+
|
|
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
|
|
688
781
|
|
|
689
|
-
|
|
690
|
-
query_sql = limit ? "SELECT * FROM (\n#{sql}\n) AS _limited LIMIT #{limit}" : sql
|
|
691
|
-
validate_sql_policy!(query_sql) if limit
|
|
782
|
+
query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
|
|
692
783
|
result = active_connection.select_all(query_sql)
|
|
693
|
-
validate_sql_result_types!(result)
|
|
694
784
|
|
|
695
785
|
{ 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
|
|
696
786
|
rescue SqlValidationError => e
|
|
697
787
|
raise ValidationError, e.message
|
|
698
788
|
end
|
|
699
789
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
709
838
|
end
|
|
710
839
|
|
|
711
840
|
# Build and execute a structured ActiveRecord query.
|
|
@@ -733,23 +862,42 @@ module Woods
|
|
|
733
862
|
# @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
|
|
734
863
|
# @return [ActiveRecord::Relation]
|
|
735
864
|
def build_query_relation(model, params)
|
|
736
|
-
|
|
737
|
-
|
|
865
|
+
clauses = validated_query_clauses(model, params)
|
|
866
|
+
relation = apply_query_clauses(model, params, clauses)
|
|
867
|
+
limit = params.fetch('limit', 10_000)
|
|
738
868
|
relation.limit(limit)
|
|
739
869
|
end
|
|
740
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
|
+
|
|
741
897
|
# Optional aggregate-expression wrappers accepted inside a `select`.
|
|
742
898
|
# Anything else must be a bare column name validated against the model.
|
|
743
899
|
# Matching is case-insensitive; the trailing `AS alias` is optional and
|
|
744
900
|
# the alias itself must be an identifier — it can't carry SQL.
|
|
745
|
-
SAFE_SELECT_EXPR = /
|
|
746
|
-
\A\s*
|
|
747
|
-
(?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
|
|
748
|
-
(?:\s+AS\s+(\w+))?
|
|
749
|
-
\s*\z
|
|
750
|
-
/ix
|
|
751
|
-
private_constant :SAFE_SELECT_EXPR
|
|
752
|
-
|
|
753
901
|
# Apply select/joins/scope/group/having/order clauses to a relation.
|
|
754
902
|
#
|
|
755
903
|
# Validates every user-supplied column/alias through the ModelValidator
|
|
@@ -761,46 +909,244 @@ module Woods
|
|
|
761
909
|
#
|
|
762
910
|
# @param model [Class] ActiveRecord model class
|
|
763
911
|
# @param params [Hash]
|
|
912
|
+
# @param clauses [Hash] prevalidated query clauses
|
|
764
913
|
# @return [ActiveRecord::Relation]
|
|
765
914
|
# @raise [ValidationError] on unsafe column/expression input
|
|
766
|
-
def apply_query_clauses(model, params
|
|
915
|
+
def apply_query_clauses(model, params, clauses)
|
|
767
916
|
model_name = params['model']
|
|
768
917
|
relation = model.all
|
|
769
918
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
relation = relation
|
|
773
|
-
relation = relation.
|
|
774
|
-
relation =
|
|
775
|
-
relation = relation.
|
|
776
|
-
relation = relation.having(*validated_having(params['having'], model_name)) if params['having']
|
|
777
|
-
relation = relation.order(validated_order(params['order'], model_name)) if params['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]
|
|
778
925
|
relation
|
|
779
926
|
end
|
|
780
927
|
|
|
781
928
|
# Normalize `select:` into an array of safe expressions. Each element
|
|
782
929
|
# must be a column name (optionally qualified and/or aliased) or a
|
|
783
|
-
# 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.
|
|
784
934
|
#
|
|
785
935
|
# @param select [String, Array<String>]
|
|
786
936
|
# @param model_name [String]
|
|
787
937
|
# @return [Array<String>]
|
|
788
938
|
def validated_select(select, model_name)
|
|
789
|
-
expressions = Array(select).
|
|
939
|
+
expressions = Array(select).map do |expr|
|
|
790
940
|
validate_select_expression!(expr.strip, model_name)
|
|
791
941
|
end
|
|
792
|
-
refuse_orphan_eav_value_selection!(expressions
|
|
942
|
+
refuse_orphan_eav_value_selection!(expressions)
|
|
793
943
|
expressions
|
|
794
944
|
end
|
|
795
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
|
|
969
|
+
end
|
|
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
|
|
796
1022
|
def validate_select_expression!(expr, model_name)
|
|
797
|
-
match =
|
|
1023
|
+
match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
|
|
798
1024
|
raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
|
|
799
1025
|
|
|
800
1026
|
refuse_redacted_select_shapes!(match.captures, model_name)
|
|
801
1027
|
expr
|
|
802
1028
|
end
|
|
803
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
|
|
1038
|
+
column = bare_col || fn_arg
|
|
1039
|
+
validate_column_reference!(column, model_name) unless column == '*'
|
|
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
|
|
1148
|
+
end
|
|
1149
|
+
|
|
804
1150
|
# Validate group_by entries — bare columns only (no functions, no SQL).
|
|
805
1151
|
#
|
|
806
1152
|
# @param columns [String, Array<String>]
|
|
@@ -824,68 +1170,152 @@ module Woods
|
|
|
824
1170
|
# Anything else is rejected — raw strings (e.g. `"1=1 UNION SELECT
|
|
825
1171
|
# password_digest FROM users"`) used to flow straight through and
|
|
826
1172
|
# enable SELECT-based exfiltration despite the SafeContext rollback.
|
|
827
|
-
HAVING_AGG_TEMPLATE = /
|
|
828
|
-
\A\s*
|
|
829
|
-
(?:
|
|
830
|
-
(?<col>\w+(?:\.\w+)?)
|
|
831
|
-
|
|
|
832
|
-
(?<agg>SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?<arg>\*|\w+(?:\.\w+)?)\s*\)
|
|
833
|
-
)
|
|
834
|
-
\s*(?<op>=|!=|<>|<=|>=|<|>)\s*\?\s*\z
|
|
835
|
-
/ix
|
|
836
|
-
private_constant :HAVING_AGG_TEMPLATE
|
|
837
|
-
|
|
838
1173
|
def validated_having(having, model_name)
|
|
839
1174
|
case having
|
|
840
1175
|
when Hash
|
|
841
1176
|
raise ValidationError, 'having: empty hash' if having.empty?
|
|
842
1177
|
|
|
843
|
-
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
|
|
844
1182
|
[having]
|
|
845
1183
|
when Array
|
|
846
|
-
|
|
1184
|
+
validated_having_array!(having, model_name)
|
|
1185
|
+
else
|
|
1186
|
+
raise ValidationError, "having: unsupported type #{having.class}"
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
847
1189
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
|
851
1199
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
855
1213
|
|
|
856
|
-
|
|
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
|
|
1230
|
+
|
|
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
|
|
857
1240
|
else
|
|
858
|
-
raise ValidationError,
|
|
1241
|
+
raise ValidationError, 'having bind must be a string, number, boolean, or null'
|
|
859
1242
|
end
|
|
860
1243
|
end
|
|
861
1244
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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)
|
|
866
1250
|
end
|
|
867
1251
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
1271
|
+
else
|
|
1272
|
+
raise ValidationError, 'scope bind must be a string, number, boolean, or null'
|
|
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
|
|
871
1298
|
end
|
|
872
1299
|
|
|
873
1300
|
# Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
|
|
874
1301
|
def validated_order(order, model_name)
|
|
875
1302
|
case order
|
|
876
1303
|
when Hash
|
|
877
|
-
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
|
|
878
1308
|
order.transform_values do |dir|
|
|
879
|
-
|
|
880
|
-
unless %i[asc desc].include?(dir_sym)
|
|
1309
|
+
unless dir.to_s.match?(Server::ORDER_DIRECTION_REGEXP)
|
|
881
1310
|
raise ValidationError, "order direction must be :asc or :desc (got #{dir.inspect})"
|
|
882
1311
|
end
|
|
883
1312
|
|
|
884
|
-
|
|
1313
|
+
dir.to_s.downcase.to_sym
|
|
885
1314
|
end
|
|
886
1315
|
when String, Symbol
|
|
887
1316
|
col = order.to_s.strip
|
|
888
|
-
|
|
1317
|
+
validate_column_reference!(col, model_name)
|
|
1318
|
+
refuse_protected_predicate_column!(col)
|
|
889
1319
|
col
|
|
890
1320
|
else
|
|
891
1321
|
raise ValidationError, "order: unsupported type #{order.class}"
|
|
@@ -899,19 +1329,23 @@ module Woods
|
|
|
899
1329
|
# blocked-table reference into `select`/`order`/`having` via a
|
|
900
1330
|
# qualified column like `users.password_digest`. Bare columns validate
|
|
901
1331
|
# against the active model through ModelValidator.
|
|
902
|
-
|
|
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)
|
|
903
1347
|
if column.include?('.')
|
|
904
|
-
|
|
905
|
-
unless safe_identifier?(table) && safe_identifier?(col)
|
|
906
|
-
raise ValidationError, "Rejected column reference: #{column.inspect}"
|
|
907
|
-
end
|
|
908
|
-
|
|
909
|
-
# Gate the table side through TableGate if one is configured.
|
|
910
|
-
begin
|
|
911
|
-
@table_gate&.check_table!(table)
|
|
912
|
-
rescue TableGateError => e
|
|
913
|
-
raise ValidationError, e.message
|
|
914
|
-
end
|
|
1348
|
+
validate_qualified_column_reference!(column, model_name, own_table)
|
|
915
1349
|
else
|
|
916
1350
|
raise ValidationError, "Rejected column reference: #{column.inspect}" unless safe_identifier?(column)
|
|
917
1351
|
|
|
@@ -919,8 +1353,55 @@ module Woods
|
|
|
919
1353
|
end
|
|
920
1354
|
end
|
|
921
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
|
+
|
|
922
1395
|
def safe_identifier?(name)
|
|
923
|
-
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
|
|
924
1405
|
end
|
|
925
1406
|
|
|
926
1407
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
@@ -928,24 +1409,24 @@ module Woods
|
|
|
928
1409
|
# Apply scope conditions (WHERE clauses) to a relation.
|
|
929
1410
|
#
|
|
930
1411
|
# Accepts Hash form for equality or Ransack-style predicate suffixes
|
|
931
|
-
# (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`)
|
|
932
|
-
#
|
|
933
|
-
# ["
|
|
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.
|
|
934
1415
|
#
|
|
935
|
-
# When `model_name` is supplied
|
|
936
|
-
#
|
|
937
|
-
# 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.
|
|
938
1418
|
#
|
|
939
1419
|
# @param relation [ActiveRecord::Relation, Class] Model or relation
|
|
940
1420
|
# @param scope [Hash, Array, nil] Filter conditions
|
|
941
|
-
# @param model_name [String, nil] Model name for
|
|
1421
|
+
# @param model_name [String, nil] Model name for Hash key validation
|
|
942
1422
|
# @return [ActiveRecord::Relation]
|
|
943
1423
|
def apply_scope(relation, scope, model_name: nil)
|
|
944
1424
|
case scope
|
|
945
1425
|
when Hash
|
|
946
1426
|
return relation unless scope.any?
|
|
947
1427
|
|
|
948
|
-
|
|
1428
|
+
refuse_redacted_scope_keys!(scope)
|
|
1429
|
+
if model_name
|
|
949
1430
|
parser = ScopePredicateParser.new(model_name: model_name, model_validator: @model_validator)
|
|
950
1431
|
parser.parse(relation, scope)
|
|
951
1432
|
else
|
|
@@ -954,14 +1435,8 @@ module Woods
|
|
|
954
1435
|
when Array
|
|
955
1436
|
return relation unless scope.any?
|
|
956
1437
|
|
|
957
|
-
#
|
|
958
|
-
#
|
|
959
|
-
# `where(raw_sql_string)` arity — unbounded SQL injection. A
|
|
960
|
-
# caller could pass `["EXISTS (SELECT 1 FROM users WHERE
|
|
961
|
-
# password_digest LIKE 'a%')"]` and turn `console_count` /
|
|
962
|
-
# `console_pluck` into a boolean exfiltration oracle against
|
|
963
|
-
# any table the DB user can read (TableGate doesn't fire on
|
|
964
|
-
# 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.
|
|
965
1440
|
validate_scope_array!(scope)
|
|
966
1441
|
relation.where(*scope)
|
|
967
1442
|
else
|
|
@@ -1026,18 +1501,12 @@ module Woods
|
|
|
1026
1501
|
|
|
1027
1502
|
placeholder_count = template.scan('?').size
|
|
1028
1503
|
bind_count = scope.length - 1
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
end
|
|
1504
|
+
unless placeholder_count == bind_count
|
|
1505
|
+
raise ValidationError,
|
|
1506
|
+
"scope template expects #{placeholder_count} bind(s), got #{bind_count}"
|
|
1507
|
+
end
|
|
1034
1508
|
|
|
1035
|
-
|
|
1036
|
-
#
|
|
1037
|
-
# @param scope [Hash]
|
|
1038
|
-
# @return [Boolean]
|
|
1039
|
-
def predicate_suffix?(scope)
|
|
1040
|
-
scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
|
|
1509
|
+
refuse_protected_predicate_references!(template)
|
|
1041
1510
|
end
|
|
1042
1511
|
|
|
1043
1512
|
# Validate that any requested +columns+ are real model columns before
|
|
@@ -1052,7 +1521,86 @@ module Woods
|
|
|
1052
1521
|
return unless params['columns']
|
|
1053
1522
|
|
|
1054
1523
|
@model_validator.validate_columns!(params['model'], params['columns'])
|
|
1055
|
-
refuse_orphan_eav_value_selection!(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)
|
|
1056
1604
|
end
|
|
1057
1605
|
|
|
1058
1606
|
# Apply column selection to a relation.
|
|
@@ -1092,7 +1640,8 @@ module Woods
|
|
|
1092
1640
|
#
|
|
1093
1641
|
# @return [Arel::Nodes::SqlLiteral]
|
|
1094
1642
|
def random_function
|
|
1095
|
-
|
|
1643
|
+
adapter = active_connection.adapter_name.downcase
|
|
1644
|
+
func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
|
|
1096
1645
|
Arel.sql("#{func}()")
|
|
1097
1646
|
end
|
|
1098
1647
|
|