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
|
@@ -23,7 +23,99 @@ module Woods
|
|
|
23
23
|
# validator.validate!('DELETE FROM users') # raises SqlValidationError
|
|
24
24
|
# validator.valid?('SELECT 1') # => true
|
|
25
25
|
#
|
|
26
|
-
|
|
26
|
+
# Metrics/ClassLength is disabled here for the same reason as tool_specs.rb:
|
|
27
|
+
# most of the length is declarative allowlist/denylist data (documented
|
|
28
|
+
# named constants), not imperative logic.
|
|
29
|
+
class SqlValidator # rubocop:disable Metrics/ClassLength
|
|
30
|
+
# SQL dialects whose normalization the lock-clause check can run over.
|
|
31
|
+
KNOWN_DIALECTS = %i[postgres mysql].freeze
|
|
32
|
+
|
|
33
|
+
# The dialect the statement will execute under, when the caller knows
|
|
34
|
+
# it. `nil` (the default) keeps the conservative union: every check
|
|
35
|
+
# runs against both dialect normalizations, which can reject a
|
|
36
|
+
# statement that is valid under one dialect's quote grammar (a MySQL
|
|
37
|
+
# `\'` escape hides prose that the PostgreSQL view reads as SQL). When
|
|
38
|
+
# the execution boundary knows the adapter, passing the matching
|
|
39
|
+
# dialect validates the statement the way the server will actually
|
|
40
|
+
# parse it. Every known bypass form stays rejected under every
|
|
41
|
+
# dialect: the executable-comment views in {#lock_clause_views} cover
|
|
42
|
+
# `#` comment splits and `/*!...*/` splits under both semantics.
|
|
43
|
+
#
|
|
44
|
+
# @return [Symbol, nil]
|
|
45
|
+
attr_reader :dialect
|
|
46
|
+
|
|
47
|
+
# mysql_modes contains the executing session's ANSI_QUOTES and
|
|
48
|
+
# NO_BACKSLASH_ESCAPES flags. nil conservatively validates all combinations.
|
|
49
|
+
def initialize(dialect: nil, mysql_modes: nil)
|
|
50
|
+
unless dialect.nil? || KNOWN_DIALECTS.include?(dialect)
|
|
51
|
+
raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{KNOWN_DIALECTS.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@dialect = dialect
|
|
55
|
+
@mysql_modes = mysql_modes
|
|
56
|
+
end
|
|
57
|
+
# Row-lock clauses that would take live row locks even under the
|
|
58
|
+
# rolled-back SafeContext transaction (M5).
|
|
59
|
+
#
|
|
60
|
+
# `SELECT ... FOR UPDATE` and friends hold row locks for the duration
|
|
61
|
+
# of the wrapping transaction — on both PostgreSQL (`FOR UPDATE`,
|
|
62
|
+
# `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, with optional
|
|
63
|
+
# `NOWAIT`/`SKIP LOCKED`) and MySQL (`FOR UPDATE` with the same
|
|
64
|
+
# modifiers, plus `LOCK IN SHARE MODE`). While SafeContext rolls the
|
|
65
|
+
# transaction back, the locks themselves are held live against other
|
|
66
|
+
# writers for the whole request — a denial-of-service lever, not a
|
|
67
|
+
# read. A dedicated check (rather than growing BODY_FORBIDDEN_KEYWORDS)
|
|
68
|
+
# because these are multi-word trailing clauses: the body-keyword scan
|
|
69
|
+
# is single-token and leader-anchored, neither of which can express
|
|
70
|
+
# "FOR immediately followed by an update/share keyword".
|
|
71
|
+
#
|
|
72
|
+
# Scanned against the noise-stripped SQL (comments and string literals
|
|
73
|
+
# removed), so prose like `WHERE note = 'waiting for update'` cannot
|
|
74
|
+
# fire it. Over-detection on a pathologically quoted identifier such
|
|
75
|
+
# as `"for update"` is accepted: conservative rejection is the
|
|
76
|
+
# validator's documented posture.
|
|
77
|
+
LOCK_CLAUSE_PATTERN = /
|
|
78
|
+
\bFOR\s+(?:NO\s+KEY\s+UPDATE|UPDATE|KEY\s+SHARE|SHARE)\b
|
|
79
|
+
|\bLOCK\s+IN\s+SHARE\s+MODE\b
|
|
80
|
+
/xi
|
|
81
|
+
|
|
82
|
+
# Matches an `AS (` opening whose balanced body {#check_writable_ctes!}
|
|
83
|
+
# inspects. Kept loose on purpose: deciding which `AS (` shapes are
|
|
84
|
+
# CTEs needs a real parser, and every non-CTE match (a WINDOW
|
|
85
|
+
# definition, a nested WITH) is only rejected when its body starts
|
|
86
|
+
# with DML, which is never valid in an allowed statement.
|
|
87
|
+
AS_BODY_PATTERN = /\bAS\s*\(/i
|
|
88
|
+
|
|
89
|
+
# A CTE (or any `AS (...)`) body that opens with a DML keyword —
|
|
90
|
+
# the writable-CTE shape, in any WITH position. A single leading
|
|
91
|
+
# `(` is tolerated: PostgreSQL rejects `WITH a AS ((DELETE ...))`
|
|
92
|
+
# outright, so this is over-detection on a syntax that never runs,
|
|
93
|
+
# not under-detection.
|
|
94
|
+
WRITABLE_CTE_BODY_PATTERN = /\A\s*\(?\s*(?:DELETE|UPDATE|INSERT)\b/i
|
|
95
|
+
|
|
96
|
+
# Matches a CTE list attached to a top-level data-modifying statement:
|
|
97
|
+
# `WITH a AS (SELECT 1) DELETE FROM users RETURNING *`. Legal grammar
|
|
98
|
+
# on PostgreSQL; the statement prefix is `WITH`, so the allowed-prefix
|
|
99
|
+
# check passes it, and DELETE/UPDATE have no body keyword for the
|
|
100
|
+
# body scans to catch (INSERT only tripped INTO, MERGE nothing).
|
|
101
|
+
#
|
|
102
|
+
# The lazy `[\s\S]*?` reaches the first closing paren that is
|
|
103
|
+
# immediately followed by a DML keyword. In an allowed statement
|
|
104
|
+
# that position is grammatically impossible: after a CTE list's
|
|
105
|
+
# final `)` only SELECT (or another WITH) is valid, and a DML word
|
|
106
|
+
# anywhere else is separated from the paren by other tokens or sits
|
|
107
|
+
# inside a string literal, which {SqlNoiseStripper.strip_noise}
|
|
108
|
+
# removed. Column names like `updated_at`/`deleted_at` cannot match:
|
|
109
|
+
# `\b` requires the keyword to start right after the paren, and a
|
|
110
|
+
# bare `update` column follows a comma or SELECT, not a paren.
|
|
111
|
+
WITH_ATTACHED_DML_PATTERN = /\bWITH\b[\s\S]*?\)\s*(?:DELETE|UPDATE|INSERT|MERGE)\b/i
|
|
112
|
+
|
|
113
|
+
# Matches a MySQL executable comment (`/*!...*/` or the version-guarded
|
|
114
|
+
# `/*!NNNNN...*/`), capturing the body. Only scanned by
|
|
115
|
+
# {#lock_clause_views} — {SqlNoiseStripper} deliberately leaves these
|
|
116
|
+
# markers in place because their meaning is version-dependent.
|
|
117
|
+
EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
|
|
118
|
+
|
|
27
119
|
# Forbidden statement prefixes (case-insensitive).
|
|
28
120
|
#
|
|
29
121
|
# Expanded beyond DML/DDL to cover:
|
|
@@ -40,8 +132,12 @@ module Woods
|
|
|
40
132
|
# `RELEASE`, `START`) — SafeContext already owns the surrounding
|
|
41
133
|
# transaction; inner tx control would corrupt it.
|
|
42
134
|
# - File I/O vectors (`LOAD`, `HANDLER`, `COPY`).
|
|
135
|
+
# - Write vector (`MERGE`) — an upsert statement that can INSERT/UPDATE/
|
|
136
|
+
# DELETE depending on match, on databases that support it. Already
|
|
137
|
+
# caught by the allowed-prefix check (it isn't SELECT/WITH/EXPLAIN),
|
|
138
|
+
# but listed here too for defense in depth against a body-scan bypass.
|
|
43
139
|
FORBIDDEN_KEYWORDS = %w[
|
|
44
|
-
INSERT UPDATE DELETE DROP ALTER TRUNCATE CREATE GRANT REVOKE
|
|
140
|
+
INSERT UPDATE DELETE MERGE DROP ALTER TRUNCATE CREATE GRANT REVOKE
|
|
45
141
|
DO CALL SET RESET LISTEN NOTIFY
|
|
46
142
|
VACUUM ANALYZE CLUSTER REINDEX REFRESH LOCK
|
|
47
143
|
PREPARE EXECUTE DEALLOCATE
|
|
@@ -64,18 +160,83 @@ module Woods
|
|
|
64
160
|
load_file sleep benchmark
|
|
65
161
|
].freeze
|
|
66
162
|
|
|
163
|
+
# Enforceable read-only function policy for `console_sql`.
|
|
164
|
+
#
|
|
165
|
+
# The previous control was a denylist (DANGEROUS_FUNCTIONS above), which
|
|
166
|
+
# is provably incomplete: PostgreSQL ships side-effecting functions that
|
|
167
|
+
# are technically legal inside a SELECT list, e.g. `pg_terminate_backend`
|
|
168
|
+
# (kills another backend), `pg_advisory_lock`/`pg_advisory_unlock`
|
|
169
|
+
# (session-held locks that outlive the rolled-back transaction),
|
|
170
|
+
# `nextval`/`setval` (permanently mutate a sequence, DDL rollback does
|
|
171
|
+
# not undo it). Enumerating every such function is a losing race.
|
|
172
|
+
#
|
|
173
|
+
# Instead, `console_sql` allowlists a conservative set of pure/read
|
|
174
|
+
# functions and rejects everything else with a typed error naming the
|
|
175
|
+
# function. This is the authoritative, documented policy: extend it
|
|
176
|
+
# deliberately, not by discovering a false positive and reaching for
|
|
177
|
+
# DANGEROUS_FUNCTIONS instead.
|
|
178
|
+
# Read-only functions supported across all three backends Woods claims
|
|
179
|
+
# (MySQL, PostgreSQL, SQLite). Aggregates, window functions, and pure
|
|
180
|
+
# scalar string/number/date/JSON readers only — nothing that mutates
|
|
181
|
+
# state, sleeps, or reaches outside the query. Kept backend-agnostic on
|
|
182
|
+
# purpose: a PG-only list rejected ordinary MySQL/SQLite analytics.
|
|
183
|
+
ALLOWED_FUNCTIONS = %w[
|
|
184
|
+
count sum avg min max coalesce nullif
|
|
185
|
+
group_concat string_agg array_agg
|
|
186
|
+
row_number rank dense_rank percent_rank cume_dist ntile
|
|
187
|
+
lag lead first_value last_value nth_value
|
|
188
|
+
lower upper length char_length character_length octet_length
|
|
189
|
+
substr substring trim ltrim rtrim btrim concat concat_ws
|
|
190
|
+
left right replace reverse repeat lpad rpad
|
|
191
|
+
position strpos split_part
|
|
192
|
+
abs round ceil ceiling floor mod power pow sqrt sign greatest least
|
|
193
|
+
cast convert
|
|
194
|
+
extract date_part date_trunc to_char to_date to_timestamp
|
|
195
|
+
strftime datetime date time julianday unixepoch
|
|
196
|
+
now current_date current_time current_timestamp
|
|
197
|
+
json_extract json_value json_type json_array_length
|
|
198
|
+
json_extract_path json_extract_path_text
|
|
199
|
+
jsonb_extract_path jsonb_extract_path_text
|
|
200
|
+
].freeze
|
|
201
|
+
|
|
202
|
+
# SQL keywords that are legitimately followed by `(` but are not
|
|
203
|
+
# function calls: a subquery predicate (`IN (SELECT ...)`), a
|
|
204
|
+
# parenthesized boolean group (`WHERE (a AND b)`), etc. Excluded from
|
|
205
|
+
# the function-allowlist scan so they are not misclassified as unknown
|
|
206
|
+
# function calls.
|
|
207
|
+
FUNCTION_SCAN_EXCLUDED_KEYWORDS = %w[
|
|
208
|
+
IN EXISTS NOT AND OR VALUES WHERE HAVING ON IS BETWEEN CASE WHEN
|
|
209
|
+
THEN ELSE END FROM JOIN USING WITH SELECT DISTINCT ALL ANY SOME
|
|
210
|
+
UNION INTERSECT EXCEPT ORDER GROUP BY ASC DESC LIMIT OFFSET AS INTO
|
|
211
|
+
OVER PARTITION FILTER WITHIN RETURNING EXPLAIN
|
|
212
|
+
].freeze
|
|
213
|
+
|
|
214
|
+
# EXPLAIN is a statement leader, so `EXPLAIN (FORMAT JSON) SELECT` is an
|
|
215
|
+
# option list, not a call; ALLOWED_PREFIXES already refuses ANALYZE inside
|
|
216
|
+
# that list (B-125).
|
|
217
|
+
# Matches a function-call shape: an identifier immediately followed by
|
|
218
|
+
# `(`, where the identifier may be bare, double-quoted (ANSI/PostgreSQL),
|
|
219
|
+
# or backtick-quoted (MySQL). Quoting the name was the bypass — a bare
|
|
220
|
+
# `\bword\(` pattern never fired on `"pg_terminate_backend"(`, so a
|
|
221
|
+
# side-effecting function wrapped in quotes defeated both the allowlist
|
|
222
|
+
# and the legacy denylist. Runs against the noise-stripped SQL (comments
|
|
223
|
+
# and string literals removed) so literal content can't be misread.
|
|
224
|
+
FUNCTION_CALL_PATTERN = /(?:"([^"]+)"|`([^`]+)`|\b([A-Za-z_][A-Za-z0-9_]*))\s*\(/
|
|
225
|
+
|
|
67
226
|
# Allowed statement prefixes (case-insensitive).
|
|
68
227
|
#
|
|
69
228
|
# `EXPLAIN ANALYZE` actually executes the planned query on PostgreSQL
|
|
70
229
|
# (and the MySQL 8.0+ `EXPLAIN ANALYZE` does the same) — explicitly
|
|
71
|
-
# reject the `ANALYZE` variant
|
|
230
|
+
# reject the `ANALYZE` variant, and PostgreSQL's `ANALYSE` spelling
|
|
231
|
+
# alias for it (both are accepted by the server; rejecting only one
|
|
232
|
+
# left the other as a bypass). PostgreSQL also accepts an option-list
|
|
72
233
|
# form `EXPLAIN (ANALYZE, FORMAT JSON) SELECT …` where `ANALYZE` follows
|
|
73
|
-
# `(` rather than whitespace; the `(?!\s*\(?\s*
|
|
74
|
-
# rejects both spellings so SafeContext doesn't
|
|
75
|
-
# "we're just planning, not running" for what is a
|
|
76
|
-
# execution. `EXPLAIN (…)` without `ANALYZE`
|
|
77
|
-
# (e.g. `EXPLAIN (FORMAT JSON) SELECT 1`).
|
|
78
|
-
ALLOWED_PREFIXES = /\A\s*(SELECT|WITH|EXPLAIN(?!\s+
|
|
234
|
+
# `(` rather than whitespace; the `(?!\s*\(?\s*ANALY[SZ]E)` lookahead
|
|
235
|
+
# rejects both spellings in both positions so SafeContext doesn't
|
|
236
|
+
# silently trust "we're just planning, not running" for what is a
|
|
237
|
+
# side-effectful execution. `EXPLAIN (…)` without `ANALYZE`/`ANALYSE`
|
|
238
|
+
# is still permitted (e.g. `EXPLAIN (FORMAT JSON) SELECT 1`).
|
|
239
|
+
ALLOWED_PREFIXES = /\A\s*(SELECT|WITH|EXPLAIN(?!\s+ANALY[SZ]E)(?!\s*\([^)]*\bANALY[SZ]E\b))\b/i
|
|
79
240
|
|
|
80
241
|
# Frozen map of forbidden keyword => regex matching the keyword at statement start.
|
|
81
242
|
# Used by {#check_forbidden_keywords!} and {#check_forbidden_keywords_in_body!}.
|
|
@@ -89,9 +250,59 @@ module Woods
|
|
|
89
250
|
[kw, /\b#{kw}\b/i]
|
|
90
251
|
end.freeze
|
|
91
252
|
|
|
92
|
-
# Frozen map of forbidden keyword => regex matching the keyword
|
|
93
|
-
#
|
|
253
|
+
# Frozen map of forbidden keyword => regex matching the keyword as a
|
|
254
|
+
# statement leader: at the very start of the SQL, or immediately after
|
|
255
|
+
# a `;` or a newline. Used by {#check_forbidden_keywords_in_body!}.
|
|
256
|
+
#
|
|
257
|
+
# A live `;` never reaches this check unescaped — #validate! rejects
|
|
258
|
+
# multiple statements earlier — so a `;` match here only ever comes
|
|
259
|
+
# from inside a comment that {SqlNoiseStripper} stripped. The same is
|
|
260
|
+
# true of the newline: every comment flavor (`--`, `#`, `/* ... */`)
|
|
261
|
+
# leaves one behind in place of its own content specifically so a
|
|
262
|
+
# comment-hidden statement (`SELECT 1 --;\nDELETE FROM users`,
|
|
263
|
+
# `SELECT 1 /*;*/ DELETE FROM users`) still reads as following a
|
|
264
|
+
# boundary once the comment is gone.
|
|
265
|
+
#
|
|
266
|
+
# Matching anywhere (the previous behavior) rejected a column
|
|
267
|
+
# legitimately named after a forbidden keyword — `WHERE do = 1`,
|
|
268
|
+
# `release`, `lock`, `handler` are all plausible column names, none of
|
|
269
|
+
# them a statement. Anchoring to leader positions is what tells the
|
|
270
|
+
# two apart.
|
|
94
271
|
FORBIDDEN_BODY_REGEXES = FORBIDDEN_KEYWORDS.to_h do |kw|
|
|
272
|
+
[kw, /(?:\A|[;\n])\s*#{kw}\b/i]
|
|
273
|
+
end.freeze
|
|
274
|
+
|
|
275
|
+
# DML keywords that are additionally forbidden as bare tokens ANYWHERE
|
|
276
|
+
# in the statement body, not just at statement-leader positions.
|
|
277
|
+
#
|
|
278
|
+
# {FORBIDDEN_BODY_REGEXES} stays leader-anchored for the full
|
|
279
|
+
# {FORBIDDEN_KEYWORDS} set because words like `do`, `lock`, `release`,
|
|
280
|
+
# `handler` are plausible bare column names (`WHERE do = 1` is a read,
|
|
281
|
+
# not a write). INSERT, UPDATE, and DELETE cannot hide behind that
|
|
282
|
+
# argument: they are reserved words on every supported backend, so a
|
|
283
|
+
# bare occurrence inside an allowed statement is never an identifier —
|
|
284
|
+
# it is a mid-body write (e.g. `SELECT 1 UPDATE posts SET status = 10`)
|
|
285
|
+
# that used to pass validation and fail as an adapter-level syntax
|
|
286
|
+
# error instead of a typed refusal at this boundary.
|
|
287
|
+
#
|
|
288
|
+
# MERGE is deliberately NOT in this set: SQLite permits an unquoted
|
|
289
|
+
# `merge` column, so body-level MERGE scanning would false-positive on
|
|
290
|
+
# ordinary selects like `SELECT merge FROM posts`. MERGE statements
|
|
291
|
+
# stay covered where they are actually statements: the allowed-prefix
|
|
292
|
+
# rule (MERGE is in {FORBIDDEN_KEYWORDS}, checked at statement start)
|
|
293
|
+
# and {WITH_ATTACHED_DML_PATTERN} for the WITH-attached shape.
|
|
294
|
+
#
|
|
295
|
+
# Scanned against the noise-stripped SQL ({SqlNoiseStripper}), so
|
|
296
|
+
# literal content never triggers (`SELECT 'update' AS word` is a
|
|
297
|
+
# value, not a write), and `\b` keeps identifier-shaped column names
|
|
298
|
+
# (`updated_at`, `last_update`) accepted. Multi-word lock clauses
|
|
299
|
+
# (`FOR UPDATE`) keep their dedicated earlier check, so its message
|
|
300
|
+
# still wins over this one.
|
|
301
|
+
DML_BODY_KEYWORDS = %w[INSERT UPDATE DELETE].freeze
|
|
302
|
+
|
|
303
|
+
# Frozen map of DML body keyword => regex matching the keyword as a
|
|
304
|
+
# bare token anywhere. Used by {#check_forbidden_keywords_in_body!}.
|
|
305
|
+
DML_BODY_REGEXES = DML_BODY_KEYWORDS.to_h do |kw|
|
|
95
306
|
[kw, /\b#{kw}\b/i]
|
|
96
307
|
end.freeze
|
|
97
308
|
|
|
@@ -105,6 +316,8 @@ module Woods
|
|
|
105
316
|
def validate!(sql)
|
|
106
317
|
raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
|
|
107
318
|
|
|
319
|
+
return validate_dialect_variants!(sql) if unknown_grammar?
|
|
320
|
+
|
|
108
321
|
normalized = sql.strip
|
|
109
322
|
|
|
110
323
|
# Reject multiple statements (semicolons not inside string literals)
|
|
@@ -118,6 +331,12 @@ module Woods
|
|
|
118
331
|
# Check for writable CTEs (before body keywords to give better error messages)
|
|
119
332
|
check_writable_ctes!(normalized)
|
|
120
333
|
|
|
334
|
+
# Check for a CTE list attached to top-level DML
|
|
335
|
+
check_with_attached_dml!(normalized)
|
|
336
|
+
|
|
337
|
+
# Check for row-lock clauses (FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE)
|
|
338
|
+
check_lock_clauses!(normalized)
|
|
339
|
+
|
|
121
340
|
# Check for forbidden keywords anywhere in the SQL body
|
|
122
341
|
check_body_forbidden_keywords!(normalized)
|
|
123
342
|
|
|
@@ -127,6 +346,11 @@ module Woods
|
|
|
127
346
|
# After stripping comments, check again for forbidden keywords that might have been hidden
|
|
128
347
|
check_forbidden_keywords_in_body!(normalized)
|
|
129
348
|
|
|
349
|
+
# Enforce the read-only function allowlist (replaces relying solely
|
|
350
|
+
# on DANGEROUS_FUNCTIONS, which cannot enumerate every side-effecting
|
|
351
|
+
# function).
|
|
352
|
+
check_function_allowlist!(normalized)
|
|
353
|
+
|
|
130
354
|
# Must start with an allowed prefix
|
|
131
355
|
return if normalized.match?(ALLOWED_PREFIXES)
|
|
132
356
|
|
|
@@ -146,13 +370,36 @@ module Woods
|
|
|
146
370
|
|
|
147
371
|
private
|
|
148
372
|
|
|
373
|
+
def unknown_grammar?
|
|
374
|
+
dialect.nil? || (dialect == :mysql && @mysql_modes.nil?)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def validate_dialect_variants!(sql)
|
|
378
|
+
if dialect.nil?
|
|
379
|
+
KNOWN_DIALECTS.each { |name| self.class.new(dialect: name).validate!(sql) }
|
|
380
|
+
else
|
|
381
|
+
SqlNoiseStripper::MYSQL_QUOTE_MODES.each do |mode|
|
|
382
|
+
self.class.new(dialect: :mysql, mysql_modes: mode).validate!(sql)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def strip_validation_noise(sql)
|
|
389
|
+
SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {}))
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def validation_dialect
|
|
393
|
+
dialect || :postgres
|
|
394
|
+
end
|
|
395
|
+
|
|
149
396
|
# Check if the SQL contains multiple statements separated by semicolons.
|
|
150
397
|
# Strips SQL comments and string literals before checking.
|
|
151
398
|
#
|
|
152
399
|
# @param sql [String]
|
|
153
400
|
# @return [Boolean]
|
|
154
401
|
def contains_multiple_statements?(sql)
|
|
155
|
-
stripped =
|
|
402
|
+
stripped = strip_validation_noise(sql)
|
|
156
403
|
stripped.include?(';')
|
|
157
404
|
end
|
|
158
405
|
|
|
@@ -168,54 +415,237 @@ module Woods
|
|
|
168
415
|
|
|
169
416
|
# Check if the SQL contains forbidden keywords anywhere in the body.
|
|
170
417
|
#
|
|
418
|
+
# Scans the noise-stripped SQL (comments and string literals removed),
|
|
419
|
+
# not the raw input. A raw scan misreads English words that happen to
|
|
420
|
+
# match a keyword inside a data literal — `WHERE body = 'please update
|
|
421
|
+
# the record'` contains the substring UPDATE but is not a write.
|
|
422
|
+
#
|
|
171
423
|
# @param sql [String]
|
|
172
424
|
# @raise [SqlValidationError] if a forbidden keyword is found
|
|
173
425
|
def check_body_forbidden_keywords!(sql)
|
|
426
|
+
stripped = strip_validation_noise(sql)
|
|
427
|
+
|
|
174
428
|
BODY_FORBIDDEN_REGEXES.each do |keyword, pattern|
|
|
175
|
-
raise SqlValidationError, "Rejected: #{keyword} is not allowed" if
|
|
429
|
+
raise SqlValidationError, "Rejected: #{keyword} is not allowed" if stripped.match?(pattern)
|
|
176
430
|
end
|
|
177
431
|
end
|
|
178
432
|
|
|
179
433
|
# Check if the SQL contains writable CTEs (WITH...DELETE/UPDATE/INSERT).
|
|
180
434
|
#
|
|
435
|
+
# Walks EVERY `AS (...)` body in the statement, not just the first one.
|
|
436
|
+
# The previous implementation anchored a single regex to the WITH
|
|
437
|
+
# leader, so a writable CTE in second-or-later position validated
|
|
438
|
+
# cleanly and PostgreSQL executed it:
|
|
439
|
+
# `WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`.
|
|
440
|
+
# There is no full SQL parser here, so the walker matches every
|
|
441
|
+
# `AS (` shape and extracts the balanced paren body after it. In an
|
|
442
|
+
# allowed statement the extra matches are harmless: a WINDOW definition
|
|
443
|
+
# (`w AS (PARTITION BY x)`) or a CTE nested inside another CTE body is
|
|
444
|
+
# only rejected when its body *starts* with a DML keyword, which is
|
|
445
|
+
# never legitimate grammar. Scans noise-stripped input for the same
|
|
446
|
+
# reason as {#check_body_forbidden_keywords!}.
|
|
447
|
+
#
|
|
181
448
|
# @param sql [String]
|
|
182
|
-
# @raise [SqlValidationError] if a writable CTE is found
|
|
449
|
+
# @raise [SqlValidationError] if a writable CTE body is found
|
|
183
450
|
def check_writable_ctes!(sql)
|
|
184
|
-
|
|
451
|
+
stripped = strip_validation_noise(sql)
|
|
452
|
+
each_as_body(stripped) do |body|
|
|
453
|
+
next unless body.match?(WRITABLE_CTE_BODY_PATTERN)
|
|
185
454
|
|
|
186
|
-
|
|
455
|
+
raise SqlValidationError, 'Rejected: writable CTEs are not allowed'
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Check if the SQL carries a row-lock clause ({LOCK_CLAUSE_PATTERN})
|
|
460
|
+
# in any dialect-normalized view of the statement.
|
|
461
|
+
#
|
|
462
|
+
# {#lock_clause_views} produces, for each SQL dialect, the
|
|
463
|
+
# noise-stripped text with every MySQL executable comment
|
|
464
|
+
# (`/*!...*/`) interpreted under BOTH of its possible semantics:
|
|
465
|
+
# replaced by whitespace (version guard unsatisfied — MySQL treats
|
|
466
|
+
# the comment as a plain comment and `LOCK /*!99999 */ IN SHARE MODE`
|
|
467
|
+
# runs as a live lock clause), and replaced by its body (guard
|
|
468
|
+
# satisfied — the body executes in place, so `LOCK /*! IN SHARE */
|
|
469
|
+
# MODE` also runs as one). Running the pattern over every view and
|
|
470
|
+
# rejecting on any match means a lock clause cannot be split or
|
|
471
|
+
# hidden by a comment form without at least one view seeing it,
|
|
472
|
+
# while no comment body is ever hidden from the check.
|
|
473
|
+
#
|
|
474
|
+
# Scans the noise-stripped SQL; see {LOCK_CLAUSE_PATTERN} for why this
|
|
475
|
+
# is a dedicated check instead of a body-keyword entry.
|
|
476
|
+
#
|
|
477
|
+
# @param sql [String]
|
|
478
|
+
# @raise [SqlValidationError] if a lock clause is found
|
|
479
|
+
def check_lock_clauses!(sql)
|
|
480
|
+
return unless lock_clause_views(sql).any? { |view| view.match?(LOCK_CLAUSE_PATTERN) }
|
|
481
|
+
|
|
482
|
+
raise SqlValidationError,
|
|
483
|
+
'Rejected: row-lock clauses (FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE) are not allowed'
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Every dialect-normalized view of the SQL the lock-clause check must
|
|
487
|
+
# consider. With {#dialect} known, only that dialect's view is
|
|
488
|
+
# produced (the server will parse the statement one way); without it,
|
|
489
|
+
# both views run as a conservative union. Each view is scanned under
|
|
490
|
+
# both executable-comment semantics; see {#check_lock_clauses!}.
|
|
491
|
+
#
|
|
492
|
+
# @param sql [String]
|
|
493
|
+
# @return [Array<String>]
|
|
494
|
+
def lock_clause_views(sql)
|
|
495
|
+
dialects = dialect ? [dialect] : KNOWN_DIALECTS
|
|
496
|
+
dialects.flat_map do |dialect_name|
|
|
497
|
+
stripped = SqlNoiseStripper.strip_noise(sql, dialect: dialect_name, **(@mysql_modes || {}))
|
|
498
|
+
[stripped.gsub(EXECUTABLE_COMMENT_PATTERN, ' '),
|
|
499
|
+
stripped.gsub(EXECUTABLE_COMMENT_PATTERN) { Regexp.last_match[1] }]
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Check if a CTE list is attached to a top-level data-modifying
|
|
504
|
+
# statement ({WITH_ATTACHED_DML_PATTERN}).
|
|
505
|
+
#
|
|
506
|
+
# PostgreSQL allows `WITH a AS (SELECT 1) DELETE FROM users RETURNING *`;
|
|
507
|
+
# the statement starts with WITH so it passes the allowed-prefix check,
|
|
508
|
+
# and only INSERT ever tripped a body keyword (INTO), incidentally.
|
|
509
|
+
# Runs before {#check_body_forbidden_keywords!} so the WITH-attached
|
|
510
|
+
# shapes get this specific message rather than the generic INTO one.
|
|
511
|
+
#
|
|
512
|
+
# @param sql [String]
|
|
513
|
+
# @raise [SqlValidationError] if DML follows the CTE list
|
|
514
|
+
def check_with_attached_dml!(sql)
|
|
515
|
+
stripped = strip_validation_noise(sql)
|
|
516
|
+
return unless stripped.match?(WITH_ATTACHED_DML_PATTERN)
|
|
517
|
+
|
|
518
|
+
raise SqlValidationError,
|
|
519
|
+
'Rejected: a WITH clause cannot be attached to a data-modifying ' \
|
|
520
|
+
'statement (DELETE/UPDATE/INSERT/MERGE)'
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Yields the balanced paren body following every `AS (` occurrence.
|
|
524
|
+
#
|
|
525
|
+
# Continues scanning from just inside each match so bodies nested
|
|
526
|
+
# inside an already-yielded body (a WITH inside a CTE) are yielded
|
|
527
|
+
# too. String literals are already stripped, so no paren inside a
|
|
528
|
+
# literal can skew the balance count.
|
|
529
|
+
#
|
|
530
|
+
# @param stripped [String] noise-stripped SQL
|
|
531
|
+
# @yieldparam body [String] text between the parens
|
|
532
|
+
def each_as_body(stripped, &block)
|
|
533
|
+
pos = 0
|
|
534
|
+
while (match = stripped.match(AS_BODY_PATTERN, pos))
|
|
535
|
+
yield balanced_paren_body(stripped, match.end(0) - 1)
|
|
536
|
+
pos = match.end(0)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Return the text between the `(` at +open_index+ and its matching
|
|
541
|
+
# `)`. An unterminated body returns the remainder of the string
|
|
542
|
+
# (over-detection only: the body still gets checked).
|
|
543
|
+
#
|
|
544
|
+
# @param text [String]
|
|
545
|
+
# @param open_index [Integer] index of the opening `(` in +text+
|
|
546
|
+
# @return [String]
|
|
547
|
+
def balanced_paren_body(text, open_index)
|
|
548
|
+
depth = 0
|
|
549
|
+
open_index.upto(text.length - 1) do |i|
|
|
550
|
+
depth += 1 if text[i] == '('
|
|
551
|
+
if text[i] == ')'
|
|
552
|
+
depth -= 1
|
|
553
|
+
return text[(open_index + 1)...i] if depth.zero?
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
text[(open_index + 1)..]
|
|
187
557
|
end
|
|
188
558
|
|
|
189
559
|
# Check if the SQL calls dangerous functions.
|
|
190
560
|
#
|
|
561
|
+
# Scans the noise-stripped SQL so a function name that only appears
|
|
562
|
+
# inside a comment or string literal isn't misread as a call.
|
|
563
|
+
#
|
|
191
564
|
# @param sql [String]
|
|
192
565
|
# @raise [SqlValidationError] if a dangerous function is found
|
|
193
566
|
def check_dangerous_functions!(sql)
|
|
567
|
+
stripped = strip_validation_noise(sql)
|
|
568
|
+
|
|
194
569
|
DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
|
|
195
|
-
raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if
|
|
570
|
+
raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if stripped.match?(pattern)
|
|
196
571
|
end
|
|
197
572
|
end
|
|
198
573
|
|
|
199
|
-
# Check
|
|
200
|
-
# This catches comment-hidden injections like "SELECT 1 --;\nDELETE FROM users".
|
|
574
|
+
# Check every function-call-shaped identifier against ALLOWED_FUNCTIONS.
|
|
201
575
|
#
|
|
202
576
|
# @param sql [String]
|
|
203
|
-
# @raise [SqlValidationError] if a
|
|
577
|
+
# @raise [SqlValidationError] if a non-allowlisted function is called
|
|
578
|
+
def check_function_allowlist!(sql)
|
|
579
|
+
stripped = strip_validation_noise(sql)
|
|
580
|
+
|
|
581
|
+
stripped.scan(FUNCTION_CALL_PATTERN) do
|
|
582
|
+
match = Regexp.last_match
|
|
583
|
+
quoted = !(match[1] || match[2]).nil?
|
|
584
|
+
identifier = match[1] || match[2] || match[3]
|
|
585
|
+
# A bare keyword before `(` is grammar (IN/EXISTS/…), not a call; a
|
|
586
|
+
# quoted name before `(` is always a call, so keyword exclusion
|
|
587
|
+
# applies only to the bare form.
|
|
588
|
+
next if !quoted && FUNCTION_SCAN_EXCLUDED_KEYWORDS.include?(identifier.upcase)
|
|
589
|
+
next if ALLOWED_FUNCTIONS.include?(identifier.downcase)
|
|
590
|
+
|
|
591
|
+
raise SqlValidationError,
|
|
592
|
+
"Rejected: function '#{identifier}' is not on the read-only function allowlist. " \
|
|
593
|
+
"Allowed: #{ALLOWED_FUNCTIONS.join(', ')}."
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Check if the SQL contains a forbidden keyword at a statement-leader
|
|
598
|
+
# position after stripping comments and string literals. This catches
|
|
599
|
+
# comment-hidden injections like "SELECT 1 --;\nDELETE FROM users",
|
|
600
|
+
# and — by also stripping literals, not just comments — avoids
|
|
601
|
+
# rejecting a legitimate value like `body = 'please update the
|
|
602
|
+
# record'`, where UPDATE is English prose inside data, not SQL.
|
|
603
|
+
# {FORBIDDEN_BODY_REGEXES} only matches leader positions (start of the
|
|
604
|
+
# SQL, or right after a `;`/newline), so a column legitimately named
|
|
605
|
+
# after a forbidden keyword (`WHERE do = 1`) is not misread as one.
|
|
606
|
+
#
|
|
607
|
+
# The DML keywords ({DML_BODY_REGEXES}) are then matched as bare tokens
|
|
608
|
+
# across every dialect-normalized view ({#dml_body_views}): they are
|
|
609
|
+
# reserved words, so they can never be bare identifiers the way `do`
|
|
610
|
+
# or `lock` can. Runs after {#check_lock_clauses!} in {#validate!}, so
|
|
611
|
+
# a lock-clause statement (`SELECT 1 FOR UPDATE`) keeps its dedicated
|
|
612
|
+
# message.
|
|
613
|
+
#
|
|
614
|
+
# @param sql [String]
|
|
615
|
+
# @raise [SqlValidationError] if a forbidden keyword is found as a
|
|
616
|
+
# statement leader, or a DML keyword anywhere in the body
|
|
204
617
|
def check_forbidden_keywords_in_body!(sql)
|
|
205
|
-
stripped =
|
|
618
|
+
stripped = strip_validation_noise(sql)
|
|
619
|
+
|
|
620
|
+
FORBIDDEN_BODY_REGEXES.each do |keyword, leader_pattern|
|
|
621
|
+
next unless stripped.match?(leader_pattern)
|
|
622
|
+
|
|
623
|
+
raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
|
|
624
|
+
end
|
|
206
625
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
next unless stripped.match?(body_pattern)
|
|
626
|
+
dml_body_views(sql).each do |view|
|
|
627
|
+
DML_BODY_REGEXES.each do |keyword, pattern|
|
|
628
|
+
next unless view.match?(pattern)
|
|
211
629
|
|
|
212
|
-
|
|
213
|
-
unless stripped.match?(FORBIDDEN_PREFIX_REGEXES[keyword])
|
|
214
|
-
raise SqlValidationError,
|
|
215
|
-
"Rejected: #{keyword} statements are not allowed (found in SQL body)"
|
|
630
|
+
raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
|
|
216
631
|
end
|
|
217
632
|
end
|
|
218
633
|
end
|
|
634
|
+
|
|
635
|
+
# Every dialect-normalized view of the SQL the DML body-token scan
|
|
636
|
+
# must consider, mirroring {#lock_clause_views}: with {#dialect} known,
|
|
637
|
+
# only that dialect's view (the server parses the statement one way, so
|
|
638
|
+
# a MySQL `\'` escape must hide prose from this scan); without it, both
|
|
639
|
+
# normalizations run as the conservative union. {SqlNoiseStripper}
|
|
640
|
+
# leaves MySQL executable comments (`/*!...*/`) visible in every view,
|
|
641
|
+
# so DML inside them stays detected.
|
|
642
|
+
#
|
|
643
|
+
# @param sql [String]
|
|
644
|
+
# @return [Array<String>]
|
|
645
|
+
def dml_body_views(sql)
|
|
646
|
+
dialects = dialect ? [dialect] : KNOWN_DIALECTS
|
|
647
|
+
dialects.map { |dialect_name| SqlNoiseStripper.strip_noise(sql, dialect: dialect_name, **(@mysql_modes || {})) }
|
|
648
|
+
end
|
|
219
649
|
end
|
|
220
650
|
end
|
|
221
651
|
end
|
|
@@ -32,10 +32,10 @@ module Woods
|
|
|
32
32
|
|
|
33
33
|
def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
|
|
34
34
|
|
|
35
|
-
def check_sql!(sql)
|
|
35
|
+
def check_sql!(sql, dialect: nil, mysql_modes: nil)
|
|
36
36
|
return unless active? && sql&.length&.positive?
|
|
37
37
|
|
|
38
|
-
SqlTableScanner.identifiers_in(sql).each do |raw|
|
|
38
|
+
SqlTableScanner.identifiers_in(sql, dialect: dialect, mysql_modes: mysql_modes).each do |raw|
|
|
39
39
|
raise TableGateError, reject_message(raw) if blocked?(raw)
|
|
40
40
|
end
|
|
41
41
|
end
|