@nitra/cursor 1.8.143 → 1.8.145
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/bin/auto-rules.md +2 -0
- package/mdc/js-bun-db.mdc +118 -0
- package/mdc/js-mssql.mdc +27 -1
- package/mdc/vue.mdc +2 -1
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +72 -11
- package/scripts/check-js-bun-db.mjs +213 -0
- package/scripts/check-js-mssql.mjs +11 -0
- package/scripts/check-vue.mjs +116 -12
- package/scripts/utils/bun-sql-scan.mjs +294 -0
- package/scripts/utils/mssql-pool-scan.mjs +174 -0
package/bin/auto-rules.md
CHANGED
|
@@ -22,6 +22,8 @@ js-pino - якщо присутній хоч один js файл, не в мо
|
|
|
22
22
|
|
|
23
23
|
js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
|
|
24
24
|
|
|
25
|
+
js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
|
|
26
|
+
|
|
25
27
|
k8s - якщо присутня хоч одна директорія k8s
|
|
26
28
|
|
|
27
29
|
nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.1'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Підтримувані версії баз даних
|
|
8
|
+
|
|
9
|
+
PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом, підключаємось як `mysql://`).
|
|
10
|
+
|
|
11
|
+
## Заміна на Bun native SQL
|
|
12
|
+
|
|
13
|
+
Якщо в проєкті використовуються бібліотеки `pg` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
|
|
14
|
+
|
|
15
|
+
- Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `mysql`, `mysql2`.
|
|
16
|
+
- Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
|
|
17
|
+
- Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
|
|
18
|
+
|
|
19
|
+
## Підключення (singleton + env)
|
|
20
|
+
|
|
21
|
+
Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
|
|
22
|
+
|
|
23
|
+
Для явного конфігу — `new SQL(...)` як **singleton** на рівні модуля, а не на кожен запит:
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
// db.js
|
|
27
|
+
import { SQL } from 'bun'
|
|
28
|
+
|
|
29
|
+
export const sql = new SQL({
|
|
30
|
+
url: process.env.DATABASE_URL,
|
|
31
|
+
max: 20,
|
|
32
|
+
idleTimeout: 30,
|
|
33
|
+
connectionTimeout: 10
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Connection string обирає адаптер автоматично:
|
|
38
|
+
|
|
39
|
+
- `postgres://...` / `postgresql://...` → PostgreSQL
|
|
40
|
+
- `mysql://...` / `mysql2://...` → MySQL/MariaDB
|
|
41
|
+
- `sqlite://...` / `file://...` / `:memory:` → SQLite
|
|
42
|
+
|
|
43
|
+
## Як виконувати запити (безпечно)
|
|
44
|
+
|
|
45
|
+
Тільки **tagged template** з `${...}` — Bun сам біндить позиційні параметри й захищає від SQL injection:
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
import { sql } from 'bun'
|
|
49
|
+
|
|
50
|
+
const userId = 42
|
|
51
|
+
const status = 'active'
|
|
52
|
+
|
|
53
|
+
const users = await sql`
|
|
54
|
+
SELECT * FROM users
|
|
55
|
+
WHERE id = ${userId} AND status = ${status}
|
|
56
|
+
`
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Об'єктний INSERT/UPDATE та `IN (...)` — через helper `sql(...)`:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
const user = { name: 'Alice', email: 'a@example.com' }
|
|
63
|
+
|
|
64
|
+
const [created] = await sql`
|
|
65
|
+
INSERT INTO users ${sql(user)}
|
|
66
|
+
RETURNING *
|
|
67
|
+
`
|
|
68
|
+
|
|
69
|
+
await sql`UPDATE users SET ${sql(user, 'name', 'email')} WHERE id = ${created.id}`
|
|
70
|
+
|
|
71
|
+
const ids = [1, 2, 3]
|
|
72
|
+
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Транзакції — через `sql.begin` (auto-commit/rollback), вкладені — через `tx.savepoint`:
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
await sql.begin(async tx => {
|
|
79
|
+
await tx`INSERT INTO users ${sql(user)}`
|
|
80
|
+
await tx`UPDATE accounts SET balance = balance - ${100} WHERE user_id = ${user.id}`
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Що НЕ робити
|
|
85
|
+
|
|
86
|
+
### Не використовувати `sql.unsafe(...)` з конкатенацією
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
// ❌ конкатенація даних у SQL — SQL injection
|
|
90
|
+
await sql.unsafe(`SELECT * FROM users WHERE id = ${userId}`)
|
|
91
|
+
|
|
92
|
+
// ❌ навіть у tagged template — динамічний список через .join(',')
|
|
93
|
+
await sql`SELECT * FROM users WHERE id IN (${ids.join(',')})`
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`sql.unsafe(text, params)` допустимий лише для **статичного** SQL без даних від користувача (наприклад, разовий DDL-скрипт), і обов'язково з масивом параметрів — ніяких `${...}` у самому рядку.
|
|
97
|
+
|
|
98
|
+
Для динамічних списків — `sql([...])` або `sql(rows, 'colA', 'colB')`, **не** `.join(',')`.
|
|
99
|
+
|
|
100
|
+
### Не створювати підключення на кожен запит
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
// ❌ нове підключення/інстанс на кожен виклик
|
|
104
|
+
function getUser(id) {
|
|
105
|
+
const sql = new SQL(process.env.DATABASE_URL)
|
|
106
|
+
return sql`SELECT * FROM users WHERE id = ${id}`
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`new SQL(...)` має створюватись **один раз** на рівні модуля. Bun сам тримає пул (`max`, `idleTimeout`, `maxLifetime`) — окремих `Pool`/`Client` як у `pg` не потрібно.
|
|
111
|
+
|
|
112
|
+
### Не лишати `pg` / `mysql2` поряд із Bun SQL
|
|
113
|
+
|
|
114
|
+
Якщо в коді з'явився `import { sql } from 'bun'`, то `pg` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД.
|
|
115
|
+
|
|
116
|
+
## Перевірка
|
|
117
|
+
|
|
118
|
+
`npx @nitra/cursor check js-bun-db`.
|
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.2'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Підтримувана версія SQL Server
|
|
@@ -160,4 +160,30 @@ WHERE NOT EXISTS (
|
|
|
160
160
|
- ліміт на кількість елементів (наприклад 5k/10k — залежить від вашого потоку).
|
|
161
161
|
- `supplierId`/ID: число/BigInt, валідне та скінченне.
|
|
162
162
|
|
|
163
|
+
## Парсинг значень для `IN (${...})`
|
|
164
|
+
|
|
165
|
+
Якщо `IN (...)` все ж використовується (а не `JOIN` на TVP), значення в `${...}` **обовʼязково** мають бути попередньо приведені числовим парсером і відфільтровані від `NaN`. Це знімає будь-яку можливість SQL injection: SQL-метасимволи в `Number`/`parseInt(...)` перетворюються на `NaN` і відсіюються.
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
// ❌ НЕ МОЖНА: значення з req.body / зовнішнього джерела без парсингу
|
|
169
|
+
const outIds = pgQ.rows.flatMap(x => x.req_body.Orders.map(o => o.OutletId))
|
|
170
|
+
await pool.query(/* sql */ String.raw`
|
|
171
|
+
SELECT ... WHERE so.OutletId IN (${outIds})
|
|
172
|
+
`)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```javascript
|
|
176
|
+
// ✅ МОЖНА: parseInt + filter(!isNaN) гарантує, що в SQL потраплять лише числа
|
|
177
|
+
const outIds = pgQ.rows
|
|
178
|
+
.flatMap(x => x.req_body.Orders.map(o => parseInt(o.OutletId)))
|
|
179
|
+
.filter(n => !isNaN(n))
|
|
180
|
+
await pool.request().query`
|
|
181
|
+
SELECT ... WHERE so.OutletId IN (${outIds})
|
|
182
|
+
`
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Допустимі парсери: `parseInt(...)`, `parseFloat(...)`, `Number(...)`, `BigInt(...)` або унарний `+x`. Літеральні масиви чисел (`[1, 2, 3]`) теж безпечні — без парсера, але без жодних рядків.
|
|
186
|
+
|
|
187
|
+
Це правило діє і для безпечного `pool.request().query\`...\`` (де mssql сам параметризує масив), і поготів для `pool.query(String.raw\`...\`)` чи `pool.query(\`...\`)`, де такий парсинг — єдиний бар'єр.
|
|
188
|
+
|
|
163
189
|
Перевірка: `npx @nitra/cursor check js-mssql`.
|
package/mdc/vue.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.3'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Vue 3 Composition API — правила для .cursorrules
|
|
@@ -111,6 +111,7 @@ const additionalInstructions = `
|
|
|
111
111
|
- Якість коду: **ESLint** + **eslint-plugin-vue**; форматування — **oxfmt**, див. `text.mdc`.
|
|
112
112
|
- Збірка та dev-сервер — **Vite**
|
|
113
113
|
- **Vue Devtools** для дебагу; продакшен-збірка — **`vite build`**, оптимізація асетів і кешування на рівні деплою / CDN.
|
|
114
|
+
- **esbuild заборонено:** у проєкті не має бути залежності `esbuild` і згадок `esbuild` у конфігах/коді. Якщо десь є налаштування або інструкції під `esbuild` — заміни на **rolldown**.
|
|
114
115
|
|
|
115
116
|
### CI/CD
|
|
116
117
|
|
package/package.json
CHANGED
package/scripts/auto-rules.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
|
|
3
3
|
*
|
|
4
|
-
* Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
|
|
4
|
+
* Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
|
|
5
|
+
* залежності `mssql` / `pg` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
|
|
5
6
|
* `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
|
|
6
7
|
*
|
|
7
8
|
* Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
|
|
@@ -11,11 +12,13 @@ import { existsSync } from 'node:fs'
|
|
|
11
12
|
import { readdir, readFile } from 'node:fs/promises'
|
|
12
13
|
import { basename, join, relative } from 'node:path'
|
|
13
14
|
|
|
15
|
+
import { textHasBunSqlImport } from './utils/bun-sql-scan.mjs'
|
|
14
16
|
import {
|
|
15
17
|
isGqlScanSourceFile,
|
|
16
18
|
shouldSkipFileForGqlScan,
|
|
17
19
|
sourceFileHasGqlTaggedTemplate
|
|
18
20
|
} from './utils/graphql-gql-scan.mjs'
|
|
21
|
+
import { contentForVueImportScan } from './utils/vue-forbidden-imports.mjs'
|
|
19
22
|
|
|
20
23
|
/** Порядок автододавання правил відповідно до `auto-rules.md`. */
|
|
21
24
|
export const AUTO_RULE_ORDER = Object.freeze([
|
|
@@ -27,6 +30,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
|
|
|
27
30
|
'graphql',
|
|
28
31
|
'js-lint',
|
|
29
32
|
'js-mssql',
|
|
33
|
+
'js-bun-db',
|
|
30
34
|
'js-pino',
|
|
31
35
|
'k8s',
|
|
32
36
|
'nginx-default-tpl',
|
|
@@ -50,12 +54,25 @@ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
|
50
54
|
const DEFAULT_DISABLED_LIST = Object.freeze([])
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
|
-
* Чи
|
|
57
|
+
* Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"` (після витягування `<script>` у `.vue`).
|
|
58
|
+
* @param {string} content вміст файлу
|
|
59
|
+
* @param {string} relativePath шлях posix відносно кореня
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function sourceContentHasBunSqlImport(content, relativePath) {
|
|
63
|
+
return textHasBunSqlImport(contentForVueImportScan(content, relativePath))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Збирає, які з переданих ключів присутні в `dependencies` хоча б одного `package.json`.
|
|
54
68
|
* @param {string} root абсолютний шлях до кореня репозиторію
|
|
55
|
-
* @
|
|
69
|
+
* @param {string[]} dependencyKeys імена залежностей (наприклад `mssql`, `pg`)
|
|
70
|
+
* @returns {Promise<Set<string>>} множина знайдених ключів
|
|
56
71
|
*/
|
|
57
|
-
async function
|
|
58
|
-
|
|
72
|
+
async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKeys) {
|
|
73
|
+
const wanted = new Set(dependencyKeys)
|
|
74
|
+
/** @type {Set<string>} */
|
|
75
|
+
const found = new Set()
|
|
59
76
|
|
|
60
77
|
/**
|
|
61
78
|
* Рекурсивний обхід каталогу з пропуском службових директорій.
|
|
@@ -63,7 +80,9 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
|
|
|
63
80
|
* @returns {Promise<void>}
|
|
64
81
|
*/
|
|
65
82
|
async function walk(dir) {
|
|
66
|
-
if (found)
|
|
83
|
+
if (found.size === wanted.size) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
67
86
|
let entries
|
|
68
87
|
try {
|
|
69
88
|
entries = await readdir(dir, { withFileTypes: true })
|
|
@@ -71,7 +90,9 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
|
|
|
71
90
|
return
|
|
72
91
|
}
|
|
73
92
|
for (const entry of entries) {
|
|
74
|
-
if (found)
|
|
93
|
+
if (found.size === wanted.size) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
75
96
|
const absPath = join(dir, entry.name)
|
|
76
97
|
if (entry.isDirectory()) {
|
|
77
98
|
const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
|
|
@@ -82,9 +103,12 @@ async function hasMssqlDependencyInAnyPackageJson(root) {
|
|
|
82
103
|
try {
|
|
83
104
|
const parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
84
105
|
const deps = parsed?.dependencies
|
|
85
|
-
if (deps && typeof deps === 'object' && !Array.isArray(deps)
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
|
|
107
|
+
for (const key of wanted) {
|
|
108
|
+
if (Object.hasOwn(deps, key)) {
|
|
109
|
+
found.add(key)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
} catch {
|
|
90
114
|
/* ігноруємо пошкоджені/недоступні package.json */
|
|
@@ -182,11 +206,40 @@ async function updateGqlFactFromFile(absPath, relPath, facts) {
|
|
|
182
206
|
}
|
|
183
207
|
}
|
|
184
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Чи сканувати файл на імпорт `sql`/`SQL` з `bun` (ті самі розширення й skip, що для gql).
|
|
211
|
+
* @param {string} relPath шлях posix відносно кореня
|
|
212
|
+
* @param {{ hasBunSqlImport: boolean }} facts агреговані факти
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
function shouldScanFileForBunSql(relPath, facts) {
|
|
216
|
+
return !facts.hasBunSqlImport && isGqlScanSourceFile(relPath) && !shouldSkipFileForGqlScan(relPath)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Оновлює ознаку `hasBunSqlImport` за вмістом файлу.
|
|
221
|
+
* @param {string} absPath абсолютний шлях до файлу
|
|
222
|
+
* @param {string} relPath шлях posix відносно кореня
|
|
223
|
+
* @param {{ hasBunSqlImport: boolean }} facts агреговані факти
|
|
224
|
+
* @returns {Promise<void>}
|
|
225
|
+
*/
|
|
226
|
+
async function updateBunSqlFactFromFile(absPath, relPath, facts) {
|
|
227
|
+
try {
|
|
228
|
+
const content = await readFile(absPath, 'utf8')
|
|
229
|
+
if (sourceContentHasBunSqlImport(content, relPath)) {
|
|
230
|
+
facts.hasBunSqlImport = true
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
/* ігноруємо пошкоджені/недоступні файли */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
185
237
|
/**
|
|
186
238
|
* Обробляє файл під час обходу дерева.
|
|
187
239
|
* @param {string} absPath абсолютний шлях до файлу
|
|
188
240
|
* @param {string} root абсолютний шлях кореня
|
|
189
241
|
* @param {{
|
|
242
|
+
* hasBunSqlImport: boolean,
|
|
190
243
|
* hasCapacitorConfig: boolean,
|
|
191
244
|
* hasDockerfile: boolean,
|
|
192
245
|
* hasGqlTaggedTemplates: boolean,
|
|
@@ -204,6 +257,9 @@ async function processFileEntry(absPath, root, facts) {
|
|
|
204
257
|
if (shouldScanFileForGql(rel, facts)) {
|
|
205
258
|
await updateGqlFactFromFile(absPath, rel, facts)
|
|
206
259
|
}
|
|
260
|
+
if (shouldScanFileForBunSql(rel, facts)) {
|
|
261
|
+
await updateBunSqlFactFromFile(absPath, rel, facts)
|
|
262
|
+
}
|
|
207
263
|
}
|
|
208
264
|
|
|
209
265
|
/**
|
|
@@ -270,6 +326,7 @@ export function isMonorepoPackage(packageJson) {
|
|
|
270
326
|
* hasCapacitorConfig: boolean,
|
|
271
327
|
* hasDockerfile: boolean,
|
|
272
328
|
* hasGaWorkflowsDir: boolean,
|
|
329
|
+
* hasBunSqlImport: boolean,
|
|
273
330
|
* hasGqlTaggedTemplates: boolean,
|
|
274
331
|
* hasJsLikeSource: boolean,
|
|
275
332
|
* hasK8sDir: boolean,
|
|
@@ -282,6 +339,7 @@ export function isMonorepoPackage(packageJson) {
|
|
|
282
339
|
*/
|
|
283
340
|
export async function collectAutoRuleFacts(root) {
|
|
284
341
|
const facts = {
|
|
342
|
+
hasBunSqlImport: false,
|
|
285
343
|
hasCapacitorConfig: false,
|
|
286
344
|
hasDockerfile: false,
|
|
287
345
|
hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
|
|
@@ -360,7 +418,9 @@ export async function detectAutoRulesAndSkills({
|
|
|
360
418
|
)
|
|
361
419
|
const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
|
|
362
420
|
const isMonorepo = isMonorepoPackage(packageJsonParsed)
|
|
363
|
-
const
|
|
421
|
+
const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
|
|
422
|
+
const hasMssqlDependency = depHits.has('mssql')
|
|
423
|
+
const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
|
|
364
424
|
|
|
365
425
|
/** @type {string[]} */
|
|
366
426
|
const detectedRules = []
|
|
@@ -400,6 +460,7 @@ export async function detectAutoRulesAndSkills({
|
|
|
400
460
|
{ enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
|
|
401
461
|
{ enabled: facts.hasJsLikeSource, id: 'js-lint' },
|
|
402
462
|
{ enabled: hasMssqlDependency, id: 'js-mssql' },
|
|
463
|
+
{ enabled: hasJsBunDbSignal, id: 'js-bun-db' },
|
|
403
464
|
{ enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
|
|
404
465
|
{ enabled: facts.hasK8sDir, id: 'k8s' },
|
|
405
466
|
{ enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє правило js-bun-db.mdc.
|
|
3
|
+
*
|
|
4
|
+
* 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
|
|
5
|
+
* бути `pg` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
|
|
6
|
+
* (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
|
|
7
|
+
*
|
|
8
|
+
* 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
|
|
9
|
+
* перевіряє небезпечні патерни:
|
|
10
|
+
* - `new SQL(...)` всередині функції (пул має бути singleton на рівні модуля).
|
|
11
|
+
* - `sql.unsafe(\`...${expr}...\`)` (інтерполяція даних у `unsafe` ламає параметризацію).
|
|
12
|
+
* - Динамічні SQL-списки через `.join(',')` у `IN (...)` / `VALUES (...)`
|
|
13
|
+
* (треба `sql([...])`).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { readFile } from 'node:fs/promises'
|
|
17
|
+
import { join, relative, sep } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
20
|
+
import {
|
|
21
|
+
findBunSqlPerRequestConnectionInText,
|
|
22
|
+
findUnsafeBunSqlDynamicSqlListInText,
|
|
23
|
+
findUnsafeBunSqlUnsafeCallInText,
|
|
24
|
+
isBunSqlScanSourceFile,
|
|
25
|
+
textHasBunSqlImport
|
|
26
|
+
} from './utils/bun-sql-scan.mjs'
|
|
27
|
+
import { walkDir } from './utils/walkDir.mjs'
|
|
28
|
+
|
|
29
|
+
/** Імена забороненої залежності у будь-якому `package.json`. */
|
|
30
|
+
const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'mysql2'])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} v parsed JSON
|
|
34
|
+
* @returns {Record<string, unknown>} object або {}
|
|
35
|
+
*/
|
|
36
|
+
function asObject(v) {
|
|
37
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
|
|
38
|
+
return /** @type {Record<string, unknown>} */ (v)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
|
|
43
|
+
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
44
|
+
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
45
|
+
*/
|
|
46
|
+
async function findAllPackageJsonPaths(repoRoot) {
|
|
47
|
+
/** @type {string[]} */
|
|
48
|
+
const paths = []
|
|
49
|
+
await walkDir(repoRoot, absPath => {
|
|
50
|
+
if (absPath.endsWith(`${sep}package.json`)) {
|
|
51
|
+
paths.push(absPath)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
55
|
+
return paths
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану Bun SQL патернів.
|
|
60
|
+
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
61
|
+
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
62
|
+
*/
|
|
63
|
+
async function findAllSourcePathsForBunSqlScan(repoRoot) {
|
|
64
|
+
/** @type {string[]} */
|
|
65
|
+
const paths = []
|
|
66
|
+
await walkDir(repoRoot, absPath => {
|
|
67
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
68
|
+
if (isBunSqlScanSourceFile(rel)) {
|
|
69
|
+
paths.push(absPath)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
73
|
+
return paths
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Перевіряє, чи в кореневому `package.json` присутні заборонені пакети у `dependencies`.
|
|
78
|
+
* @param {string[]} pkgJsonPaths абсолютні шляхи всіх `package.json` у репо
|
|
79
|
+
* @param {string} repoRoot абсолютний шлях до кореня
|
|
80
|
+
* @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
|
|
81
|
+
* @returns {Promise<number>} кількість знайдених порушень
|
|
82
|
+
*/
|
|
83
|
+
async function checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter) {
|
|
84
|
+
const { pass, fail } = reporter
|
|
85
|
+
let bad = 0
|
|
86
|
+
for (const absPath of pkgJsonPaths) {
|
|
87
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
88
|
+
let parsed
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
91
|
+
} catch {
|
|
92
|
+
fail(`js-bun-db: ${rel} — невалідний JSON`)
|
|
93
|
+
bad++
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const deps = asObject(parsed.dependencies)
|
|
97
|
+
for (const name of FORBIDDEN_DEPENDENCIES) {
|
|
98
|
+
if (Object.hasOwn(deps, name)) {
|
|
99
|
+
bad++
|
|
100
|
+
fail(
|
|
101
|
+
`js-bun-db: ${rel}: dependencies.${name} — замінити на Bun native SQL ` +
|
|
102
|
+
`(import { sql, SQL } from 'bun', https://bun.com/docs/runtime/sql) (js-bun-db.mdc)`
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (bad === 0) {
|
|
108
|
+
pass(`js-bun-db: жоден package.json не містить ${FORBIDDEN_DEPENDENCIES.join(' / ')} у dependencies`)
|
|
109
|
+
}
|
|
110
|
+
return bad
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Сканує JS/TS-джерела на небезпечні патерни Bun SQL.
|
|
115
|
+
* @param {string[]} sourcePaths абсолютні шляхи джерел
|
|
116
|
+
* @param {string} repoRoot абсолютний шлях до кореня
|
|
117
|
+
* @param {{ pass: (m: string) => void, fail: (m: string) => void }} reporter
|
|
118
|
+
* @returns {Promise<{ hasBunSqlImport: boolean, perRequest: number, unsafeCall: number, dynamicList: number }>}
|
|
119
|
+
* `hasBunSqlImport` — чи знайдено хоч один `import { sql|SQL } from 'bun'` у джерелах;
|
|
120
|
+
* решта — кількість порушень кожного типу.
|
|
121
|
+
*/
|
|
122
|
+
async function scanSourcesForBunSqlPatterns(sourcePaths, repoRoot, reporter) {
|
|
123
|
+
const { fail } = reporter
|
|
124
|
+
let hasBunSqlImport = false
|
|
125
|
+
let perRequest = 0
|
|
126
|
+
let unsafeCall = 0
|
|
127
|
+
let dynamicList = 0
|
|
128
|
+
|
|
129
|
+
for (const absPath of sourcePaths) {
|
|
130
|
+
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
131
|
+
const content = await readFile(absPath, 'utf8')
|
|
132
|
+
if (!hasBunSqlImport && textHasBunSqlImport(content)) {
|
|
133
|
+
hasBunSqlImport = true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const v of findBunSqlPerRequestConnectionInText(content, rel)) {
|
|
137
|
+
perRequest++
|
|
138
|
+
fail(
|
|
139
|
+
`js-bun-db: ${rel}:${v.line} — не створюй new SQL(...) всередині функцій; ` +
|
|
140
|
+
`тримай singleton на рівні модуля (js-bun-db.mdc): ${v.snippet}`
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
for (const v of findUnsafeBunSqlUnsafeCallInText(content, rel)) {
|
|
144
|
+
unsafeCall++
|
|
145
|
+
fail(
|
|
146
|
+
`js-bun-db: ${rel}:${v.line} — sql.unsafe(\`...\${...}...\`) недопустимо: ` +
|
|
147
|
+
`використовуй tagged template sql\`...\${value}...\` або sql.unsafe('static', [params]) (js-bun-db.mdc): ${v.snippet}`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
for (const v of findUnsafeBunSqlDynamicSqlListInText(content, rel)) {
|
|
151
|
+
dynamicList++
|
|
152
|
+
fail(
|
|
153
|
+
`js-bun-db: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') ` +
|
|
154
|
+
`у IN (...) / VALUES (...); використовуй sql([...]) (js-bun-db.mdc): ${v.snippet}`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { hasBunSqlImport, perRequest, unsafeCall, dynamicList }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Перевіряє відповідність проєкту правилу js-bun-db.mdc
|
|
164
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
165
|
+
*/
|
|
166
|
+
export async function check() {
|
|
167
|
+
const reporter = createCheckReporter()
|
|
168
|
+
const { pass } = reporter
|
|
169
|
+
|
|
170
|
+
const repoRoot = process.cwd()
|
|
171
|
+
const rootPkg = join(repoRoot, 'package.json')
|
|
172
|
+
if (!existsSync(rootPkg)) {
|
|
173
|
+
pass('js-bun-db: package.json у корені відсутній — перевірку пропущено')
|
|
174
|
+
return reporter.getExitCode()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const pkgJsonPaths = await findAllPackageJsonPaths(repoRoot)
|
|
178
|
+
if (pkgJsonPaths.length === 0) {
|
|
179
|
+
pass('js-bun-db: package.json не знайдено — перевірку пропущено')
|
|
180
|
+
return reporter.getExitCode()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await checkForbiddenDependencies(pkgJsonPaths, repoRoot, reporter)
|
|
184
|
+
|
|
185
|
+
const sourcePaths = await findAllSourcePathsForBunSqlScan(repoRoot)
|
|
186
|
+
if (sourcePaths.length === 0) {
|
|
187
|
+
pass('js-bun-db: немає JS/TS файлів для скану патернів Bun SQL')
|
|
188
|
+
return reporter.getExitCode()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { hasBunSqlImport, perRequest, unsafeCall, dynamicList } = await scanSourcesForBunSqlPatterns(
|
|
192
|
+
sourcePaths,
|
|
193
|
+
repoRoot,
|
|
194
|
+
reporter
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (!hasBunSqlImport) {
|
|
198
|
+
pass("js-bun-db: Bun SQL не використовується в коді (немає import { sql|SQL } from 'bun')")
|
|
199
|
+
return reporter.getExitCode()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (perRequest === 0) {
|
|
203
|
+
pass('js-bun-db: немає створення new SQL(...) всередині функцій (singleton на рівні модуля)')
|
|
204
|
+
}
|
|
205
|
+
if (unsafeCall === 0) {
|
|
206
|
+
pass('js-bun-db: немає небезпечних викликів sql.unsafe(`...${...}...`)')
|
|
207
|
+
}
|
|
208
|
+
if (dynamicList === 0) {
|
|
209
|
+
pass("js-bun-db: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return reporter.getExitCode()
|
|
213
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
findSharedMssqlRequestInText,
|
|
19
19
|
findUnsafeMssqlQueryTemplateCallInText,
|
|
20
20
|
findUnsafeMssqlDynamicSqlListInText,
|
|
21
|
+
findUnsafeMssqlInListUnparsedInText,
|
|
21
22
|
isMssqlScanSourceFile
|
|
22
23
|
} from './utils/mssql-pool-scan.mjs'
|
|
23
24
|
import { walkDir } from './utils/walkDir.mjs'
|
|
@@ -177,6 +178,7 @@ export async function check() {
|
|
|
177
178
|
let sharedRequestViolations = 0
|
|
178
179
|
let unsafeQueryCalls = 0
|
|
179
180
|
let unsafeDynamicSqlLists = 0
|
|
181
|
+
let unparsedInLists = 0
|
|
180
182
|
for (const absPath of sourcePaths) {
|
|
181
183
|
const rel = relative(repoRoot, absPath).split('\\').join('/')
|
|
182
184
|
const content = await readFile(absPath, 'utf8')
|
|
@@ -204,6 +206,12 @@ export async function check() {
|
|
|
204
206
|
`js-mssql: ${rel}:${v.line} — заборонено підставляти у SQL динамічні списки через .join(',') (типово IN (...) / VALUES (...)); використовуй TVP (sql.Table) + JOIN/INSERT (js-mssql.mdc): ${v.snippet}`
|
|
205
207
|
)
|
|
206
208
|
}
|
|
209
|
+
for (const v of findUnsafeMssqlInListUnparsedInText(content, rel)) {
|
|
210
|
+
unparsedInLists++
|
|
211
|
+
fail(
|
|
212
|
+
`js-mssql: ${rel}:${v.line} — у SQL IN (\${...}) значення мають бути попередньо приведені числовим парсером (parseInt/Number/BigInt/parseFloat) і відфільтровані від NaN, інакше можливий SQL injection (js-mssql.mdc): ${v.snippet}`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
if (violations === 0) {
|
|
@@ -218,6 +226,9 @@ export async function check() {
|
|
|
218
226
|
if (unsafeDynamicSqlLists === 0) {
|
|
219
227
|
pass("js-mssql: немає небезпечних динамічних SQL-списків через .join(',') у IN/VALUES")
|
|
220
228
|
}
|
|
229
|
+
if (unparsedInLists === 0) {
|
|
230
|
+
pass('js-mssql: немає підстановок IN (${...}) без числового парсера значень')
|
|
231
|
+
}
|
|
221
232
|
}
|
|
222
233
|
|
|
223
234
|
return reporter.getExitCode()
|
package/scripts/check-vue.mjs
CHANGED
|
@@ -24,6 +24,98 @@ import { walkDir } from './utils/walkDir.mjs'
|
|
|
24
24
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
25
25
|
|
|
26
26
|
const MAJOR_VERSION_RE = /(\d+)/
|
|
27
|
+
const ESBUILD_RE = /\besbuild\b/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Визначає, чи можна сканувати файл як текст на згадки `esbuild`.
|
|
31
|
+
* @param {string} relPosix відносний шлях у posix-форматі
|
|
32
|
+
* @returns {boolean} true якщо файл варто перевірити
|
|
33
|
+
*/
|
|
34
|
+
function isEsbuildScanFile(relPosix) {
|
|
35
|
+
if (
|
|
36
|
+
relPosix.startsWith('node_modules/') ||
|
|
37
|
+
relPosix.startsWith('dist/') ||
|
|
38
|
+
relPosix.startsWith('build/') ||
|
|
39
|
+
relPosix.startsWith('coverage/') ||
|
|
40
|
+
relPosix.startsWith('.git/')
|
|
41
|
+
) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lower = relPosix.toLowerCase()
|
|
46
|
+
if (
|
|
47
|
+
lower === 'bun.lock' ||
|
|
48
|
+
lower === 'bun.lockb' ||
|
|
49
|
+
lower === 'package-lock.json' ||
|
|
50
|
+
lower === 'yarn.lock' ||
|
|
51
|
+
lower === 'pnpm-lock.yaml'
|
|
52
|
+
) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
lower.endsWith('.js') ||
|
|
58
|
+
lower.endsWith('.mjs') ||
|
|
59
|
+
lower.endsWith('.cjs') ||
|
|
60
|
+
lower.endsWith('.ts') ||
|
|
61
|
+
lower.endsWith('.tsx') ||
|
|
62
|
+
lower.endsWith('.vue') ||
|
|
63
|
+
lower.endsWith('.json') ||
|
|
64
|
+
lower.endsWith('.jsonc') ||
|
|
65
|
+
lower.endsWith('.yaml') ||
|
|
66
|
+
lower.endsWith('.yml') ||
|
|
67
|
+
lower.endsWith('.md') ||
|
|
68
|
+
lower.endsWith('.mdc')
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Сканує дерево пакета на згадки `esbuild` і підказує заміну на `rolldown`.
|
|
74
|
+
* @param {string} rootDir відносний шлях до пакета
|
|
75
|
+
* @param {string} absPackageRoot абсолютний шлях до кореня пакета
|
|
76
|
+
* @param {string} prefix параметр prefix
|
|
77
|
+
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
78
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
79
|
+
*/
|
|
80
|
+
async function checkEsbuildMentions(rootDir, absPackageRoot, prefix, passFn, fail) {
|
|
81
|
+
/** @type {{ rel: string; line: number; snippet: string }[]} */
|
|
82
|
+
const hits = []
|
|
83
|
+
|
|
84
|
+
await walkDir(absPackageRoot, absPath => {
|
|
85
|
+
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
86
|
+
if (!isEsbuildScanFile(rel)) return
|
|
87
|
+
hits.push({ rel, line: 0, snippet: '' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ми використали hits як буфер шляхів; зараз перетворимо на реальні співпадіння
|
|
91
|
+
/** @type {{ rel: string; line: number; snippet: string }[]} */
|
|
92
|
+
const matches = []
|
|
93
|
+
for (const { rel } of hits) {
|
|
94
|
+
const content = await readFile(join(absPackageRoot, rel), 'utf8')
|
|
95
|
+
if (!ESBUILD_RE.test(content)) continue
|
|
96
|
+
|
|
97
|
+
const lines = content.split('\n')
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
if (ESBUILD_RE.test(lines[i])) {
|
|
100
|
+
matches.push({ rel, line: i + 1, snippet: lines[i].trim() })
|
|
101
|
+
if (matches.length >= 30) break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (matches.length >= 30) break
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (matches.length === 0) {
|
|
108
|
+
passFn(`${prefix}немає згадок 'esbuild' у джерелах пакета (очікується rolldown)`)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const m of matches) {
|
|
113
|
+
fail(`${prefix}${m.rel}:${m.line} — знайдено 'esbuild'. Замінити на 'rolldown'. Фрагмент: ${m.snippet}`)
|
|
114
|
+
}
|
|
115
|
+
if (matches.length >= 30) {
|
|
116
|
+
fail(`${prefix}показано перші 30 збігів 'esbuild' (замінити на 'rolldown')`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
27
119
|
|
|
28
120
|
/**
|
|
29
121
|
* Формує зрозумілий для людини підпис пакета для повідомлень перевірки.
|
|
@@ -107,6 +199,9 @@ async function checkViteConfig(rootDir, prefix, passFn, fail) {
|
|
|
107
199
|
return
|
|
108
200
|
}
|
|
109
201
|
const content = await readFile(join(rootDir, viteConfig), 'utf8')
|
|
202
|
+
if (ESBUILD_RE.test(content)) {
|
|
203
|
+
fail(`${prefix}${viteConfig} містить 'esbuild' — заміни на 'rolldown'`)
|
|
204
|
+
}
|
|
110
205
|
const checks = [
|
|
111
206
|
{ token: 'VueMacros', ok: `${viteConfig} використовує VueMacros`, err: `${viteConfig} не містить VueMacros` },
|
|
112
207
|
{ token: 'AutoImport', ok: `${viteConfig} використовує AutoImport`, err: `${viteConfig} не містить AutoImport` }
|
|
@@ -175,6 +270,10 @@ async function checkVuePackage(rootDir, fail, passFn) {
|
|
|
175
270
|
const devDeps = pkg.devDependencies || {}
|
|
176
271
|
const allDeps = { ...deps, ...devDeps }
|
|
177
272
|
|
|
273
|
+
if (allDeps.esbuild) {
|
|
274
|
+
fail(`${prefix}esbuild заборонено (знайдено: ${allDeps.esbuild}). Замінити на rolldown та прибрати esbuild.`)
|
|
275
|
+
}
|
|
276
|
+
|
|
178
277
|
checkRequiredDep(deps, 'vue', prefix, passFn, fail, 'vue відсутній в dependencies')
|
|
179
278
|
checkViteVersion(devDeps, prefix, passFn, fail)
|
|
180
279
|
checkRequiredDep(
|
|
@@ -205,6 +304,7 @@ async function checkVuePackage(rootDir, fail, passFn) {
|
|
|
205
304
|
|
|
206
305
|
await checkViteConfig(rootDir, prefix, passFn, fail)
|
|
207
306
|
await checkVueImportViolations(rootDir, join(process.cwd(), rootDir), prefix, passFn, fail)
|
|
307
|
+
await checkEsbuildMentions(rootDir, join(process.cwd(), rootDir), prefix, passFn, fail)
|
|
208
308
|
}
|
|
209
309
|
|
|
210
310
|
/**
|
|
@@ -215,17 +315,6 @@ export async function check() {
|
|
|
215
315
|
const reporter = createCheckReporter()
|
|
216
316
|
const { pass, fail } = reporter
|
|
217
317
|
|
|
218
|
-
if (existsSync('.vscode/extensions.json')) {
|
|
219
|
-
const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
|
|
220
|
-
if (ext.recommendations?.includes('Vue.volar')) {
|
|
221
|
-
pass('extensions.json містить Vue.volar')
|
|
222
|
-
} else {
|
|
223
|
-
fail('extensions.json не містить Vue.volar — додай до recommendations')
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
226
|
-
fail('.vscode/extensions.json не існує')
|
|
227
|
-
}
|
|
228
|
-
|
|
229
318
|
const roots = await getMonorepoPackageRootDirs()
|
|
230
319
|
/** @type {string[]} */
|
|
231
320
|
const vueRoots = []
|
|
@@ -237,8 +326,23 @@ export async function check() {
|
|
|
237
326
|
}
|
|
238
327
|
}
|
|
239
328
|
|
|
329
|
+
if (vueRoots.length > 0) {
|
|
330
|
+
if (existsSync('.vscode/extensions.json')) {
|
|
331
|
+
const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
|
|
332
|
+
if (ext.recommendations?.includes('Vue.volar')) {
|
|
333
|
+
pass('extensions.json містить Vue.volar')
|
|
334
|
+
} else {
|
|
335
|
+
fail('extensions.json не містить Vue.volar — додай до recommendations')
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
fail('.vscode/extensions.json не існує (для Vue-проєкту потрібна рекомендація Vue.volar)')
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
pass('Vue.volar: пропущено (у repo немає пакетів з vue у dependencies)')
|
|
342
|
+
}
|
|
343
|
+
|
|
240
344
|
if (vueRoots.length === 0) {
|
|
241
|
-
|
|
345
|
+
pass('vue не знайдено в dependencies жодного пакета (перевірка vue пропущена)')
|
|
242
346
|
return reporter.getExitCode()
|
|
243
347
|
}
|
|
244
348
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-сканер небезпечних патернів Bun SQL (`import { sql, SQL } from 'bun'`).
|
|
3
|
+
*
|
|
4
|
+
* Знаходить:
|
|
5
|
+
* - `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля,
|
|
6
|
+
* а не на кожен виклик handler-а.
|
|
7
|
+
* - Виклик `sql.unsafe(\`...${expr}...\`)` з даними у TemplateLiteral —
|
|
8
|
+
* `sql.unsafe` приймає лише статичний SQL (плюс масив параметрів); інтерполяція
|
|
9
|
+
* у текст руйнує параметризацію і відкриває SQL injection.
|
|
10
|
+
* - Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``:
|
|
11
|
+
* навіть «через tagged template» у запит потрапляє готовий шматок SQL замість
|
|
12
|
+
* параметризованих значень — треба `sql([...])`.
|
|
13
|
+
*
|
|
14
|
+
* Семантика — через **oxc-parser**, без regex по тексту коду.
|
|
15
|
+
* Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній
|
|
16
|
+
* результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку.
|
|
17
|
+
*/
|
|
18
|
+
import { parseSync } from 'oxc-parser'
|
|
19
|
+
|
|
20
|
+
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
|
|
21
|
+
const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
|
|
22
|
+
const BUN_SQL_IMPORT_RE = /\bimport\s*\{[\s\S]*?\b(sql|SQL)\b[\s\S]*?\}\s*from\s*["']bun["']/u
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Мова для Oxc за шляхом файлу (розширення).
|
|
26
|
+
* @param {string} filePath віртуальний або реальний шлях до файлу
|
|
27
|
+
* @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
|
|
28
|
+
*/
|
|
29
|
+
function langFromPath(filePath) {
|
|
30
|
+
const lower = filePath.toLowerCase()
|
|
31
|
+
if (lower.endsWith('.tsx')) return 'tsx'
|
|
32
|
+
if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) return 'ts'
|
|
33
|
+
if (lower.endsWith('.jsx')) return 'jsx'
|
|
34
|
+
return 'js'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Номер рядка (1-based) за зміщенням у буфері.
|
|
39
|
+
* @param {string} content повний текст файлу
|
|
40
|
+
* @param {number} offset байтове зміщення початку фрагмента
|
|
41
|
+
* @returns {number} номер рядка від 1
|
|
42
|
+
*/
|
|
43
|
+
function offsetToLine(content, offset) {
|
|
44
|
+
let line = 1
|
|
45
|
+
const n = Math.min(offset, content.length)
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
if (content.codePointAt(i) === 10) line++
|
|
48
|
+
}
|
|
49
|
+
return line
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Стискає пробіли для повідомлення про порушення.
|
|
54
|
+
* @param {string} s фрагмент коду
|
|
55
|
+
* @returns {string} скорочений однорядковий рядок
|
|
56
|
+
*/
|
|
57
|
+
function normalizeSnippet(s) {
|
|
58
|
+
return s.replaceAll(/\s+/gu, ' ').trim().slice(0, 180)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Чи є вузол функцією.
|
|
63
|
+
* @param {unknown} node AST node
|
|
64
|
+
* @returns {boolean} true, якщо це будь-який вузол-функція
|
|
65
|
+
*/
|
|
66
|
+
function isFunctionNode(node) {
|
|
67
|
+
return (
|
|
68
|
+
!!node &&
|
|
69
|
+
typeof node === 'object' &&
|
|
70
|
+
typeof node.type === 'string' &&
|
|
71
|
+
(node.type === 'FunctionDeclaration' ||
|
|
72
|
+
node.type === 'FunctionExpression' ||
|
|
73
|
+
node.type === 'ArrowFunctionExpression')
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Рекурсивний обхід AST з предками, щоб визначати контекст (всередині функції чи ні).
|
|
79
|
+
* @param {unknown} node поточний вузол
|
|
80
|
+
* @param {unknown[]} ancestors масив предків від кореня до parent
|
|
81
|
+
* @param {(n: Record<string, unknown>, ancestors: unknown[]) => void} visit відвідувач для вузлів з `type`
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
function walkAstWithAncestors(node, ancestors, visit) {
|
|
85
|
+
if (!node || typeof node !== 'object') return
|
|
86
|
+
if (Array.isArray(node)) {
|
|
87
|
+
for (const item of node) walkAstWithAncestors(item, ancestors, visit)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rec = /** @type {Record<string, unknown>} */ (node)
|
|
92
|
+
if (typeof rec.type === 'string') {
|
|
93
|
+
visit(rec, ancestors)
|
|
94
|
+
ancestors = [...ancestors, rec]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const key of Object.keys(node)) {
|
|
98
|
+
if (key === 'parent') continue
|
|
99
|
+
const v = rec[key]
|
|
100
|
+
if (v && typeof v === 'object') {
|
|
101
|
+
walkAstWithAncestors(v, ancestors, visit)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Парсить файл та повертає program або null, якщо є синтаксичні помилки.
|
|
108
|
+
* @param {string} content вихідний код
|
|
109
|
+
* @param {string} virtualPath шлях для вибору `lang`
|
|
110
|
+
* @returns {unknown | null} `result.program` або null
|
|
111
|
+
*/
|
|
112
|
+
function parseProgramOrNull(content, virtualPath) {
|
|
113
|
+
const lang = langFromPath(virtualPath || 'scan.ts')
|
|
114
|
+
let result
|
|
115
|
+
try {
|
|
116
|
+
result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
if (result.errors?.length) return null
|
|
121
|
+
return result.program
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Чи це `new SQL(...)` (Identifier callee з імʼям `SQL`).
|
|
126
|
+
* @param {unknown} node AST node
|
|
127
|
+
* @returns {boolean} true, якщо це `new SQL(...)`
|
|
128
|
+
*/
|
|
129
|
+
function isNewSqlConstructor(node) {
|
|
130
|
+
if (!node || node.type !== 'NewExpression') return false
|
|
131
|
+
const callee = node.callee
|
|
132
|
+
return !!callee && callee.type === 'Identifier' && callee.name === 'SQL'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Чи це виклик `<obj>.unsafe(...)` з TemplateLiteral як першим аргументом і expressions усередині нього.
|
|
137
|
+
* Допустимий лише `sql.unsafe('static text', [params])`; з `${...}` у TemplateLiteral — небезпечно.
|
|
138
|
+
* @param {unknown} node AST node
|
|
139
|
+
* @returns {boolean} true для небезпечного `sql.unsafe(\`... ${x} ...\`)`
|
|
140
|
+
*/
|
|
141
|
+
function isUnsafeCallWithInterpolatedTemplate(node) {
|
|
142
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
143
|
+
const callee = node.callee
|
|
144
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
145
|
+
const prop = callee.property
|
|
146
|
+
if (!prop || prop.type !== 'Identifier' || prop.name !== 'unsafe') return false
|
|
147
|
+
const args = node.arguments
|
|
148
|
+
if (!Array.isArray(args) || args.length === 0) return false
|
|
149
|
+
const first = args[0]
|
|
150
|
+
if (!first || first.type !== 'TemplateLiteral') return false
|
|
151
|
+
const expressions = first.expressions
|
|
152
|
+
return Array.isArray(expressions) && expressions.length > 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Чи це `.join(...)` виклик (типово для динамічних списків у SQL).
|
|
157
|
+
* @param {unknown} node AST node
|
|
158
|
+
* @returns {boolean} true, якщо це CallExpression `*.join(...)`
|
|
159
|
+
*/
|
|
160
|
+
function isJoinCall(node) {
|
|
161
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
162
|
+
const callee = node.callee
|
|
163
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
164
|
+
const prop = callee.property
|
|
165
|
+
return !!prop && prop.type === 'Identifier' && prop.name === 'join'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Текст quasis у TemplateLiteral (без expressions).
|
|
170
|
+
* @param {unknown} template TemplateLiteral
|
|
171
|
+
* @returns {string} обʼєднаний raw-текст
|
|
172
|
+
*/
|
|
173
|
+
function templateQuasisText(template) {
|
|
174
|
+
if (!template || template.type !== 'TemplateLiteral') return ''
|
|
175
|
+
const quasis = template.quasis
|
|
176
|
+
if (!Array.isArray(quasis) || quasis.length === 0) return ''
|
|
177
|
+
let out = ''
|
|
178
|
+
for (const q of quasis) {
|
|
179
|
+
if (!q || typeof q !== 'object') continue
|
|
180
|
+
const value = q.value
|
|
181
|
+
if (!value || typeof value !== 'object') continue
|
|
182
|
+
if (typeof value.raw === 'string') out += value.raw
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Чи виглядає TemplateLiteral як SQL-контекст зі списком (IN/VALUES (...)).
|
|
189
|
+
* @param {unknown} template TemplateLiteral
|
|
190
|
+
* @returns {boolean} true, якщо текст містить `IN (` або `VALUES (`
|
|
191
|
+
*/
|
|
192
|
+
function isSqlListContextTemplate(template) {
|
|
193
|
+
return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
|
|
198
|
+
* @param {string} content вихідний код
|
|
199
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
200
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
201
|
+
*/
|
|
202
|
+
export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'scan.ts') {
|
|
203
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
204
|
+
if (!program) return []
|
|
205
|
+
|
|
206
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
207
|
+
const out = []
|
|
208
|
+
walkAstWithAncestors(program, [], (node, ancestors) => {
|
|
209
|
+
if (!isNewSqlConstructor(node)) return
|
|
210
|
+
const insideFunction = ancestors.some(n => isFunctionNode(n))
|
|
211
|
+
if (!insideFunction) return
|
|
212
|
+
out.push({
|
|
213
|
+
line: offsetToLine(content, node.start),
|
|
214
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Знаходить виклики `sql.unsafe(\`...${...}...\`)` (TemplateLiteral з expressions).
|
|
222
|
+
* @param {string} content вихідний код
|
|
223
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
224
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
225
|
+
*/
|
|
226
|
+
export function findUnsafeBunSqlUnsafeCallInText(content, virtualPath = 'scan.ts') {
|
|
227
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
228
|
+
if (!program) return []
|
|
229
|
+
|
|
230
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
231
|
+
const out = []
|
|
232
|
+
walkAstWithAncestors(program, [], node => {
|
|
233
|
+
if (!isUnsafeCallWithInterpolatedTemplate(node)) return
|
|
234
|
+
out.push({
|
|
235
|
+
line: offsetToLine(content, node.start),
|
|
236
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Знаходить динамічні SQL-списки у TaggedTemplateExpression / TemplateLiteral в контексті
|
|
244
|
+
* `IN (...)` або `VALUES (...)`, де серед expressions є виклик `.join(...)`.
|
|
245
|
+
* @param {string} content вихідний код
|
|
246
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
247
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
248
|
+
*/
|
|
249
|
+
export function findUnsafeBunSqlDynamicSqlListInText(content, virtualPath = 'scan.ts') {
|
|
250
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
251
|
+
if (!program) return []
|
|
252
|
+
|
|
253
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
254
|
+
const out = []
|
|
255
|
+
walkAstWithAncestors(program, [], node => {
|
|
256
|
+
/** @type {unknown} */
|
|
257
|
+
let template = null
|
|
258
|
+
if (node.type === 'TemplateLiteral') {
|
|
259
|
+
template = node
|
|
260
|
+
} else if (node.type === 'TaggedTemplateExpression') {
|
|
261
|
+
template = node.quasi
|
|
262
|
+
}
|
|
263
|
+
if (!template || typeof template !== 'object' || template.type !== 'TemplateLiteral') return
|
|
264
|
+
if (!isSqlListContextTemplate(template)) return
|
|
265
|
+
const expressions = template.expressions
|
|
266
|
+
if (!Array.isArray(expressions) || expressions.length === 0) return
|
|
267
|
+
if (!expressions.some(expr => isJoinCall(expr))) return
|
|
268
|
+
out.push({
|
|
269
|
+
line: offsetToLine(content, template.start),
|
|
270
|
+
snippet: normalizeSnippet(content.slice(template.start, template.end))
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"`.
|
|
278
|
+
* Скан по сирому тексту — без AST, щоб бути дешевим: викликається на кожному
|
|
279
|
+
* JS/TS-файлі при зборі ознак для авто-детекту правил.
|
|
280
|
+
* @param {string} content вміст файлу
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
export function textHasBunSqlImport(content) {
|
|
284
|
+
return BUN_SQL_IMPORT_RE.test(content)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Чи сканувати цей файл за розширенням (JS/TS-сімʼя, без `.d.ts`).
|
|
289
|
+
* @param {string} relativePathPosix відносний шлях (posix)
|
|
290
|
+
* @returns {boolean} true, якщо розширення підходить для AST-скану
|
|
291
|
+
*/
|
|
292
|
+
export function isBunSqlScanSourceFile(relativePathPosix) {
|
|
293
|
+
return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
|
|
294
|
+
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* повторно використовувати між запитами.
|
|
13
13
|
* - небезпечні “динамічні списки” в SQL, коли в TemplateLiteral/TaggedTemplateExpression
|
|
14
14
|
* підставляють рядки, зібрані через `.join(',')` (типово для `IN (...)` або `VALUES (...)`).
|
|
15
|
+
* - підстановки `IN (${expr})`, де `expr` не пройшов числовий парсер (parseInt/Number/BigInt
|
|
16
|
+
* /parseFloat або унарний `+`) і не є літеральним масивом чисел — навіть у tagged template
|
|
17
|
+
* це додатковий шар захисту від SQL injection (див. js-mssql.mdc).
|
|
15
18
|
*
|
|
16
19
|
* Семантика береться з **oxc-parser** по AST, щоб не покладатися на regex.
|
|
17
20
|
* Якщо файл не парситься або містить синтаксичні помилки — повертаємо порожній
|
|
@@ -21,6 +24,8 @@ import { parseSync } from 'oxc-parser'
|
|
|
21
24
|
|
|
22
25
|
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
|
|
23
26
|
const SQL_LIST_CONTEXT_RE = /\b(in|values)\b\s*\(/iu
|
|
27
|
+
const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
|
|
28
|
+
const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Мова для Oxc за шляхом файлу (розширення).
|
|
@@ -346,6 +351,175 @@ export function findUnsafeMssqlDynamicSqlListInText(content, virtualPath = 'scan
|
|
|
346
351
|
return out
|
|
347
352
|
}
|
|
348
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Чи елементи літерального масиву — лише числові (numeric/bigint) літерали.
|
|
356
|
+
* Такі масиви безпечні в `IN (...)` навіть без явного парсера.
|
|
357
|
+
* @param {unknown} node AST node
|
|
358
|
+
* @returns {boolean} true, якщо це непорожній масив суто числових літералів
|
|
359
|
+
*/
|
|
360
|
+
function isLiteralNumericArrayExpression(node) {
|
|
361
|
+
if (!node || typeof node !== 'object' || node.type !== 'ArrayExpression') return false
|
|
362
|
+
const elements = node.elements
|
|
363
|
+
if (!Array.isArray(elements) || elements.length === 0) return false
|
|
364
|
+
return elements.every(el => {
|
|
365
|
+
if (!el || typeof el !== 'object') return false
|
|
366
|
+
if (el.type === 'NumericLiteral' || el.type === 'BigIntLiteral') return true
|
|
367
|
+
if (el.type === 'Literal' && (typeof el.value === 'number' || typeof el.value === 'bigint')) {
|
|
368
|
+
return true
|
|
369
|
+
}
|
|
370
|
+
return false
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Чи містить піддерево виклик числового парсера (parseInt/parseFloat/Number/BigInt)
|
|
376
|
+
* або унарний `+` (приведення до Number). Це сигнал, що значення гарантовано числове
|
|
377
|
+
* і не може містити SQL-метасимволи.
|
|
378
|
+
* @param {unknown} node AST node
|
|
379
|
+
* @returns {boolean} true, якщо знайдено числовий парсер у піддереві
|
|
380
|
+
*/
|
|
381
|
+
function subtreeHasNumericParseCall(node) {
|
|
382
|
+
if (!node || typeof node !== 'object') return false
|
|
383
|
+
if (Array.isArray(node)) return node.some(subtreeHasNumericParseCall)
|
|
384
|
+
|
|
385
|
+
if (node.type === 'CallExpression') {
|
|
386
|
+
const callee = node.callee
|
|
387
|
+
if (callee && callee.type === 'Identifier' && NUMERIC_PARSE_FN_NAMES.has(callee.name)) {
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
if (callee && callee.type === 'MemberExpression' && !callee.computed) {
|
|
391
|
+
const prop = callee.property
|
|
392
|
+
if (prop && prop.type === 'Identifier' && NUMERIC_PARSE_FN_NAMES.has(prop.name)) {
|
|
393
|
+
return true
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (node.type === 'UnaryExpression' && node.operator === '+') return true
|
|
398
|
+
|
|
399
|
+
for (const key of Object.keys(node)) {
|
|
400
|
+
if (key === 'parent') continue
|
|
401
|
+
const v = node[key]
|
|
402
|
+
if (v && typeof v === 'object' && subtreeHasNumericParseCall(v)) return true
|
|
403
|
+
}
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Збирає всі VariableDeclarator-вузли в AST (для трасування Identifier-ів до їх init).
|
|
409
|
+
* @param {unknown} programNode AST root (Program)
|
|
410
|
+
* @returns {Array<Record<string, unknown>>} список VariableDeclarator-ів
|
|
411
|
+
*/
|
|
412
|
+
function collectVariableDeclarators(programNode) {
|
|
413
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
414
|
+
const out = []
|
|
415
|
+
walkAstWithAncestors(programNode, [], node => {
|
|
416
|
+
if (node.type === 'VariableDeclarator') out.push(node)
|
|
417
|
+
})
|
|
418
|
+
return out
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Чи виглядає вираз, який підставляється в `IN (${...})`, як «безпечно розпарсений»:
|
|
423
|
+
* - літеральний масив чисел;
|
|
424
|
+
* - саме піддерево містить виклик числового парсера (parseInt/Number/BigInt/parseFloat/+x);
|
|
425
|
+
* - Identifier, чий init у файлі рекурсивно задовольняє ці умови.
|
|
426
|
+
*
|
|
427
|
+
* Якщо для Identifier немає видимого init (наприклад параметр функції чи import),
|
|
428
|
+
* вираз вважається не парсованим — потрібен явний парсер на місці підстановки.
|
|
429
|
+
*
|
|
430
|
+
* @param {unknown} expr вираз з template.expressions
|
|
431
|
+
* @param {Array<Record<string, unknown>>} declarators VariableDeclarator-и файлу
|
|
432
|
+
* @param {Set<string>} [seen] іменa Identifier-ів, що вже трасуються (анти-цикл)
|
|
433
|
+
* @returns {boolean} true, якщо вираз можна вважати безпечно числовим
|
|
434
|
+
*/
|
|
435
|
+
function isInListExpressionParsed(expr, declarators, seen = new Set()) {
|
|
436
|
+
if (!expr || typeof expr !== 'object') return false
|
|
437
|
+
if (isLiteralNumericArrayExpression(expr)) return true
|
|
438
|
+
if (subtreeHasNumericParseCall(expr)) return true
|
|
439
|
+
|
|
440
|
+
if (expr.type === 'Identifier' && typeof expr.name === 'string') {
|
|
441
|
+
if (seen.has(expr.name)) return false
|
|
442
|
+
const nextSeen = new Set(seen)
|
|
443
|
+
nextSeen.add(expr.name)
|
|
444
|
+
const inits = declarators
|
|
445
|
+
.filter(d => {
|
|
446
|
+
const id = d.id
|
|
447
|
+
return (
|
|
448
|
+
!!id &&
|
|
449
|
+
typeof id === 'object' &&
|
|
450
|
+
id.type === 'Identifier' &&
|
|
451
|
+
id.name === expr.name &&
|
|
452
|
+
!!d.init
|
|
453
|
+
)
|
|
454
|
+
})
|
|
455
|
+
.map(d => d.init)
|
|
456
|
+
if (inits.length === 0) return false
|
|
457
|
+
return inits.every(init => isInListExpressionParsed(init, declarators, nextSeen))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Знаходить підстановки `IN (${expr})` у TemplateLiteral, де `expr` не пройшов числовий парсер.
|
|
465
|
+
*
|
|
466
|
+
* Навіть у безпечному `pool.request().query\`...\`` краще явно парсити значення (parseInt/
|
|
467
|
+
* Number/BigInt/parseFloat) та фільтрувати NaN — це гарантує, що жодний елемент не може
|
|
468
|
+
* містити SQL-метасимволи, навіть якщо колись query-функція або обгортка зміняться. У
|
|
469
|
+
* небезпечних контекстах (наприклад `pool.query(String.raw\`...\`)`) це єдиний бар'єр від SQL
|
|
470
|
+
* injection.
|
|
471
|
+
*
|
|
472
|
+
* Випадки `${arr.join(',')}` свідомо ігноруються — їх ловить
|
|
473
|
+
* {@link findUnsafeMssqlDynamicSqlListInText}.
|
|
474
|
+
*
|
|
475
|
+
* @param {string} content вихідний код
|
|
476
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
477
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
478
|
+
*/
|
|
479
|
+
export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan.ts') {
|
|
480
|
+
const lang = langFromPath(virtualPath || 'scan.ts')
|
|
481
|
+
let result
|
|
482
|
+
try {
|
|
483
|
+
result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
|
|
484
|
+
} catch {
|
|
485
|
+
return []
|
|
486
|
+
}
|
|
487
|
+
if (result.errors?.length) return []
|
|
488
|
+
|
|
489
|
+
const declarators = collectVariableDeclarators(result.program)
|
|
490
|
+
|
|
491
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
492
|
+
const out = []
|
|
493
|
+
walkAstWithAncestors(result.program, [], node => {
|
|
494
|
+
if (node.type !== 'TemplateLiteral') return
|
|
495
|
+
const quasis = node.quasis
|
|
496
|
+
const expressions = node.expressions
|
|
497
|
+
if (!Array.isArray(quasis) || !Array.isArray(expressions) || expressions.length === 0) return
|
|
498
|
+
|
|
499
|
+
for (let i = 0; i < expressions.length; i++) {
|
|
500
|
+
const q = quasis[i]
|
|
501
|
+
const rawText =
|
|
502
|
+
q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string'
|
|
503
|
+
? q.value.raw
|
|
504
|
+
: ''
|
|
505
|
+
if (!IN_PLACEHOLDER_END_RE.test(rawText)) continue
|
|
506
|
+
|
|
507
|
+
const expr = expressions[i]
|
|
508
|
+
if (!expr || typeof expr !== 'object') continue
|
|
509
|
+
if (isJoinCall(expr)) continue
|
|
510
|
+
if (isInListExpressionParsed(expr, declarators)) continue
|
|
511
|
+
|
|
512
|
+
const startOffset = typeof expr.start === 'number' ? expr.start : node.start
|
|
513
|
+
out.push({
|
|
514
|
+
line: offsetToLine(content, startOffset),
|
|
515
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
return out
|
|
521
|
+
}
|
|
522
|
+
|
|
349
523
|
/**
|
|
350
524
|
* Чи сканувати цей файл за розширенням (JS/TS-сім'я).
|
|
351
525
|
* @param {string} relativePathPosix відносний шлях (posix)
|