fix(db): harden writer-coverage assertion per M4-1a review
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Review findings F1-F8 (REQUEST_CHANGES) addressed: - F5: replace regex comment stripping with a string-aware lexer producing comment-free code plus accurate string/template spans (handles nesting, regex literals, escapes) - F3: prong (i) now tracks namespace imports and re-export conduits via a fixpoint over the scanned import graph - F1/F2: tagged-template exemption dropped; backlog.ts joins the infra register; capability-gated prong (iii) — driver import flags any-receiver execute/query/unsafe, factory import flags any-receiver execute/unsafe, db-shaped receiver backstop covers DI'd handles; sql.raw tracked through aliases and namespaces - F4: closed importer enumeration added for createDb/createPgliteDb; composition property of remaining register modules documented - F6: plugins/ added to scan roots - F7: per-parent slug scoping witnessed at platform_projects and workspaces - F8: dynamic-import disposition register added; stricter-than-contract stances documented in the header The review's 8 evasion samples are embedded as permanent controls run through the production analyzer, plus clean controls guarding against false positives.
This commit is contained in:
@@ -265,6 +265,36 @@ function witnessSuite(getHandle: () => AnyDb): void {
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes platform_projects and workspaces slugs per parent (refuse same-parent duplicate, accept cross-parent)', async () => {
|
||||
// Dedicated parent estate so this test leaves estate2 a leaf (the §3.4
|
||||
// cascade witness depends on that).
|
||||
const estate3Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate3Id, name: 'Estate 3', slug: `${T}-e3`, companyId });
|
||||
// platform_projects: (estate_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-pp1`, estateId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
const pp2Id = randomUUID();
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: pp2Id, name: 'ok', slug: `${T}-pp1`, estateId: estate3Id });
|
||||
// workspaces: (platform_project_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-ws1`, platformProjectId: ppId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-ws1`, platformProjectId: pp2Id });
|
||||
});
|
||||
|
||||
// ── §6.2 column allowlist ──────────────────────────────────────────────────
|
||||
|
||||
it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => {
|
||||
|
||||
@@ -2,46 +2,65 @@
|
||||
* Hierarchy writer-coverage assertion — contract 1
|
||||
* (docs/requirements/hierarchy-schema.md) §6.3(b).
|
||||
*
|
||||
* A static CI assertion over the Gateway and package production sources with
|
||||
* three prongs, each bound to a closed, explicitly enumerated allowlist:
|
||||
* A static CI assertion over all production sources (apps/, packages/,
|
||||
* plugins/) with three prongs, each bound to a closed, explicitly enumerated
|
||||
* allowlist:
|
||||
*
|
||||
* (i) Symbol prong — write references (insert/update/delete) to the
|
||||
* class-table schema symbols occur only in allowlisted modules.
|
||||
* Import aliasing is followed: `import { companies as c }` makes `c`
|
||||
* a class-table symbol in that file.
|
||||
* (ii) Literal prong — a class-table name inside a SQL string or tagged
|
||||
* SQL template outside the allowlist fails. Schema definitions and
|
||||
* generated migrations are excluded from this prong (per contract).
|
||||
* (iii) Raw-execution prong — raw-SQL execution primitives (the ORM's
|
||||
* raw/unsafe constructors, driver-level clients) outside the writer
|
||||
* allowlist and the infrastructure register fail, regardless of SQL
|
||||
* content. Direct database-driver imports count as raw-execution
|
||||
* capability: they are what makes dynamically assembled SQL
|
||||
* executable, and the import is statically detectable even when the
|
||||
* SQL string is not.
|
||||
* Schema symbols are tracked through named imports (aliased or not),
|
||||
* namespace imports, and re-export conduits: any scanned module that
|
||||
* re-exports the schema (or another conduit) is itself treated as a
|
||||
* schema source, computed to a fixpoint.
|
||||
* (ii) Literal prong — a class-table name inside a string or template
|
||||
* span that also carries SQL context fails outside the allowlist.
|
||||
* Spans are produced by a real lexer, so comments cannot hide code
|
||||
* and strings cannot hide comments. Schema definitions and generated
|
||||
* migrations are excluded from this prong only (per contract).
|
||||
* (iii) Raw-execution prong — content-independent. A file is RAW-CAPABLE
|
||||
* when it imports a database driver or the createDb/createPgliteDb
|
||||
* factories (directly or via namespace). In a raw-capable file
|
||||
* outside the allowlist and register, EVERY `.execute(`, `.query(`,
|
||||
* and `.unsafe(` call fails, on any receiver, with any argument —
|
||||
* there is no tagged-template exemption (§6.3(b): "regardless of what
|
||||
* the SQL string contains or how it is constructed"). In files
|
||||
* without detected capability (the DI residual), a conventional
|
||||
* receiver backstop still fires on db/client-shaped receivers.
|
||||
* `sql.raw` is tracked through import aliasing and namespaces.
|
||||
*
|
||||
* Runtime code-construction primitives (eval, new Function) and non-literal
|
||||
* dynamic imports fail anywhere — allowlist and register included.
|
||||
* dynamic imports fail anywhere — allowlist and register included. This is
|
||||
* deliberately stricter than the contract's minimum: an unanalyzable import
|
||||
* or constructed code defeats every static prong, so there is no enumerated
|
||||
* disposition path for them.
|
||||
*
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
||||
* empty today: the hierarchy command family (M4-1b) has not landed, so no
|
||||
* production module may write the class tables. The infrastructure register
|
||||
* holds legitimate non-hierarchy raw execution (migration runner, storage
|
||||
* adapters, health probes); registered modules are exempt from prong (iii)
|
||||
* only — prongs (i) and (ii) apply to them with no exemption, and no
|
||||
* registered module may appear on the writer allowlist. Neither list may take
|
||||
* a generic raw-SQL helper, and an allowlisted module must not export a
|
||||
* function that executes caller-supplied SQL (review-enforced, §5.1).
|
||||
* holds legitimate non-hierarchy raw execution; registered modules are exempt
|
||||
* from prong (iii) only — prongs (i) and (ii) apply to them with no
|
||||
* exemption, and no registered module may appear on the writer allowlist.
|
||||
*
|
||||
* Register modules whose exports let a CALLER reach SQL execution carry their
|
||||
* own closed importer enumerations (the laundering path §6.3(b) closes):
|
||||
* the migration runner, migrate-tier, and the createDb/createPgliteDb
|
||||
* factories. The remaining registered modules execute only fixed statements
|
||||
* or collection-CRUD over the storage `(id, data)` shape — which cannot
|
||||
* address class-table columns — and export no caller-supplied-SQL surface;
|
||||
* that composition property is review-enforced (§5.1) on any change to them.
|
||||
*
|
||||
* A false positive is resolved in the same PR by adding the module to the one
|
||||
* enumerated list its role permits — never by weakening the assertion.
|
||||
*
|
||||
* Test files (*.spec.*, *.test.*, __tests__/) are not scanned: they are not
|
||||
* production mutation paths, and the contract's own §6 witnesses must write
|
||||
* class tables directly to witness database constraints.
|
||||
* class tables directly to witness database constraints. The known evasion
|
||||
* forms from the M4-1a review are kept below as permanent controls: the
|
||||
* analyzer must flag every one of them, so a regression that reopens an
|
||||
* escape fails this suite.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -63,7 +82,8 @@ const CLASS_TABLES = [
|
||||
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
|
||||
* EMPTY until the hierarchy command family lands (M4-1b). Adding a module
|
||||
* here is a contract-conformance decision reviewed under §5.1 — the module
|
||||
* must be part of the Gateway hierarchy command path.
|
||||
* must be part of the Gateway hierarchy command path, and it must not export
|
||||
* a function that executes caller-supplied SQL.
|
||||
*/
|
||||
const WRITER_ALLOWLIST: string[] = [];
|
||||
|
||||
@@ -76,9 +96,10 @@ const INFRA_REGISTER: string[] = [
|
||||
'packages/db/src/client.ts', // connection factory (imports postgres driver)
|
||||
'packages/db/src/client-pglite.ts', // PGlite factory (imports the pglite driver)
|
||||
'packages/db/src/migrate.ts', // migration runner (hash-ledger DDL execution)
|
||||
'packages/db/src/backlog.ts', // backlog domain module: parameterized sql`` over backlog tables only
|
||||
'packages/memory/src/insights.ts', // analytics raw query over memory tables
|
||||
'packages/storage/src/tier-detection.ts', // driver import for tier probing
|
||||
'packages/storage/src/adapters/pglite.ts', // storage adapter (driver-level query)
|
||||
'packages/storage/src/tier-detection.ts', // driver import for tier probing (fixed statements)
|
||||
'packages/storage/src/adapters/pglite.ts', // storage adapter (collection CRUD over (id, data))
|
||||
'packages/storage/src/adapters/postgres.ts', // storage adapter (extension bootstrap)
|
||||
'packages/storage/src/migrate-tier.ts', // storage tier migration
|
||||
'packages/storage/src/cli.ts', // storage CLI health probe
|
||||
@@ -86,17 +107,13 @@ const INFRA_REGISTER: string[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Closed importer enumerations for registered modules that EXPORT
|
||||
* SQL-executing functions (the laundering path §6.3b closes). Import edges
|
||||
* are checked re-export-aware — the db package barrel and literal dynamic
|
||||
* `import('@mosaicstack/db')` are edges like any static import. The measured
|
||||
* production importer set of the migration runner (contract 1 revision 9):
|
||||
* the Gateway database module, the storage Postgres adapter, and two mosaic
|
||||
* CLI commands routed through literal dynamic imports. The gateway
|
||||
* schema-check module receives the runner's functions by parameter injection
|
||||
* and has no import edge, so it is not enumerated. Being enumerated confers
|
||||
* nothing else: importers stay subject to prongs (i)/(ii) and gain no writer
|
||||
* standing.
|
||||
* Closed importer enumerations for registered modules whose exports execute
|
||||
* SQL or hand out an executing handle. An import edge is a static value
|
||||
* import naming the symbol (from the module path or the package barrel), or
|
||||
* a literal dynamic import of the package in a file using the symbol.
|
||||
* `import type` is erased and is not an edge; parameter injection (the
|
||||
* gateway schema-check module) has no edge. Being enumerated confers nothing
|
||||
* else: importers stay subject to every prong and gain no writer standing.
|
||||
*/
|
||||
const MIGRATION_RUNNER_SYMBOLS = ['runMigrations', 'runPgliteMigrations', 'getMigrationStatus'];
|
||||
const MIGRATION_RUNNER_IMPORTERS: string[] = [
|
||||
@@ -115,9 +132,31 @@ const MIGRATE_TIER_IMPORTERS: string[] = [
|
||||
'packages/storage/src/cli.ts', // storage CLI command surface
|
||||
'packages/storage/src/index.ts', // package barrel re-export (public API)
|
||||
];
|
||||
/**
|
||||
* Enumerated disposition for non-literal dynamic imports (§6.3b review F8):
|
||||
* files here may use a computed import specifier; every other prong still
|
||||
* applies to them in full. Each entry needs a justification.
|
||||
*/
|
||||
const DYNAMIC_IMPORT_REGISTER: string[] = [
|
||||
'plugins/macp/src/index.ts', // loads the ACP runtime SDK from a configured sdkRoot; no db access
|
||||
];
|
||||
/** Importers of the db-handle factories (measured set; a new importer must be reviewed in). */
|
||||
const DB_FACTORY_SYMBOLS = ['createDb', 'createPgliteDb'];
|
||||
const DB_FACTORY_IMPORTERS: string[] = [
|
||||
'apps/gateway/src/database/database.module.ts',
|
||||
'packages/brain/src/cli.ts',
|
||||
'packages/log/src/cli.ts',
|
||||
'packages/memory/src/adapters/pgvector.ts',
|
||||
'packages/memory/src/cli.ts',
|
||||
'packages/mosaic/src/commands/fleet-backlog.ts',
|
||||
'packages/storage/src/adapters/postgres.ts',
|
||||
'packages/storage/src/cli.ts',
|
||||
'packages/storage/src/migrate-tier.ts',
|
||||
];
|
||||
|
||||
const SCAN_ROOTS = ['apps', 'packages'];
|
||||
const SCAN_ROOTS = ['apps', 'packages', 'plugins'];
|
||||
const EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
||||
const DRIVER_SPECIFIERS = ['postgres', 'pg', '@electric-sql/pglite'];
|
||||
|
||||
function isTestPath(rel: string): boolean {
|
||||
return (
|
||||
@@ -143,7 +182,7 @@ function collectSources(): string[] {
|
||||
if (entry === 'node_modules' || entry === 'dist') continue;
|
||||
walk(full);
|
||||
} else if (EXTENSIONS.has(full.slice(full.lastIndexOf('.')))) {
|
||||
const rel = relative(REPO_ROOT, full);
|
||||
const rel = relative(REPO_ROOT, full).split(sep).join('/');
|
||||
if (!isTestPath(rel)) files.push(rel);
|
||||
}
|
||||
}
|
||||
@@ -154,36 +193,187 @@ function collectSources(): string[] {
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** Strip line and block comments so commented-out code cannot trip prongs. */
|
||||
function stripComments(src: string): string {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lexer: single pass producing (a) comment-free source with strings intact
|
||||
// (for import/export and call-site regexes) and (b) the string/template text
|
||||
// spans (for the literal prong). String-aware, so `//` inside a string is not
|
||||
// a comment and a quote inside a comment does not open a string. Template
|
||||
// expressions re-enter code mode (nesting supported); regex literals are
|
||||
// recognized with the standard prev-token heuristic so their contents cannot
|
||||
// open a phantom string.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Lexed {
|
||||
code: string;
|
||||
spans: string[];
|
||||
}
|
||||
|
||||
/** Extract string and template literal spans (approximation, multi-line for templates). */
|
||||
function stringSpans(src: string): string[] {
|
||||
function lexSource(src: string): Lexed {
|
||||
let code = '';
|
||||
const spans: string[] = [];
|
||||
const re = /`[^`]*`|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/gs;
|
||||
for (const m of src.matchAll(re)) spans.push(m[0]);
|
||||
return spans;
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
// Template nesting: each entry is the accumulated text of one template.
|
||||
const tplStack: string[] = [];
|
||||
// Brace depth inside the current ${ } expression, one entry per nesting level.
|
||||
const exprDepth: number[] = [];
|
||||
let lastSig = ''; // last significant code char (regex-vs-division heuristic)
|
||||
let lastWord = ''; // last identifier/keyword emitted to code
|
||||
|
||||
const emit = (ch: string): void => {
|
||||
code += ch;
|
||||
if (!/\s/.test(ch)) {
|
||||
lastSig = ch;
|
||||
if (/[A-Za-z0-9_$]/.test(ch)) lastWord += ch;
|
||||
else lastWord = '';
|
||||
}
|
||||
};
|
||||
|
||||
const regexCanStart = (): boolean => {
|
||||
if (lastSig === '' || '([{,;=:!&|?+-*%^<>~'.includes(lastSig)) return true;
|
||||
return ['return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'do', 'else'].includes(
|
||||
lastWord,
|
||||
);
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i]!;
|
||||
const next = i + 1 < n ? src[i + 1]! : '';
|
||||
|
||||
if (tplStack.length > 0 && exprDepth.length < tplStack.length) {
|
||||
// Inside template literal text.
|
||||
const top = tplStack.length - 1;
|
||||
if (ch === '\\') {
|
||||
tplStack[top] += src.slice(i, i + 2);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
spans.push(tplStack.pop()!);
|
||||
emit('`');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '$' && next === '{') {
|
||||
exprDepth.push(0);
|
||||
emit('$');
|
||||
emit('{');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
tplStack[top] += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code mode (possibly inside a ${ } expression).
|
||||
if (ch === '/' && next === '/') {
|
||||
while (i < n && src[i] !== '\n') i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
i += 2;
|
||||
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1;
|
||||
i += 2;
|
||||
emit(' ');
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
let span = '';
|
||||
i += 1;
|
||||
while (i < n && src[i] !== ch) {
|
||||
if (src[i] === '\\') {
|
||||
span += src.slice(i, i + 2);
|
||||
i += 2;
|
||||
} else {
|
||||
span += src[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
spans.push(span);
|
||||
// Keep quoted strings in code output so import specifiers stay parseable.
|
||||
emit(ch);
|
||||
code += span;
|
||||
emit(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
tplStack.push('');
|
||||
emit('`');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && regexCanStart()) {
|
||||
// Regex literal: consume without interpreting quotes/backticks inside.
|
||||
i += 1;
|
||||
let inClass = false;
|
||||
while (i < n) {
|
||||
const rc = src[i]!;
|
||||
if (rc === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (rc === '[') inClass = true;
|
||||
else if (rc === ']') inClass = false;
|
||||
else if (rc === '/' && !inClass) break;
|
||||
else if (rc === '\n') break; // not a regex after all; bail safely
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
while (i < n && /[a-z]/.test(src[i]!)) i += 1; // flags
|
||||
emit('/');
|
||||
continue;
|
||||
}
|
||||
if (exprDepth.length > 0) {
|
||||
const top = exprDepth.length - 1;
|
||||
if (ch === '{') exprDepth[top] = exprDepth[top]! + 1;
|
||||
if (ch === '}') {
|
||||
if (exprDepth[top] === 0) {
|
||||
exprDepth.pop();
|
||||
emit('}');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
exprDepth[top] = exprDepth[top]! - 1;
|
||||
}
|
||||
}
|
||||
emit(ch);
|
||||
i += 1;
|
||||
}
|
||||
while (tplStack.length > 0) spans.push(tplStack.pop()!);
|
||||
return { code, spans };
|
||||
}
|
||||
|
||||
/** Local names (including aliases) under which class-table symbols are imported. */
|
||||
function classSymbolAliases(src: string): string[] {
|
||||
const names = new Set<string>();
|
||||
const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
||||
for (const m of src.matchAll(importRe)) {
|
||||
const specifier = m[2]!;
|
||||
if (!/@mosaicstack\/db|\.\.?\/(?:.*\/)?(?:schema|index)(?:\.js)?$/.test(specifier)) continue;
|
||||
for (const part of m[1]!.split(',')) {
|
||||
const seg = part.trim().replace(/^type\s+/, '');
|
||||
if (!seg) continue;
|
||||
const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg);
|
||||
const original = asMatch ? asMatch[1]! : seg;
|
||||
const local = asMatch ? asMatch[2]! : seg;
|
||||
if (CLASS_SYMBOLS.includes(original)) names.add(local);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import graph helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function resolveSpecifier(fromRel: string, spec: string, fileSet: Set<string>): string | null {
|
||||
if (!spec.startsWith('.')) return null;
|
||||
const base = join(dirname(fromRel), spec).split(sep).join('/');
|
||||
const noJs = base.replace(/\.(js|mjs|cjs)$/, '');
|
||||
for (const cand of [
|
||||
base,
|
||||
noJs,
|
||||
`${noJs}.ts`,
|
||||
`${noJs}.tsx`,
|
||||
`${noJs}.mts`,
|
||||
`${noJs}.cts`,
|
||||
`${noJs}/index.ts`,
|
||||
]) {
|
||||
if (fileSet.has(cand)) return cand;
|
||||
}
|
||||
return [...names];
|
||||
return null;
|
||||
}
|
||||
|
||||
const IMPORT_RE =
|
||||
/import\s*(type\s+)?(?:(\w+)\s*,\s*)?(?:\{([^}]*)\}|\*\s*as\s+(\w+)|(\w+))?\s*from\s*['"]([^'"]+)['"]/g;
|
||||
const EXPORT_FROM_RE =
|
||||
/export\s*(type\s+)?(?:\{([^}]*)\}|\*(?:\s*as\s+\w+)?)\s*from\s*['"]([^'"]+)['"]/g;
|
||||
|
||||
interface FileFacts {
|
||||
rel: string;
|
||||
code: string;
|
||||
spans: string[];
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
@@ -192,11 +382,252 @@ interface Violation {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
const sources = collectSources();
|
||||
/**
|
||||
* Compute the fixpoint set of "schema sources": module paths from which a
|
||||
* class-table symbol can be imported. Seeds: the schema module and the db
|
||||
* package barrel (plus the bare '@mosaicstack/db' specifier, handled
|
||||
* separately). Any scanned module that re-exports from a schema source
|
||||
* (star, or a named list carrying a class symbol) — or imports class symbols
|
||||
* and re-exports those local names — joins the set.
|
||||
*/
|
||||
function computeSchemaConduits(files: FileFacts[], fileSet: Set<string>): Set<string> {
|
||||
const conduits = new Set<string>(['packages/db/src/schema.ts', 'packages/db/src/index.ts']);
|
||||
const isSchemaSpec = (rel: string, spec: string): boolean => {
|
||||
if (spec === '@mosaicstack/db') return true;
|
||||
const resolved = resolveSpecifier(rel, spec, fileSet);
|
||||
return resolved !== null && conduits.has(resolved);
|
||||
};
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const f of files) {
|
||||
if (conduits.has(f.rel)) continue;
|
||||
let isConduit = false;
|
||||
for (const m of f.code.matchAll(EXPORT_FROM_RE)) {
|
||||
if (m[1]) continue; // export type — erased
|
||||
if (!isSchemaSpec(f.rel, m[3]!)) continue;
|
||||
if (m[2] === undefined) {
|
||||
isConduit = true; // export * from schema source
|
||||
} else if (CLASS_SYMBOLS.some((s) => new RegExp(`\\b${s}\\b`).test(m[2]!))) {
|
||||
isConduit = true;
|
||||
}
|
||||
}
|
||||
if (!isConduit) {
|
||||
const aliases = classAliases(f, conduits, fileSet);
|
||||
if (aliases.named.length > 0) {
|
||||
const exported = [...f.code.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)]
|
||||
.map((m) => m[1]!)
|
||||
.join(',');
|
||||
if (aliases.named.some((a) => new RegExp(`\\b${a}\\b`).test(exported))) isConduit = true;
|
||||
}
|
||||
}
|
||||
if (isConduit) {
|
||||
conduits.add(f.rel);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return conduits;
|
||||
}
|
||||
|
||||
it('scans a non-empty production source set', () => {
|
||||
expect(sources.length).toBeGreaterThan(100);
|
||||
interface ClassAliases {
|
||||
named: string[]; // local identifiers bound to class-table symbols
|
||||
namespaces: string[]; // namespace identifiers over a schema source
|
||||
}
|
||||
|
||||
function classAliases(f: FileFacts, conduits: Set<string>, fileSet: Set<string>): ClassAliases {
|
||||
const named = new Set<string>();
|
||||
const namespaces = new Set<string>();
|
||||
for (const m of f.code.matchAll(IMPORT_RE)) {
|
||||
const [, typeOnly, , namedList, nsName, , spec] = m;
|
||||
if (typeOnly) continue;
|
||||
const fromSchema =
|
||||
spec === '@mosaicstack/db' ||
|
||||
(() => {
|
||||
const r = resolveSpecifier(f.rel, spec!, fileSet);
|
||||
return r !== null && conduits.has(r);
|
||||
})();
|
||||
if (!fromSchema) continue;
|
||||
if (nsName) namespaces.add(nsName);
|
||||
if (namedList) {
|
||||
for (const part of namedList.split(',')) {
|
||||
const seg = part.trim();
|
||||
if (!seg || seg.startsWith('type ')) continue;
|
||||
const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg);
|
||||
const original = asMatch ? asMatch[1]! : seg;
|
||||
const local = asMatch ? asMatch[2]! : seg;
|
||||
if (CLASS_SYMBOLS.includes(original)) named.add(local);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { named: [...named], namespaces: [...namespaces] };
|
||||
}
|
||||
|
||||
/** Value-import edge naming one of `symbols` (named import from anywhere, or literal dynamic package import while using the symbol). */
|
||||
function hasSymbolImportEdge(code: string, symbols: string[], packageName: string): boolean {
|
||||
const staticEdge = new RegExp(
|
||||
`import\\s*(?!type\\b)(?:\\w+\\s*,\\s*)?\\{[^}]*\\b(${symbols.join('|')})\\b[^}]*\\}\\s*from\\s*['"][^'"]+['"]`,
|
||||
).test(code);
|
||||
const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code);
|
||||
const dynamicEdge =
|
||||
usesSymbol &&
|
||||
new RegExp(`import\\s*\\(\\s*['"]${packageName.replace('/', '\\/')}['"]\\s*\\)`).test(code);
|
||||
return staticEdge || dynamicEdge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The analyzer — pure over (rel, source) so the evasion controls below can
|
||||
// feed synthetic files through the exact production logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const rel = f.rel;
|
||||
const code = f.code;
|
||||
const inAllowlist = WRITER_ALLOWLIST.includes(rel);
|
||||
const inRegister = INFRA_REGISTER.includes(rel);
|
||||
const isSchemaDefinition = rel === 'packages/db/src/schema.ts';
|
||||
|
||||
// Runtime code construction: fails anywhere.
|
||||
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(code)) {
|
||||
violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' });
|
||||
}
|
||||
// Non-literal dynamic import: makes the import graph unanalyzable.
|
||||
if (!DYNAMIC_IMPORT_REGISTER.includes(rel) && /\bimport\s*\(\s*(?!['"])/.test(code)) {
|
||||
violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' });
|
||||
}
|
||||
|
||||
const aliases = classAliases(f, conduits, fileSet);
|
||||
|
||||
// Prong (i): schema-symbol writes — alias-, namespace-, and conduit-aware.
|
||||
if (!inAllowlist) {
|
||||
const targets: string[] = [...aliases.named];
|
||||
for (const ns of aliases.namespaces) {
|
||||
for (const s of CLASS_SYMBOLS) targets.push(`${ns}\\.${s}`);
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
const writeRe = new RegExp(
|
||||
`\\.(insert|update|delete)\\s*\\(\\s*(${targets.join('|')})\\b`,
|
||||
'g',
|
||||
);
|
||||
for (const m of code.matchAll(writeRe)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'i-symbol',
|
||||
detail: `.${m[1]}(${m[2]}) outside the writer allowlist`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (ii): class-table names in string/template spans with SQL context.
|
||||
// The SQL keyword must be ADJACENT to the table name (optionally quoted):
|
||||
// co-residence anywhere in one span over-matches prose (English "from" plus
|
||||
// "pnpm workspaces" in an embedded doc string is not SQL).
|
||||
if (!inAllowlist && !isSchemaDefinition) {
|
||||
const sqlAdjacentRe = new RegExp(
|
||||
`\\b(insert\\s+into|update|delete\\s+from|from|join|truncate(\\s+table)?|alter\\s+table|drop\\s+table|references|into)\\s+["'\`]?(${CLASS_TABLES.join('|')})\\b`,
|
||||
'i',
|
||||
);
|
||||
for (const span of f.spans) {
|
||||
if (sqlAdjacentRe.test(span)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'ii-literal',
|
||||
detail: `class-table name in SQL context: ${span.slice(0, 80)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (iii): content-independent raw execution.
|
||||
if (!inAllowlist && !inRegister) {
|
||||
const driverImport = new RegExp(
|
||||
`(from\\s*|import\\s*\\(\\s*)['"](${DRIVER_SPECIFIERS.map((s) => s.replace('/', '\\/')).join('|')})['"]`,
|
||||
).test(code);
|
||||
const factoryImport =
|
||||
hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') ||
|
||||
(aliases.namespaces.length > 0 &&
|
||||
new RegExp(`\\b(${aliases.namespaces.join('|')})\\.(createDb|createPgliteDb)\\b`).test(
|
||||
code,
|
||||
));
|
||||
if (driverImport) {
|
||||
violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'direct driver import' });
|
||||
// A driver client exposes query/execute/unsafe as raw primitives:
|
||||
// flag them all on any receiver, any argument.
|
||||
for (const m of code.matchAll(/\.(execute|query|unsafe)\s*\(/g)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'iii-raw-execution',
|
||||
detail: `.${m[1]}() in a driver-importing module`,
|
||||
});
|
||||
}
|
||||
} else if (factoryImport) {
|
||||
// A drizzle handle's raw primitive is .execute (its .query namespace is
|
||||
// the relational builder, and unrelated .query() methods are common),
|
||||
// so factory capability flags execute/unsafe on any receiver and query
|
||||
// only on the db/client-shaped receiver backstop below.
|
||||
for (const m of code.matchAll(/\.(execute|unsafe)\s*\(/g)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'iii-raw-execution',
|
||||
detail: `.${m[1]}() in a factory-importing module`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!driverImport) {
|
||||
// DI residual backstop: db/client-shaped receivers fire regardless of
|
||||
// detected capability (a handle can arrive by injection).
|
||||
for (const m of code.matchAll(
|
||||
/\b(?:db|database|client|conn|connection|pool|pg|pglite|tx|trx)\.(execute|query|unsafe)\s*\(/g,
|
||||
)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'iii-raw-execution',
|
||||
detail: `.${m[1]}() on a db-shaped receiver`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// sql.raw through aliases and namespaces (drizzle-orm and the db barrel).
|
||||
const sqlAliases = new Set<string>();
|
||||
for (const m of code.matchAll(IMPORT_RE)) {
|
||||
const [, typeOnly, , namedList, nsName, , spec] = m;
|
||||
if (typeOnly) continue;
|
||||
if (!/^drizzle-orm|^@mosaicstack\/db$/.test(spec!)) continue;
|
||||
if (namedList) {
|
||||
for (const part of namedList.split(',')) {
|
||||
const seg = part.trim();
|
||||
const asMatch = /^sql\s+as\s+(\w+)$/.exec(seg);
|
||||
if (seg === 'sql') sqlAliases.add('sql');
|
||||
else if (asMatch) sqlAliases.add(asMatch[1]!);
|
||||
}
|
||||
}
|
||||
if (nsName) sqlAliases.add(`${nsName}\\.sql`);
|
||||
}
|
||||
if (sqlAliases.size > 0) {
|
||||
const rawRe = new RegExp(`\\b(${[...sqlAliases].join('|')})\\.raw\\s*\\(`);
|
||||
if (rawRe.test(code)) {
|
||||
violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'sql.raw()' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
const sourceRels = collectSources();
|
||||
const fileSet = new Set(sourceRels);
|
||||
const files: FileFacts[] = sourceRels.map((rel) => {
|
||||
const { code, spans } = lexSource(readFileSync(join(REPO_ROOT, rel), 'utf8'));
|
||||
return { rel, code, spans };
|
||||
});
|
||||
const conduits = computeSchemaConduits(files, fileSet);
|
||||
|
||||
it('scans a non-empty production source set including plugins', () => {
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
expect(sourceRels.some((r) => r.startsWith('plugins/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('enumerated modules exist on disk (no stale allowlist/register entries)', () => {
|
||||
@@ -205,6 +636,8 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
...INFRA_REGISTER,
|
||||
...MIGRATION_RUNNER_IMPORTERS,
|
||||
...MIGRATE_TIER_IMPORTERS,
|
||||
...DB_FACTORY_IMPORTERS,
|
||||
...DYNAMIC_IMPORT_REGISTER,
|
||||
]) {
|
||||
expect(existsSync(join(REPO_ROOT, p)), `enumerated module missing: ${p}`).toBe(true);
|
||||
}
|
||||
@@ -216,87 +649,8 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('three-prong writer coverage holds', () => {
|
||||
const violations: Violation[] = [];
|
||||
const allow = new Set(WRITER_ALLOWLIST);
|
||||
const register = new Set(INFRA_REGISTER);
|
||||
|
||||
for (const rel of sources) {
|
||||
const raw = readFileSync(join(REPO_ROOT, rel), 'utf8');
|
||||
const src = stripComments(raw);
|
||||
const inAllowlist = allow.has(rel);
|
||||
const inRegister = register.has(rel);
|
||||
const isSchemaDefinition = rel === 'packages/db/src/schema.ts';
|
||||
|
||||
// Runtime code construction: fails anywhere.
|
||||
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(src)) {
|
||||
violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' });
|
||||
}
|
||||
// Non-literal dynamic import: makes the import graph unanalyzable.
|
||||
if (/\bimport\s*\(\s*(?!['"`])/.test(src)) {
|
||||
violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' });
|
||||
}
|
||||
|
||||
// Prong (i): schema-symbol writes — alias-aware.
|
||||
if (!inAllowlist) {
|
||||
const aliases = classSymbolAliases(src);
|
||||
if (aliases.length > 0) {
|
||||
const writeRe = new RegExp(
|
||||
`\\.(insert|update|delete)\\s*\\(\\s*(${aliases.join('|')})\\b`,
|
||||
'g',
|
||||
);
|
||||
for (const m of src.matchAll(writeRe)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'i-symbol',
|
||||
detail: `.${m[1]}(${m[2]}) outside the writer allowlist`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (ii): class-table names in SQL strings/templates.
|
||||
// Schema definitions and generated migrations are excluded (contract);
|
||||
// migrations live outside the scanned source roots already.
|
||||
if (!inAllowlist && !isSchemaDefinition) {
|
||||
const tableRe = new RegExp(`\\b(${CLASS_TABLES.join('|')})\\b`);
|
||||
const sqlContextRe =
|
||||
/\b(select|insert\s+into|update|delete\s+from|join|truncate|alter\s+table|drop\s+table|references)\b/i;
|
||||
for (const span of stringSpans(src)) {
|
||||
if (tableRe.test(span) && sqlContextRe.test(span)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'ii-literal',
|
||||
detail: `class-table name in SQL context: ${span.slice(0, 80)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (iii): raw-execution primitives outside allowlist ∪ register.
|
||||
if (!inAllowlist && !inRegister) {
|
||||
const rawPatterns: Array<[RegExp, string]> = [
|
||||
[/\bsql\.raw\s*\(/, 'sql.raw()'],
|
||||
[/\.unsafe\s*\(/, '.unsafe()'],
|
||||
// `.execute(sql\`...\`)` is exempt: the tagged template keeps the
|
||||
// SQL literal in source, where prong (ii) scans it. The flagged
|
||||
// forms are the ones whose SQL content is not statically visible
|
||||
// at the call site: `.execute(variable)` and `.execute("string")`.
|
||||
[/\b(?:db|database|tx|trx)\.execute\s*\((?!\s*sql`)/, 'raw db.execute()'],
|
||||
[/from\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]/, 'direct driver import'],
|
||||
[
|
||||
/\bimport\s*\(\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]\s*\)/,
|
||||
'dynamic driver import',
|
||||
],
|
||||
];
|
||||
for (const [re, label] of rawPatterns) {
|
||||
if (re.test(src)) {
|
||||
violations.push({ file: rel, prong: 'iii-raw-execution', detail: label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('three-prong writer coverage holds over the production tree', () => {
|
||||
const violations = files.flatMap((f) => analyzeFile(f, conduits, fileSet));
|
||||
expect(
|
||||
violations,
|
||||
violations.map((v) => `[prong ${v.prong}] ${v.file}: ${v.detail}`).join('\n'),
|
||||
@@ -305,22 +659,14 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
|
||||
it('migration-runner import edges are exactly the closed importer enumeration', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sources) {
|
||||
if (rel.startsWith('packages/db/src/')) continue; // the runner's own package
|
||||
const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8'));
|
||||
const symbolRe = new RegExp(`\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b`);
|
||||
if (!symbolRe.test(src)) continue;
|
||||
// An import edge is a static value import from the db package (or its
|
||||
// migrate module) naming a runner symbol, or a literal dynamic
|
||||
// import('@mosaicstack/db') in a file that uses a runner symbol.
|
||||
// `import type` is erased at runtime and is not an edge; parameter
|
||||
// injection (the gateway schema-check module) has no edge.
|
||||
const staticEdge = new RegExp(
|
||||
`import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"](@mosaicstack/db|[^'"]*migrate(\\.js)?)['"]`,
|
||||
).test(src);
|
||||
const dynamicEdge = /import\s*\(\s*['"]@mosaicstack\/db['"]\s*\)/.test(src);
|
||||
if ((staticEdge || dynamicEdge) && !MIGRATION_RUNNER_IMPORTERS.includes(rel)) {
|
||||
offenders.push(rel);
|
||||
for (const f of files) {
|
||||
if (f.rel.startsWith('packages/db/src/')) continue; // the runner's own package
|
||||
if (!new RegExp(`\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b`).test(f.code)) continue;
|
||||
if (
|
||||
hasSymbolImportEdge(f.code, MIGRATION_RUNNER_SYMBOLS, '@mosaicstack/db') &&
|
||||
!MIGRATION_RUNNER_IMPORTERS.includes(f.rel)
|
||||
) {
|
||||
offenders.push(f.rel);
|
||||
}
|
||||
}
|
||||
expect(offenders, `unenumerated migration-runner importers:\n${offenders.join('\n')}`).toEqual(
|
||||
@@ -330,20 +676,121 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
|
||||
it('migrate-tier import edges are exactly the closed importer enumeration', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sources) {
|
||||
if (rel === 'packages/storage/src/migrate-tier.ts') continue;
|
||||
const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8'));
|
||||
// Edge = direct module-path import, or a value import of a migrate-tier
|
||||
// symbol from the storage package barrel (the barrel is itself
|
||||
// enumerated as a re-exporter, so barrel consumers must not escape).
|
||||
for (const f of files) {
|
||||
if (f.rel === 'packages/storage/src/migrate-tier.ts') continue;
|
||||
const pathEdge =
|
||||
/from\s*['"][^'"]*migrate-tier(\.js)?['"]/.test(src) ||
|
||||
/import\s*\(\s*['"][^'"]*migrate-tier(\.js)?['"]\s*\)/.test(src);
|
||||
const barrelEdge = new RegExp(
|
||||
`import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATE_TIER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"]@mosaicstack/storage['"]`,
|
||||
).test(src);
|
||||
if ((pathEdge || barrelEdge) && !MIGRATE_TIER_IMPORTERS.includes(rel)) offenders.push(rel);
|
||||
/from\s*['"][^'"]*migrate-tier(\.js)?['"]/.test(f.code) ||
|
||||
/import\s*\(\s*['"][^'"]*migrate-tier(\.js)?['"]\s*\)/.test(f.code);
|
||||
const symbolEdge =
|
||||
new RegExp(`\\b(${MIGRATE_TIER_SYMBOLS.join('|')})\\b`).test(f.code) &&
|
||||
hasSymbolImportEdge(f.code, MIGRATE_TIER_SYMBOLS, '@mosaicstack/storage');
|
||||
if ((pathEdge || symbolEdge) && !MIGRATE_TIER_IMPORTERS.includes(f.rel)) {
|
||||
offenders.push(f.rel);
|
||||
}
|
||||
}
|
||||
expect(offenders, `unenumerated migrate-tier importers:\n${offenders.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('db-factory (createDb/createPgliteDb) import edges are exactly the closed importer enumeration', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const f of files) {
|
||||
if (f.rel.startsWith('packages/db/src/')) continue; // the factories' own package
|
||||
if (!new RegExp(`\\b(${DB_FACTORY_SYMBOLS.join('|')})\\b`).test(f.code)) continue;
|
||||
if (
|
||||
hasSymbolImportEdge(f.code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') &&
|
||||
!DB_FACTORY_IMPORTERS.includes(f.rel)
|
||||
) {
|
||||
offenders.push(f.rel);
|
||||
}
|
||||
}
|
||||
expect(offenders, `unenumerated db-factory importers:\n${offenders.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Permanent evasion controls: every known escape from the M4-1a review must
|
||||
// be flagged by the analyzer, and the two legitimate controls must pass.
|
||||
// Synthetic files run through the exact production analyzer.
|
||||
// -------------------------------------------------------------------------
|
||||
const EVASIONS: Array<{ name: string; src: string }> = [
|
||||
{
|
||||
name: 'E1 namespace import write',
|
||||
src: `import * as dbSchema from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(dbSchema.companies).values({}); }`,
|
||||
},
|
||||
{
|
||||
name: 'E2 re-export laundering (conduit consumer)',
|
||||
src: `import { companies } from './evasion-barrel.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(companies).values({}); }`,
|
||||
},
|
||||
{
|
||||
name: 'E3 renamed createDb handle',
|
||||
src: `import { createDb } from '@mosaicstack/db';\nexport async function f(t: string) { const d = createDb('u'); await d.execute('DELETE FROM ' + t); }`,
|
||||
},
|
||||
{
|
||||
name: 'E4 aliased sql.raw',
|
||||
src: `import { sql as q } from 'drizzle-orm';\nimport { db } from './x.js';\nexport async function f(t: string) { await db.execute(q.raw('TRUNCATE ' + t)); }`,
|
||||
},
|
||||
{
|
||||
name: 'E5 interpolated tagged template on db receiver',
|
||||
src: `import { sql } from 'drizzle-orm';\nimport { db } from './x.js';\nexport async function f() { const tbl = 'hierarchy_grants'; await db.execute(sql\`DELETE FROM \${tbl}\`); }`,
|
||||
},
|
||||
{
|
||||
name: 'E6 driver client via import',
|
||||
src: `import postgres from 'postgres';\nexport async function f(t: string) { const c = postgres('u'); await c.unsafe('TRUNCATE ' + t); }`,
|
||||
},
|
||||
{
|
||||
name: 'E7 DI-shaped driver client query',
|
||||
src: `export class R { constructor(private client: { query(s: string): Promise<unknown> }) {}\n async f(t: string) { await this.client.query('TRUNCATE ' + t); } }`,
|
||||
},
|
||||
{
|
||||
name: 'E8 string containing // does not hide code',
|
||||
src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { const x = 'oops//'; await db.insert(companies).values({}); }`,
|
||||
},
|
||||
{
|
||||
name: 'C-literal SQL string with class table',
|
||||
src: `export const q = 'DELETE FROM hierarchy_grants WHERE role = $1';`,
|
||||
},
|
||||
{
|
||||
name: 'C-eval',
|
||||
src: `export function f(s: string) { return eval(s); }`,
|
||||
},
|
||||
];
|
||||
const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [
|
||||
{
|
||||
name: 'clean: schema read via select',
|
||||
src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { return db.select().from(companies); }`,
|
||||
},
|
||||
{
|
||||
name: 'clean: comment mentioning a table is not SQL',
|
||||
src: `// syncs hierarchy_grants downstream\nexport const n = 1;`,
|
||||
},
|
||||
];
|
||||
|
||||
it('analyzer flags every known evasion form', () => {
|
||||
// A synthetic conduit so E2 exercises the fixpoint through a real re-export.
|
||||
const barrel: FileFacts = {
|
||||
rel: 'packages/db/src/evasion-barrel.ts',
|
||||
...lexSource(`export { companies } from '@mosaicstack/db';`),
|
||||
};
|
||||
for (const e of EVASIONS) {
|
||||
const rel = 'packages/db/src/evasion-sample.ts';
|
||||
const synthetic: FileFacts = { rel, ...lexSource(e.src) };
|
||||
const synthSet = new Set([...fileSet, rel, barrel.rel]);
|
||||
const synthConduits = computeSchemaConduits([...files, barrel, synthetic], synthSet);
|
||||
const v = analyzeFile(synthetic, synthConduits, synthSet);
|
||||
expect(v.length, `evasion not caught: ${e.name}`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('analyzer passes legitimate non-writer code (no false positives on controls)', () => {
|
||||
for (const c of CLEAN_CONTROLS) {
|
||||
const rel = 'packages/db/src/clean-sample.ts';
|
||||
const synthetic: FileFacts = { rel, ...lexSource(c.src) };
|
||||
const synthSet = new Set([...fileSet, rel]);
|
||||
const synthConduits = computeSchemaConduits([...files, synthetic], synthSet);
|
||||
const v = analyzeFile(synthetic, synthConduits, synthSet);
|
||||
expect(
|
||||
v,
|
||||
`false positive on ${c.name}: ${v.map((x) => `${x.prong}:${x.detail}`).join('; ')}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user