@nitra/cursor 1.8.150 → 1.8.151
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/mdc/js-bun-db.mdc +34 -1
- package/mdc/js-mssql.mdc +25 -1
- package/package.json +1 -1
- package/scripts/check-js-bun-db.mjs +26 -2
- package/scripts/check-js-mssql.mjs +20 -1
- package/scripts/utils/bun-sql-scan.mjs +225 -0
- package/scripts/utils/mssql-pool-scan.mjs +175 -0
package/mdc/js-bun-db.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.2'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Підтримувані версії баз даних
|
|
@@ -72,6 +72,22 @@ const ids = [1, 2, 3]
|
|
|
72
72
|
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
## `IN (...)`: значення з template literal — тільки через змінну + guard на пустоту
|
|
76
|
+
|
|
77
|
+
Якщо список для `IN (...)` підставляється через `${...}` у template literal, його **потрібно**:
|
|
78
|
+
|
|
79
|
+
- винести в **окрему змінну** (не підставляти вираз напряму в `${...}`);
|
|
80
|
+
- **перевірити на пустоту** перед запитом і **throw** (щоб не виконувати некоректний SQL або запит з неочікуваною семантикою).
|
|
81
|
+
|
|
82
|
+
Приклад:
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
const ids = inputIds.map(Number).filter(n => Number.isFinite(n))
|
|
86
|
+
if (!ids.length) throw new Error('ids is empty')
|
|
87
|
+
|
|
88
|
+
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
89
|
+
```
|
|
90
|
+
|
|
75
91
|
Транзакції — через `sql.begin` (auto-commit/rollback), вкладені — через `tx.savepoint`:
|
|
76
92
|
|
|
77
93
|
```javascript
|
|
@@ -81,6 +97,23 @@ await sql.begin(async tx => {
|
|
|
81
97
|
})
|
|
82
98
|
```
|
|
83
99
|
|
|
100
|
+
## Коментар під час виправлення SQL injection
|
|
101
|
+
|
|
102
|
+
Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна конкатенації/`.join(',')` на `sql(ids)` або перехід з `sql.unsafe(...)` на tagged template), **додай поруч короткий коментар** з описом причини.
|
|
103
|
+
|
|
104
|
+
Вимоги до коментаря:
|
|
105
|
+
|
|
106
|
+
- пояснити **що саме було небезпечно** (конкатенація, підмішування user input, динамічний `IN (...)`, тощо);
|
|
107
|
+
- пояснити **чому новий варіант безпечний** (параметризація через tagged template / `sql(...)`);
|
|
108
|
+
- без “романів”: 1–2 рядки, достатньо для ревʼю.
|
|
109
|
+
|
|
110
|
+
Приклад:
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
// SQLi fix: не конкатенуємо значення в `IN (...)`; Bun parameterize через `sql(ids)`.
|
|
114
|
+
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
115
|
+
```
|
|
116
|
+
|
|
84
117
|
## Що НЕ робити
|
|
85
118
|
|
|
86
119
|
### Не використовувати `sql.unsafe(...)` з конкатенацією
|
package/mdc/js-mssql.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Використання mssql в nodejs
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.3'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Підтримувана версія SQL Server
|
|
@@ -54,6 +54,23 @@ const result = await pool.request().query`
|
|
|
54
54
|
|
|
55
55
|
Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
|
|
56
56
|
|
|
57
|
+
## Коментар під час виправлення SQL injection
|
|
58
|
+
|
|
59
|
+
Коли виправляєш місце з потенційним **SQL injection** (наприклад, заміна `query(\`...\`)` на `query\`...\`` або прибирання динамічних списків/конкатенації), **додай поруч короткий коментар** з описом причини.
|
|
60
|
+
|
|
61
|
+
Вимоги до коментаря:
|
|
62
|
+
|
|
63
|
+
- вказати **що було небезпечно** (звичайна інтерполяція в рядок, конкатенація, динамічний список);
|
|
64
|
+
- вказати **чому новий варіант безпечний** (tagged template / параметризація / TVP);
|
|
65
|
+
- 1–2 рядки, без дублювання очевидного.
|
|
66
|
+
|
|
67
|
+
Приклад:
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
// SQLi fix: query`...` (tagged template) параметризує значення; query(`...`) небезпечний через інтерполяцію.
|
|
71
|
+
await pool.request().query`SELECT * FROM users WHERE id = ${userId}`
|
|
72
|
+
```
|
|
73
|
+
|
|
57
74
|
## Що НЕ робити
|
|
58
75
|
|
|
59
76
|
### Не робити `query(\`...\`)`
|
|
@@ -166,6 +183,11 @@ WHERE NOT EXISTS (
|
|
|
166
183
|
|
|
167
184
|
Якщо `IN (...)` все ж використовується (а не `JOIN` на TVP), значення в `${...}` **обовʼязково** мають бути попередньо приведені числовим парсером і відфільтровані від `NaN`. Це знімає будь-яку можливість SQL injection: SQL-метасимволи в `Number`/`parseInt(...)` перетворюються на `NaN` і відсіюються.
|
|
168
185
|
|
|
186
|
+
Додатково:
|
|
187
|
+
|
|
188
|
+
- значення для `IN (${...})` потрібно **винести в окрему змінну** перед запитом (не підставляти вираз напряму в `${...}`);
|
|
189
|
+
- цю змінну потрібно **перевірити на пустоту** і якщо список порожній — **throw error** (щоб не виконувати некоректний запит).
|
|
190
|
+
|
|
169
191
|
```javascript
|
|
170
192
|
// ❌ НЕ МОЖНА: значення з req.body / зовнішнього джерела без парсингу
|
|
171
193
|
const outIds = pgQ.rows.flatMap(x => x.req_body.Orders.map(o => o.OutletId))
|
|
@@ -176,9 +198,11 @@ await pool.query(/* sql */ String.raw`
|
|
|
176
198
|
|
|
177
199
|
```javascript
|
|
178
200
|
// ✅ МОЖНА: parseInt + filter(!isNaN) гарантує, що в SQL потраплять лише числа
|
|
201
|
+
// і перед запитом робимо guard на пустоту, щоб не виконувати некоректний SQL.
|
|
179
202
|
const outIds = pgQ.rows
|
|
180
203
|
.flatMap(x => x.req_body.Orders.map(o => parseInt(o.OutletId)))
|
|
181
204
|
.filter(n => !isNaN(n))
|
|
205
|
+
if (!outIds.length) throw new Error('outIds is empty')
|
|
182
206
|
await pool.request().query`
|
|
183
207
|
SELECT ... WHERE so.OutletId IN (${outIds})
|
|
184
208
|
`
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
|
20
20
|
import {
|
|
21
21
|
findBunSqlPerRequestConnectionInText,
|
|
22
22
|
findUnsafeBunSqlDynamicSqlListInText,
|
|
23
|
+
findUnsafeBunSqlInListMissingEmptyGuardInText,
|
|
23
24
|
findUnsafeBunSqlUnsafeCallInText,
|
|
24
25
|
isBunSqlScanSourceFile,
|
|
25
26
|
textHasBunSqlImport
|
|
@@ -125,6 +126,7 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
|
|
|
125
126
|
let perRequest = 0
|
|
126
127
|
let unsafeCall = 0
|
|
127
128
|
let dynamicList = 0
|
|
129
|
+
let inListGuard = 0
|
|
128
130
|
|
|
129
131
|
for (const absPath of sourcePaths) {
|
|
130
132
|
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
@@ -154,9 +156,28 @@ async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
|
|
|
154
156
|
`у IN (...) / VALUES (...); використовуй sql([...]) (js-bun-db.mdc): ${v.snippet}`
|
|
155
157
|
)
|
|
156
158
|
}
|
|
159
|
+
for (const v of findUnsafeBunSqlInListMissingEmptyGuardInText(content, rel)) {
|
|
160
|
+
inListGuard++
|
|
161
|
+
if (v.reason === 'missing_guard') {
|
|
162
|
+
fail(
|
|
163
|
+
`js-bun-db: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту ` +
|
|
164
|
+
`з throw (наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-bun-db.mdc): ${v.snippet}`
|
|
165
|
+
)
|
|
166
|
+
} else if (v.reason === 'sql_helper_not_var') {
|
|
167
|
+
fail(
|
|
168
|
+
`js-bun-db: ${rel}:${v.line} — IN-список у \${sql(...)} має підставлятись зі змінної (Identifier) ` +
|
|
169
|
+
`після валідації на пустоту + throw (js-bun-db.mdc): ${v.snippet}`
|
|
170
|
+
)
|
|
171
|
+
} else {
|
|
172
|
+
fail(
|
|
173
|
+
`js-bun-db: ${rel}:${v.line} — значення для IN (...) у template literal треба винести в окрему змінну ` +
|
|
174
|
+
`і перевірити на пустоту (throw), не підставляти вираз напряму (js-bun-db.mdc): ${v.snippet}`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
return { hasBunSqlImport, perRequest, unsafeCall, dynamicList }
|
|
180
|
+
return { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard }
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
/**
|
|
@@ -188,7 +209,7 @@ export async function check() {
|
|
|
188
209
|
return reporter.getExitCode()
|
|
189
210
|
}
|
|
190
211
|
|
|
191
|
-
const { hasBunSqlImport, perRequest, unsafeCall, dynamicList } = await scanSourcesForBunSqlPatterns(
|
|
212
|
+
const { hasBunSqlImport, perRequest, unsafeCall, dynamicList, inListGuard } = await scanSourcesForBunSqlPatterns(
|
|
192
213
|
sourcePaths,
|
|
193
214
|
repoRoot,
|
|
194
215
|
reporter
|
|
@@ -208,6 +229,9 @@ export async function check() {
|
|
|
208
229
|
if (dynamicList === 0) {
|
|
209
230
|
pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
|
|
210
231
|
}
|
|
232
|
+
if (inListGuard === 0) {
|
|
233
|
+
pass('js-bun-db: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
|
|
234
|
+
}
|
|
211
235
|
|
|
212
236
|
return reporter.getExitCode()
|
|
213
237
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
findUnsafeMssqlQueryTemplateCallInText,
|
|
20
20
|
findUnsafeMssqlDynamicSqlListInText,
|
|
21
21
|
findUnsafeMssqlInListUnparsedInText,
|
|
22
|
+
findUnsafeMssqlInListMissingEmptyGuardInText,
|
|
22
23
|
isMssqlScanSourceFile
|
|
23
24
|
} from './utils/mssql-pool-scan.mjs'
|
|
24
25
|
import { walkDir } from './utils/walkDir.mjs'
|
|
@@ -203,6 +204,20 @@ function scanMssqlOneSourceFile(rel, content, counters, fail) {
|
|
|
203
204
|
`js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
|
|
204
205
|
)
|
|
205
206
|
}
|
|
207
|
+
for (const v of findUnsafeMssqlInListMissingEmptyGuardInText(content, rel)) {
|
|
208
|
+
counters.inListGuardViolations++
|
|
209
|
+
if (v.reason === 'missing_guard') {
|
|
210
|
+
fail(
|
|
211
|
+
`js-mssql: ${rel}:${v.line} — перед IN-списком ${JSON.stringify(v.name)} потрібна перевірка на пустоту з throw ` +
|
|
212
|
+
`(наприклад if (!${v.name}.length) throw ...), інакше можливі некоректні запити (js-mssql.mdc): ${v.snippet}`
|
|
213
|
+
)
|
|
214
|
+
} else {
|
|
215
|
+
fail(
|
|
216
|
+
`js-mssql: ${rel}:${v.line} — значення для IN (\${...}) у template literal треба винести в окрему змінну ` +
|
|
217
|
+
`і перевірити на пустоту (throw), не підставляти вираз напряму (js-mssql.mdc): ${v.snippet}`
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
/**
|
|
@@ -226,6 +241,9 @@ function reportZeroMssqlSourceViolations(counters, pass) {
|
|
|
226
241
|
if (counters.unparsedInLists === 0) {
|
|
227
242
|
pass(`js-mssql: немає підстановок IN (\${...}) без числового парсера значень`)
|
|
228
243
|
}
|
|
244
|
+
if (counters.inListGuardViolations === 0) {
|
|
245
|
+
pass('js-mssql: усі IN-списки винесені у змінні та мають перевірку на пустоту з throw')
|
|
246
|
+
}
|
|
229
247
|
}
|
|
230
248
|
|
|
231
249
|
/**
|
|
@@ -247,7 +265,8 @@ async function auditMssqlSources(repoRoot, pass, fail) {
|
|
|
247
265
|
sharedRequestViolations: 0,
|
|
248
266
|
unsafeQueryCalls: 0,
|
|
249
267
|
unsafeDynamicSqlLists: 0,
|
|
250
|
-
unparsedInLists: 0
|
|
268
|
+
unparsedInLists: 0,
|
|
269
|
+
inListGuardViolations: 0
|
|
251
270
|
}
|
|
252
271
|
for (const absPath of sourcePaths) {
|
|
253
272
|
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
@@ -27,6 +27,157 @@ import {
|
|
|
27
27
|
|
|
28
28
|
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
29
29
|
const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
|
|
30
|
+
const IN_PLACEHOLDER_END_RE = /\bin\s*(\(\s*)?$/iu
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} node AST node
|
|
34
|
+
* @param {string} name імʼя змінної
|
|
35
|
+
* @returns {boolean} true, якщо це MemberExpression `${name}.length`
|
|
36
|
+
*/
|
|
37
|
+
function isLengthMember(node, name) {
|
|
38
|
+
return (
|
|
39
|
+
!!node &&
|
|
40
|
+
typeof node === 'object' &&
|
|
41
|
+
node.type === 'MemberExpression' &&
|
|
42
|
+
!node.computed &&
|
|
43
|
+
node.object &&
|
|
44
|
+
node.object.type === 'Identifier' &&
|
|
45
|
+
node.object.name === name &&
|
|
46
|
+
node.property &&
|
|
47
|
+
node.property.type === 'Identifier' &&
|
|
48
|
+
node.property.name === 'length'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} node AST node
|
|
54
|
+
* @returns {boolean} true, якщо це числовий 0-літерал
|
|
55
|
+
*/
|
|
56
|
+
function isZeroNumberLiteral(node) {
|
|
57
|
+
return (
|
|
58
|
+
!!node &&
|
|
59
|
+
typeof node === 'object' &&
|
|
60
|
+
((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} node AST node
|
|
66
|
+
* @returns {boolean} true, якщо це Identifier з імʼям `sql`
|
|
67
|
+
*/
|
|
68
|
+
function isSqlHelperIdentifier(node) {
|
|
69
|
+
return !!node && typeof node === 'object' && node.type === 'Identifier' && node.name === 'sql'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Витягає імʼя змінної списку для `IN ...`:
|
|
74
|
+
* - `${ids}` → `ids`
|
|
75
|
+
* - `${sql(ids)}` → `ids`
|
|
76
|
+
* @param {unknown} expr template expression
|
|
77
|
+
* @returns {{ name: string } | { error: 'not_var' } | { error: 'sql_helper_not_var' }} імʼя змінної або причина відмови
|
|
78
|
+
*/
|
|
79
|
+
function extractInListVarNameFromExpr(expr) {
|
|
80
|
+
if (!expr || typeof expr !== 'object') return { error: 'not_var' }
|
|
81
|
+
if (expr.type === 'Identifier' && typeof expr.name === 'string') return { name: expr.name }
|
|
82
|
+
|
|
83
|
+
if (expr.type === 'CallExpression' && isSqlHelperIdentifier(expr.callee)) {
|
|
84
|
+
const args = expr.arguments
|
|
85
|
+
if (!Array.isArray(args) || args.length === 0) return { error: 'sql_helper_not_var' }
|
|
86
|
+
const first = args[0]
|
|
87
|
+
if (first && typeof first === 'object' && first.type === 'Identifier' && typeof first.name === 'string') {
|
|
88
|
+
return { name: first.name }
|
|
89
|
+
}
|
|
90
|
+
return { error: 'sql_helper_not_var' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { error: 'not_var' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Чи містить тест if-умови перевірку “список порожній”.
|
|
98
|
+
* Підтримує базові форми:
|
|
99
|
+
* - `if (!ids.length) ...`
|
|
100
|
+
* - `if (ids.length === 0) ...` / `<= 0` / `< 1`
|
|
101
|
+
* @param {unknown} test IfStatement.test
|
|
102
|
+
* @param {string} name імʼя змінної списку
|
|
103
|
+
* @returns {boolean} true, якщо це перевірка на пустоту списку
|
|
104
|
+
*/
|
|
105
|
+
function isEmptyListTest(test, name) {
|
|
106
|
+
if (!test || typeof test !== 'object') return false
|
|
107
|
+
|
|
108
|
+
if (test.type === 'UnaryExpression' && test.operator === '!') {
|
|
109
|
+
const arg = test.argument
|
|
110
|
+
if (!arg || typeof arg !== 'object') return false
|
|
111
|
+
return isLengthMember(arg, name)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (test.type === 'BinaryExpression') {
|
|
115
|
+
const { left, right, operator } = test
|
|
116
|
+
if (!['===', '==', '<=', '<'].includes(operator)) return false
|
|
117
|
+
if (isLengthMember(left, name) && isZeroNumberLiteral(right)) return true
|
|
118
|
+
// допускаємо `0 === ids.length` теж
|
|
119
|
+
if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '==')) return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Чи є в consequent (або в його BlockStatement) ThrowStatement.
|
|
127
|
+
* @param {unknown} consequent IfStatement.consequent
|
|
128
|
+
* @returns {boolean} true, якщо consequent містить throw
|
|
129
|
+
*/
|
|
130
|
+
function consequentHasThrow(consequent) {
|
|
131
|
+
if (!consequent || typeof consequent !== 'object') return false
|
|
132
|
+
if (consequent.type === 'ThrowStatement') return true
|
|
133
|
+
if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
|
|
134
|
+
return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
|
|
135
|
+
}
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
|
|
141
|
+
* @param {unknown} block BlockStatement
|
|
142
|
+
* @param {number} statementIndex індекс statement, перед яким шукаємо guard
|
|
143
|
+
* @param {string} name імʼя змінної списку
|
|
144
|
+
* @returns {boolean} true, якщо guard знайдено
|
|
145
|
+
*/
|
|
146
|
+
function hasEmptyGuardBefore(block, statementIndex, name) {
|
|
147
|
+
if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
|
|
148
|
+
const body = block.body
|
|
149
|
+
if (!Array.isArray(body)) return false
|
|
150
|
+
for (let i = 0; i < statementIndex; i++) {
|
|
151
|
+
const st = body[i]
|
|
152
|
+
if (!st || typeof st !== 'object') continue
|
|
153
|
+
if (st.type !== 'IfStatement') continue
|
|
154
|
+
if (!isEmptyListTest(st.test, name)) continue
|
|
155
|
+
if (!consequentHasThrow(st.consequent)) continue
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Знаходить найближчий enclosing BlockStatement і statement всередині нього.
|
|
163
|
+
* @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
|
|
164
|
+
* @returns {{ block: unknown, index: number } | null} block+індекс statement або null
|
|
165
|
+
*/
|
|
166
|
+
function findEnclosingBlockAndStatementIndex(ancestors) {
|
|
167
|
+
if (!Array.isArray(ancestors) || ancestors.length === 0) return null
|
|
168
|
+
|
|
169
|
+
// statement — перший зверху вузол, який лежить у block.body
|
|
170
|
+
// шукаємо пару (block, statement), де statement ∈ block.body
|
|
171
|
+
for (let i = ancestors.length - 1; i >= 1; i--) {
|
|
172
|
+
const maybeStatement = ancestors[i]
|
|
173
|
+
const maybeBlock = ancestors[i - 1]
|
|
174
|
+
if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
|
|
175
|
+
if (!Array.isArray(maybeBlock.body)) continue
|
|
176
|
+
const idx = maybeBlock.body.indexOf(maybeStatement)
|
|
177
|
+
if (idx !== -1) return { block: maybeBlock, index: idx }
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
30
181
|
|
|
31
182
|
/**
|
|
32
183
|
* Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
|
|
@@ -139,6 +290,80 @@ export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'sca
|
|
|
139
290
|
return out
|
|
140
291
|
}
|
|
141
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Збирає порушення для одного TemplateLiteral вузла: `IN ... ${...}` потребує
|
|
295
|
+
* змінної + guard `if (empty) throw`.
|
|
296
|
+
* @param {Record<string, unknown>} template TemplateLiteral
|
|
297
|
+
* @param {unknown[]} ancestors ancestors з walkAstWithAncestors
|
|
298
|
+
* @param {string} content вихідний код
|
|
299
|
+
* @param {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} out буфер результатів
|
|
300
|
+
*/
|
|
301
|
+
function collectInListGuardViolationsFromTemplate(template, ancestors, content, out) {
|
|
302
|
+
const expressions = template.expressions
|
|
303
|
+
const quasis = template.quasis
|
|
304
|
+
if (!Array.isArray(expressions) || expressions.length === 0) return
|
|
305
|
+
if (!Array.isArray(quasis) || quasis.length === 0) return
|
|
306
|
+
|
|
307
|
+
for (const [i, expr] of expressions.entries()) {
|
|
308
|
+
const q = quasis[i]
|
|
309
|
+
const raw =
|
|
310
|
+
q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string' ? q.value.raw : ''
|
|
311
|
+
if (!IN_PLACEHOLDER_END_RE.test(raw)) continue
|
|
312
|
+
|
|
313
|
+
const extracted = extractInListVarNameFromExpr(expr)
|
|
314
|
+
if ('error' in extracted) {
|
|
315
|
+
out.push({
|
|
316
|
+
line: offsetToLine(content, template.start),
|
|
317
|
+
snippet: normalizeSnippet(content.slice(template.start, template.end)),
|
|
318
|
+
reason: extracted.error
|
|
319
|
+
})
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const place = findEnclosingBlockAndStatementIndex(ancestors)
|
|
324
|
+
if (!place || !hasEmptyGuardBefore(place.block, place.index, extracted.name)) {
|
|
325
|
+
out.push({
|
|
326
|
+
line: offsetToLine(content, template.start),
|
|
327
|
+
snippet: normalizeSnippet(content.slice(template.start, template.end)),
|
|
328
|
+
reason: 'missing_guard',
|
|
329
|
+
name: extracted.name
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Знаходить підстановки списків у `IN (...)`, які:
|
|
337
|
+
* - не винесені в окрему змінну (в `${...}` стоїть не Identifier або `sql(<non-Identifier>)`);
|
|
338
|
+
* - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
|
|
339
|
+
* @param {string} content вихідний код
|
|
340
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
341
|
+
* @returns {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} список порушень
|
|
342
|
+
*/
|
|
343
|
+
export function findUnsafeBunSqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
|
|
344
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
345
|
+
if (!program) return []
|
|
346
|
+
|
|
347
|
+
/** @type {{ line: number, snippet: string, reason: 'not_var' | 'sql_helper_not_var' | 'missing_guard', name?: string }[]} */
|
|
348
|
+
const out = []
|
|
349
|
+
|
|
350
|
+
walkAstWithAncestors(program, [], (node, ancestors) => {
|
|
351
|
+
/** @type {unknown} */
|
|
352
|
+
let template = null
|
|
353
|
+
if (node.type === 'TemplateLiteral') {
|
|
354
|
+
template = node
|
|
355
|
+
} else if (node.type === 'TaggedTemplateExpression') {
|
|
356
|
+
template = node.quasi
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
|
|
360
|
+
if (!isSqlListContextTemplate(template)) return
|
|
361
|
+
collectInListGuardViolationsFromTemplate(template, ancestors, content, out)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
return out
|
|
365
|
+
}
|
|
366
|
+
|
|
142
367
|
/**
|
|
143
368
|
* Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
|
|
144
369
|
* Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
|
|
@@ -36,6 +36,113 @@ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
|
|
|
36
36
|
const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
|
|
37
37
|
const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Чи містить тест if-умови перевірку “список порожній”.
|
|
41
|
+
* Підтримує базові форми:
|
|
42
|
+
* - `if (!ids.length) ...`
|
|
43
|
+
* - `if (ids.length === 0) ...` / `<= 0` / `< 1`
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} test IfStatement.test
|
|
46
|
+
* @param {string} name імʼя змінної списку
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isEmptyListTest(test, name) {
|
|
50
|
+
if (!test || typeof test !== 'object') return false
|
|
51
|
+
|
|
52
|
+
if (test.type === 'UnaryExpression' && test.operator === '!') {
|
|
53
|
+
const arg = test.argument
|
|
54
|
+
if (!arg || typeof arg !== 'object') return false
|
|
55
|
+
if (arg.type === 'MemberExpression' && !arg.computed) {
|
|
56
|
+
const obj = arg.object
|
|
57
|
+
const prop = arg.property
|
|
58
|
+
return !!obj && obj.type === 'Identifier' && obj.name === name && !!prop && prop.type === 'Identifier' && prop.name === 'length'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (test.type === 'BinaryExpression') {
|
|
63
|
+
const { left, right, operator } = test
|
|
64
|
+
const isLen = node =>
|
|
65
|
+
!!node &&
|
|
66
|
+
typeof node === 'object' &&
|
|
67
|
+
node.type === 'MemberExpression' &&
|
|
68
|
+
!node.computed &&
|
|
69
|
+
node.object &&
|
|
70
|
+
node.object.type === 'Identifier' &&
|
|
71
|
+
node.object.name === name &&
|
|
72
|
+
node.property &&
|
|
73
|
+
node.property.type === 'Identifier' &&
|
|
74
|
+
node.property.name === 'length'
|
|
75
|
+
const isZero = node =>
|
|
76
|
+
!!node &&
|
|
77
|
+
typeof node === 'object' &&
|
|
78
|
+
((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
|
|
79
|
+
|
|
80
|
+
if (!['===', '==', '<=', '<'].includes(operator)) return false
|
|
81
|
+
if (isLen(left) && isZero(right)) return true
|
|
82
|
+
// допускаємо `0 === ids.length` теж
|
|
83
|
+
if (isZero(left) && isLen(right) && (operator === '===' || operator === '==')) return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Чи є в consequent (або в його BlockStatement) ThrowStatement.
|
|
91
|
+
* @param {unknown} consequent IfStatement.consequent
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
function consequentHasThrow(consequent) {
|
|
95
|
+
if (!consequent || typeof consequent !== 'object') return false
|
|
96
|
+
if (consequent.type === 'ThrowStatement') return true
|
|
97
|
+
if (consequent.type === 'BlockStatement' && Array.isArray(consequent.body)) {
|
|
98
|
+
return consequent.body.some(s => s && typeof s === 'object' && s.type === 'ThrowStatement')
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Шукає “guard” `if (empty) throw` перед statementIndex у межах того ж BlockStatement.
|
|
105
|
+
* @param {unknown} block BlockStatement
|
|
106
|
+
* @param {number} statementIndex індекс statement, перед яким шукаємо guard
|
|
107
|
+
* @param {string} name імʼя змінної списку
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
function hasEmptyGuardBefore(block, statementIndex, name) {
|
|
111
|
+
if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
|
|
112
|
+
const body = block.body
|
|
113
|
+
if (!Array.isArray(body)) return false
|
|
114
|
+
for (let i = 0; i < statementIndex; i++) {
|
|
115
|
+
const st = body[i]
|
|
116
|
+
if (!st || typeof st !== 'object') continue
|
|
117
|
+
if (st.type !== 'IfStatement') continue
|
|
118
|
+
if (!isEmptyListTest(st.test, name)) continue
|
|
119
|
+
if (!consequentHasThrow(st.consequent)) continue
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Знаходить найближчий enclosing BlockStatement і statement всередині нього.
|
|
127
|
+
* @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
|
|
128
|
+
* @returns {{ block: unknown, index: number } | null}
|
|
129
|
+
*/
|
|
130
|
+
function findEnclosingBlockAndStatementIndex(ancestors) {
|
|
131
|
+
if (!Array.isArray(ancestors) || ancestors.length === 0) return null
|
|
132
|
+
|
|
133
|
+
// statement — перший зверху вузол, який лежить у block.body
|
|
134
|
+
// шукаємо пару (block, statement), де statement ∈ block.body
|
|
135
|
+
for (let i = ancestors.length - 1; i >= 1; i--) {
|
|
136
|
+
const maybeStatement = ancestors[i]
|
|
137
|
+
const maybeBlock = ancestors[i - 1]
|
|
138
|
+
if (!maybeBlock || typeof maybeBlock !== 'object' || maybeBlock.type !== 'BlockStatement') continue
|
|
139
|
+
if (!Array.isArray(maybeBlock.body)) continue
|
|
140
|
+
const idx = maybeBlock.body.indexOf(maybeStatement)
|
|
141
|
+
if (idx !== -1) return { block: maybeBlock, index: idx }
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
39
146
|
/**
|
|
40
147
|
* Чи це `new sql.ConnectionPool(...)` або `new mssql.ConnectionPool(...)`.
|
|
41
148
|
* @param {unknown} node AST node
|
|
@@ -381,6 +488,47 @@ function collectInListUnparsedFromTemplate(node, content, declarators, out) {
|
|
|
381
488
|
}
|
|
382
489
|
}
|
|
383
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Збирає порушення для одного TemplateLiteral: якщо у `IN (${...})`:
|
|
493
|
+
* - `${...}` не є Identifier (значення не винесені у змінну);
|
|
494
|
+
* - або це Identifier, але перед запитом немає guard `if (empty) throw`.
|
|
495
|
+
*
|
|
496
|
+
* @param {Record<string, unknown>} node TemplateLiteral
|
|
497
|
+
* @param {unknown[]} ancestors ancestors від walkAstWithAncestors
|
|
498
|
+
* @param {string} content вихідний код
|
|
499
|
+
* @param {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} out буфер результатів
|
|
500
|
+
*/
|
|
501
|
+
function collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out) {
|
|
502
|
+
if (node.type !== 'TemplateLiteral') return
|
|
503
|
+
const quasis = node.quasis
|
|
504
|
+
const expressions = node.expressions
|
|
505
|
+
if (!Array.isArray(quasis) || !Array.isArray(expressions) || expressions.length === 0) return
|
|
506
|
+
|
|
507
|
+
for (const [i, expr] of expressions.entries()) {
|
|
508
|
+
if (!IN_PLACEHOLDER_END_RE.test(quasiRawText(quasis[i]))) continue
|
|
509
|
+
if (!expr || typeof expr !== 'object') continue
|
|
510
|
+
|
|
511
|
+
if (expr.type !== 'Identifier' || typeof expr.name !== 'string') {
|
|
512
|
+
out.push({
|
|
513
|
+
line: offsetToLine(content, node.start),
|
|
514
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end)),
|
|
515
|
+
reason: 'not_var'
|
|
516
|
+
})
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const place = findEnclosingBlockAndStatementIndex(ancestors)
|
|
521
|
+
if (!place || !hasEmptyGuardBefore(place.block, place.index, expr.name)) {
|
|
522
|
+
out.push({
|
|
523
|
+
line: offsetToLine(content, node.start),
|
|
524
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end)),
|
|
525
|
+
reason: 'missing_guard',
|
|
526
|
+
name: expr.name
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
384
532
|
/**
|
|
385
533
|
* Знаходить підстановки IN (вираз) у TemplateLiteral, де вираз не пройшов числовий парсер.
|
|
386
534
|
*
|
|
@@ -410,6 +558,33 @@ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan
|
|
|
410
558
|
return out
|
|
411
559
|
}
|
|
412
560
|
|
|
561
|
+
/**
|
|
562
|
+
* Знаходить підстановки списків у `IN (${...})`, які:
|
|
563
|
+
* - не винесені в окрему змінну (в `${...}` стоїть не Identifier);
|
|
564
|
+
* - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
|
|
565
|
+
*
|
|
566
|
+
* @param {string} content вихідний код
|
|
567
|
+
* @param {string} [virtualPath] шлях для вибору мови парсера (lang)
|
|
568
|
+
* @returns {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} список порушень
|
|
569
|
+
*/
|
|
570
|
+
export function findUnsafeMssqlInListMissingEmptyGuardInText(content, virtualPath = 'scan.ts') {
|
|
571
|
+
const lang = langFromPath(virtualPath || 'scan.ts')
|
|
572
|
+
let result
|
|
573
|
+
try {
|
|
574
|
+
result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
|
|
575
|
+
} catch {
|
|
576
|
+
return []
|
|
577
|
+
}
|
|
578
|
+
if (result.errors?.length) return []
|
|
579
|
+
|
|
580
|
+
/** @type {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} */
|
|
581
|
+
const out = []
|
|
582
|
+
walkAstWithAncestors(result.program, [], (node, ancestors) =>
|
|
583
|
+
collectInListMissingEmptyGuardFromTemplate(node, ancestors, content, out)
|
|
584
|
+
)
|
|
585
|
+
return out
|
|
586
|
+
}
|
|
587
|
+
|
|
413
588
|
/**
|
|
414
589
|
* Чи сканувати цей файл за розширенням (JS/TS-сім'я).
|
|
415
590
|
* @param {string} relativePathPosix відносний шлях (posix)
|