@nitra/cursor 1.8.208 → 1.8.210
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/CHANGELOG.md +103 -0
- package/mdc/js-bun-db.mdc +55 -1
- package/package.json +1 -1
- package/policy/abie/health_check_policy/health_check_policy.rego +5 -1
- package/policy/abie/http_route_base/http_route_base.rego +2 -1
- package/policy/hasura/svc_hl/svc_hl.rego +2 -1
- package/policy/k8s/manifest/manifest.rego +2 -0
- package/scripts/check-adr.mjs +10 -88
- package/scripts/check-ga.mjs +14 -192
- package/scripts/check-js-bun-db.mjs +44 -4
- package/scripts/check-js-lint.mjs +14 -115
- package/scripts/check-npm-module.mjs +17 -155
- package/scripts/utils/bun-sql-scan.mjs +192 -0
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
offsetToLine,
|
|
26
26
|
parseProgramAndCommentsOrNull,
|
|
27
27
|
parseProgramOrNull,
|
|
28
|
+
templateQuasisText,
|
|
28
29
|
walkAstWithAncestors
|
|
29
30
|
} from './ast-scan-utils.mjs'
|
|
30
31
|
|
|
@@ -43,6 +44,18 @@ const ALLOW_PG_LEFTOVER_MARKER_RE = /\ballow-pg-leftover\s*:\s*\S+/u
|
|
|
43
44
|
// формально існують і там, тому опт-аут маркером лишається доречним.
|
|
44
45
|
const PG_LEFTOVER_METHOD_NAMES = new Set(['connect', 'end'])
|
|
45
46
|
|
|
47
|
+
// pg-format placeholders — `%L` (literal), `%I` (identifier), `%s` (raw string).
|
|
48
|
+
// Якщо у тілі функції з підозрілим іменем зустрічається такий літерал/regex —
|
|
49
|
+
// це pg-format-сумісний шим (drop-in замінник pg-format поверх Bun SQL).
|
|
50
|
+
const PG_FORMAT_PLACEHOLDER_RE = /%[LIs]/u
|
|
51
|
+
// Імена функцій-кандидатів на pg-format-шим. Спрацьовує лише у поєднанні
|
|
52
|
+
// з наявністю `%L` / `%I` / `%s` у тілі — щоб не плутати з невинним `format(date)`.
|
|
53
|
+
const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'pgFmt'])
|
|
54
|
+
// Імена quote/escape-хелперів — самі по собі сильний сигнал pg-format-шиму,
|
|
55
|
+
// без додаткової перевірки тіла. Це pg-format-специфічні API, нерідко публікуються
|
|
56
|
+
// як named export з модуля-обгортки.
|
|
57
|
+
const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
|
|
58
|
+
|
|
46
59
|
/**
|
|
47
60
|
* @param {unknown} node AST node
|
|
48
61
|
* @param {string} name імʼя змінної
|
|
@@ -267,6 +280,185 @@ function asPgLeftoverCall(node) {
|
|
|
267
280
|
return { name: /** @type {'connect' | 'end'} */ (prop.name) }
|
|
268
281
|
}
|
|
269
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Чи це CallExpression `<obj>.unsafe(...)` (для пошуку в тілі query-шиму).
|
|
285
|
+
* Дублює `isUnsafeCall` з основного скану, але локально — щоб не залежати
|
|
286
|
+
* від порядку оголошень у файлі.
|
|
287
|
+
* @param {unknown} node AST node
|
|
288
|
+
* @returns {boolean} true для `<obj>.unsafe(...)`
|
|
289
|
+
*/
|
|
290
|
+
function isUnsafeCallNode(node) {
|
|
291
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
292
|
+
const callee = node.callee
|
|
293
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
|
|
294
|
+
const prop = callee.property
|
|
295
|
+
return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Чи містить піддерево вузла рядковий або regex-літерал з `%L` / `%I` / `%s`.
|
|
300
|
+
* Покриває:
|
|
301
|
+
* - `Literal` зі строковим `value`,
|
|
302
|
+
* - `StringLiteral` (oxc),
|
|
303
|
+
* - `TemplateLiteral` (через текст quasis),
|
|
304
|
+
* - `RegExpLiteral` / `Literal` з `regex.pattern`.
|
|
305
|
+
* @param {unknown} root корінь піддерева (зазвичай тіло функції)
|
|
306
|
+
* @returns {boolean} true, якщо знайдено pg-format-плейсхолдер
|
|
307
|
+
*/
|
|
308
|
+
function nodeContainsPgFormatPlaceholder(root) {
|
|
309
|
+
let found = false
|
|
310
|
+
walkAstWithAncestors(root, [], n => {
|
|
311
|
+
if (found) return
|
|
312
|
+
const t = n.type
|
|
313
|
+
if (t === 'Literal' || t === 'StringLiteral') {
|
|
314
|
+
if (typeof n.value === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.value)) {
|
|
315
|
+
found = true
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const regex = n.regex
|
|
319
|
+
if (regex && typeof regex.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(regex.pattern)) {
|
|
320
|
+
found = true
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (t === 'RegExpLiteral' && typeof n.pattern === 'string' && PG_FORMAT_PLACEHOLDER_RE.test(n.pattern)) {
|
|
325
|
+
found = true
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (t === 'TemplateLiteral') {
|
|
329
|
+
if (PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
|
|
330
|
+
found = true
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
return found
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Витягає (name, body) з вузла, що оголошує функцію верхнього рівня:
|
|
339
|
+
* - `function format(...) {...}`,
|
|
340
|
+
* - `const format = (...) => {...}` / `= function(...) {...}`.
|
|
341
|
+
* @param {Record<string, unknown>} node AST node
|
|
342
|
+
* @returns {{ name: string, body: unknown } | null} ім'я та тіло, або null
|
|
343
|
+
*/
|
|
344
|
+
function asNamedFunctionDecl(node) {
|
|
345
|
+
if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
|
|
346
|
+
return { name: node.id.name, body: node.body }
|
|
347
|
+
}
|
|
348
|
+
if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
|
|
349
|
+
const init = node.init
|
|
350
|
+
if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
|
|
351
|
+
return { name: node.id.name, body: init.body }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Знаходить визначення pg-format-сумісних шимів у джерелі. Прапорує:
|
|
359
|
+
* - функції з іменами `format` / `pgFormat` / `sqlFormat` / `pgFmt`, у тілі яких
|
|
360
|
+
* зустрічається літерал/regex з `%L` / `%I` / `%s` — це drop-in pg-format;
|
|
361
|
+
* - функції з іменами `quoteLiteral` / `quoteIdent` / `escapeLiteral` / `escapeIdent`
|
|
362
|
+
* незалежно від тіла — це pg-format-специфічні API, не потрібні з Bun SQL.
|
|
363
|
+
*
|
|
364
|
+
* Скан запускається лише в файлах, де є `import { sql|SQL } from 'bun'`, щоб
|
|
365
|
+
* не плутати, наприклад, форматер дат чи URL-escape з SQL-шимом.
|
|
366
|
+
* @param {string} content вихідний код
|
|
367
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
368
|
+
* @returns {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} список порушень
|
|
369
|
+
*/
|
|
370
|
+
export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts') {
|
|
371
|
+
if (!textHasBunSqlImport(content)) return []
|
|
372
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
373
|
+
if (!program) return []
|
|
374
|
+
|
|
375
|
+
/** @type {{ line: number, snippet: string, kind: 'format_function' | 'quote_helper', name: string }[]} */
|
|
376
|
+
const out = []
|
|
377
|
+
walkAstWithAncestors(program, [], node => {
|
|
378
|
+
const decl = asNamedFunctionDecl(node)
|
|
379
|
+
if (!decl) return
|
|
380
|
+
/** @type {'format_function' | 'quote_helper' | null} */
|
|
381
|
+
let kind = null
|
|
382
|
+
if (QUOTE_HELPER_NAMES.has(decl.name)) {
|
|
383
|
+
kind = 'quote_helper'
|
|
384
|
+
} else if (PG_FORMAT_SHIM_FUNC_NAMES.has(decl.name) && nodeContainsPgFormatPlaceholder(decl.body)) {
|
|
385
|
+
kind = 'format_function'
|
|
386
|
+
}
|
|
387
|
+
if (!kind) return
|
|
388
|
+
out.push({
|
|
389
|
+
line: offsetToLine(content, node.start),
|
|
390
|
+
snippet: normalizeSnippet(content.slice(node.start, Math.min(node.end, node.start + 240))),
|
|
391
|
+
kind,
|
|
392
|
+
name: decl.name
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Знаходить pg-сумісні query-обгортки виду
|
|
400
|
+
* `{ query(text, params) { return <sql>.unsafe(text, params) } }`
|
|
401
|
+
* у файлах, що імпортують Bun SQL. Така обгортка маскує `unsafe` під
|
|
402
|
+
* «безпечним» ім'ям і повертає injection-поверхню в код.
|
|
403
|
+
*
|
|
404
|
+
* Спрацьовує, коли всі умови виконані:
|
|
405
|
+
* - вузол — `Property` з `key.name === 'query'` всередині `ObjectExpression`;
|
|
406
|
+
* - значення — функція з 1–2 параметрами, перший — Identifier з типовим
|
|
407
|
+
* pg-іменем (`text` / `sql` / `query`);
|
|
408
|
+
* - у тілі функції є виклик `<obj>.unsafe(...)`.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} content вихідний код
|
|
411
|
+
* @param {string} [virtualPath] шлях для вибору `lang`
|
|
412
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
413
|
+
*/
|
|
414
|
+
export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.ts') {
|
|
415
|
+
if (!textHasBunSqlImport(content)) return []
|
|
416
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
417
|
+
if (!program) return []
|
|
418
|
+
|
|
419
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
420
|
+
const out = []
|
|
421
|
+
walkAstWithAncestors(program, [], node => {
|
|
422
|
+
if (node.type !== 'ObjectExpression') return
|
|
423
|
+
const properties = node.properties
|
|
424
|
+
if (!Array.isArray(properties)) return
|
|
425
|
+
for (const prop of properties) {
|
|
426
|
+
if (!prop || prop.type !== 'Property') continue
|
|
427
|
+
const key = prop.key
|
|
428
|
+
const keyName =
|
|
429
|
+
key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
|
|
430
|
+
if (keyName !== 'query') continue
|
|
431
|
+
const value = prop.value
|
|
432
|
+
if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
|
|
433
|
+
const params = value.params
|
|
434
|
+
const firstName = Array.isArray(params) && params[0]?.type === 'Identifier' ? params[0].name : null
|
|
435
|
+
const looksLikePgQuery =
|
|
436
|
+
Array.isArray(params) && params.length >= 1 && params.length <= 2 && /^(text|sql|query)$/u.test(firstName || '')
|
|
437
|
+
if (!looksLikePgQuery) continue
|
|
438
|
+
if (!nodeContainsUnsafeCall(value.body)) continue
|
|
439
|
+
out.push({
|
|
440
|
+
line: offsetToLine(content, prop.start),
|
|
441
|
+
snippet: normalizeSnippet(content.slice(prop.start, prop.end))
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
return out
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Чи є у піддереві виклик `<obj>.unsafe(...)`.
|
|
450
|
+
* @param {unknown} root корінь піддерева
|
|
451
|
+
* @returns {boolean} true, якщо знайдено
|
|
452
|
+
*/
|
|
453
|
+
function nodeContainsUnsafeCall(root) {
|
|
454
|
+
let found = false
|
|
455
|
+
walkAstWithAncestors(root, [], n => {
|
|
456
|
+
if (found) return
|
|
457
|
+
if (isUnsafeCallNode(n)) found = true
|
|
458
|
+
})
|
|
459
|
+
return found
|
|
460
|
+
}
|
|
461
|
+
|
|
270
462
|
/**
|
|
271
463
|
* Знаходить `new SQL(...)` всередині функцій (handler на кожен запит замість singleton).
|
|
272
464
|
* @param {string} content вихідний код
|