woods 1.6.2 → 1.6.3
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 +11 -0
- data/CONTRIBUTING.md +14 -9
- data/README.md +1 -1
- data/lib/woods/console/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +34 -11
- data/lib/woods/console/sql_noise_stripper.rb +35 -7
- data/lib/woods/console/sql_table_scanner.rb +49 -9
- data/lib/woods/console/sql_validator.rb +13 -8
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/version.rb +1 -1
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b77f03a9d89a92c666a6ef9b8b20ed8583270fa0cfbb923acea9d6fbc973f9d2
|
|
4
|
+
data.tar.gz: 8336dccba436603daadaccd125888bc40702f16c4f514002bff298c9d8905f9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff12092063cd2a853c293812705b64f38c83da9a682d89bdcf243665ab810b5d5368e2d086a3a2949d885d677845637a3773b58c4322b62e56b83585f6f7e208
|
|
7
|
+
data.tar.gz: '0329bd31a7ddd6b0d05dedb46b4942a09d5534faf69668e77d45b1b9a8d37f1c3b860cc222446ddef7e64717e716d7388ef1217f3efe80578e481b875a7edba6'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.3] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
### Build
|
|
13
|
+
|
|
14
|
+
- Add the explicit, no-publish preparation cycle for the 1.6.3 security maintenance release; keep version writes task-owned and publication dependent on a reviewed trusted-main SHA pin.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- Apply blocked-table policy to resolved model relations and SQLite table syntax before reads, retain table boundaries across comments, and reject quoted spellings of existing denied SQL functions.
|
|
19
|
+
- Scan the serialized Console response after protected-field redaction so Symbol values and custom JSON output cannot bypass credential scanning.
|
|
20
|
+
|
|
10
21
|
## [1.6.2] - 2026-09-18
|
|
11
22
|
|
|
12
23
|
### Fixed
|
data/CONTRIBUTING.md
CHANGED
|
@@ -128,29 +128,34 @@ By contributing, you agree that your contributions will be licensed under the MI
|
|
|
128
128
|
|
|
129
129
|
## Maintenance release
|
|
130
130
|
|
|
131
|
-
This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.
|
|
131
|
+
This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.3
|
|
132
132
|
security patch while `main` carries the newer 2.0 prerelease line. It does not
|
|
133
133
|
establish a permanent stable branch or authorize another maintenance version.
|
|
134
|
-
The approved target is `release/1.6.
|
|
134
|
+
The approved target is `release/1.6.3`, based on immutable `v1.6.2`. Create that
|
|
135
135
|
remote target only after the trusted main maintenance policy is reviewed.
|
|
136
136
|
|
|
137
137
|
From a clean checkout, the only supported transitions are:
|
|
138
138
|
|
|
139
139
|
```sh
|
|
140
|
-
bin/rake "release:reopen[1.6.
|
|
140
|
+
bin/rake "release:reopen[1.6.3.alpha]"
|
|
141
141
|
# Review and commit the generated development-state diff.
|
|
142
|
-
bin/rake "release:prepare[1.6.
|
|
142
|
+
bin/rake "release:prepare[1.6.3]"
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
The tasks never commit, tag, push, dispatch or publish. Never edit VERSION or the
|
|
146
|
-
README maintenance `release-state` banner manually. The
|
|
147
|
-
banner;
|
|
148
|
-
`changelog/<type>_<slug>.md` entries into a dated
|
|
146
|
+
README maintenance `release-state` banner manually. The existing maintenance
|
|
147
|
+
banner must be present; both tasks update it. Prepare also folds classified
|
|
148
|
+
Unreleased notes and optional `changelog/<type>_<slug>.md` entries into a dated
|
|
149
|
+
release heading. Entry files are
|
|
149
150
|
nonempty Markdown without headings; supported types include `fixed`, `security`,
|
|
150
151
|
`build`, and `documentation`. Invalid transitions, dirty trees, malformed notes,
|
|
151
152
|
and missing/duplicate/unknown fences refuse before writes. This legacy profile
|
|
152
153
|
has no v2 migration guide or v2 surface inventory requirement.
|
|
153
154
|
|
|
155
|
+
CI also runs on pushes to the exact `release/1.6.3` branch, so a private-advisory
|
|
156
|
+
merge receives upstream validation before the trusted-main SHA pin. Publication
|
|
157
|
+
still requires a separate green tag-push run.
|
|
158
|
+
|
|
154
159
|
Validate the full suite, lint, booted extraction, real Console credential rotation,
|
|
155
160
|
and installed maintenance package tests. CI builds one gem plus its SHA-256
|
|
156
161
|
sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
|
|
@@ -165,12 +170,12 @@ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
|
|
|
165
170
|
--options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
|
|
166
171
|
```
|
|
167
172
|
|
|
168
|
-
Preparation is not publication; check RubyGems before describing 1.6.
|
|
173
|
+
Preparation is not publication; check RubyGems before describing 1.6.3 as released.
|
|
169
174
|
The legacy automatic tag publisher is disabled, and Bundler's `release`,
|
|
170
175
|
`release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
|
|
171
176
|
maintainer may later tag the reviewed merge commit and dispatch the trusted
|
|
172
177
|
**main** workflow. That workflow must explicitly allow the exact tag, protected
|
|
173
|
-
maintenance branch, immutable 1.6.
|
|
178
|
+
maintenance branch, immutable 1.6.2 base, reviewed final candidate SHA, required
|
|
174
179
|
CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
|
|
175
180
|
exact maintenance merge SHA; only then may the maintainer tag it. The
|
|
176
181
|
workflow publishes the already tested gem bytes; never rebuild or publish locally.
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Woods
|
|
6
6
|
|
|
7
7
|
<!-- release-state:maintenance-banner -->
|
|
8
|
-
> **Woods 1.6.
|
|
8
|
+
> **Woods 1.6.3: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
|
|
9
9
|
> Preparation does not establish publication. Check [RubyGems versions](https://rubygems.org/gems/woods/versions)
|
|
10
10
|
> before selecting an install version. See [maintenance release policy](CONTRIBUTING.md#maintenance-release).
|
|
11
11
|
<!-- release-state:end -->
|
|
@@ -145,7 +145,7 @@ module Woods
|
|
|
145
145
|
|
|
146
146
|
# Scan a value (String, Hash, Array, or any other object) for credentials.
|
|
147
147
|
#
|
|
148
|
-
# Strings are
|
|
148
|
+
# Strings and Symbols are scanned against every active pattern. Hash values and Array
|
|
149
149
|
# elements are walked recursively; keys and non-string scalars
|
|
150
150
|
# (Integer, Float, true/false, nil) pass through untouched.
|
|
151
151
|
#
|
|
@@ -164,6 +164,7 @@ module Woods
|
|
|
164
164
|
def walk(value, counts, index)
|
|
165
165
|
case value
|
|
166
166
|
when String then scan_string(value, counts, index)
|
|
167
|
+
when Symbol then scan_string(value.to_s, counts, index).to_sym
|
|
167
168
|
when Hash then walk_hash(value, counts, index)
|
|
168
169
|
when Array then value.map { |item| walk(item, counts, index) }
|
|
169
170
|
else value
|
|
@@ -77,15 +77,33 @@ module Woods
|
|
|
77
77
|
response = @conn_mgr.send_request(request)
|
|
78
78
|
return error_from_response(response, request) unless response['ok']
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
result = scan_for_credentials(result, request)
|
|
82
|
-
text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
|
|
83
|
-
success_response(text)
|
|
80
|
+
render_response(response['result'], request)
|
|
84
81
|
rescue ConnectionError => e
|
|
85
82
|
scanned = scan_for_credentials("Connection error: #{e.message}", request)
|
|
86
83
|
error_response(scanned)
|
|
87
84
|
end
|
|
88
85
|
|
|
86
|
+
def render_response(result, request)
|
|
87
|
+
# Remove protected values before custom serializers run, then scan
|
|
88
|
+
# the materialized representation consumed by both response renderers.
|
|
89
|
+
result = @ctx.redact(result)
|
|
90
|
+
result = JSON.parse(JSON.generate(result))
|
|
91
|
+
result = @ctx.redact(result)
|
|
92
|
+
result = scan_for_credentials(result, request)
|
|
93
|
+
text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
|
|
94
|
+
success_response(text)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
# SDK 0.x includes uncaught exception messages in its tool errors.
|
|
97
|
+
# Serializer/parser/renderer messages may contain unscanned values.
|
|
98
|
+
rendering_error_response(request)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def rendering_error_response(request)
|
|
102
|
+
error_response(scan_for_credentials('Console response could not be serialized safely.', request))
|
|
103
|
+
rescue StandardError
|
|
104
|
+
error_response('[REDACTION_FAILED]')
|
|
105
|
+
end
|
|
106
|
+
|
|
89
107
|
def error_from_response(response, request)
|
|
90
108
|
error_text = "#{response['error_type']}: #{response['error']}"
|
|
91
109
|
error_text = scan_for_credentials(error_text, request)
|
|
@@ -10,6 +10,7 @@ require_relative 'model_validator'
|
|
|
10
10
|
require_relative 'safe_context'
|
|
11
11
|
require_relative 'scope_predicate_parser'
|
|
12
12
|
require_relative 'sql_noise_stripper'
|
|
13
|
+
require_relative 'sql_validator'
|
|
13
14
|
require_relative 'table_gate'
|
|
14
15
|
|
|
15
16
|
module Woods
|
|
@@ -425,8 +426,9 @@ module Woods
|
|
|
425
426
|
return unless @table_gate
|
|
426
427
|
|
|
427
428
|
begin
|
|
428
|
-
|
|
429
|
-
|
|
429
|
+
SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
|
|
430
|
+
@table_gate.check_sql!(sql, dialect: sql_dialect == :sqlite ? :sqlite : nil)
|
|
431
|
+
rescue TableGateError, SqlValidationError => e
|
|
430
432
|
raise ValidationError, e.message
|
|
431
433
|
end
|
|
432
434
|
end
|
|
@@ -454,7 +456,7 @@ module Woods
|
|
|
454
456
|
def handle_count(params)
|
|
455
457
|
model = resolve_model(params['model'])
|
|
456
458
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
457
|
-
{ 'count' => scope.count }
|
|
459
|
+
{ 'count' => checked_relation(scope).count }
|
|
458
460
|
end
|
|
459
461
|
|
|
460
462
|
def handle_sample(params)
|
|
@@ -463,16 +465,17 @@ module Woods
|
|
|
463
465
|
limit = [params.fetch('limit', 5).to_i, 25].min
|
|
464
466
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
465
467
|
scope = apply_columns(scope, params['columns'])
|
|
466
|
-
records = scope.order(random_function).limit(limit)
|
|
468
|
+
records = checked_relation(scope.order(random_function).limit(limit))
|
|
467
469
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
468
470
|
end
|
|
469
471
|
|
|
470
472
|
def handle_find(params)
|
|
471
473
|
model = resolve_model(params['model'])
|
|
474
|
+
scope = checked_relation(model)
|
|
472
475
|
record = if params['id']
|
|
473
|
-
|
|
476
|
+
scope.find_by(id: params['id'])
|
|
474
477
|
elsif params['by']
|
|
475
|
-
|
|
478
|
+
scope.find_by(params['by'])
|
|
476
479
|
end
|
|
477
480
|
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
478
481
|
end
|
|
@@ -484,7 +487,7 @@ module Woods
|
|
|
484
487
|
limit = [params.fetch('limit', 100).to_i, 1000].min
|
|
485
488
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
486
489
|
scope = scope.distinct if params['distinct']
|
|
487
|
-
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
490
|
+
values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
|
|
488
491
|
{ 'columns' => Array(columns), 'values' => values }
|
|
489
492
|
end
|
|
490
493
|
|
|
@@ -501,6 +504,7 @@ module Woods
|
|
|
501
504
|
model = resolve_model(params['model'])
|
|
502
505
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
503
506
|
|
|
507
|
+
scope = checked_relation(scope)
|
|
504
508
|
value = if function == 'count'
|
|
505
509
|
column ? scope.count(column.to_sym) : scope.count
|
|
506
510
|
else
|
|
@@ -511,7 +515,7 @@ module Woods
|
|
|
511
515
|
|
|
512
516
|
def handle_association_count(params)
|
|
513
517
|
model = resolve_model(params['model'])
|
|
514
|
-
record = model.find(params['id'])
|
|
518
|
+
record = checked_relation(model).find(params['id'])
|
|
515
519
|
association_name = params['association']
|
|
516
520
|
|
|
517
521
|
unless model.reflect_on_association(association_name.to_sym)
|
|
@@ -527,7 +531,26 @@ module Woods
|
|
|
527
531
|
|
|
528
532
|
scope = record.public_send(association_name)
|
|
529
533
|
scope = apply_scope(scope, params['scope'])
|
|
530
|
-
{ 'count' => scope.count }
|
|
534
|
+
{ 'count' => checked_relation(scope).count }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Resolve the default scope once and execute the same checked relation.
|
|
538
|
+
# @param scope [Class, ActiveRecord::Relation] Pending model read
|
|
539
|
+
# @return [Class, ActiveRecord::Relation] Authorized relation
|
|
540
|
+
def checked_relation(scope)
|
|
541
|
+
return scope unless @table_gate
|
|
542
|
+
|
|
543
|
+
relation = scope.all
|
|
544
|
+
gate_sql!(relation.to_sql)
|
|
545
|
+
relation
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def sql_dialect
|
|
549
|
+
name = active_connection.adapter_name.to_s.downcase
|
|
550
|
+
return :sqlite if name.include?('sqlite')
|
|
551
|
+
return :mysql if name.include?('mysql')
|
|
552
|
+
|
|
553
|
+
:postgres
|
|
531
554
|
end
|
|
532
555
|
|
|
533
556
|
def gate_association!(model_name, association)
|
|
@@ -575,7 +598,7 @@ module Woods
|
|
|
575
598
|
|
|
576
599
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
577
600
|
scope = apply_columns(scope, params['columns'])
|
|
578
|
-
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
601
|
+
records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
|
|
579
602
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
580
603
|
end
|
|
581
604
|
|
|
@@ -599,7 +622,7 @@ module Woods
|
|
|
599
622
|
raise ValidationError, 'Missing required parameter: sql' unless sql
|
|
600
623
|
|
|
601
624
|
require_relative 'sql_validator'
|
|
602
|
-
SqlValidator.new.validate!(sql)
|
|
625
|
+
SqlValidator.new(dialect: sql_dialect).validate!(sql)
|
|
603
626
|
# Post-validation, pre-execution TableGate — blocks every configured
|
|
604
627
|
# table even if the sql is otherwise well-formed.
|
|
605
628
|
gate_sql!(sql)
|
|
@@ -24,7 +24,7 @@ module Woods
|
|
|
24
24
|
# SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
|
|
25
25
|
# # => "SELECT '' FROM t"
|
|
26
26
|
#
|
|
27
|
-
module SqlNoiseStripper
|
|
27
|
+
module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
|
|
28
28
|
# Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
|
|
29
29
|
# Line comments are stripped to (but not including) the newline so that
|
|
30
30
|
# newline-separated statement structure is preserved for callers that
|
|
@@ -40,7 +40,7 @@ module Woods
|
|
|
40
40
|
|
|
41
41
|
def self.strip_comments(sql)
|
|
42
42
|
out = sql.gsub(LINE_COMMENT, '')
|
|
43
|
-
out.gsub(BLOCK_COMMENT, '')
|
|
43
|
+
out.gsub(BLOCK_COMMENT, ' ')
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Strips single-quoted string literals and (for the `:postgres` dialect)
|
|
@@ -53,7 +53,7 @@ module Woods
|
|
|
53
53
|
# single-quote scanner.
|
|
54
54
|
#
|
|
55
55
|
# @param sql [String] the SQL string to process
|
|
56
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
56
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
|
|
57
57
|
# - `:postgres` — single-quoted strings support `''` as an apostrophe
|
|
58
58
|
# escape. Backslash is treated literally and does not escape quotes.
|
|
59
59
|
# Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
|
|
@@ -61,13 +61,14 @@ module Woods
|
|
|
61
61
|
# and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
|
|
62
62
|
# are also stripped (MySQL does not use them, but stripping them is
|
|
63
63
|
# harmless and keeps the two dialects consistent).
|
|
64
|
+
# - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
|
|
64
65
|
# @return [String] a new string with all string literals replaced by `''`
|
|
65
66
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
66
67
|
DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
|
|
67
68
|
SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
|
|
68
69
|
SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
|
|
69
70
|
|
|
70
|
-
SUPPORTED_DIALECTS = %i[postgres mysql].freeze
|
|
71
|
+
SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
|
|
71
72
|
private_constant :SUPPORTED_DIALECTS
|
|
72
73
|
|
|
73
74
|
def self.strip_literals(sql, dialect: :postgres)
|
|
@@ -77,7 +78,7 @@ module Woods
|
|
|
77
78
|
|
|
78
79
|
# Strip dollar-quoted strings first so stray apostrophes inside them
|
|
79
80
|
# do not interfere with the single-quote scanner.
|
|
80
|
-
out = sql.gsub(DOLLAR_QUOTED, "''")
|
|
81
|
+
out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
|
|
81
82
|
|
|
82
83
|
pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
|
|
83
84
|
out.gsub(pattern, "''")
|
|
@@ -98,7 +99,7 @@ module Woods
|
|
|
98
99
|
# character rather than swallowing the rest of the statement.
|
|
99
100
|
#
|
|
100
101
|
# @param sql [String] the SQL string to process
|
|
101
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
102
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
|
|
102
103
|
# single-quote escape rules (see {.strip_literals}).
|
|
103
104
|
# @return [String] a new string with comments removed and every string
|
|
104
105
|
# literal replaced by `''`
|
|
@@ -126,7 +127,16 @@ module Woods
|
|
|
126
127
|
out << ch
|
|
127
128
|
i += 1
|
|
128
129
|
end
|
|
129
|
-
elsif
|
|
130
|
+
elsif ['"', '`'].include?(ch)
|
|
131
|
+
close = quoted_identifier_end(sql, i, ch)
|
|
132
|
+
if close
|
|
133
|
+
out << sql[i...close]
|
|
134
|
+
i = close
|
|
135
|
+
else
|
|
136
|
+
out << ch
|
|
137
|
+
i += 1
|
|
138
|
+
end
|
|
139
|
+
elsif dialect != :sqlite && ch == '$' && (tag = dollar_tag_at(sql, i))
|
|
130
140
|
close = sql.index(tag, i + tag.length)
|
|
131
141
|
if close
|
|
132
142
|
out << "''"
|
|
@@ -141,6 +151,7 @@ module Woods
|
|
|
141
151
|
elsif ch == '/' && sql[i + 1] == '*'
|
|
142
152
|
close = sql.index('*/', i + 2)
|
|
143
153
|
if close
|
|
154
|
+
out << ' '
|
|
144
155
|
i = close + 2
|
|
145
156
|
else
|
|
146
157
|
# Unterminated block comment: never under-detect. Leave it in
|
|
@@ -158,6 +169,23 @@ module Woods
|
|
|
158
169
|
out
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
# Preserve quoted identifiers while shielding their comment markers.
|
|
173
|
+
# @api private
|
|
174
|
+
def self.quoted_identifier_end(sql, start, quote)
|
|
175
|
+
i = start + 1
|
|
176
|
+
while i < sql.length
|
|
177
|
+
if sql[i] == quote
|
|
178
|
+
return i + 1 unless sql[i + 1] == quote
|
|
179
|
+
|
|
180
|
+
i += 2
|
|
181
|
+
else
|
|
182
|
+
i += 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
private_class_method :quoted_identifier_end
|
|
188
|
+
|
|
161
189
|
# Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
|
|
162
190
|
# `$tag$`) at the start of the given slice.
|
|
163
191
|
DOLLAR_TAG = /\A\$\w*\$/
|
|
@@ -120,11 +120,11 @@ module Woods
|
|
|
120
120
|
#
|
|
121
121
|
# @param sql [String, nil] the SQL string to scan
|
|
122
122
|
# @return [Array<String>] identifiers in first-encounter order, deduplicated
|
|
123
|
-
def self.identifiers_in(sql)
|
|
123
|
+
def self.identifiers_in(sql, dialect: nil)
|
|
124
124
|
return [] if sql.nil? || sql.empty?
|
|
125
125
|
|
|
126
126
|
results = []
|
|
127
|
-
%i[postgres mysql].each do |dialect|
|
|
127
|
+
(dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
|
|
128
128
|
stripped = strip_noise(sql, dialect: dialect)
|
|
129
129
|
collect_join_identifiers(stripped, results)
|
|
130
130
|
collect_from_identifiers(stripped, results)
|
|
@@ -132,6 +132,49 @@ module Woods
|
|
|
132
132
|
results.uniq
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Table-factor prefixes, retaining commas after balanced subqueries and
|
|
136
|
+
# JOIN predicates. Each nested FROM/JOIN is also scanned independently.
|
|
137
|
+
# @param sql [String] noise-stripped SQL
|
|
138
|
+
# @return [Array<String>]
|
|
139
|
+
def self.relation_factors(sql)
|
|
140
|
+
sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
|
|
141
|
+
suffix = sql[Regexp.last_match.end(0)..]
|
|
142
|
+
split_top_level_commas(relation_clause(suffix))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Quoted tokens shield punctuation and clause words; a closing parenthesis
|
|
147
|
+
# at depth zero ends this query's clause, not a nested table expression.
|
|
148
|
+
def self.relation_clause(suffix)
|
|
149
|
+
depth = 0
|
|
150
|
+
tokens = /
|
|
151
|
+
"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
|
|
152
|
+
\b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
|
|
153
|
+
/ix
|
|
154
|
+
suffix.to_enum(:scan, tokens).each do
|
|
155
|
+
token = Regexp.last_match[0]
|
|
156
|
+
start = Regexp.last_match.begin(0)
|
|
157
|
+
finish = Regexp.last_match.end(0)
|
|
158
|
+
boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
|
|
159
|
+
return suffix[0...start] if depth.zero? && boundary
|
|
160
|
+
|
|
161
|
+
depth += 1 if token == '('
|
|
162
|
+
depth -= 1 if token == ')'
|
|
163
|
+
end
|
|
164
|
+
suffix
|
|
165
|
+
end
|
|
166
|
+
private_class_method :relation_clause
|
|
167
|
+
|
|
168
|
+
def self.relation_keyword_boundary?(prefix, rest, token)
|
|
169
|
+
return false unless token.match?(/\A[A-Za-z]/)
|
|
170
|
+
return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
|
|
171
|
+
return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
|
|
172
|
+
return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
|
|
173
|
+
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
private_class_method :relation_keyword_boundary?
|
|
177
|
+
|
|
135
178
|
# @api private
|
|
136
179
|
# Comments and literals must be stripped in a single combined pass —
|
|
137
180
|
# stripping them separately lets a comment marker inside a literal
|
|
@@ -153,12 +196,9 @@ module Woods
|
|
|
153
196
|
|
|
154
197
|
# @api private
|
|
155
198
|
def self.collect_from_identifiers(sql, results)
|
|
156
|
-
sql.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ident = lead_identifier(chunk)
|
|
160
|
-
results << ident if ident
|
|
161
|
-
end
|
|
199
|
+
relation_factors(sql).each do |chunk|
|
|
200
|
+
ident = lead_identifier(chunk)
|
|
201
|
+
results << ident if ident
|
|
162
202
|
end
|
|
163
203
|
end
|
|
164
204
|
private_class_method :collect_from_identifiers
|
|
@@ -169,7 +209,7 @@ module Woods
|
|
|
169
209
|
depth = 0
|
|
170
210
|
buf = +''
|
|
171
211
|
parts = []
|
|
172
|
-
clause.
|
|
212
|
+
clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
|
|
173
213
|
case ch
|
|
174
214
|
when '('
|
|
175
215
|
depth += 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'woods/console/sql_noise_stripper'
|
|
4
|
+
require 'woods/console/sqlite_read_guard'
|
|
4
5
|
|
|
5
6
|
# @see Woods
|
|
6
7
|
module Woods
|
|
@@ -95,17 +96,17 @@ module Woods
|
|
|
95
96
|
[kw, /\b#{kw}\b/i]
|
|
96
97
|
end.freeze
|
|
97
98
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end.freeze
|
|
99
|
+
# @param dialect [Symbol, nil] Known connection dialect, when available
|
|
100
|
+
def initialize(dialect: nil)
|
|
101
|
+
@dialect = dialect
|
|
102
|
+
end
|
|
103
103
|
|
|
104
104
|
# @raise [SqlValidationError] if the SQL is not a safe read-only statement
|
|
105
105
|
def validate!(sql)
|
|
106
106
|
raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
|
|
107
107
|
|
|
108
108
|
normalized = sql.strip
|
|
109
|
+
SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
|
|
109
110
|
|
|
110
111
|
# Reject multiple statements (semicolons not inside string literals)
|
|
111
112
|
if contains_multiple_statements?(normalized)
|
|
@@ -152,7 +153,7 @@ module Woods
|
|
|
152
153
|
# @param sql [String]
|
|
153
154
|
# @return [Boolean]
|
|
154
155
|
def contains_multiple_statements?(sql)
|
|
155
|
-
stripped = SqlNoiseStripper.strip_noise(sql)
|
|
156
|
+
stripped = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
|
|
156
157
|
stripped.include?(';')
|
|
157
158
|
end
|
|
158
159
|
|
|
@@ -191,8 +192,12 @@ module Woods
|
|
|
191
192
|
# @param sql [String]
|
|
192
193
|
# @raise [SqlValidationError] if a dangerous function is found
|
|
193
194
|
def check_dangerous_functions!(sql)
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
view = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
|
|
196
|
+
view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
|
|
197
|
+
func = (quoted || backtick || bare).downcase
|
|
198
|
+
next unless DANGEROUS_FUNCTIONS.include?(func)
|
|
199
|
+
|
|
200
|
+
raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
|
|
196
201
|
end
|
|
197
202
|
end
|
|
198
203
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/console/sql_noise_stripper'
|
|
4
|
+
require 'woods/console/sql_table_scanner'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Console
|
|
8
|
+
# Refuse SQLite-specific syntax outside the scanner's supported grammar.
|
|
9
|
+
# This is a conservative read boundary, not a general-purpose SQL parser.
|
|
10
|
+
module SqliteReadGuard
|
|
11
|
+
SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
12
|
+
UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
|
|
13
|
+
QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
|
|
14
|
+
|
|
15
|
+
# @param sql [String] SQL to execute on SQLite
|
|
16
|
+
# @raise [SqlValidationError] when an identifier or table factor cannot be checked
|
|
17
|
+
# @return [void]
|
|
18
|
+
def self.validate!(sql)
|
|
19
|
+
view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
|
|
20
|
+
refuse! if view.match?(UNSUPPORTED_SYNTAX)
|
|
21
|
+
SqlTableScanner.relation_factors(view).each do |factor|
|
|
22
|
+
factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
|
|
23
|
+
refuse! unless supported_factor?(factor.strip)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A SELECT/WITH subquery has its own independently scanned table factors.
|
|
28
|
+
# Parenthesized table groups and string-quoted names are refused rather
|
|
29
|
+
# than silently omitted from the blocked-table scan.
|
|
30
|
+
def self.supported_factor?(factor)
|
|
31
|
+
return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
|
|
32
|
+
|
|
33
|
+
match = SqlTableScanner::LEAD_IDENT.match(factor)
|
|
34
|
+
match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
|
|
35
|
+
end
|
|
36
|
+
private_class_method :supported_factor?
|
|
37
|
+
|
|
38
|
+
def self.refuse!
|
|
39
|
+
raise SqlValidationError,
|
|
40
|
+
'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
|
|
41
|
+
'Use simple bare or double-quoted identifiers and SELECT subqueries.'
|
|
42
|
+
end
|
|
43
|
+
private_class_method :refuse!
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
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)
|
|
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).each do |raw|
|
|
39
39
|
raise TableGateError, reject_message(raw) if blocked?(raw)
|
|
40
40
|
end
|
|
41
41
|
end
|
data/lib/woods/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: woods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.6.
|
|
4
|
+
version: 1.6.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leah Armstrong
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mcp
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/woods/console/sql_noise_stripper.rb
|
|
181
181
|
- lib/woods/console/sql_table_scanner.rb
|
|
182
182
|
- lib/woods/console/sql_validator.rb
|
|
183
|
+
- lib/woods/console/sqlite_read_guard.rb
|
|
183
184
|
- lib/woods/console/table_gate.rb
|
|
184
185
|
- lib/woods/console/tool_specs.rb
|
|
185
186
|
- lib/woods/console/tools/tier1.rb
|
|
@@ -359,10 +360,10 @@ licenses:
|
|
|
359
360
|
- MIT
|
|
360
361
|
metadata:
|
|
361
362
|
homepage_uri: https://github.com/lost-in-the/woods
|
|
362
|
-
source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.
|
|
363
|
-
changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.
|
|
363
|
+
source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.3
|
|
364
|
+
changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.3/CHANGELOG.md
|
|
364
365
|
bug_tracker_uri: https://github.com/lost-in-the/woods/issues
|
|
365
|
-
documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.
|
|
366
|
+
documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.3/docs
|
|
366
367
|
rubygems_mfa_required: 'true'
|
|
367
368
|
post_install_message:
|
|
368
369
|
rdoc_options: []
|