@nitra/cursor 1.8.150 → 1.8.152
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/AGENTS.template.md +8 -0
- package/README.md +7 -2
- package/bin/n-cursor.js +8 -3
- package/mdc/js-bun-db.mdc +34 -1
- package/mdc/js-mssql.mdc +25 -1
- package/package.json +1 -1
- package/scripts/build-agents-commands.mjs +88 -0
- 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/AGENTS.template.md
CHANGED
|
@@ -18,6 +18,14 @@ The primary development rules are stored in the Cursor rules directory:
|
|
|
18
18
|
{{name}}
|
|
19
19
|
{{/skills}}
|
|
20
20
|
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
Generated from the root `package.json` on each `npx @nitra/cursor` sync. Prefer `bun run <script>` for project scripts.
|
|
24
|
+
|
|
25
|
+
{{#commands}}
|
|
26
|
+
{{name}}
|
|
27
|
+
{{/commands}}
|
|
28
|
+
|
|
21
29
|
## Instructions for all agents
|
|
22
30
|
|
|
23
31
|
Before making changes, read the relevant rule files for the area you are working on.
|
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ CLI автоматично (команда завантаження правил
|
|
|
63
63
|
1. Знайде або створить `.n-cursor.json` у поточній директорії (із полем `$schema` на JSON Schema пакету; якщо файл уже є без коректного `$schema`, поле буде додано або оновлено при зчитуванні конфігу)
|
|
64
64
|
2. Створить директорію `.cursor/rules/`, якщо її ще немає
|
|
65
65
|
3. Скопіює кожне з перелічених у конфігу правило з `mdc/` установленого пакету і збереже файли з префіксом `n-`
|
|
66
|
-
4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям)
|
|
66
|
+
4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям); секція команд — з **`package.json`** кореня (див. `{{#commands}}` у шаблоні).
|
|
67
67
|
|
|
68
68
|
## Приклад виводу
|
|
69
69
|
|
|
@@ -114,13 +114,18 @@ npm/
|
|
|
114
114
|
|
|
115
115
|
Під час запуску CLI тіло між `{{#services}}` і `{{/services}}` повторюється для кожного `*.mdc` у `.cursor/rules/`; у `{{name}}` підставляється вже готовий markdown-рядок (наприклад `- .cursor/rules/n-text.mdc`).
|
|
116
116
|
|
|
117
|
-
3.
|
|
117
|
+
3. Для секції **Skills** використовуйте блок **`{{#skills}}` … `{{/skills}}`** з тим самим `{{name}}`: рядки формуються з каталогів у `.cursor/skills/` (див. також `buildSkillBulletItems` у `bin/n-cursor.js`).
|
|
118
|
+
|
|
119
|
+
4. Для секції **Commands** використовуйте **`{{#commands}}` … `{{/commands}}`**: список генерується з кореневого **`package.json`** (поле `scripts` — відомі ключі у фіксованому порядку, плюс додаткові `lint-*`) та завжди доповнюється рядками про **`npx @nitra/cursor`** і **`npx @nitra/cursor check`**. Логіка винесена в **`npm/scripts/build-agents-commands.mjs`**.
|
|
120
|
+
|
|
121
|
+
5. Після змін у шаблоні перевірте локально: у тестовому репозиторії з `.n-cursor.json` виконайте `npx`/`bunx` на зібраному пакеті або `node npm/bin/n-cursor.js` з кореня того репозиторію і переконайтеся, що **`AGENTS.md`** виглядає як очікується.
|
|
118
122
|
|
|
119
123
|
### Логіка в коді CLI
|
|
120
124
|
|
|
121
125
|
- Шлях до шаблону: поруч із `mdc/`, тобто `…/node_modules/@nitra/cursor/AGENTS.template.md` після встановлення пакету.
|
|
122
126
|
- Оновлення **`AGENTS.md`** виконується **після** циклу завантаження правил, щоб список відображав актуальний вміст `.cursor/rules/` на диску.
|
|
123
127
|
- Якщо каталогу `.cursor/rules/` немає або в ньому немає `*.mdc`, блок `{{#services}}` стає порожнім; решта шаблону все одно записується в **`AGENTS.md`**.
|
|
128
|
+
- Секція **`commands`** залежить лише від **`package.json` у корені cwd**; якщо файлу немає або `scripts` відсутній, у блоці лишаються мінімальні рядки (`bun i`, виклики CLI).
|
|
124
129
|
|
|
125
130
|
## Мета проекту
|
|
126
131
|
|
package/bin/n-cursor.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
|
|
20
20
|
* пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
|
|
21
|
+
* Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
|
|
21
22
|
*
|
|
22
23
|
* Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
|
|
23
24
|
* пакетом), яких немає у списку rules у .n-cursor.json. Інші .mdc у цій директорії залишаються.
|
|
@@ -45,6 +46,7 @@ import { basename, dirname, join } from 'node:path'
|
|
|
45
46
|
import { cwd } from 'node:process'
|
|
46
47
|
import { fileURLToPath } from 'node:url'
|
|
47
48
|
|
|
49
|
+
import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
|
|
48
50
|
import { detectAutoRulesAndSkills, mergeConfigWithAutoDetected, normalizeIdList } from '../scripts/auto-rules.mjs'
|
|
49
51
|
import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
|
|
50
52
|
import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
|
|
@@ -424,19 +426,21 @@ function expandMustacheSection(template, section, items, prop) {
|
|
|
424
426
|
}
|
|
425
427
|
|
|
426
428
|
/**
|
|
427
|
-
* Підставляє у вміст AGENTS.template.md список шляхів до файлів
|
|
429
|
+
* Підставляє у вміст AGENTS.template.md список шляхів до файлів правил, skills і команд з package.json
|
|
428
430
|
* @param {string} templateText вміст AGENTS.template.md
|
|
429
431
|
* @param {string[]} mdcBasenames імена файлів (*.mdc) з .cursor/rules
|
|
430
432
|
* @param {{ name: string }[]} skillItems рядки для секції Skills
|
|
433
|
+
* @param {{ name: string }[]} commandItems рядки для секції commands
|
|
431
434
|
* @returns {string} готовий markdown для AGENTS.md
|
|
432
435
|
*/
|
|
433
|
-
function renderAgentsTemplate(templateText, mdcBasenames, skillItems) {
|
|
436
|
+
function renderAgentsTemplate(templateText, mdcBasenames, skillItems, commandItems) {
|
|
434
437
|
let result = templateText
|
|
435
438
|
const serviceItems = mdcBasenames.map(mdcName => ({
|
|
436
439
|
name: `- ${RULES_DIR}/${mdcName}`
|
|
437
440
|
}))
|
|
438
441
|
result = expandMustacheSection(result, 'services', serviceItems, 'name')
|
|
439
442
|
result = expandMustacheSection(result, 'skills', skillItems, 'name')
|
|
443
|
+
result = expandMustacheSection(result, 'commands', commandItems, 'name')
|
|
440
444
|
return result
|
|
441
445
|
}
|
|
442
446
|
|
|
@@ -650,7 +654,8 @@ async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
|
|
|
650
654
|
const templateText = await readFile(agentsTemplatePath, 'utf8')
|
|
651
655
|
const mdcFiles = await listProjectRulesMdcFiles()
|
|
652
656
|
const skillItems = await buildSkillBulletItems()
|
|
653
|
-
const
|
|
657
|
+
const commandItems = await buildAgentsCommandBulletItems(cwd())
|
|
658
|
+
const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
|
|
654
659
|
const agentsPath = join(cwd(), AGENTS_FILE)
|
|
655
660
|
const hadFile = existsSync(agentsPath)
|
|
656
661
|
const out = body.endsWith('\n') ? body : `${body}\n`
|
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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Формує markdown-рядки для секції «Команди» у AGENTS.md.
|
|
3
|
+
*
|
|
4
|
+
* Джерело істини — `package.json` у корені цільового репозиторію: з поля `scripts` беруться відомі ключі
|
|
5
|
+
* у стабільному порядку, додатково — усі `lint-*`, яких не було в основному списку.
|
|
6
|
+
*
|
|
7
|
+
* Наприкінці завжди додаються рядки про CLI `@nitra/cursor` (синхрон правил / programmatic check),
|
|
8
|
+
* на початку — рекомендована команда `bun i` за конвенціями monorepo.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const PACKAGE_NAME = '@nitra/cursor'
|
|
15
|
+
const AGENTS_MD = 'AGENTS.md'
|
|
16
|
+
|
|
17
|
+
/** Порядок виводу скриптів із `package.json` (лише ті, що реально існують). */
|
|
18
|
+
const SCRIPT_KEYS_ORDER = /** @type {const} */ ([
|
|
19
|
+
'test',
|
|
20
|
+
'lint',
|
|
21
|
+
'lint-js',
|
|
22
|
+
'lint-text',
|
|
23
|
+
'lint-ga',
|
|
24
|
+
'lint-k8s',
|
|
25
|
+
'lint-docker',
|
|
26
|
+
'start',
|
|
27
|
+
'dev',
|
|
28
|
+
'build'
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Зчитує `scripts` з `package.json` у `projectRoot` або повертає порожній об'єкт.
|
|
33
|
+
* @param {string} projectRoot абсолютний шлях до кореня репозиторію
|
|
34
|
+
* @returns {Promise<Record<string, string>>} об'єкт скриптів
|
|
35
|
+
*/
|
|
36
|
+
async function readPackageScripts(projectRoot) {
|
|
37
|
+
const pkgPath = join(projectRoot, 'package.json')
|
|
38
|
+
if (!existsSync(pkgPath)) {
|
|
39
|
+
return {}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(pkgPath, 'utf8')
|
|
43
|
+
const pkg = JSON.parse(raw)
|
|
44
|
+
if (pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object') {
|
|
45
|
+
return /** @type {Record<string, string>} */ (pkg.scripts)
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// некоректний JSON або IO — секція команд лишиться з мінімумом (bun i + npx)
|
|
49
|
+
}
|
|
50
|
+
return {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Повертає елементи для Mustache-секції `commands` у AGENTS.template.md.
|
|
55
|
+
* @param {string} projectRoot абсолютний шлях до кореня репозиторію (зазвичай `process.cwd()`)
|
|
56
|
+
* @returns {Promise<{ name: string }[]>} рядки з полем `name` для `expandMustacheSection`
|
|
57
|
+
*/
|
|
58
|
+
export async function buildAgentsCommandBulletItems(projectRoot) {
|
|
59
|
+
const scripts = await readPackageScripts(projectRoot)
|
|
60
|
+
const items = /** @type {{ name: string }[]} */ ([])
|
|
61
|
+
|
|
62
|
+
items.push({ name: `- **Залежності**: \`bun i\`` })
|
|
63
|
+
|
|
64
|
+
const added = new Set()
|
|
65
|
+
|
|
66
|
+
for (const key of SCRIPT_KEYS_ORDER) {
|
|
67
|
+
if (typeof scripts[key] === 'string' && scripts[key].length > 0) {
|
|
68
|
+
items.push({ name: `- **${key}**: \`bun run ${key}\`` })
|
|
69
|
+
added.add(key)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lintExtraKeys = Object.keys(scripts)
|
|
74
|
+
.filter(k => k.startsWith('lint-') && !added.has(k) && typeof scripts[k] === 'string')
|
|
75
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
76
|
+
|
|
77
|
+
for (const key of lintExtraKeys) {
|
|
78
|
+
items.push({ name: `- **${key}**: \`bun run ${key}\`` })
|
|
79
|
+
added.add(key)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
items.push({
|
|
83
|
+
name: `- **Оновити правила та ${AGENTS_MD}** (після змін у правилах/шаблоні CLI): \`npx ${PACKAGE_NAME}\``
|
|
84
|
+
})
|
|
85
|
+
items.push({ name: `- **Перевірки правил (programmatic)**: \`npx ${PACKAGE_NAME} check\`` })
|
|
86
|
+
|
|
87
|
+
return items
|
|
88
|
+
}
|
|
@@ -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)
|