fix(db): close round-5 review evasions in writer-coverage assertion
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Unify member-access matching on shared DOT/BRACKET_OPEN/MEMBER_SEG shapes (dot, bracket, ?. and ! forms) across both conduit computations, the write targets, the factory-import clause, and the DI backstop. Treat export-default as an expression at both ends: strip parens/assertions, classify bare-word vs namespace-member chain, on the schema AND capability sides. Add the capability-side export-default ns-member pass (round-5 finding 2). Admit bare SQL qualifier words (TABLE, ONLY, IF EXISTS) between keyword and table name in prong (ii). Controls E54-E68.
This commit is contained in:
@@ -87,7 +87,9 @@
|
||||
* function returns, or method extraction. Demonstrated escapes in this
|
||||
* class: `const u = this.client.unsafe; u.call(this.client, s)` (the
|
||||
* verb never appears as a member call), an alias chain three or more
|
||||
* steps deep, and a helper function that returns a schema symbol.
|
||||
* steps deep, a helper function that returns a schema symbol, and an
|
||||
* export whose expression COMPUTES the value (a ternary, a call result)
|
||||
* rather than naming a binding or member.
|
||||
* Closing it requires data-flow analysis (a type-checker-backed rewrite,
|
||||
* tracked for M4-1b consideration); the counterfactual — flagging every
|
||||
* bare identifier call — false-positives on essentially all callback
|
||||
@@ -444,6 +446,33 @@ const IMPORT_RE =
|
||||
const EXPORT_FROM_RE =
|
||||
/export\s*(type\s+)?(?:\{([^}]*)\}|\*(?:\s*as\s+\w+)?)\s*from\s*['"]([^'"]+)['"]/g;
|
||||
|
||||
// Member-access shapes, tolerant of optional chaining and non-null
|
||||
// assertions (ns?.companies, ns!.companies, ns?.['companies']) — a `?.` or
|
||||
// `!` between receiver and member must not break a match.
|
||||
const DOT = `\\s*(?:\\?\\.|!\\s*\\.|\\.)\\s*`;
|
||||
const BRACKET_OPEN = `\\s*(?:\\?\\.|!)?\\s*\\[`;
|
||||
const MEMBER_SEG = `(?:${DOT}\\w+|${BRACKET_OPEN}\\s*['"\`]\\w+['"\`]\\s*\\])`;
|
||||
/** Terminal member access matching one of `alt`'s names, dot or bracket form. */
|
||||
const memberTail = (alt: string): string =>
|
||||
`(?:${DOT}(?:${alt})\\b|${BRACKET_OPEN}\\s*['"\`](?:${alt})['"\`]\\s*\\])`;
|
||||
/**
|
||||
* Reduce an exported expression to its core: strip parenthesization and
|
||||
* trailing type assertions (`(companies)`, `companies as unknown as object`)
|
||||
* so `export default <expr>` passes see the binding under the dressing.
|
||||
*/
|
||||
function stripExprDressing(raw: string): string {
|
||||
let expr = raw.trim();
|
||||
for (let prev = ''; prev !== expr; ) {
|
||||
prev = expr;
|
||||
expr = expr
|
||||
.replace(/^\(\s*/, '')
|
||||
.replace(/\s*\)$/, '')
|
||||
.replace(/\s+as\s+[^()]+$/, '')
|
||||
.trim();
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
interface FileFacts {
|
||||
rel: string;
|
||||
code: string;
|
||||
@@ -543,7 +572,7 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set<string>): Schema
|
||||
for (const ns of aliases.namespaces) {
|
||||
for (const m of f.code.matchAll(
|
||||
new RegExp(
|
||||
`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${CLASS_SYMBOLS.join('|')})\\b`,
|
||||
`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}${MEMBER_SEG}*${memberTail(CLASS_SYMBOLS.join('|'))}`,
|
||||
'g',
|
||||
),
|
||||
)) {
|
||||
@@ -552,24 +581,28 @@ function computeSchemaConduits(files: FileFacts[], fileSet: Set<string>): Schema
|
||||
}
|
||||
for (const local of aliases.named) {
|
||||
for (const m of f.code.matchAll(
|
||||
new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*${local}\\b`, 'g'),
|
||||
new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*\\(*\\s*${local}\\b`, 'g'),
|
||||
)) {
|
||||
mine.named.add(m[1]!);
|
||||
}
|
||||
}
|
||||
// The default slot is an export name like any other:
|
||||
// `export default companies;` / `export default mod.companies;`
|
||||
for (const m of f.code.matchAll(/export\s+default\s+(\w+)\s*;/g)) {
|
||||
if (aliases.named.includes(m[1]!)) mine.named.add('default');
|
||||
if (aliases.namespaces.includes(m[1]!)) mine.ns.add('default');
|
||||
}
|
||||
for (const ns of aliases.namespaces) {
|
||||
if (
|
||||
new RegExp(
|
||||
`export\\s+default\\s+${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${CLASS_SYMBOLS.join('|')})\\b`,
|
||||
).test(f.code)
|
||||
) {
|
||||
mine.named.add('default');
|
||||
// The default slot is an export name like any other, whatever the
|
||||
// expression dressing: bare local, parenthesized, type-asserted, or
|
||||
// a namespace member chain in dot or bracket form, semicolon or not.
|
||||
for (const m of f.code.matchAll(/export\s+default\s+([^;\n]+)/g)) {
|
||||
const expr = stripExprDressing(m[1]!);
|
||||
if (/^\w+$/.test(expr)) {
|
||||
if (aliases.named.includes(expr)) mine.named.add('default');
|
||||
if (aliases.namespaces.includes(expr)) mine.ns.add('default');
|
||||
} else {
|
||||
for (const ns of aliases.namespaces) {
|
||||
if (
|
||||
new RegExp(`^${ns}${MEMBER_SEG}*${memberTail(CLASS_SYMBOLS.join('|'))}$`).test(expr)
|
||||
) {
|
||||
mine.named.add('default');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,11 +714,11 @@ function classAliases(f: FileFacts, conduits: SchemaConduits, fileSet: Set<strin
|
||||
}
|
||||
for (const m of f.code.matchAll(
|
||||
new RegExp(
|
||||
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(\\w+)\\b`,
|
||||
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}${MEMBER_SEG}*(?:${DOT}(\\w+)\\b|${BRACKET_OPEN}\\s*['"\`](\\w+)['"\`]\\s*\\])`,
|
||||
'g',
|
||||
),
|
||||
)) {
|
||||
if (memberSyms.has(m[2]!)) named.add(m[1]!);
|
||||
if (memberSyms.has((m[2] ?? m[3])!)) named.add(m[1]!);
|
||||
else namespaces.add(m[1]!);
|
||||
}
|
||||
}
|
||||
@@ -797,9 +830,12 @@ function computeCapabilityConduits(
|
||||
}
|
||||
for (const [ns, src] of nss) {
|
||||
for (const am of f.code.matchAll(
|
||||
new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}\\s*\\.\\s*(\\w+)\\b`, 'g'),
|
||||
new RegExp(
|
||||
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${ns}(?:${DOT}(\\w+)\\b|${BRACKET_OPEN}\\s*['"\`](\\w+)['"\`]\\s*\\])`,
|
||||
'g',
|
||||
),
|
||||
)) {
|
||||
if (src.has(am[2]!)) locals.add(am[1]!);
|
||||
if (src.has((am[2] ?? am[3])!)) locals.add(am[1]!);
|
||||
}
|
||||
}
|
||||
if (locals.size > 0) {
|
||||
@@ -815,8 +851,32 @@ function computeCapabilityConduits(
|
||||
if (new RegExp(`export\\s+(?:const|let|var|function)\\s+${local}\\b`).test(f.code)) {
|
||||
mine.add(local);
|
||||
}
|
||||
if (new RegExp(`export\\s+default\\s+${local}\\b`).test(f.code)) {
|
||||
mine.add('default');
|
||||
// A derived binding exported under a NEW name re-exports the
|
||||
// capability under that name: `export const mk2 = (mk);`
|
||||
for (const dm of f.code.matchAll(
|
||||
new RegExp(`export\\s+(?:const|let|var)\\s+(\\w+)\\s*=\\s*\\(*\\s*${local}\\b`, 'g'),
|
||||
)) {
|
||||
mine.add(dm[1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The default slot, under any expression dressing — a local factory
|
||||
// binding (`export default (createDb)`) or a namespace factory member
|
||||
// (`export default mod.createDb`, `export default mod['createDb']`) —
|
||||
// mirrors the schema side's default-export pass.
|
||||
for (const m of f.code.matchAll(/export\s+default\s+([^;\n]+)/g)) {
|
||||
const expr = stripExprDressing(m[1]!);
|
||||
if (/^\w+$/.test(expr)) {
|
||||
if (locals.has(expr)) mine.add('default');
|
||||
} else {
|
||||
for (const [ns, src] of nss) {
|
||||
const t = new RegExp(
|
||||
`^${ns}(?:${DOT}(\\w+)\\b|${BRACKET_OPEN}\\s*['"\`](\\w+)['"\`]\\s*\\])$`,
|
||||
).exec(expr);
|
||||
if (t && src.has((t[1] ?? t[2])!)) {
|
||||
mine.add('default');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -837,12 +897,13 @@ function hasSymbolImportEdge(code: string, symbols: string[], packageName: strin
|
||||
).test(code);
|
||||
const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code);
|
||||
const dynamicEdge = usesSymbol && new RegExp(`import\\s*\\(\\s*['"]${pkg}['"]\\s*\\)`).test(code);
|
||||
// Namespace form: `import * as ns from '<pkg>'` + `ns.<symbol>` usage.
|
||||
// Namespace form: `import * as ns from '<pkg>'` + `ns.<symbol>` usage
|
||||
// (dot or bracket member, optional-chain/non-null tolerant).
|
||||
let nsEdge = false;
|
||||
for (const m of code.matchAll(
|
||||
new RegExp(`import\\s*\\*\\s*as\\s+(\\w+)\\s*from\\s*['"]${pkg}['"]`, 'g'),
|
||||
)) {
|
||||
if (new RegExp(`\\b${m[1]}\\s*\\.\\s*(${symbols.join('|')})\\b`).test(code)) nsEdge = true;
|
||||
if (new RegExp(`\\b${m[1]}${memberTail(symbols.join('|'))}`).test(code)) nsEdge = true;
|
||||
}
|
||||
return staticEdge || dynamicEdge || nsEdge;
|
||||
}
|
||||
@@ -964,9 +1025,9 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] {
|
||||
const symAlt = [...aliases.memberSyms].join('|');
|
||||
for (const ns of aliases.namespaces) {
|
||||
// Allow intermediate property segments (ns.schema.companies — nested
|
||||
// namespace re-exports) and literal computed access (ns['companies']).
|
||||
targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(?:${symAlt})\\b`);
|
||||
targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\[\\s*['"\`](?:${symAlt})['"\`]\\s*\\]`);
|
||||
// namespace re-exports), literal computed access (ns['companies']),
|
||||
// and optional-chain/non-null markers (ns?.companies, ns!.companies).
|
||||
targets.push(`${ns}${MEMBER_SEG}*${memberTail(symAlt)}`);
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
// The argument prefix tolerates parenthesization, spread, and array
|
||||
@@ -1013,15 +1074,19 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] {
|
||||
// The SQL keyword must be ADJACENT to the table name — co-residence
|
||||
// anywhere in one span over-matches prose ("pnpm workspaces" plus an
|
||||
// unrelated "from" in an embedded doc string is not SQL). Adjacency
|
||||
// tolerates schema qualification (public.hierarchy_grants), interposed
|
||||
// block comments, and quoting (including escaped quotes in span text).
|
||||
// tolerates schema qualification (public.hierarchy_grants), bare
|
||||
// qualifier words (TABLE, ONLY, IF EXISTS), interposed block comments,
|
||||
// and quoting (including escaped quotes in span text).
|
||||
if (!inAllowlist && !isSchemaDefinition) {
|
||||
const kw =
|
||||
'(?:insert\\s+into|update|delete\\s+from|from|join|truncate(?:\\s+table)?|alter\\s+table|drop\\s+table|references|into|copy|on|lock(?:\\s+table)?)';
|
||||
const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/|--[^\\n]*\\n)+';
|
||||
// Bare qualifier words may sit between keyword and table name:
|
||||
// GRANT … ON TABLE t, DELETE FROM ONLY t, DROP TABLE IF EXISTS t.
|
||||
const qual = `(?:(?:table|only|if\\s+exists)${gap})*`;
|
||||
const q = `(?:\\\\?["'\`])?`;
|
||||
const sqlAdjacentRe = new RegExp(
|
||||
`\\b${kw}${gap}${q}(?:${q}\\w+${q}\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`,
|
||||
`\\b${kw}${gap}${qual}${q}(?:${q}\\w+${q}\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`,
|
||||
'i',
|
||||
);
|
||||
for (const span of f.spans) {
|
||||
@@ -1066,7 +1131,7 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] {
|
||||
hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') ||
|
||||
(aliases.namespaces.length > 0 &&
|
||||
new RegExp(
|
||||
`\\b(${aliases.namespaces.join('|')})(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(createDb|createPgliteDb)\\b`,
|
||||
`\\b(?:${aliases.namespaces.join('|')})${MEMBER_SEG}*${memberTail('createDb|createPgliteDb')}`,
|
||||
).test(code)) ||
|
||||
// Destructuring a factory symbol OUT of a schema namespace confers
|
||||
// capability whatever the local rename: const { createDb: mk } = dbns.
|
||||
@@ -1102,9 +1167,13 @@ function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] {
|
||||
// DI residual backstop: db/client-shaped receivers fire regardless of
|
||||
// detected capability (a handle can arrive by injection).
|
||||
// The receiver may be a dotted name OR a literal bracketed member with
|
||||
// a conventional name: this['db'].query(…).
|
||||
// a conventional name — this['db'].query(…) — and the final member
|
||||
// access tolerates ?. and ! markers (this.db?.query(…)).
|
||||
for (const m of code.matchAll(
|
||||
/(?:\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)|\[\s*['"](?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)['"]\s*\])\s*\.\s*(execute|query|unsafe)\s*\(/g,
|
||||
new RegExp(
|
||||
`(?:\\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)|\\[\\s*['"](?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)['"]\\s*\\])${DOT}(execute|query|unsafe)\\s*\\(`,
|
||||
'g',
|
||||
),
|
||||
)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
@@ -1572,6 +1641,114 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
name: 'E53 bracketed conventional receiver',
|
||||
src: `export class R { async f(t: string) { await this['db'].query('TRUNCATE ' + t); } }`,
|
||||
},
|
||||
{
|
||||
name: 'E54 parenthesized default export of schema symbol',
|
||||
src: `import c from './evasion-mid18.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid18.ts',
|
||||
src: `import { companies } from '@mosaicstack/db';\nexport default (companies);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E55 bracket-member default export over dynamic namespace',
|
||||
src: `import c from './evasion-mid19.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid19.ts',
|
||||
src: `const mod = await import('@mosaicstack/db');\nexport default mod['companies'];`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E56 type-asserted default export of schema symbol',
|
||||
src: `import c from './evasion-mid20.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(c).values({}); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid20.ts',
|
||||
src: `import { companies } from '@mosaicstack/db';\nexport default companies as unknown as object;`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E57 export-const bracket member of schema namespace',
|
||||
src: `import { co } from './evasion-mid21.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(co).values({}); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid21.ts',
|
||||
src: `import * as ns from '@mosaicstack/db';\nexport const co = ns['companies'];`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E58 default export of dynamic namespace factory member',
|
||||
src: `import mk from './evasion-mid22.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid22.ts',
|
||||
src: `const mod = await import('@mosaicstack/db');\nexport default mod.createDb;`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E59 default export of static namespace factory member',
|
||||
src: `import mk from './evasion-mid23.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid23.ts',
|
||||
src: `import * as ns from '@mosaicstack/db';\nexport default ns.createDb;`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E60 export-const bracket factory member',
|
||||
src: `import { mk } from './evasion-mid24.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid24.ts',
|
||||
src: `import * as ns from '@mosaicstack/db';\nexport const mk = ns['createDb'];`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E61 parenthesized default export of factory local',
|
||||
src: `import mk from './evasion-mid25.js';\nexport async function f(t: string) { await mk('u').execute('DELETE FROM ' + t); }`,
|
||||
extras: [
|
||||
{
|
||||
rel: 'packages/db/src/evasion-mid25.ts',
|
||||
src: `import { createDb } from '@mosaicstack/db';\nexport default (createDb);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E62 GRANT ON TABLE class table in SQL span',
|
||||
src: `export const q = 'GRANT SELECT ON TABLE hierarchy_grants TO auditor';`,
|
||||
},
|
||||
{
|
||||
name: 'E63 DROP TABLE IF EXISTS class table in SQL span',
|
||||
src: `export const q = 'DROP TABLE IF EXISTS hierarchy_grants';`,
|
||||
},
|
||||
{
|
||||
name: 'E64 DELETE FROM ONLY class table in SQL span',
|
||||
src: `export const q = 'DELETE FROM ONLY hierarchy_grants WHERE id = $1';`,
|
||||
},
|
||||
{
|
||||
name: 'E65 optional-chained namespace write target',
|
||||
src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns?.companies).values({}); }`,
|
||||
},
|
||||
{
|
||||
name: 'E66 non-null-asserted namespace write target',
|
||||
src: `import * as ns from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns!.companies).values({}); }`,
|
||||
},
|
||||
{
|
||||
name: 'E67 optional-chained DI receiver',
|
||||
src: `export class R { constructor(private db?: { query(s: string): Promise<unknown> }) {}\n async f(t: string) { await this.db?.query('TRUNCATE ' + t); } }`,
|
||||
},
|
||||
{
|
||||
name: 'E68 optional-chained bracketed DI receiver',
|
||||
src: `export class R { async f(t: string) { await this['db']?.query('TRUNCATE ' + t); } }`,
|
||||
},
|
||||
];
|
||||
const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user