aikido-zen 1.3.0 → 1.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da6cdcfab4ab64c1d5f25d652fe71da5f869e32516faffec48ad4c542abb2aec
4
- data.tar.gz: 87ff936a996e0f4b4cd459cd4293cb60853c6f8f26f7b5b61ef698600e4921a9
3
+ metadata.gz: aa5de81052f55031065a0e154a9fe929d8928832cbaff2cad5657f0fd262a8ed
4
+ data.tar.gz: 0f7b951bef59f101be88141f284fda6f9786803868e3e54be84fb1f7c5a7909b
5
5
  SHA512:
6
- metadata.gz: 42e79d8f15a8bdb3ec3fb4fe7b83648ae86784ee3c52a383b2052a7ed28957506aac56a1d90b18f5a0baa0eef65ea449aa46c0fde95a87e57ffbd7382fcf1b55
7
- data.tar.gz: 979ee37703c1198f9a3b261819410e88ad53542e13a80b18cfe9458b5088fab5ce7cda228ef54ba01db2277eeda94db15bc23c396b0fa718fb9bf3f8cd7f3971
6
+ metadata.gz: 36338bd37e39be16311ead1f8caae49dc378f62b8641ee28fb0d7f264f6c3578f1a3db1291e33613e5c4ca443d5b3ba5e64babe827a6a5dcd9cc55fc1e787184
7
+ data.tar.gz: 31cce0645b2d3d092424c41bfccdbcc6166efb9295101074727cdc13b48a1914b2ad3c3db74010996ff2072d1b04231740ea3f90cb4b8b372201af5a4d2ed6fc
data/.simplecov CHANGED
@@ -3,7 +3,7 @@
3
3
  # Due to dependency resolution, on Ruby 2.x we're stuck with a _very_ old
4
4
  # SimpleCov version, and it doesn't really give us any benefit to run coverage
5
5
  # in separate ruby versions since we don't branch on ruby version in the code.
6
- return if RUBY_VERSION < "3.0"
6
+ return if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.0")
7
7
  return if ENV["DISABLE_COVERAGE"] == "true"
8
8
 
9
9
  # Output coverage as LCOV to support CodeCov
@@ -0,0 +1,130 @@
1
+ # IDOR Protection
2
+
3
+ IDOR stands for Insecure Direct Object Reference — it's when one account can access another account's data because a query doesn't properly filter by account.
4
+
5
+ If your SaaS has accounts (or organizations, workspaces, teams, ...) and uses a column like `tenant_id` to keep each account's data separate, IDOR protection ensures every SQL query filters on the correct tenant. Zen analyzes queries at runtime and raises an error if a query is missing that filter or uses the wrong tenant ID, catching mistakes like:
6
+
7
+ - A `SELECT` that forgets the tenant filter, letting one account read another's orders
8
+ - An `UPDATE` or `DELETE` without a tenant filter, letting one account modify another's data
9
+ - An `INSERT` that omits the tenant column, creating orphaned or misassigned rows
10
+
11
+ Zen catches these at runtime so they surface during development and testing, not in production. See [IDOR vulnerability explained](https://www.aikido.dev/blog/idor-vulnerability-explained) for more background.
12
+
13
+ > [!IMPORTANT]
14
+ > IDOR protection always raises an `Aikido::Zen::IDOR::Error` on violations regardless of block/detect mode. A missing filter is a developer bug, not an external attack.
15
+
16
+ ## Setup
17
+
18
+ ### 1. Enable IDOR protection at startup
19
+
20
+ ```ruby
21
+ ...
22
+
23
+ Aikido::Zen.config.idor_protection_enabled = true
24
+ Aikido::Zen.config.idor_tenant_column_name = "tenant_id"
25
+ Aikido::Zen.config.idor_excluded_table_names = ["users"]
26
+
27
+ ...
28
+ ```
29
+
30
+ - `idor_tenant_column_name` — the column name that identifies the tenant in your database tables (e.g. `account_id`, `organization_id`, `team_id`).
31
+ - `idor_excluded_table_names` — tables that Zen should skip IDOR checks for, because rows aren't scoped to a single tenant (e.g. a shared `users` table that stores users across all tenants).
32
+
33
+ ### 2. Set the tenant ID per request
34
+
35
+ Every request must have a tenant ID when IDOR protection is enabled. Call `Aikido::Zen.set_tenant_id` early in your request handler (e.g. in middleware after authentication):
36
+
37
+ ```ruby
38
+ Aikido::Zen.set_tenant_id(1)
39
+ ```
40
+
41
+ > [!IMPORTANT]
42
+ > If `Aikido::Zen.set_tenant_id` is not called for a request, Zen will raise an `Aikido::Zen::IDOR::Error` when a SQL query is executed.
43
+
44
+ ### 3. Bypass for specific queries (optional)
45
+
46
+ Some queries don't need tenant filtering (e.g. aggregations across all tenants for an admin dashboard). Use `Aikido::Zen.without_idor_protection` to bypass the check for a specific block:
47
+
48
+ ```ruby
49
+ ...
50
+
51
+ # IDOR checks are skipped for queries inside this block
52
+ result = Aikido::Zen.without_idor_protection do
53
+ db.execute("SELECT count(*) FROM agents WHERE status = 'running'");
54
+ end
55
+
56
+ ...
57
+ ```
58
+
59
+ ## Troubleshooting
60
+
61
+ <details>
62
+ <summary>Missing tenant filter</summary>
63
+
64
+ ```
65
+ Zen IDOR protection: query on table 'orders' is missing a filter on column 'tenant_id'
66
+ ```
67
+
68
+ This means you have a query like `SELECT * FROM orders WHERE status = 'active'` that doesn't filter on `tenant_id`. The same check applies to `UPDATE` and `DELETE` queries.
69
+
70
+ </details>
71
+
72
+ <details>
73
+ <summary>Wrong tenant ID value</summary>
74
+
75
+ ```
76
+ Zen IDOR protection: query on table 'orders' filters 'tenant_id' with value '456' but tenant ID is '123'
77
+ ```
78
+
79
+ This means the query filters on `tenant_id`, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
80
+
81
+ </details>
82
+
83
+ <details>
84
+ <summary>Missing tenant column in INSERT</summary>
85
+
86
+ ```
87
+ Zen IDOR protection: INSERT on table 'orders' is missing column 'tenant_id'
88
+ ```
89
+
90
+ This means an `INSERT` statement doesn't include the tenant column. Every INSERT must include the tenant column with the correct tenant ID value.
91
+
92
+ </details>
93
+
94
+ <details>
95
+ <summary>Wrong tenant ID in INSERT</summary>
96
+
97
+ ```
98
+ Zen IDOR protection: INSERT on table 'orders' sets 'tenant_id' to '456' but tenant ID is '123'
99
+ ```
100
+
101
+ This means the INSERT includes the tenant column, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
102
+
103
+ </details>
104
+
105
+ <details>
106
+ <summary>Missing Aikido::Zen.set_tenant_id call</summary>
107
+
108
+ ```
109
+ Zen IDOR protection: Aikido::Zen.set_tenant_id was not called for this request. Every request must have a tenant ID when IDOR protection is enabled.
110
+ ```
111
+
112
+ </details>
113
+
114
+ ## Supported databases
115
+
116
+ - SQLite (via `sqlite3` Gem)
117
+ - PostgreSQL (via `pg` Gem)
118
+ - MySQL (via `mysql2` and `trilogy` Gems)
119
+
120
+ Any ORM or query builder that uses these database packages under the hood is supported (e.g. ActiveRecord). ORMs that use their own database engine are not supported unless configured to use a supported driver adapter.
121
+
122
+ ## Limitations
123
+
124
+ ## Statements that are always allowed
125
+
126
+ Zen only checks statements that read or modify row data (`SELECT`, `INSERT`, `UPDATE`, `DELETE`). The following statement types are also recognized and never trigger an IDOR error:
127
+
128
+ - DDL — `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, ...
129
+ - Session commands — `SET`, `SHOW`, ...
130
+ - Transactions — `BEGIN`, `COMMIT`, `ROLLBACK`, ...
@@ -0,0 +1,9 @@
1
+ # Blocking invalid SQL queries
2
+
3
+ Zen blocks SQL queries that it can't tokenize when they contain user input. This prevents attackers from bypassing SQL injection detection with malformed queries. For example, ClickHouse ignores invalid SQL after `;`, and SQLite runs queries before an unclosed `/*` comment.
4
+
5
+ This is on by default. In blocking mode, these queries are blocked. In detection-only mode, they are reported but still executed.
6
+
7
+ If you see false positives (legitimate queries being blocked), set
8
+ `AIKIDO_BLOCK_INVALID_SQL=false` in your environment, or set
9
+ `Aikido::Zen.config.block_invalid_sql = false`.
@@ -121,11 +121,12 @@ module Aikido::Zen
121
121
  attr_reader :input
122
122
  attr_reader :dialect
123
123
 
124
- def initialize(query:, input:, dialect:, **opts)
124
+ def initialize(query:, input:, dialect:, failed_to_tokenize:, **opts)
125
125
  super(**opts)
126
126
  @query = query
127
127
  @input = input
128
128
  @dialect = dialect
129
+ @failed_to_tokenize = failed_to_tokenize
129
130
  end
130
131
 
131
132
  def humanized_name
@@ -139,8 +140,9 @@ module Aikido::Zen
139
140
  def metadata
140
141
  {
141
142
  sql: @query,
142
- dialect: @dialect.name
143
- }
143
+ dialect: @dialect.name,
144
+ failedToTokenize: @failed_to_tokenize || nil
145
+ }.compact
144
146
  end
145
147
 
146
148
  def exception(*)
@@ -28,6 +28,14 @@ module Aikido::Zen
28
28
  attr_accessor :blocking_mode
29
29
  alias_method :blocking_mode?, :blocking_mode
30
30
 
31
+ # @return [Boolean] is the agent in debugging mode?
32
+ attr_accessor :debugging
33
+ alias_method :debugging?, :debugging
34
+
35
+ # @return [String] the token obtained when configuring the Firewall in the
36
+ # Aikido interface.
37
+ attr_accessor :api_token
38
+
31
39
  # @return [URI] The HTTP host for the Aikido API. Defaults to
32
40
  # +https://guard.aikido.dev+.
33
41
  attr_reader :api_endpoint
@@ -39,10 +47,6 @@ module Aikido::Zen
39
47
  # @return [Hash] HTTP timeouts for communicating with the API.
40
48
  attr_reader :api_timeouts
41
49
 
42
- # @return [String] the token obtained when configuring the Firewall in the
43
- # Aikido interface.
44
- attr_accessor :api_token
45
-
46
50
  # @return [Integer] the interval in seconds to poll the runtime API for
47
51
  # settings changes. Defaults to evey 60 seconds.
48
52
  attr_accessor :polling_interval
@@ -67,10 +71,6 @@ module Aikido::Zen
67
71
  # Defaults to `aikido-detached-agent.sock`.
68
72
  attr_accessor :detached_agent_socket_path
69
73
 
70
- # @return [Boolean] is the agent in debugging mode?
71
- attr_accessor :debugging
72
- alias_method :debugging?, :debugging
73
-
74
74
  # @return [String] environment specific HTTP header providing the client IP.
75
75
  attr_accessor :client_ip_header
76
76
 
@@ -165,6 +165,12 @@ module Aikido::Zen
165
165
  attr_accessor :harden
166
166
  alias_method :harden?, :harden
167
167
 
168
+ # @return [Boolean] whether Aikido Zen should block SQL queries that fail
169
+ # tokenization when user input is present. Defaults to false. Can be set
170
+ # through AIKIDO_BLOCK_INVALID_SQL environment variable.
171
+ attr_accessor :block_invalid_sql
172
+ alias_method :block_invalid_sql?, :block_invalid_sql
173
+
168
174
  # @return [Integer] how many suspicious requests are allowed before an
169
175
  # attack wave detected event is reported.
170
176
  # Defaults to 15 requests.
@@ -188,19 +194,41 @@ module Aikido::Zen
188
194
  # Defaults to 15 entries.
189
195
  attr_accessor :attack_wave_max_cache_samples
190
196
 
197
+ # @return [Float, nil] the timeout in seconds for regular expression matching.
198
+ # Applied to selected internal regular expressions to mitigate ReDoS risks.
199
+ # Defaults to 1.0 seconds.
200
+ attr_accessor :redos_regexp_timeout
201
+
202
+ # @return [Boolean] whether the IDOR protection feature is enabled.
203
+ # Defaults to false.
204
+ attr_accessor :idor_protection_enabled
205
+ alias_method :idor_protection_enabled?, :idor_protection_enabled
206
+
207
+ # @return [String] the tenant column name for IDOR protection.
208
+ # Defaults to nil.
209
+ attr_accessor :idor_tenant_column_name
210
+
211
+ # @return [Array<String>] the table names to exclude for IDOR protection.
212
+ # Defaults to [].
213
+ attr_accessor :idor_excluded_table_names
214
+
215
+ # @return [Integer] the maximum number of entries in the LRU cache.
216
+ # Defaults to 1000 entries.
217
+ attr_accessor :idor_max_cache_entries
218
+
191
219
  def initialize
192
220
  self.insert_middleware_after = ::ActionDispatch::RemoteIp
193
221
  self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
194
222
  self.blocking_mode = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCK", false))
195
- self.api_timeouts = 10
223
+ self.debugging = read_boolean_from_env(ENV.fetch("AIKIDO_DEBUG", false))
224
+ self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
196
225
  self.api_endpoint = ENV.fetch("AIKIDO_ENDPOINT", DEFAULT_AIKIDO_ENDPOINT)
197
226
  self.realtime_endpoint = ENV.fetch("AIKIDO_REALTIME_ENDPOINT", DEFAULT_RUNTIME_BASE_URL)
198
- self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
227
+ self.api_timeouts = 10
199
228
  self.polling_interval = 60 # 1 min
200
229
  self.initial_heartbeat_delays = [30, 60 * 2] # 30 sec, 2 min
201
230
  self.json_encoder = DEFAULT_JSON_ENCODER
202
231
  self.json_decoder = DEFAULT_JSON_DECODER
203
- self.debugging = read_boolean_from_env(ENV.fetch("AIKIDO_DEBUG", false))
204
232
  self.logger = Logger.new($stdout, progname: "aikido", level: debugging ? Logger::DEBUG : Logger::INFO)
205
233
  self.detached_agent_socket_path = ENV.fetch("AIKIDO_DETACHED_AGENT_SOCKET_PATH", DEFAULT_DETACHED_AGENT_SOCKET_PATH)
206
234
  self.client_ip_header = ENV.fetch("AIKIDO_CLIENT_IP_HEADER", nil)
@@ -208,25 +236,31 @@ module Aikido::Zen
208
236
  self.max_compressed_stats = 100
209
237
  self.max_outbound_connections = 200
210
238
  self.max_users_tracked = 1000
211
- self.request_builder = Aikido::Zen::Context::RACK_REQUEST_BUILDER
212
239
  self.blocked_responder = DEFAULT_BLOCKED_RESPONDER
213
240
  self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
214
241
  self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
215
- self.server_rate_limit_deadline = 30 * 60 # 30 min
216
- self.client_rate_limit_period = 60 * 60 # 1 hour
217
- self.client_rate_limit_max_events = 100
218
242
  self.collect_api_schema = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", true))
219
243
  self.api_schema_max_samples = Integer(ENV.fetch("AIKIDO_MAX_API_DISCOVERY_SAMPLES", 10))
220
244
  self.api_schema_collection_max_depth = 20
221
245
  self.api_schema_collection_max_properties = 20
246
+ self.request_builder = Aikido::Zen::Context::RACK_REQUEST_BUILDER
247
+ self.client_rate_limit_period = 60 * 60 # 1 hour
248
+ self.client_rate_limit_max_events = 100
249
+ self.server_rate_limit_deadline = 30 * 60 # 30 min
222
250
  self.stored_ssrf = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_STORED_SSRF", true))
223
251
  self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
224
252
  self.harden = read_boolean_from_env(ENV.fetch("AIKIDO_HARDEN", true))
253
+ self.block_invalid_sql = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCK_INVALID_SQL", false))
225
254
  self.attack_wave_threshold = 15
226
255
  self.attack_wave_min_time_between_requests = 60 * 1000 # 1 min (ms)
227
256
  self.attack_wave_min_time_between_events = 20 * 60 * 1000 # 20 min (ms)
228
257
  self.attack_wave_max_cache_entries = 10_000
229
258
  self.attack_wave_max_cache_samples = 15
259
+ self.redos_regexp_timeout = 1.0
260
+ self.idor_protection_enabled = false
261
+ self.idor_tenant_column_name = nil
262
+ self.idor_excluded_table_names = []
263
+ self.idor_max_cache_entries = 1000
230
264
  end
231
265
 
232
266
  # Set the base URL for API requests.
@@ -14,12 +14,36 @@ module Aikido::Zen
14
14
  request = Aikido::Zen::Request.new(delegate, framework: "rack", router: router)
15
15
 
16
16
  Context.new(request) do |req|
17
+ query = begin
18
+ req.GET
19
+ rescue
20
+ {}
21
+ end
22
+
23
+ body = begin
24
+ req.POST
25
+ rescue
26
+ {}
27
+ end
28
+
29
+ header = begin
30
+ req.normalized_headers
31
+ rescue
32
+ {}
33
+ end
34
+
35
+ cookie = begin
36
+ req.cookies
37
+ rescue
38
+ {}
39
+ end
40
+
17
41
  {
18
- query: req.GET,
19
- body: req.POST,
42
+ query: query,
43
+ body: body,
20
44
  route: {},
21
- header: req.normalized_headers,
22
- cookie: req.cookies,
45
+ header: header,
46
+ cookie: cookie,
23
47
  subdomain: []
24
48
  }
25
49
  end
@@ -34,13 +34,49 @@ module Aikido::Zen
34
34
  end
35
35
 
36
36
  Context.new(request) do |req|
37
+ query = begin
38
+ req.query_parameters
39
+ rescue
40
+ {}
41
+ end
42
+
43
+ body = begin
44
+ req.request_parameters
45
+ rescue
46
+ {}
47
+ end
48
+
49
+ route = begin
50
+ req.path_parameters
51
+ rescue
52
+ {}
53
+ end
54
+
55
+ header = begin
56
+ req.normalized_headers
57
+ rescue
58
+ {}
59
+ end
60
+
61
+ cookie = begin
62
+ decrypt_cookies.call(req)
63
+ rescue
64
+ {}
65
+ end
66
+
67
+ subdomain = begin
68
+ req.subdomains
69
+ rescue
70
+ []
71
+ end
72
+
37
73
  {
38
- query: req.query_parameters,
39
- body: req.request_parameters,
40
- route: req.path_parameters,
41
- header: req.normalized_headers,
42
- cookie: decrypt_cookies.call(req),
43
- subdomain: req.subdomains
74
+ query: query,
75
+ body: body,
76
+ route: route,
77
+ header: header,
78
+ cookie: cookie,
79
+ subdomain: subdomain
44
80
  }
45
81
  end
46
82
  end
@@ -30,6 +30,10 @@ module Aikido::Zen
30
30
  attr_accessor :protection_disabled
31
31
  alias_method :protection_disabled?, :protection_disabled
32
32
 
33
+ # @return [Boolean]
34
+ attr_accessor :idor_protection_enabled
35
+ alias_method :idor_protection_enabled?, :idor_protection_enabled
36
+
33
37
  # @param request [Rack::Request] a Request object that implements the
34
38
  # Rack::Request API, to which we will delegate behavior.
35
39
  # @param settings [Aikido::Zen::RuntimeSettings]
@@ -45,6 +49,7 @@ module Aikido::Zen
45
49
  @metadata = {}
46
50
  @scanning = false
47
51
  @protection_disabled = false
52
+ @idor_protection_enabled = false
48
53
  end
49
54
 
50
55
  # Fetch some metadata stored in the Context.
@@ -19,6 +19,16 @@ module Aikido
19
19
  normalized_path.chomp!("/") unless normalized_path == "/"
20
20
  normalized_path
21
21
  end
22
+
23
+ # Returns a copy of the regexp with the timeout set if timeout is supported.
24
+ #
25
+ # @param regexp [Regexp] the regexp
26
+ # @return [Regexp] the regexp with timeout set
27
+ def self.regexp_with_timeout(regexp, timeout: Aikido::Zen.config.redos_regexp_timeout)
28
+ return regexp if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2")
29
+
30
+ Regexp.new(regexp.source, regexp.options, timeout: timeout)
31
+ end
22
32
  end
23
33
  end
24
34
  end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module IDOR
5
+ class Table
6
+ def self.from_json(data)
7
+ new(
8
+ name: data["name"],
9
+ alt_name: data["alias"]
10
+ )
11
+ end
12
+
13
+ # @param name [String]
14
+ # @param alt_name [String, nil]
15
+ def initialize(name:, alt_name: nil)
16
+ @name = name
17
+ @alt_name = alt_name
18
+ end
19
+
20
+ # @return [String]
21
+ attr_accessor :name
22
+ # @return [String, nil]
23
+ attr_accessor :alt_name
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ other.name == name &&
28
+ other.alt_name == alt_name
29
+ end
30
+ alias_method :eql?, :==
31
+ end
32
+
33
+ class FilterColumn
34
+ def self.from_json(data)
35
+ new(
36
+ table_qualifier: data["table"],
37
+ name: data["column"],
38
+ value: data["value"],
39
+ is_placeholder: data["is_placeholder"],
40
+ placeholder_number: data["placeholder_number"]
41
+ )
42
+ end
43
+
44
+ # @param table_qualifier [String, nil]
45
+ # @param name [String]
46
+ # @param value [String]
47
+ # @param is_placeholder [Boolean]
48
+ # @param placeholder_number [Integer, nil]
49
+ def initialize(table_qualifier:, name:, value:, is_placeholder:, placeholder_number: nil)
50
+ @table_qualifier = table_qualifier
51
+ @name = name
52
+ @value = value
53
+ @is_placeholder = is_placeholder
54
+ @placeholder_number = placeholder_number
55
+ end
56
+
57
+ # @return [String, nil]
58
+ attr_accessor :table_qualifier
59
+
60
+ # @return [String]
61
+ attr_accessor :name
62
+
63
+ # @return [String]
64
+ attr_accessor :value
65
+
66
+ # @return [Boolean]
67
+ attr_accessor :is_placeholder
68
+
69
+ # @return [Integer, nil]
70
+ attr_accessor :placeholder_number
71
+
72
+ def ==(other)
73
+ other.is_a?(self.class) &&
74
+ other.table_qualifier == table_qualifier &&
75
+ other.name == name &&
76
+ other.value == value &&
77
+ other.is_placeholder == is_placeholder &&
78
+ other.placeholder_number == placeholder_number
79
+ end
80
+ alias_method :eql?, :==
81
+ end
82
+
83
+ class InsertColumn
84
+ def self.from_json(data)
85
+ new(
86
+ name: data["column"],
87
+ value: data["value"],
88
+ is_placeholder: data["is_placeholder"],
89
+ placeholder_number: data["placeholder_number"]
90
+ )
91
+ end
92
+
93
+ # @param name [String]
94
+ # @param value [String]
95
+ # @param is_placeholder [Boolean]
96
+ # @param placeholder_number [Integer, nil]
97
+ def initialize(name:, value:, is_placeholder:, placeholder_number: nil)
98
+ @name = name
99
+ @value = value
100
+ @is_placeholder = is_placeholder
101
+ @placeholder_number = placeholder_number
102
+ end
103
+
104
+ # @return [String]
105
+ attr_accessor :name
106
+
107
+ # @return [String]
108
+ attr_accessor :value
109
+
110
+ # @return [Boolean]
111
+ attr_accessor :is_placeholder
112
+
113
+ # @return [Integer, nil]
114
+ attr_accessor :placeholder_number
115
+
116
+ def ==(other)
117
+ other.is_a?(self.class) &&
118
+ other.name == name &&
119
+ other.value == value &&
120
+ other.is_placeholder == is_placeholder &&
121
+ other.placeholder_number == placeholder_number
122
+ end
123
+ alias_method :eql?, :==
124
+ end
125
+
126
+ class SQLQueryResult
127
+ def self.from_json(data)
128
+ new(
129
+ kind: data["kind"].to_sym,
130
+ tables: data["tables"].map { |value| Table.from_json(value) },
131
+ filter_columns: data["filters"].map { |value| FilterColumn.from_json(value) },
132
+ insert_columns: data["insert_columns"]&.map do |values|
133
+ values.map { |value| InsertColumn.from_json(value) }
134
+ end
135
+ )
136
+ end
137
+
138
+ # @param kind [:select, :insert, :update, :delete]
139
+ # @param tables [Array<Aikido::Zen::IDOR::Table>]
140
+ # @param filter_columns [Array<Aikido::Zen::IDOR::FilterColumn>]
141
+ # @param insert_columns [Array<Array<Aikido::Zen::IDOR::InsertColumn>>, nil]
142
+ def initialize(kind:, tables:, filter_columns:, insert_columns: nil)
143
+ raise ArgumentError, "kind must be one of :select, :insert, :update, or :delete" unless [:select, :insert, :update, :delete].include?(kind)
144
+
145
+ @kind = kind
146
+ @tables = tables
147
+ @filter_columns = filter_columns
148
+ @insert_columns = insert_columns
149
+ end
150
+
151
+ # @return [:select, :insert, :update, :delete]
152
+ attr_accessor :kind
153
+
154
+ # @return [Array<Aikido::Zen::IDOR::Table>]
155
+ attr_accessor :tables
156
+
157
+ # @return [Array<Aikido::Zen::IDOR::FilterColumn>]
158
+ attr_accessor :filter_columns
159
+
160
+ # @return [Array<Array<Aikido::Zen::IDOR::InsertColumn>>, nil]
161
+ attr_accessor :insert_columns
162
+
163
+ def ==(other)
164
+ other.is_a?(self.class) &&
165
+ other.kind == kind &&
166
+ other.tables == tables &&
167
+ other.filter_columns == filter_columns &&
168
+ other.insert_columns == insert_columns
169
+ end
170
+ alias_method :eql?, :==
171
+ end
172
+ end
173
+ end