aikido-zen 1.3.1-x86_64-linux → 1.4.0-x86_64-linux
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/docs/idor-protection.md +130 -0
- data/lib/aikido/zen/config.rb +21 -0
- data/lib/aikido/zen/context.rb +5 -0
- data/lib/aikido/zen/idor/analysis_result.rb +173 -0
- data/lib/aikido/zen/idor/protector.rb +149 -0
- data/lib/aikido/zen/idor.rb +4 -0
- data/lib/aikido/zen/internals.rb +27 -2
- data/lib/aikido/zen/libzen-v0.1.61-x86_64-linux.so +0 -0
- data/lib/aikido/zen/rails_engine.rb +4 -0
- data/lib/aikido/zen/request.rb +6 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +1 -21
- data/lib/aikido/zen/sinks/mysql2.rb +26 -2
- data/lib/aikido/zen/sinks/pg.rb +42 -10
- data/lib/aikido/zen/sinks/sqlite3.rb +49 -5
- data/lib/aikido/zen/sinks/trilogy.rb +6 -2
- data/lib/aikido/zen/sql.rb +108 -0
- data/lib/aikido/zen/version.rb +2 -2
- data/lib/aikido/zen.rb +68 -5
- metadata +8 -3
- data/lib/aikido/zen/libzen-v0.1.60-x86_64-linux.so +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b80bb497a21b3f2210721b7fe18b09a4264560aa4d03d4257b84b6892e486b70
|
|
4
|
+
data.tar.gz: e1e71248d0c8d470954556581f830a2ac345cfacddf5a5e05a885cb63df87713
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbe0e5bf65b875bf0c744b65071a66f5d9a922b9365f4a365a87c916f5a2f80456346cc48b1114858a602160954cb650de9435868edc192589d5daf29c5657c1
|
|
7
|
+
data.tar.gz: d94397031a6922689d44ab1392b0f2fbcfabd9865550b9bcdc96deb0f03ee0659650a9f6ff6161faa2fb37e90b25c09251c21043112784e05c31d4721c20822f
|
|
@@ -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`, ...
|
data/lib/aikido/zen/config.rb
CHANGED
|
@@ -199,6 +199,23 @@ module Aikido::Zen
|
|
|
199
199
|
# Defaults to 1.0 seconds.
|
|
200
200
|
attr_accessor :redos_regexp_timeout
|
|
201
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
|
+
|
|
202
219
|
def initialize
|
|
203
220
|
self.insert_middleware_after = ::ActionDispatch::RemoteIp
|
|
204
221
|
self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
|
|
@@ -240,6 +257,10 @@ module Aikido::Zen
|
|
|
240
257
|
self.attack_wave_max_cache_entries = 10_000
|
|
241
258
|
self.attack_wave_max_cache_samples = 15
|
|
242
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
|
|
243
264
|
end
|
|
244
265
|
|
|
245
266
|
# Set the base URL for API requests.
|
data/lib/aikido/zen/context.rb
CHANGED
|
@@ -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.
|
|
@@ -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
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aikido::Zen
|
|
4
|
+
module IDOR
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Protector
|
|
9
|
+
# @api private
|
|
10
|
+
# Visible for testing.
|
|
11
|
+
attr_accessor :cache
|
|
12
|
+
|
|
13
|
+
def initialize(config: Aikido::Zen.config)
|
|
14
|
+
@config = config
|
|
15
|
+
|
|
16
|
+
@cache = CappedMap.new(@config.idor_max_cache_entries, mode: :lru)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param sql [String]
|
|
20
|
+
# @param dialect_name [Symbol]
|
|
21
|
+
# @param params [Array, nil]
|
|
22
|
+
# @param context [Aikido::Zen::Context]
|
|
23
|
+
# @raise [Aikido::Zen::IDOR::Error, Aikido::Zen::InternalsError]
|
|
24
|
+
def protect(sql, dialect_name, params, context)
|
|
25
|
+
return unless @config.idor_protection_enabled? && context.idor_protection_enabled?
|
|
26
|
+
|
|
27
|
+
tenant_id = context.request.tenant_id
|
|
28
|
+
|
|
29
|
+
if tenant_id.nil?
|
|
30
|
+
raise Aikido::Zen::IDOR::Error.new("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.")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
dialect = Aikido::Zen::SQL::Dialects.fetch(dialect_name)
|
|
34
|
+
|
|
35
|
+
analysis = analyze(sql, dialect)
|
|
36
|
+
|
|
37
|
+
analysis.each do |query_result|
|
|
38
|
+
if query_result.kind == :insert
|
|
39
|
+
protect_insert(dialect, query_result, tenant_id, params)
|
|
40
|
+
else
|
|
41
|
+
protect_filter(dialect, query_result, tenant_id, params)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @param sql [String]
|
|
49
|
+
# @param dialect [Aikido::Zen::SQL::Dialects::Dialect]
|
|
50
|
+
# @return [Array<Aikido::Zen::IDOR::SQLQueryResult>]
|
|
51
|
+
# @raise [Aikido::Zen::IDOR::Error, Aikido::Zen::InternalsError]
|
|
52
|
+
def analyze(sql, dialect)
|
|
53
|
+
cache_key = [dialect.internals_key, sql]
|
|
54
|
+
|
|
55
|
+
analysis = @cache[cache_key]
|
|
56
|
+
return analysis if analysis
|
|
57
|
+
|
|
58
|
+
analysis = Internals.idor_analyze_sql(sql, dialect)
|
|
59
|
+
|
|
60
|
+
# :nocov:
|
|
61
|
+
unless analysis
|
|
62
|
+
raise IDOR::Error, "Zen IDOR protection: failed to analyze SQL query"
|
|
63
|
+
end
|
|
64
|
+
# :nocov:
|
|
65
|
+
|
|
66
|
+
if analysis.is_a?(Hash) && analysis["error"]
|
|
67
|
+
raise IDOR::Error, "Zen IDOR protection: #{analysis["error"]}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
result = analysis.map do |value|
|
|
71
|
+
Aikido::Zen::IDOR::SQLQueryResult.from_json(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@cache[cache_key] = result
|
|
75
|
+
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def protect_insert(dialect, query_result, tenant_id, params)
|
|
80
|
+
query_result.tables.each do |table|
|
|
81
|
+
next if @config.idor_excluded_table_names.include?(table.name)
|
|
82
|
+
|
|
83
|
+
unless query_result.insert_columns
|
|
84
|
+
# INSERT ... SELECT without explicit columns — can't verify tenant column
|
|
85
|
+
raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
query_result.insert_columns.each do |row|
|
|
89
|
+
tenant_column = row.find { |column| column.name == @config.idor_tenant_column_name }
|
|
90
|
+
|
|
91
|
+
unless tenant_column
|
|
92
|
+
raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
resolved_tenant_id = tenant_column.value
|
|
96
|
+
|
|
97
|
+
if tenant_column.is_placeholder
|
|
98
|
+
resolved_tenant_id = dialect.resolve_placeholder(tenant_column.value, tenant_column.placeholder_number, params)
|
|
99
|
+
|
|
100
|
+
unless resolved_tenant_id
|
|
101
|
+
raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' has a placeholder for '#{@config.idor_tenant_column_name}' that could not be resolved"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if resolved_tenant_id.to_s != tenant_id.to_s
|
|
106
|
+
raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' sets '#{@config.idor_tenant_column_name}' to '#{resolved_tenant_id}' but tenant ID is '#{tenant_id}'"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def protect_filter(dialect, query_result, tenant_id, params)
|
|
113
|
+
query_result.tables.each do |table|
|
|
114
|
+
next if @config.idor_excluded_table_names.include?(table.name)
|
|
115
|
+
|
|
116
|
+
tenant_column = query_result.filter_columns.find do |column|
|
|
117
|
+
next false if column.name != @config.idor_tenant_column_name
|
|
118
|
+
|
|
119
|
+
next column.table_qualifier == table.name || column.table_qualifier == table.alt_name if column.table_qualifier
|
|
120
|
+
|
|
121
|
+
# Unqualified column (e.g. WHERE tenant_id = $1 without table prefix):
|
|
122
|
+
# We can only safely attribute it to the current table when there's
|
|
123
|
+
# exactly one table in the query. With multiple tables, we can't know
|
|
124
|
+
# which table the unqualified column belongs to.
|
|
125
|
+
query_result.tables.size == 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless tenant_column
|
|
129
|
+
raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
resolved_tenant_id = tenant_column.value
|
|
133
|
+
|
|
134
|
+
if tenant_column.is_placeholder
|
|
135
|
+
resolved_tenant_id = dialect.resolve_placeholder(tenant_column.value, tenant_column.placeholder_number, params)
|
|
136
|
+
|
|
137
|
+
unless resolved_tenant_id
|
|
138
|
+
raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' has a placeholder for '#{@config.idor_tenant_column_name}' that could not be resolved"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if resolved_tenant_id.to_s != tenant_id.to_s
|
|
143
|
+
raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' sets '#{@config.idor_tenant_column_name}' to '#{resolved_tenant_id}' but tenant ID is '#{tenant_id}'"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/aikido/zen/internals.rb
CHANGED
|
@@ -26,10 +26,12 @@ module Aikido::Zen
|
|
|
26
26
|
|
|
27
27
|
names << "#{lib_name}-#{platform}.#{lib_ext}"
|
|
28
28
|
|
|
29
|
+
# :nocov:
|
|
29
30
|
unless platform.version.nil?
|
|
30
31
|
platform.version = nil
|
|
31
32
|
names << "#{lib_name}-#{platform}.#{lib_ext}"
|
|
32
33
|
end
|
|
34
|
+
# :nocov:
|
|
33
35
|
|
|
34
36
|
names
|
|
35
37
|
end
|
|
@@ -51,7 +53,9 @@ module Aikido::Zen
|
|
|
51
53
|
# empty
|
|
52
54
|
end
|
|
53
55
|
end
|
|
56
|
+
# :nocov:
|
|
54
57
|
raise LoadError, "Zen could not load its native extension #{libzen_name}"
|
|
58
|
+
# :nocov:
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
begin
|
|
@@ -59,12 +63,16 @@ module Aikido::Zen
|
|
|
59
63
|
|
|
60
64
|
# @!method self.detect_sql_injection_native(query, input, dialect)
|
|
61
65
|
# @param (see .detect_sql_injection)
|
|
62
|
-
# @
|
|
66
|
+
# @return [Integer] 0 if no injection detected, 1 if an injection was
|
|
63
67
|
# detected, 2 if there was an internal error, or 3 if SQL tokenization failed.
|
|
64
68
|
# @raise [Aikido::Zen::InternalsError] if there's a problem loading or
|
|
65
69
|
# calling libzen.
|
|
66
70
|
attach_function :detect_sql_injection_native, :detect_sql_injection,
|
|
67
71
|
[:pointer, :size_t, :pointer, :size_t, :int], :int
|
|
72
|
+
|
|
73
|
+
attach_function :idor_analyze_sql_native, :idor_analyze_sql_ffi, [:pointer, :size_t, :int], :pointer
|
|
74
|
+
|
|
75
|
+
attach_function :idor_free_string_native, :free_string, [:pointer], :void
|
|
68
76
|
rescue LoadError, FFI::NotFoundError => err # rubocop:disable Lint/ShadowedException
|
|
69
77
|
# :nocov:
|
|
70
78
|
|
|
@@ -76,6 +84,11 @@ module Aikido::Zen
|
|
|
76
84
|
raise InternalsError.new(attempt, "loading", libzen_name)
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
def self.idor_analyze_sql(query, *)
|
|
88
|
+
attempt = format("%p for SQL analysis", query)
|
|
89
|
+
raise InternalsError.new(attempt, "loading", libzen_name)
|
|
90
|
+
end
|
|
91
|
+
|
|
79
92
|
# :nocov:
|
|
80
93
|
else
|
|
81
94
|
# Analyzes the SQL query to detect if the provided user input is being
|
|
@@ -86,7 +99,7 @@ module Aikido::Zen
|
|
|
86
99
|
# @param dialect [Integer, #to_int] the SQL Dialect identifier in libzen.
|
|
87
100
|
# See {Aikido::Zen::Scanners::SQLInjectionScanner::DIALECTS}.
|
|
88
101
|
#
|
|
89
|
-
# @
|
|
102
|
+
# @return [Boolean]
|
|
90
103
|
# @raise [Aikido::Zen::InternalsError] if there's a problem loading or
|
|
91
104
|
# calling libzen.
|
|
92
105
|
def self.detect_sql_injection(query, input, dialect)
|
|
@@ -108,6 +121,18 @@ module Aikido::Zen
|
|
|
108
121
|
|
|
109
122
|
result
|
|
110
123
|
end
|
|
124
|
+
|
|
125
|
+
def self.idor_analyze_sql(query, dialect)
|
|
126
|
+
query_bytes = encode_safely(query)
|
|
127
|
+
query_ptr = FFI::MemoryPointer.new(:uint8, query_bytes.bytesize)
|
|
128
|
+
query_ptr.put_bytes(0, query_bytes)
|
|
129
|
+
|
|
130
|
+
result_ptr = idor_analyze_sql_native(query_ptr, query_bytes.bytesize, dialect)
|
|
131
|
+
result_json = result_ptr.read_string
|
|
132
|
+
idor_free_string_native(result_ptr)
|
|
133
|
+
|
|
134
|
+
JSON.parse(result_json)
|
|
135
|
+
end
|
|
111
136
|
end
|
|
112
137
|
|
|
113
138
|
class << self
|
|
Binary file
|
|
@@ -39,6 +39,10 @@ module Aikido::Zen
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
ActiveSupport.on_load(:action_controller) do
|
|
42
|
+
before_action do
|
|
43
|
+
Aikido::Zen.enable_idor_protection if Aikido::Zen.config.idor_protection_enabled?
|
|
44
|
+
end
|
|
45
|
+
|
|
42
46
|
# Due to how Rails sets up its middleware chain, the routing is evaluated
|
|
43
47
|
# (and the Request object constructed) in the app that terminates the
|
|
44
48
|
# chain, so no amount of middleware will be able to access it.
|
data/lib/aikido/zen/request.rb
CHANGED
|
@@ -17,6 +17,12 @@ module Aikido::Zen
|
|
|
17
17
|
# @see Aikido::Zen.track_user
|
|
18
18
|
attr_accessor :actor
|
|
19
19
|
|
|
20
|
+
# The current tenant, if set by the host app.
|
|
21
|
+
#
|
|
22
|
+
# @return [Integer, String, nil]
|
|
23
|
+
# @see Aikido::Zen.set_tenant_id
|
|
24
|
+
attr_accessor :tenant_id
|
|
25
|
+
|
|
20
26
|
def initialize(delegate, config = Aikido::Zen.config, framework:, router:)
|
|
21
27
|
super(delegate)
|
|
22
28
|
@config = config
|
|
@@ -24,10 +24,7 @@ module Aikido::Zen
|
|
|
24
24
|
# @return [Aikido::Zen::Attack, nil] an Attack if any user input is
|
|
25
25
|
# detected to be attempting a SQL injection, or nil if this is safe.
|
|
26
26
|
def self.call(query:, dialect:, scan:, sink:, context:, operation:)
|
|
27
|
-
dialect =
|
|
28
|
-
Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
|
|
29
|
-
DIALECTS[:common]
|
|
30
|
-
end
|
|
27
|
+
dialect = Aikido::Zen::SQL::Dialects.fetch(dialect)
|
|
31
28
|
|
|
32
29
|
context.payloads.each do |payload|
|
|
33
30
|
scanner = new(query, payload.value.to_s, dialect)
|
|
@@ -93,23 +90,6 @@ module Aikido::Zen
|
|
|
93
90
|
|
|
94
91
|
raise err
|
|
95
92
|
end
|
|
96
|
-
|
|
97
|
-
# @api private
|
|
98
|
-
Dialect = Struct.new(:name, :internals_key, keyword_init: true) do
|
|
99
|
-
alias_method :to_s, :name
|
|
100
|
-
alias_method :to_int, :internals_key
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Maps easy-to-use Symbols to a struct that keeps both the name and the
|
|
104
|
-
# internal identifier used by libzen.
|
|
105
|
-
#
|
|
106
|
-
# @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
|
|
107
|
-
DIALECTS = {
|
|
108
|
-
common: Dialect.new(name: "SQL", internals_key: 0),
|
|
109
|
-
mysql: Dialect.new(name: "MySQL", internals_key: 8),
|
|
110
|
-
postgresql: Dialect.new(name: "PostgreSQL", internals_key: 9),
|
|
111
|
-
sqlite: Dialect.new(name: "SQLite", internals_key: 12)
|
|
112
|
-
}
|
|
113
93
|
end
|
|
114
94
|
end
|
|
115
95
|
end
|
|
@@ -18,8 +18,32 @@ module Aikido::Zen
|
|
|
18
18
|
::Mysql2::Client.class_eval do
|
|
19
19
|
extend Sinks::DSL
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
presafe_sink_before :query do |sql|
|
|
22
|
+
Sinks::DSL.safe do
|
|
23
|
+
Helpers.scan(sql, "query")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Aikido::Zen.idor_protect(sql, :mysql)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
presafe_sink_after :prepare do |result, sql|
|
|
30
|
+
result.aikido_idor_sql = sql
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
::Mysql2::Statement.class_eval do
|
|
35
|
+
extend Sinks::DSL
|
|
36
|
+
|
|
37
|
+
attr_accessor :aikido_idor_sql
|
|
38
|
+
|
|
39
|
+
presafe_sink_before :execute do |*args, **kwargs|
|
|
40
|
+
sql = aikido_idor_sql
|
|
41
|
+
|
|
42
|
+
Sinks::DSL.safe do
|
|
43
|
+
Helpers.scan(sql, "query")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Aikido::Zen.idor_protect(sql, :mysql, args)
|
|
23
47
|
end
|
|
24
48
|
end
|
|
25
49
|
end
|
data/lib/aikido/zen/sinks/pg.rb
CHANGED
|
@@ -44,31 +44,63 @@ module Aikido::Zen
|
|
|
44
44
|
|
|
45
45
|
[
|
|
46
46
|
:send_query,
|
|
47
|
-
:exec,
|
|
48
|
-
:sync_exec,
|
|
47
|
+
:exec, # also known as: async_exec
|
|
49
48
|
:async_exec,
|
|
49
|
+
:sync_exec
|
|
50
|
+
].each do |method_name|
|
|
51
|
+
presafe_sink_before method_name do |sql|
|
|
52
|
+
Helpers.safe do
|
|
53
|
+
Helpers.scan(sql, method_name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Aikido::Zen.idor_protect(sql, :postgresql)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
[
|
|
50
61
|
:send_query_params,
|
|
51
|
-
:exec_params,
|
|
52
|
-
:
|
|
53
|
-
:
|
|
62
|
+
:exec_params, # also known as: async_exec_params
|
|
63
|
+
:async_exec_params,
|
|
64
|
+
:sync_exec_params
|
|
54
65
|
].each do |method_name|
|
|
55
|
-
presafe_sink_before method_name do |
|
|
66
|
+
presafe_sink_before method_name do |sql, params|
|
|
56
67
|
Helpers.safe do
|
|
57
|
-
Helpers.scan(
|
|
68
|
+
Helpers.scan(sql, method_name)
|
|
58
69
|
end
|
|
70
|
+
|
|
71
|
+
Aikido::Zen.idor_protect(sql, :postgresql, params)
|
|
59
72
|
end
|
|
60
73
|
end
|
|
61
74
|
|
|
75
|
+
def aikido_idor_prepared_statements
|
|
76
|
+
@aikido_idor_prepared_statements ||= {}
|
|
77
|
+
end
|
|
78
|
+
|
|
62
79
|
[
|
|
63
80
|
:send_prepare,
|
|
64
|
-
:prepare,
|
|
81
|
+
:prepare, # also known as: async_prepare
|
|
65
82
|
:async_prepare,
|
|
66
83
|
:sync_prepare
|
|
67
84
|
].each do |method_name|
|
|
68
|
-
presafe_sink_before method_name do |
|
|
85
|
+
presafe_sink_before method_name do |statement_name, sql|
|
|
86
|
+
aikido_idor_prepared_statements[statement_name] = sql
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
[
|
|
91
|
+
:send_query_prepared,
|
|
92
|
+
:exec_prepared, # also known as: async_exec_prepared
|
|
93
|
+
:async_exec_prepared,
|
|
94
|
+
:sync_exec_prepared
|
|
95
|
+
].each do |method_name|
|
|
96
|
+
presafe_sink_before method_name do |statement_name, params|
|
|
97
|
+
sql = aikido_idor_prepared_statements[statement_name]
|
|
98
|
+
|
|
69
99
|
Helpers.safe do
|
|
70
|
-
Helpers.scan(
|
|
100
|
+
Helpers.scan(sql, method_name)
|
|
71
101
|
end
|
|
102
|
+
|
|
103
|
+
Aikido::Zen.idor_protect(sql, :postgresql, params)
|
|
72
104
|
end
|
|
73
105
|
end
|
|
74
106
|
end
|
|
@@ -22,19 +22,63 @@ module Aikido::Zen
|
|
|
22
22
|
::SQLite3::Database.class_eval do
|
|
23
23
|
extend Sinks::DSL
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
[
|
|
26
|
+
:execute,
|
|
27
|
+
:execute_batch
|
|
28
|
+
].each do |method_name|
|
|
29
|
+
presafe_sink_before method_name do |sql, bind_vars|
|
|
30
|
+
Sinks::DSL.safe do
|
|
31
|
+
Helpers.scan(sql, "database.execute")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Aikido::Zen.idor_protect(sql, :sqlite, bind_vars)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
26
37
|
|
|
27
38
|
# SQLite3::Database#exec_batch is an internal native private method.
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
presafe_sink_before :exec_batch do |sql, *args, **kwargs|
|
|
40
|
+
Sinks::DSL.safe do
|
|
41
|
+
Helpers.scan(sql, "exec_batch")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Aikido::Zen.idor_protect(sql, :sqlite)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
alias_method :prepare__internal_for_aikido_zen, :prepare
|
|
48
|
+
|
|
49
|
+
def prepare(*args, **kwargs, &blk)
|
|
50
|
+
sql, = args
|
|
51
|
+
|
|
52
|
+
Sinks::DSL.safe do
|
|
53
|
+
Helpers.scan(sql, "statement.execute")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless blk
|
|
57
|
+
result = prepare__internal_for_aikido_zen(*args, **kwargs)
|
|
58
|
+
result.aikido_idor_sql = sql
|
|
59
|
+
return result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
prepare__internal_for_aikido_zen(*args, **kwargs) do |stmt|
|
|
63
|
+
stmt.aikido_idor_sql = sql
|
|
64
|
+
blk.call(stmt)
|
|
65
|
+
end
|
|
30
66
|
end
|
|
31
67
|
end
|
|
32
68
|
|
|
33
69
|
::SQLite3::Statement.class_eval do
|
|
34
70
|
extend Sinks::DSL
|
|
35
71
|
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
attr_accessor :aikido_idor_sql
|
|
73
|
+
|
|
74
|
+
presafe_sink_before :execute do |*bind_vars|
|
|
75
|
+
sql = aikido_idor_sql
|
|
76
|
+
|
|
77
|
+
Sinks::DSL.safe do
|
|
78
|
+
Helpers.scan(sql, "statement.execute")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Aikido::Zen.idor_protect(sql, :sqlite, bind_vars)
|
|
38
82
|
end
|
|
39
83
|
end
|
|
40
84
|
end
|
|
@@ -18,8 +18,12 @@ module Aikido::Zen
|
|
|
18
18
|
::Trilogy.class_eval do
|
|
19
19
|
extend Sinks::DSL
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
presafe_sink_before :query do |sql|
|
|
22
|
+
Sinks::DSL.safe do
|
|
23
|
+
Helpers.scan(sql, "query")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Aikido::Zen.idor_protect(sql, :mysql)
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aikido::Zen
|
|
4
|
+
module SQL
|
|
5
|
+
module Dialects
|
|
6
|
+
# @api private
|
|
7
|
+
Dialect = Struct.new(:name, :internals_key, :placeholder_resolver, keyword_init: true) do
|
|
8
|
+
alias_method :to_s, :name
|
|
9
|
+
alias_method :to_int, :internals_key
|
|
10
|
+
|
|
11
|
+
def resolve_placeholder(*args, **kwargs)
|
|
12
|
+
placeholder_resolver.call(*args, **kwargs)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param value [String]
|
|
17
|
+
# @param placeholder_number [Integer, nil]
|
|
18
|
+
# @param params [Array<Object>, nil]
|
|
19
|
+
# @return [Object]
|
|
20
|
+
def self.common_placeholder_resolver(value, placeholder_number, params)
|
|
21
|
+
return nil unless params
|
|
22
|
+
|
|
23
|
+
params[placeholder_number] unless placeholder_number.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param value [String]
|
|
27
|
+
# @param placeholder_number [Integer, nil]
|
|
28
|
+
# @param params [Array<Object>, nil]
|
|
29
|
+
# @return [Object]
|
|
30
|
+
def self.postgresql_placeholder_resolver(value, placeholder_number, params)
|
|
31
|
+
return nil unless params
|
|
32
|
+
|
|
33
|
+
match = value.match(/^\$(\d+)$/)
|
|
34
|
+
if match
|
|
35
|
+
index = match[1].to_i - 1
|
|
36
|
+
return if index < 0
|
|
37
|
+
|
|
38
|
+
params[index]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param value [String]
|
|
43
|
+
# @param placeholder_number [Integer, nil]
|
|
44
|
+
# @param params [Array<Object>, nil]
|
|
45
|
+
# @return [Object]
|
|
46
|
+
def self.sqlite_placeholder_resolver(value, placeholder_number, params)
|
|
47
|
+
return nil unless params
|
|
48
|
+
|
|
49
|
+
return params[placeholder_number] unless placeholder_number.nil?
|
|
50
|
+
|
|
51
|
+
case value
|
|
52
|
+
when /^\?(\d+)$/
|
|
53
|
+
match = Regexp.last_match
|
|
54
|
+
|
|
55
|
+
index = match[1].to_i - 1
|
|
56
|
+
return if index < 0
|
|
57
|
+
|
|
58
|
+
params[index]
|
|
59
|
+
when /^[:@$]([A-Za-z_][A-Za-z0-9_]*)$/
|
|
60
|
+
match = Regexp.last_match
|
|
61
|
+
|
|
62
|
+
key = match[1]
|
|
63
|
+
|
|
64
|
+
params.flatten.each do |param|
|
|
65
|
+
if Hash === param
|
|
66
|
+
param.each do |param_key, param_value|
|
|
67
|
+
return param_value if param_key.to_s == key
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Maps easy-to-use Symbols to a struct that keeps both the name and the
|
|
75
|
+
# internal identifier used by libzen.
|
|
76
|
+
#
|
|
77
|
+
# @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
|
|
78
|
+
DIALECTS = {
|
|
79
|
+
common: Dialect.new(
|
|
80
|
+
name: "SQL",
|
|
81
|
+
internals_key: 0,
|
|
82
|
+
placeholder_resolver: method(:common_placeholder_resolver)
|
|
83
|
+
),
|
|
84
|
+
mysql: Dialect.new(
|
|
85
|
+
name: "MySQL",
|
|
86
|
+
internals_key: 8,
|
|
87
|
+
placeholder_resolver: method(:common_placeholder_resolver)
|
|
88
|
+
),
|
|
89
|
+
postgresql: Dialect.new(
|
|
90
|
+
name: "PostgreSQL",
|
|
91
|
+
internals_key: 9,
|
|
92
|
+
placeholder_resolver: method(:postgresql_placeholder_resolver)
|
|
93
|
+
),
|
|
94
|
+
sqlite: Dialect.new(
|
|
95
|
+
name: "SQLite",
|
|
96
|
+
internals_key: 12,
|
|
97
|
+
placeholder_resolver: method(:sqlite_placeholder_resolver)
|
|
98
|
+
)
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
101
|
+
# @param dialect [Symbol]
|
|
102
|
+
# @return [Aikido::Zen::SQL::Dialects::Dialect]
|
|
103
|
+
def self.fetch(dialect)
|
|
104
|
+
DIALECTS.fetch(dialect, DIALECTS[:common])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/aikido/zen/version.rb
CHANGED
data/lib/aikido/zen.rb
CHANGED
|
@@ -26,6 +26,8 @@ require_relative "zen/outbound_connection"
|
|
|
26
26
|
require_relative "zen/runtime_settings"
|
|
27
27
|
require_relative "zen/rate_limiter"
|
|
28
28
|
require_relative "zen/attack_wave"
|
|
29
|
+
require_relative "zen/sql"
|
|
30
|
+
require_relative "zen/idor"
|
|
29
31
|
require_relative "zen/scanners"
|
|
30
32
|
|
|
31
33
|
module Aikido
|
|
@@ -213,17 +215,78 @@ module Aikido
|
|
|
213
215
|
alias_method :set_user, :track_user
|
|
214
216
|
end
|
|
215
217
|
|
|
218
|
+
# @return [Aikido::Zen::AttackWave::Detector] the attack wave detector.
|
|
219
|
+
def self.attack_wave_detector
|
|
220
|
+
@attack_wave_detector ||= AttackWave::Detector.new
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [Aikido::Zen::IDOR::Protector]
|
|
224
|
+
def self.idor_protector
|
|
225
|
+
@idor_protector ||= IDOR::Protector.new
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @param sql [String]
|
|
229
|
+
# @param dialect [Symbol]
|
|
230
|
+
# @param params [Array, nil]
|
|
231
|
+
# @return [void]
|
|
232
|
+
# @raise [Aikido::Zen::IDOR::Error]
|
|
233
|
+
def self.idor_protect(sql, dialect_name, params = nil)
|
|
234
|
+
context = current_context
|
|
235
|
+
return unless context
|
|
236
|
+
|
|
237
|
+
idor_protector.protect(sql, dialect_name, params, context)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Enable IDOR protection for the current context.
|
|
241
|
+
#
|
|
242
|
+
# @return [void]
|
|
243
|
+
def self.enable_idor_protection
|
|
244
|
+
context = current_context
|
|
245
|
+
return unless context
|
|
246
|
+
|
|
247
|
+
context.idor_protection_enabled = true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Set the tenant ID for the current request.
|
|
251
|
+
#
|
|
252
|
+
# @param tenant_id [Integer, String, nil]
|
|
253
|
+
# @return [void]
|
|
254
|
+
def self.set_tenant_id(tenant_id)
|
|
255
|
+
context = current_context
|
|
256
|
+
return unless context
|
|
257
|
+
|
|
258
|
+
context.request.tenant_id = tenant_id
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Execute a block with the IDOR protection disabled.
|
|
262
|
+
#
|
|
263
|
+
# @yield the block to execute with the IDOR protection disabled.
|
|
264
|
+
# @return [Object] the result of the block
|
|
265
|
+
# @raise [ArgumentError] if no block is given
|
|
266
|
+
def self.without_idor_protection
|
|
267
|
+
raise ArgumentError, "block required" unless block_given?
|
|
268
|
+
|
|
269
|
+
context = current_context
|
|
270
|
+
|
|
271
|
+
if context
|
|
272
|
+
begin
|
|
273
|
+
original_idor_protection_enabled = context.idor_protection_enabled
|
|
274
|
+
context.idor_protection_enabled = false
|
|
275
|
+
yield
|
|
276
|
+
ensure
|
|
277
|
+
context.idor_protection_enabled = original_idor_protection_enabled
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
yield
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
216
284
|
# Marks that the Zen middleware was installed properly
|
|
217
285
|
# @return void
|
|
218
286
|
def self.middleware_installed!
|
|
219
287
|
collector.middleware_installed!
|
|
220
288
|
end
|
|
221
289
|
|
|
222
|
-
# @return [Aikido::Zen::AttackWave::Detector] the attack wave detector.
|
|
223
|
-
def self.attack_wave_detector
|
|
224
|
-
@attack_wave_detector ||= AttackWave::Detector.new
|
|
225
|
-
end
|
|
226
|
-
|
|
227
290
|
# @!visibility private
|
|
228
291
|
# Load all sources.
|
|
229
292
|
#
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aikido-zen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Aikido Security
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -91,6 +91,7 @@ files:
|
|
|
91
91
|
- benchmarks/rails7.1_sql_injection.js
|
|
92
92
|
- docs/banner.svg
|
|
93
93
|
- docs/config.md
|
|
94
|
+
- docs/idor-protection.md
|
|
94
95
|
- docs/invalid-sql-queries.md
|
|
95
96
|
- docs/proxy.md
|
|
96
97
|
- docs/rails.md
|
|
@@ -126,8 +127,11 @@ files:
|
|
|
126
127
|
- lib/aikido/zen/errors.rb
|
|
127
128
|
- lib/aikido/zen/event.rb
|
|
128
129
|
- lib/aikido/zen/helpers.rb
|
|
130
|
+
- lib/aikido/zen/idor.rb
|
|
131
|
+
- lib/aikido/zen/idor/analysis_result.rb
|
|
132
|
+
- lib/aikido/zen/idor/protector.rb
|
|
129
133
|
- lib/aikido/zen/internals.rb
|
|
130
|
-
- lib/aikido/zen/libzen-v0.1.
|
|
134
|
+
- lib/aikido/zen/libzen-v0.1.61-x86_64-linux.so
|
|
131
135
|
- lib/aikido/zen/middleware/allowed_address_checker.rb
|
|
132
136
|
- lib/aikido/zen/middleware/attack_protector.rb
|
|
133
137
|
- lib/aikido/zen/middleware/attack_wave_protector.rb
|
|
@@ -197,6 +201,7 @@ files:
|
|
|
197
201
|
- lib/aikido/zen/sinks/trilogy.rb
|
|
198
202
|
- lib/aikido/zen/sinks/typhoeus.rb
|
|
199
203
|
- lib/aikido/zen/sinks_dsl.rb
|
|
204
|
+
- lib/aikido/zen/sql.rb
|
|
200
205
|
- lib/aikido/zen/synchronizable.rb
|
|
201
206
|
- lib/aikido/zen/system_info.rb
|
|
202
207
|
- lib/aikido/zen/version.rb
|
|
Binary file
|