@intentsolutionsio/penetration-tester 2.0.0 → 3.0.4
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.
- package/.claude-plugin/plugin.json +8 -3
- package/README.md +8 -0
- package/commands/pentest.md +5 -0
- package/package.json +8 -3
- package/skills/analyzing-tls-config/SKILL.md +221 -0
- package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
- package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
- package/skills/analyzing-tls-config/references/THEORY.md +128 -0
- package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
- package/skills/auditing-cors-policy/SKILL.md +186 -0
- package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
- package/skills/auditing-cors-policy/references/THEORY.md +142 -0
- package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
- package/skills/auditing-npm-dependencies/SKILL.md +254 -0
- package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
- package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
- package/skills/auditing-python-dependencies/SKILL.md +251 -0
- package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
- package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
- package/skills/checking-http-security-headers/SKILL.md +176 -0
- package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
- package/skills/checking-http-security-headers/references/THEORY.md +137 -0
- package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
- package/skills/checking-license-compliance/SKILL.md +225 -0
- package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
- package/skills/checking-license-compliance/references/THEORY.md +152 -0
- package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
- package/skills/composing-vulnerability-report/SKILL.md +212 -0
- package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
- package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
- package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
- package/skills/confirming-pentest-authorization/SKILL.md +247 -0
- package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
- package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
- package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
- package/skills/defining-pentest-scope/SKILL.md +227 -0
- package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
- package/skills/defining-pentest-scope/references/THEORY.md +170 -0
- package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
- package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
- package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
- package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
- package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
- package/skills/detecting-debug-endpoints/SKILL.md +207 -0
- package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
- package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
- package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
- package/skills/detecting-directory-listing/SKILL.md +206 -0
- package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
- package/skills/detecting-directory-listing/references/THEORY.md +203 -0
- package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
- package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
- package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
- package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
- package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
- package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
- package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
- package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
- package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
- package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
- package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
- package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
- package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
- package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
- package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
- package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
- package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
- package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
- package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
- package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
- package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
- package/skills/detecting-weak-cryptography/SKILL.md +147 -0
- package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
- package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
- package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
- package/skills/fingerprinting-server-software/SKILL.md +191 -0
- package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
- package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
- package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
- package/skills/generating-executive-summary/SKILL.md +261 -0
- package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
- package/skills/generating-executive-summary/references/THEORY.md +195 -0
- package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
- package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
- package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
- package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
- package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
- package/skills/performing-penetration-testing/SKILL.md +282 -190
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
- package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
- package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
- package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
- package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
- package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
- package/skills/recording-pentest-engagement/SKILL.md +253 -0
- package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
- package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
- package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
- package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
- package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
- package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
- package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
- package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
- package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
- package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
- package/skills/tracing-transitive-vulnerabilities/scripts/trace_vulns.py +484 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# SQL-Injection Remediation Playbook
|
|
2
|
+
|
|
3
|
+
Each section is a copy-paste-ready before/after pair for the matched
|
|
4
|
+
pattern. The principle is universal: parameterized query with
|
|
5
|
+
placeholder, values passed as a separate argument.
|
|
6
|
+
|
|
7
|
+
## Python — sqlite3 / psycopg / SQLAlchemy
|
|
8
|
+
|
|
9
|
+
### Before
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
def get_user(user_id):
|
|
13
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
14
|
+
return cursor.fetchone()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### After (psycopg)
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
def get_user(user_id):
|
|
21
|
+
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
22
|
+
return cursor.fetchone()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### After (SQLAlchemy ORM)
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
def get_user(user_id):
|
|
29
|
+
return session.query(User).filter(User.id == user_id).first()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Identifier interpolation (dynamic table / column) — allow-list
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
SORTABLE = {"id", "name", "email", "created_at"}
|
|
36
|
+
|
|
37
|
+
def list_users(sort_by):
|
|
38
|
+
if sort_by not in SORTABLE:
|
|
39
|
+
raise ValueError(f"Invalid sort column: {sort_by}")
|
|
40
|
+
# Now safe: sort_by is one of a finite known-safe set
|
|
41
|
+
cursor.execute(f"SELECT * FROM users ORDER BY {sort_by}")
|
|
42
|
+
return cursor.fetchall()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For runtime-dynamic identifiers (rare), use psycopg's typed
|
|
46
|
+
identifier helper:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from psycopg.sql import SQL, Identifier
|
|
50
|
+
|
|
51
|
+
def query_table(table_name):
|
|
52
|
+
if table_name not in ALLOWED_TABLES:
|
|
53
|
+
raise ValueError()
|
|
54
|
+
stmt = SQL("SELECT * FROM {}").format(Identifier(table_name))
|
|
55
|
+
cursor.execute(stmt)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Node.js — mysql2 / pg / sequelize / knex
|
|
59
|
+
|
|
60
|
+
### Before (mysql2)
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
const result = await db.query(`SELECT * FROM users WHERE id = ${userId}`);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### After (mysql2)
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
const [rows] = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### After (pg)
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
const result = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Sequelize before
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const users = await sequelize.query(
|
|
82
|
+
`SELECT * FROM users WHERE name = '${name}'`,
|
|
83
|
+
{ type: QueryTypes.SELECT }
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Sequelize after
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
const users = await sequelize.query(
|
|
91
|
+
"SELECT * FROM users WHERE name = :name",
|
|
92
|
+
{
|
|
93
|
+
replacements: { name },
|
|
94
|
+
type: QueryTypes.SELECT
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Knex before
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
const rows = await knex.raw(`SELECT * FROM users WHERE id = ${id}`);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Knex after (use the query builder, not raw)
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
const rows = await knex("users").where("id", id);
|
|
109
|
+
// Or with raw + binding:
|
|
110
|
+
const rows = await knex.raw("SELECT * FROM users WHERE id = ?", [id]);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Ruby on Rails
|
|
114
|
+
|
|
115
|
+
### Before
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
User.where("name = '#{params[:name]}'")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### After (hash form)
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
User.where(name: params[:name])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### After (array form with `?`)
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
User.where("name = ?", params[:name])
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### For raw SQL
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
User.find_by_sql([
|
|
137
|
+
"SELECT * FROM users WHERE name = ? AND active = ?",
|
|
138
|
+
params[:name], true
|
|
139
|
+
])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Go (database/sql)
|
|
143
|
+
|
|
144
|
+
### Before
|
|
145
|
+
|
|
146
|
+
```go
|
|
147
|
+
rows, err := db.Query(fmt.Sprintf("SELECT * FROM users WHERE id = %d", id))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### After (MySQL / SQLite — `?` placeholders)
|
|
151
|
+
|
|
152
|
+
```go
|
|
153
|
+
rows, err := db.Query("SELECT * FROM users WHERE id = ?", id)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### After (PostgreSQL — `$1` placeholders)
|
|
157
|
+
|
|
158
|
+
```go
|
|
159
|
+
rows, err := db.Query("SELECT * FROM users WHERE id = $1", id)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### sqlx (named binds)
|
|
163
|
+
|
|
164
|
+
```go
|
|
165
|
+
type User struct {
|
|
166
|
+
ID int `db:"id"`
|
|
167
|
+
Name string `db:"name"`
|
|
168
|
+
}
|
|
169
|
+
var u User
|
|
170
|
+
err := db.Get(&u, "SELECT * FROM users WHERE id = $1", id)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Java (JDBC)
|
|
174
|
+
|
|
175
|
+
### Before
|
|
176
|
+
|
|
177
|
+
```java
|
|
178
|
+
Statement stmt = conn.createStatement();
|
|
179
|
+
ResultSet rs = stmt.executeQuery("SELECT * FROM users WHERE id = " + id);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### After
|
|
183
|
+
|
|
184
|
+
```java
|
|
185
|
+
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
|
|
186
|
+
stmt.setInt(1, id);
|
|
187
|
+
ResultSet rs = stmt.executeQuery();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Hibernate / JPA
|
|
191
|
+
|
|
192
|
+
```java
|
|
193
|
+
List<User> users = entityManager
|
|
194
|
+
.createQuery("SELECT u FROM User u WHERE u.id = :id", User.class)
|
|
195
|
+
.setParameter("id", id)
|
|
196
|
+
.getResultList();
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## PHP
|
|
200
|
+
|
|
201
|
+
### Before (legacy mysql_query)
|
|
202
|
+
|
|
203
|
+
```php
|
|
204
|
+
$result = mysql_query("SELECT * FROM users WHERE id = " . $id);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### After (PDO with named placeholders)
|
|
208
|
+
|
|
209
|
+
```php
|
|
210
|
+
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
|
|
211
|
+
$stmt->execute(["id" => $id]);
|
|
212
|
+
$user = $stmt->fetch();
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### After (mysqli with positional)
|
|
216
|
+
|
|
217
|
+
```php
|
|
218
|
+
$stmt = $mysqli->prepare("SELECT * FROM users WHERE id = ?");
|
|
219
|
+
$stmt->bind_param("i", $id);
|
|
220
|
+
$stmt->execute();
|
|
221
|
+
$result = $stmt->get_result();
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## C# / .NET
|
|
225
|
+
|
|
226
|
+
### Before
|
|
227
|
+
|
|
228
|
+
```csharp
|
|
229
|
+
var cmd = new SqlCommand("SELECT * FROM users WHERE id = " + userId, conn);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### After
|
|
233
|
+
|
|
234
|
+
```csharp
|
|
235
|
+
var cmd = new SqlCommand("SELECT * FROM users WHERE id = @userId", conn);
|
|
236
|
+
cmd.Parameters.AddWithValue("@userId", userId);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Entity Framework / EF Core
|
|
240
|
+
|
|
241
|
+
```csharp
|
|
242
|
+
var user = context.Users.FirstOrDefault(u => u.Id == userId);
|
|
243
|
+
// or with raw SQL + interpolated query (EF Core auto-parameterizes interpolation in FromSqlInterpolated):
|
|
244
|
+
var users = context.Users.FromSqlInterpolated(
|
|
245
|
+
$"SELECT * FROM users WHERE id = {userId}"
|
|
246
|
+
).ToList();
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Note: `FromSqlInterpolated` is one of the rare safe interpolation
|
|
250
|
+
APIs — EF Core treats interpolated values as parameters under the
|
|
251
|
+
hood.
|
|
252
|
+
|
|
253
|
+
## Pre-commit hook
|
|
254
|
+
|
|
255
|
+
`.pre-commit-config.yaml`:
|
|
256
|
+
|
|
257
|
+
```yaml
|
|
258
|
+
repos:
|
|
259
|
+
- repo: local
|
|
260
|
+
hooks:
|
|
261
|
+
- id: scan-sqli
|
|
262
|
+
name: Scan for SQL-injection patterns
|
|
263
|
+
entry: python3 plugins/security/penetration-tester/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py
|
|
264
|
+
language: system
|
|
265
|
+
args: ['--min-severity', 'high']
|
|
266
|
+
pass_filenames: false
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## CI integration
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
- name: SQL-injection pattern scan
|
|
273
|
+
run: |
|
|
274
|
+
python3 plugins/security/penetration-tester/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py \
|
|
275
|
+
. --min-severity high --format json --output sqli-scan.json
|
|
276
|
+
- run: |
|
|
277
|
+
if jq 'length > 0' sqli-scan.json | grep -q true; then
|
|
278
|
+
echo "::error::SQL injection patterns detected"
|
|
279
|
+
cat sqli-scan.json
|
|
280
|
+
exit 1
|
|
281
|
+
fi
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## After remediation: dynamic-SQL hardening
|
|
285
|
+
|
|
286
|
+
If your application legitimately builds dynamic SQL (multi-tenant
|
|
287
|
+
routing, user-defined queries in a reporting tool), the
|
|
288
|
+
parameterization-only approach isn't sufficient. Add:
|
|
289
|
+
|
|
290
|
+
1. **Allow-list identifiers.** Maintain a fixed set of permitted
|
|
291
|
+
table / column names. Validate against the set before
|
|
292
|
+
interpolation.
|
|
293
|
+
|
|
294
|
+
2. **Use the database's identifier-quoting API.** Each driver
|
|
295
|
+
exposes one (psycopg's `Identifier`, Sequelize's
|
|
296
|
+
`sequelize.escape`, etc.).
|
|
297
|
+
|
|
298
|
+
3. **Lowest-privilege database role.** The application's DB user
|
|
299
|
+
should only have the permissions the app needs. A SELECT-only
|
|
300
|
+
query running under a role that can't INSERT/UPDATE/DELETE
|
|
301
|
+
bounds the blast radius of any injection that does slip through.
|
|
302
|
+
|
|
303
|
+
4. **Database firewall (optional, high-value targets).** Tools
|
|
304
|
+
like ProxySQL or PostgreSQL's `pg_audit` can log every query
|
|
305
|
+
and alert on anomalies (queries containing `; DROP`, queries
|
|
306
|
+
accessing unexpected tables).
|
|
307
|
+
|
|
308
|
+
## Verification after remediation
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
python3 ${CLAUDE_PLUGIN_ROOT}/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py \
|
|
312
|
+
/path/to/repo --min-severity medium
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Expected: exit 0, zero medium-or-higher findings. Remaining LOW
|
|
316
|
+
findings (Django `.extra()` calls with verified allow-listed
|
|
317
|
+
identifiers) are acceptable trade-offs documented per-call.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# SQL-Injection Pattern Theory
|
|
2
|
+
|
|
3
|
+
## Why this class persists
|
|
4
|
+
|
|
5
|
+
SQL injection has been the canonical web vulnerability since the
|
|
6
|
+
late 1990s. Every framework's documentation warns about it. Every
|
|
7
|
+
ORM defaults to parameterized queries. Yet it remains a top-10
|
|
8
|
+
finding category year over year.
|
|
9
|
+
|
|
10
|
+
Three patterns generate ongoing introductions:
|
|
11
|
+
|
|
12
|
+
1. **The "just need a dynamic table name" trap.** ORMs handle
|
|
13
|
+
parameterized VALUES well but typically can't parameterize
|
|
14
|
+
IDENTIFIERS (table names, column names). An engineer
|
|
15
|
+
string-builds the identifier portion, leaves the values
|
|
16
|
+
parameterized, and convinces themselves it's safe. Then a
|
|
17
|
+
feature request adds user-controlled column sorting and the
|
|
18
|
+
"safe" pattern becomes the vulnerability.
|
|
19
|
+
|
|
20
|
+
2. **Legacy code that predates the ORM.** Pre-ORM Java / PHP /
|
|
21
|
+
classic ASP codebases used raw JDBC / mysql_query() / Recordset
|
|
22
|
+
APIs. Migrating to parameterized queries is mechanical but
|
|
23
|
+
tedious. Many migrations stalled at 70-80% and the remaining
|
|
24
|
+
raw queries are still in production.
|
|
25
|
+
|
|
26
|
+
3. **Misuse of "raw" escape hatches.** Every ORM has a raw()
|
|
27
|
+
method (knex.raw, sequelize.query, ActiveRecord.execute,
|
|
28
|
+
Django .extra()) for cases where the ORM's query builder
|
|
29
|
+
doesn't cover something. The escape hatch is fine; concat
|
|
30
|
+
into the escape hatch is the failure mode.
|
|
31
|
+
|
|
32
|
+
## How parameterization actually works
|
|
33
|
+
|
|
34
|
+
The vulnerable pattern looks like this (Python):
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The string is built by Python before being passed to the database
|
|
41
|
+
driver. The driver sees one giant string `SELECT * FROM users
|
|
42
|
+
WHERE id = 1 OR 1=1`. The injection is in the string before the
|
|
43
|
+
SQL parser even sees it. The parser parses the whole thing as a
|
|
44
|
+
single statement.
|
|
45
|
+
|
|
46
|
+
Parameterized version:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The driver sends the query (with `%s` as a placeholder) AND the
|
|
53
|
+
parameters AS SEPARATE PROTOCOL FIELDS. The database parses the
|
|
54
|
+
query first, identifies the placeholder, then BINDS the parameter
|
|
55
|
+
into the parameter slot. The bound parameter is not parsed as
|
|
56
|
+
SQL — it's treated as a literal value, regardless of what
|
|
57
|
+
characters it contains.
|
|
58
|
+
|
|
59
|
+
Even an attacker sending `1 OR 1=1` as the `user_id` value gets
|
|
60
|
+
treated as the literal string `1 OR 1=1` looking for a row where
|
|
61
|
+
the ID column equals that exact string.
|
|
62
|
+
|
|
63
|
+
This is why parameterization works: the parser separates structure
|
|
64
|
+
from data. Concatenation eliminates the separation.
|
|
65
|
+
|
|
66
|
+
## Per-language patterns
|
|
67
|
+
|
|
68
|
+
### Python
|
|
69
|
+
|
|
70
|
+
**Unsafe:**
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
|
74
|
+
cursor.execute("SELECT * FROM users WHERE id = " + str(user_id))
|
|
75
|
+
cursor.execute("SELECT * FROM users WHERE id = %s" % user_id)
|
|
76
|
+
cursor.execute("SELECT * FROM users WHERE id = {}".format(user_id))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Safe:**
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# sqlite3
|
|
83
|
+
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
|
84
|
+
|
|
85
|
+
# psycopg / psycopg2
|
|
86
|
+
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
87
|
+
|
|
88
|
+
# SQLAlchemy Core
|
|
89
|
+
result = conn.execute(text("SELECT * FROM users WHERE id = :uid"), {"uid": user_id})
|
|
90
|
+
|
|
91
|
+
# SQLAlchemy ORM
|
|
92
|
+
session.query(User).filter(User.id == user_id).all()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Node.js
|
|
96
|
+
|
|
97
|
+
**Unsafe:**
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
db.query(`SELECT * FROM users WHERE id = ${userId}`)
|
|
101
|
+
db.query("SELECT * FROM users WHERE id = " + userId)
|
|
102
|
+
sequelize.query(`SELECT * FROM users WHERE name = '${name}'`)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Safe:**
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
// mysql2
|
|
109
|
+
db.query("SELECT * FROM users WHERE id = ?", [userId])
|
|
110
|
+
|
|
111
|
+
// pg
|
|
112
|
+
db.query("SELECT * FROM users WHERE id = $1", [userId])
|
|
113
|
+
|
|
114
|
+
// sequelize with replacements
|
|
115
|
+
sequelize.query(
|
|
116
|
+
"SELECT * FROM users WHERE name = :name",
|
|
117
|
+
{ replacements: { name }, type: QueryTypes.SELECT }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// knex (parameterized by default)
|
|
121
|
+
knex("users").where("id", userId)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Ruby (Rails)
|
|
125
|
+
|
|
126
|
+
**Unsafe:**
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
User.where("name = '#{name}'")
|
|
130
|
+
ActiveRecord::Base.connection.execute("SELECT * FROM users WHERE id = #{id}")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Safe:**
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Hash conditions
|
|
137
|
+
User.where(name: name)
|
|
138
|
+
|
|
139
|
+
# Array conditions with ?
|
|
140
|
+
User.where("name = ?", name)
|
|
141
|
+
|
|
142
|
+
# Or for raw SQL:
|
|
143
|
+
User.find_by_sql(["SELECT * FROM users WHERE name = ?", name])
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Go
|
|
147
|
+
|
|
148
|
+
**Unsafe:**
|
|
149
|
+
|
|
150
|
+
```go
|
|
151
|
+
db.Query(fmt.Sprintf("SELECT * FROM users WHERE id = %d", id))
|
|
152
|
+
db.Query("SELECT * FROM users WHERE id = " + strconv.Itoa(id))
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Safe:**
|
|
156
|
+
|
|
157
|
+
```go
|
|
158
|
+
db.Query("SELECT * FROM users WHERE id = ?", id)
|
|
159
|
+
// PostgreSQL driver uses $1, $2, ...:
|
|
160
|
+
db.Query("SELECT * FROM users WHERE id = $1", id)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Java
|
|
164
|
+
|
|
165
|
+
**Unsafe:**
|
|
166
|
+
|
|
167
|
+
```java
|
|
168
|
+
Statement stmt = conn.createStatement();
|
|
169
|
+
stmt.executeQuery("SELECT * FROM users WHERE id = " + id);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Safe:**
|
|
173
|
+
|
|
174
|
+
```java
|
|
175
|
+
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
|
|
176
|
+
stmt.setInt(1, id);
|
|
177
|
+
ResultSet rs = stmt.executeQuery();
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### PHP
|
|
181
|
+
|
|
182
|
+
**Unsafe:**
|
|
183
|
+
|
|
184
|
+
```php
|
|
185
|
+
mysqli_query($conn, "SELECT * FROM users WHERE id = $id");
|
|
186
|
+
mysql_query("SELECT * FROM users WHERE id = " . $id);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Safe (PDO):**
|
|
190
|
+
|
|
191
|
+
```php
|
|
192
|
+
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
|
|
193
|
+
$stmt->execute(["id" => $id]);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Safe (mysqli):**
|
|
197
|
+
|
|
198
|
+
```php
|
|
199
|
+
$stmt = $mysqli->prepare("SELECT * FROM users WHERE id = ?");
|
|
200
|
+
$stmt->bind_param("i", $id);
|
|
201
|
+
$stmt->execute();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## The identifier-interpolation problem
|
|
205
|
+
|
|
206
|
+
ORMs parameterize VALUES, not IDENTIFIERS. If you need to
|
|
207
|
+
dynamically choose a table or column at runtime (e.g., for
|
|
208
|
+
multi-tenant routing or user-controlled sort columns), you can't
|
|
209
|
+
use parameter binding for it.
|
|
210
|
+
|
|
211
|
+
The right pattern is allow-list validation:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# Allow-list known columns
|
|
215
|
+
SORTABLE_COLUMNS = {"id", "name", "created_at", "updated_at"}
|
|
216
|
+
sort_col = request.args.get("sort_by")
|
|
217
|
+
if sort_col not in SORTABLE_COLUMNS:
|
|
218
|
+
raise ValueError("Invalid sort column")
|
|
219
|
+
cursor.execute(f"SELECT * FROM users ORDER BY {sort_col}", ())
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The interpolated `sort_col` is now constrained to a finite set of
|
|
223
|
+
known-safe values. The f-string is fine because the only possible
|
|
224
|
+
values are pre-validated.
|
|
225
|
+
|
|
226
|
+
For dynamic table names (rare; usually indicates a schema-design
|
|
227
|
+
issue), apply the same allow-list pattern PLUS quote the identifier:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
# Python with psycopg
|
|
231
|
+
from psycopg.sql import SQL, Identifier
|
|
232
|
+
cursor.execute(SQL("SELECT * FROM {}").format(Identifier(table_name)))
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`Identifier()` applies the database's identifier quoting rules,
|
|
236
|
+
preventing the table-name from being parsed as SQL.
|
|
237
|
+
|
|
238
|
+
## Why the scanner can't be perfect
|
|
239
|
+
|
|
240
|
+
Static-pattern matching has irreducible false positives:
|
|
241
|
+
|
|
242
|
+
- A literal-only f-string `f"SELECT * FROM users WHERE id = 1"`
|
|
243
|
+
matches the pattern but has no injection vector (the interpolated
|
|
244
|
+
value is `1`, a literal).
|
|
245
|
+
- A pre-validated value `f"SELECT ... ORDER BY {col}"` where `col`
|
|
246
|
+
is from a known allow-list looks identical to the vulnerable
|
|
247
|
+
pattern but is safe.
|
|
248
|
+
|
|
249
|
+
A full taint-tracking AST analyzer (Semgrep, CodeQL, Bandit with
|
|
250
|
+
`-r`) would catch these. This skill is a regex pass — high
|
|
251
|
+
recall, moderate precision. Treat findings as "verify each by
|
|
252
|
+
reading the code," not "auto-merge a fix."
|
|
253
|
+
|
|
254
|
+
## Primary sources
|
|
255
|
+
|
|
256
|
+
- [CWE-89 SQL Injection](https://cwe.mitre.org/data/definitions/89.html)
|
|
257
|
+
- [OWASP A03:2021 Injection](https://owasp.org/Top10/A03_2021-Injection/)
|
|
258
|
+
- [OWASP SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)
|
|
259
|
+
- [Bobby Tables — A guide to preventing SQL injection](https://bobby-tables.com/)
|
|
260
|
+
- Psycopg parameterized-query docs
|
|
261
|
+
- ActiveRecord query interface documentation
|