fix(db): close round-2 review evasions in writer-coverage assertion
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Per the second M4-1a detached review (REQUEST_CHANGES, R1-R8): - R1: prong (i) now tracks namespace destructuring, nested namespace re-exports (ns.schema.companies), and literal dynamic-import bindings (destructured and namespace) per contract rev 9. - R2: capability-conduit fixpoint (computeCapabilityConduits) closes driver/factory laundering through export-from modules. - R3: dynamic-import check is per call site with a full-literal tail match, so concatenated specifiers no longer pass. - R4: computed-member calls with literal verb names (obj['insert'], obj['query']) and spaced member access are flagged. - R5: prong (ii) adjacency tolerates schema qualification, interposed block comments, COPY, and escaped quotes. - R6/R8: header documents KNOWN RESIDUALS (DI receiver rename in capability-free files, computed non-literal member access, scan perimeter) with the measured counterfactuals. - createRequire fails outside a 5-module measured register (R1 route). - 15 new permanent evasion controls (E9-E23) with helper-file conduits run through the production analyzer. Calibration: tree-wide prong test green with zero new exclusions; tsc, eslint, and the full package suite pass.
This commit is contained in:
@@ -28,11 +28,31 @@
|
|||||||
* receiver backstop still fires on db/client-shaped receivers.
|
* receiver backstop still fires on db/client-shaped receivers.
|
||||||
* `sql.raw` is tracked through import aliasing and namespaces.
|
* `sql.raw` is tracked through import aliasing and namespaces.
|
||||||
*
|
*
|
||||||
* Runtime code-construction primitives (eval, new Function) and non-literal
|
* Runtime code-construction primitives (eval, new Function) fail anywhere —
|
||||||
* dynamic imports fail anywhere — allowlist and register included. This is
|
* allowlist and register included: constructed code defeats every static
|
||||||
* deliberately stricter than the contract's minimum: an unanalyzable import
|
* prong, so there is no enumerated disposition path for it. Unanalyzable
|
||||||
* or constructed code defeats every static prong, so there is no enumerated
|
* import routes (a dynamic import whose specifier is not a single string
|
||||||
* disposition path for them.
|
* literal, and createRequire) fail everywhere except their own enumerated
|
||||||
|
* disposition registers below. A LITERAL dynamic import is an import edge
|
||||||
|
* like any static import (contract rev 9): its bindings (destructured or
|
||||||
|
* namespace) feed prong (i) exactly as static import bindings do.
|
||||||
|
*
|
||||||
|
* KNOWN RESIDUALS (deliberate, reviewed trade-offs — not claims of closure):
|
||||||
|
* - In a file with NO detected raw capability (no driver/factory import on
|
||||||
|
* any tracked route), prong (iii) falls back to a conventional-receiver
|
||||||
|
* backstop (db/client/pool/tx/…). A dependency-injected handle bound to
|
||||||
|
* an unconventional name in such a file is an OPEN evasion path. The
|
||||||
|
* counterfactual was measured: any-receiver flagging in capability-free
|
||||||
|
* files false-positives on real non-SQL .execute()/.query() sites
|
||||||
|
* (runtime-provider-registry, connector-lease, the log CLI), so the
|
||||||
|
* residual is accepted and reviews of DI provider modules carry it.
|
||||||
|
* - Computed member access with a NON-literal name (obj[verb]()) is not
|
||||||
|
* statically resolvable; literal computed access (obj['insert']()) is
|
||||||
|
* flagged. Constructing the verb at runtime is adjacent to eval and is
|
||||||
|
* expected to be caught in review.
|
||||||
|
* - The scan perimeter is <root>/<pkg>/src for the three roots; production
|
||||||
|
* TS outside a src/ directory (e.g. packages/mosaic/framework/**) is not
|
||||||
|
* scanned (verified free of db/driver/execute references at review time).
|
||||||
*
|
*
|
||||||
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
||||||
* empty today: the hierarchy command family (M4-1b) has not landed, so no
|
* empty today: the hierarchy command family (M4-1b) has not landed, so no
|
||||||
@@ -140,6 +160,18 @@ const MIGRATE_TIER_IMPORTERS: string[] = [
|
|||||||
const DYNAMIC_IMPORT_REGISTER: string[] = [
|
const DYNAMIC_IMPORT_REGISTER: string[] = [
|
||||||
'plugins/macp/src/index.ts', // loads the ACP runtime SDK from a configured sdkRoot; no db access
|
'plugins/macp/src/index.ts', // loads the ACP runtime SDK from a configured sdkRoot; no db access
|
||||||
];
|
];
|
||||||
|
/**
|
||||||
|
* Enumerated disposition for createRequire (an unanalyzable CJS import
|
||||||
|
* route). Every entry uses it for package.json version reads or module
|
||||||
|
* resolution only (measured); every other prong still applies in full.
|
||||||
|
*/
|
||||||
|
const CREATE_REQUIRE_REGISTER: string[] = [
|
||||||
|
'packages/mosaic/src/cli.ts', // package.json version read
|
||||||
|
'packages/mosaic/src/commands/gateway/daemon.ts', // module path resolution
|
||||||
|
'packages/mosaic/src/commands/launch.ts', // package.json version read + resolution
|
||||||
|
'packages/mosaic/src/commands/lease-activation-probe.ts', // injectable module resolver default
|
||||||
|
'plugins/macp/src/index.ts', // OpenCode SDK resolution
|
||||||
|
];
|
||||||
/** Importers of the db-handle factories (measured set; a new importer must be reviewed in). */
|
/** Importers of the db-handle factories (measured set; a new importer must be reviewed in). */
|
||||||
const DB_FACTORY_SYMBOLS = ['createDb', 'createPgliteDb'];
|
const DB_FACTORY_SYMBOLS = ['createDb', 'createPgliteDb'];
|
||||||
const DB_FACTORY_IMPORTERS: string[] = [
|
const DB_FACTORY_IMPORTERS: string[] = [
|
||||||
@@ -435,51 +467,155 @@ interface ClassAliases {
|
|||||||
namespaces: string[]; // namespace identifiers over a schema source
|
namespaces: string[]; // namespace identifiers over a schema source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse an import named-binding list ("a, b as c") into locals bound to class symbols. */
|
||||||
|
function importBindings(namedList: string, named: Set<string>): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a destructuring pattern ("a, b: c") into locals bound to class symbols. */
|
||||||
|
function destructureBindings(pattern: string, named: Set<string>): void {
|
||||||
|
for (const part of pattern.split(',')) {
|
||||||
|
const seg = part.trim();
|
||||||
|
if (!seg) continue;
|
||||||
|
const m = /^(\w+)\s*(?::\s*(\w+))?$/.exec(seg);
|
||||||
|
if (m && CLASS_SYMBOLS.includes(m[1]!)) named.add(m[2] ?? m[1]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSchemaSpecifier(
|
||||||
|
rel: string,
|
||||||
|
spec: string,
|
||||||
|
conduits: Set<string>,
|
||||||
|
fileSet: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
if (spec === '@mosaicstack/db') return true;
|
||||||
|
const r = resolveSpecifier(rel, spec, fileSet);
|
||||||
|
return r !== null && conduits.has(r);
|
||||||
|
}
|
||||||
|
|
||||||
function classAliases(f: FileFacts, conduits: Set<string>, fileSet: Set<string>): ClassAliases {
|
function classAliases(f: FileFacts, conduits: Set<string>, fileSet: Set<string>): ClassAliases {
|
||||||
const named = new Set<string>();
|
const named = new Set<string>();
|
||||||
const namespaces = new Set<string>();
|
const namespaces = new Set<string>();
|
||||||
for (const m of f.code.matchAll(IMPORT_RE)) {
|
for (const m of f.code.matchAll(IMPORT_RE)) {
|
||||||
const [, typeOnly, , namedList, nsName, , spec] = m;
|
const [, typeOnly, , namedList, nsName, , spec] = m;
|
||||||
if (typeOnly) continue;
|
if (typeOnly) continue;
|
||||||
const fromSchema =
|
if (!isSchemaSpecifier(f.rel, spec!, conduits, fileSet)) continue;
|
||||||
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 (nsName) namespaces.add(nsName);
|
||||||
if (namedList) {
|
if (namedList) importBindings(namedList, named);
|
||||||
for (const part of namedList.split(',')) {
|
}
|
||||||
const seg = part.trim();
|
// Literal dynamic imports of a schema source are import edges like any
|
||||||
if (!seg || seg.startsWith('type ')) continue;
|
// other (contract rev 9): both binding shapes feed prong (i).
|
||||||
const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg);
|
for (const m of f.code.matchAll(
|
||||||
const original = asMatch ? asMatch[1]! : seg;
|
/(?:const|let|var)\s*(?:\{([^}]*)\}|(\w+))\s*=\s*await\s+import\s*\(\s*(['"])([^'"]+)\3\s*\)/g,
|
||||||
const local = asMatch ? asMatch[2]! : seg;
|
)) {
|
||||||
if (CLASS_SYMBOLS.includes(original)) named.add(local);
|
const [, pattern, nsName, , spec] = m;
|
||||||
}
|
if (!isSchemaSpecifier(f.rel, spec!, conduits, fileSet)) continue;
|
||||||
|
if (nsName) namespaces.add(nsName);
|
||||||
|
if (pattern) destructureBindings(pattern, named);
|
||||||
|
}
|
||||||
|
// Destructuring from a schema namespace binds class symbols to locals:
|
||||||
|
// `import * as s from '@mosaicstack/db'; const { companies } = s;`
|
||||||
|
for (const ns of [...namespaces]) {
|
||||||
|
for (const m of f.code.matchAll(
|
||||||
|
new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${ns}\\b`, 'g'),
|
||||||
|
)) {
|
||||||
|
destructureBindings(m[1]!, named);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { named: [...named], namespaces: [...namespaces] };
|
return { named: [...named], namespaces: [...namespaces] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability conduits: modules that re-export raw-execution capability, so a
|
||||||
|
* consumer can obtain it without a literal driver/factory specifier.
|
||||||
|
* `driver` — re-exports (star, named, or default) from a driver package or
|
||||||
|
* another driver conduit; importing ANYTHING from one confers driver
|
||||||
|
* capability (the conduit module itself is additionally flagged by prong
|
||||||
|
* (iii)'s literal check, so these routes fail at both ends).
|
||||||
|
* `factory` — re-exports createDb/createPgliteDb from the db package or
|
||||||
|
* another factory conduit; importing from one while referencing a factory
|
||||||
|
* symbol confers factory capability.
|
||||||
|
*/
|
||||||
|
function computeCapabilityConduits(
|
||||||
|
files: FileFacts[],
|
||||||
|
fileSet: Set<string>,
|
||||||
|
): { driver: Set<string>; factory: Set<string> } {
|
||||||
|
const driver = new Set<string>();
|
||||||
|
const factory = new Set<string>();
|
||||||
|
const isDriverSpec = (rel: string, spec: string): boolean => {
|
||||||
|
if (DRIVER_SPECIFIERS.includes(spec)) return true;
|
||||||
|
const r = resolveSpecifier(rel, spec, fileSet);
|
||||||
|
return r !== null && driver.has(r);
|
||||||
|
};
|
||||||
|
const isFactorySpec = (rel: string, spec: string): boolean => {
|
||||||
|
if (spec === '@mosaicstack/db') return true;
|
||||||
|
const r = resolveSpecifier(rel, spec, fileSet);
|
||||||
|
return r !== null && factory.has(r);
|
||||||
|
};
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const f of files) {
|
||||||
|
for (const m of f.code.matchAll(EXPORT_FROM_RE)) {
|
||||||
|
if (m[1]) continue; // export type — erased
|
||||||
|
const spec = m[3]!;
|
||||||
|
if (!driver.has(f.rel) && isDriverSpec(f.rel, spec)) {
|
||||||
|
driver.add(f.rel);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!factory.has(f.rel) && isFactorySpec(f.rel, spec)) {
|
||||||
|
const named = m[2];
|
||||||
|
if (named === undefined || /\b(createDb|createPgliteDb)\b/.test(named)) {
|
||||||
|
factory.add(f.rel);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `export { default as x } from 'postgres'` matches EXPORT_FROM_RE's
|
||||||
|
// named branch above; `export x from` is not valid syntax — covered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { driver, factory };
|
||||||
|
}
|
||||||
|
|
||||||
/** Value-import edge naming one of `symbols` (named import from anywhere, or literal dynamic package import while using the symbol). */
|
/** 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 {
|
function hasSymbolImportEdge(code: string, symbols: string[], packageName: string): boolean {
|
||||||
|
const pkg = packageName.replace('/', '\\/');
|
||||||
const staticEdge = new RegExp(
|
const staticEdge = new RegExp(
|
||||||
`import\\s*(?!type\\b)(?:\\w+\\s*,\\s*)?\\{[^}]*\\b(${symbols.join('|')})\\b[^}]*\\}\\s*from\\s*['"][^'"]+['"]`,
|
`import\\s*(?!type\\b)(?:\\w+\\s*,\\s*)?\\{[^}]*\\b(${symbols.join('|')})\\b[^}]*\\}\\s*from\\s*['"][^'"]+['"]`,
|
||||||
).test(code);
|
).test(code);
|
||||||
const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code);
|
const usesSymbol = new RegExp(`\\b(${symbols.join('|')})\\b`).test(code);
|
||||||
const dynamicEdge =
|
const dynamicEdge = usesSymbol && new RegExp(`import\\s*\\(\\s*['"]${pkg}['"]\\s*\\)`).test(code);
|
||||||
usesSymbol &&
|
// Namespace form: `import * as ns from '<pkg>'` + `ns.<symbol>` usage.
|
||||||
new RegExp(`import\\s*\\(\\s*['"]${packageName.replace('/', '\\/')}['"]\\s*\\)`).test(code);
|
let nsEdge = false;
|
||||||
return staticEdge || dynamicEdge;
|
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;
|
||||||
|
}
|
||||||
|
return staticEdge || dynamicEdge || nsEdge;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The analyzer — pure over (rel, source) so the evasion controls below can
|
// The analyzer — pure over (rel, source) so the evasion controls below can
|
||||||
// feed synthetic files through the exact production logic.
|
// feed synthetic files through the exact production logic.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>): Violation[] {
|
interface AnalysisCtx {
|
||||||
|
fileSet: Set<string>;
|
||||||
|
conduits: Set<string>; // schema-symbol sources (prong i)
|
||||||
|
driverConduits: Set<string>; // driver-capability re-exporters (prong iii)
|
||||||
|
factoryConduits: Set<string>; // factory-capability re-exporters (prong iii)
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeFile(f: FileFacts, ctx: AnalysisCtx): Violation[] {
|
||||||
|
const { fileSet, conduits, driverConduits, factoryConduits } = ctx;
|
||||||
const violations: Violation[] = [];
|
const violations: Violation[] = [];
|
||||||
const rel = f.rel;
|
const rel = f.rel;
|
||||||
const code = f.code;
|
const code = f.code;
|
||||||
@@ -491,9 +627,20 @@ function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>):
|
|||||||
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(code)) {
|
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(code)) {
|
||||||
violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' });
|
violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' });
|
||||||
}
|
}
|
||||||
// Non-literal dynamic import: makes the import graph unanalyzable.
|
// Dynamic import whose specifier is not a single string literal: the
|
||||||
if (!DYNAMIC_IMPORT_REGISTER.includes(rel) && /\bimport\s*\(\s*(?!['"])/.test(code)) {
|
// import graph becomes unanalyzable. Checked per call site, so a literal
|
||||||
violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' });
|
// first fragment ('x' + y) does not slip past.
|
||||||
|
if (!DYNAMIC_IMPORT_REGISTER.includes(rel)) {
|
||||||
|
for (const m of code.matchAll(/\bimport\s*\(/g)) {
|
||||||
|
const tail = code.slice((m.index ?? 0) + m[0].length);
|
||||||
|
if (!/^\s*(['"])(?:[^'"\\]|\\.)*?\1\s*[,)]/.test(tail)) {
|
||||||
|
violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// createRequire: an unanalyzable CJS import route.
|
||||||
|
if (!CREATE_REQUIRE_REGISTER.includes(rel) && /\bcreateRequire\s*\(/.test(code)) {
|
||||||
|
violations.push({ file: rel, prong: 'dynamic-import', detail: 'createRequire()' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliases = classAliases(f, conduits, fileSet);
|
const aliases = classAliases(f, conduits, fileSet);
|
||||||
@@ -502,11 +649,13 @@ function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>):
|
|||||||
if (!inAllowlist) {
|
if (!inAllowlist) {
|
||||||
const targets: string[] = [...aliases.named];
|
const targets: string[] = [...aliases.named];
|
||||||
for (const ns of aliases.namespaces) {
|
for (const ns of aliases.namespaces) {
|
||||||
for (const s of CLASS_SYMBOLS) targets.push(`${ns}\\.${s}`);
|
// Allow intermediate property segments: ns.schema.companies (nested
|
||||||
|
// namespace re-exports) as well as ns.companies.
|
||||||
|
for (const s of CLASS_SYMBOLS) targets.push(`${ns}(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*${s}`);
|
||||||
}
|
}
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
const writeRe = new RegExp(
|
const writeRe = new RegExp(
|
||||||
`\\.(insert|update|delete)\\s*\\(\\s*(${targets.join('|')})\\b`,
|
`\\.\\s*(insert|update|delete)\\s*\\(\\s*(${targets.join('|')})\\b`,
|
||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
for (const m of code.matchAll(writeRe)) {
|
for (const m of code.matchAll(writeRe)) {
|
||||||
@@ -519,13 +668,43 @@ function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed member access with a literal verb name (obj['insert'](…))
|
||||||
|
// bypasses dot-based call detection; the tree has no legitimate use of the
|
||||||
|
// form (measured), so it fails outright. Write verbs fail outside the
|
||||||
|
// allowlist; execution verbs fail outside allowlist ∪ register.
|
||||||
|
if (!inAllowlist) {
|
||||||
|
const writeBracket = /\[\s*['"](insert|update|delete)['"]\s*\]\s*\(/g;
|
||||||
|
for (const m of code.matchAll(writeBracket)) {
|
||||||
|
violations.push({
|
||||||
|
file: rel,
|
||||||
|
prong: 'i-symbol',
|
||||||
|
detail: `['${m[1]}']() computed-member write call`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!inRegister) {
|
||||||
|
for (const m of code.matchAll(/\[\s*['"](execute|query|unsafe)['"]\s*\]\s*\(/g)) {
|
||||||
|
violations.push({
|
||||||
|
file: rel,
|
||||||
|
prong: 'iii-raw-execution',
|
||||||
|
detail: `['${m[1]}']() computed-member execution call`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prong (ii): class-table names in string/template spans with SQL context.
|
// Prong (ii): class-table names in string/template spans with SQL context.
|
||||||
// The SQL keyword must be ADJACENT to the table name (optionally quoted):
|
// The SQL keyword must be ADJACENT to the table name — co-residence
|
||||||
// co-residence anywhere in one span over-matches prose (English "from" plus
|
// anywhere in one span over-matches prose ("pnpm workspaces" plus an
|
||||||
// "pnpm workspaces" in an embedded doc string is not SQL).
|
// 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).
|
||||||
if (!inAllowlist && !isSchemaDefinition) {
|
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)';
|
||||||
|
const gap = '(?:\\s|/\\*[\\s\\S]*?\\*/)+';
|
||||||
|
const q = `(?:\\\\?["'\`])?`;
|
||||||
const sqlAdjacentRe = new RegExp(
|
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`,
|
`\\b${kw}${gap}${q}(?:\\w+\\s*\\.\\s*)?${q}(${CLASS_TABLES.join('|')})\\b`,
|
||||||
'i',
|
'i',
|
||||||
);
|
);
|
||||||
for (const span of f.spans) {
|
for (const span of f.spans) {
|
||||||
@@ -541,15 +720,28 @@ function analyzeFile(f: FileFacts, conduits: Set<string>, fileSet: Set<string>):
|
|||||||
|
|
||||||
// Prong (iii): content-independent raw execution.
|
// Prong (iii): content-independent raw execution.
|
||||||
if (!inAllowlist && !inRegister) {
|
if (!inAllowlist && !inRegister) {
|
||||||
const driverImport = new RegExp(
|
const literalDriver = new RegExp(
|
||||||
`(from\\s*|import\\s*\\(\\s*)['"](${DRIVER_SPECIFIERS.map((s) => s.replace('/', '\\/')).join('|')})['"]`,
|
`(from\\s*|import\\s*\\(\\s*)['"](${DRIVER_SPECIFIERS.map((s) => s.replace('/', '\\/')).join('|')})['"]`,
|
||||||
).test(code);
|
).test(code);
|
||||||
|
// Any value import from a driver conduit confers driver capability.
|
||||||
|
let conduitDriver = false;
|
||||||
|
let factoryConduitImport = false;
|
||||||
|
for (const m of f.code.matchAll(IMPORT_RE)) {
|
||||||
|
if (m[1]) continue; // import type — erased
|
||||||
|
const r = resolveSpecifier(rel, m[6]!, fileSet);
|
||||||
|
if (r !== null && driverConduits.has(r)) conduitDriver = true;
|
||||||
|
if (r !== null && factoryConduits.has(r)) factoryConduitImport = true;
|
||||||
|
}
|
||||||
|
const driverImport = literalDriver || conduitDriver;
|
||||||
|
const usesFactorySymbol = /\b(createDb|createPgliteDb)\b/.test(code);
|
||||||
const factoryImport =
|
const factoryImport =
|
||||||
hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') ||
|
hasSymbolImportEdge(code, DB_FACTORY_SYMBOLS, '@mosaicstack/db') ||
|
||||||
|
(factoryConduitImport && usesFactorySymbol) ||
|
||||||
(aliases.namespaces.length > 0 &&
|
(aliases.namespaces.length > 0 &&
|
||||||
new RegExp(`\\b(${aliases.namespaces.join('|')})\\.(createDb|createPgliteDb)\\b`).test(
|
usesFactorySymbol &&
|
||||||
code,
|
new RegExp(
|
||||||
));
|
`\\b(${aliases.namespaces.join('|')})(?:\\s*\\.\\s*\\w+)*\\s*\\.\\s*(createDb|createPgliteDb)\\b`,
|
||||||
|
).test(code));
|
||||||
if (driverImport) {
|
if (driverImport) {
|
||||||
violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'direct driver import' });
|
violations.push({ file: rel, prong: 'iii-raw-execution', detail: 'direct driver import' });
|
||||||
// A driver client exposes query/execute/unsafe as raw primitives:
|
// A driver client exposes query/execute/unsafe as raw primitives:
|
||||||
@@ -624,6 +816,13 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
return { rel, code, spans };
|
return { rel, code, spans };
|
||||||
});
|
});
|
||||||
const conduits = computeSchemaConduits(files, fileSet);
|
const conduits = computeSchemaConduits(files, fileSet);
|
||||||
|
const capConduits = computeCapabilityConduits(files, fileSet);
|
||||||
|
const ctx: AnalysisCtx = {
|
||||||
|
fileSet,
|
||||||
|
conduits,
|
||||||
|
driverConduits: capConduits.driver,
|
||||||
|
factoryConduits: capConduits.factory,
|
||||||
|
};
|
||||||
|
|
||||||
it('scans a non-empty production source set including plugins', () => {
|
it('scans a non-empty production source set including plugins', () => {
|
||||||
expect(files.length).toBeGreaterThan(100);
|
expect(files.length).toBeGreaterThan(100);
|
||||||
@@ -638,6 +837,7 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
...MIGRATE_TIER_IMPORTERS,
|
...MIGRATE_TIER_IMPORTERS,
|
||||||
...DB_FACTORY_IMPORTERS,
|
...DB_FACTORY_IMPORTERS,
|
||||||
...DYNAMIC_IMPORT_REGISTER,
|
...DYNAMIC_IMPORT_REGISTER,
|
||||||
|
...CREATE_REQUIRE_REGISTER,
|
||||||
]) {
|
]) {
|
||||||
expect(existsSync(join(REPO_ROOT, p)), `enumerated module missing: ${p}`).toBe(true);
|
expect(existsSync(join(REPO_ROOT, p)), `enumerated module missing: ${p}`).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -650,7 +850,7 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('three-prong writer coverage holds over the production tree', () => {
|
it('three-prong writer coverage holds over the production tree', () => {
|
||||||
const violations = files.flatMap((f) => analyzeFile(f, conduits, fileSet));
|
const violations = files.flatMap((f) => analyzeFile(f, ctx));
|
||||||
expect(
|
expect(
|
||||||
violations,
|
violations,
|
||||||
violations.map((v) => `[prong ${v.prong}] ${v.file}: ${v.detail}`).join('\n'),
|
violations.map((v) => `[prong ${v.prong}] ${v.file}: ${v.detail}`).join('\n'),
|
||||||
@@ -711,7 +911,11 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
// be flagged by the analyzer, and the two legitimate controls must pass.
|
// be flagged by the analyzer, and the two legitimate controls must pass.
|
||||||
// Synthetic files run through the exact production analyzer.
|
// Synthetic files run through the exact production analyzer.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const EVASIONS: Array<{ name: string; src: string }> = [
|
const EVASIONS: Array<{
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
extras?: Array<{ rel: string; src: string }>;
|
||||||
|
}> = [
|
||||||
{
|
{
|
||||||
name: 'E1 namespace import write',
|
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({}); }`,
|
src: `import * as dbSchema from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db.insert(dbSchema.companies).values({}); }`,
|
||||||
@@ -752,6 +956,85 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
name: 'C-eval',
|
name: 'C-eval',
|
||||||
src: `export function f(s: string) { return eval(s); }`,
|
src: `export function f(s: string) { return eval(s); }`,
|
||||||
},
|
},
|
||||||
|
// --- round-2 review shapes (R1–R5) ---
|
||||||
|
{
|
||||||
|
name: 'E9 namespace destructuring',
|
||||||
|
src: `import * as dbSchema from '@mosaicstack/db';\nimport { db } from './x.js';\nconst { companies } = dbSchema;\nexport async function f() { await db.insert(companies).values({}); }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E10 nested namespace re-export',
|
||||||
|
src: `import * as ns from './evasion-mid.js';\nimport { db } from './x.js';\nexport async function f() { await db.insert(ns.schema.companies).values({}); }`,
|
||||||
|
extras: [
|
||||||
|
{
|
||||||
|
rel: 'packages/db/src/evasion-mid.ts',
|
||||||
|
src: `export * as schema from '@mosaicstack/db';`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E11 literal dynamic import, destructured binding',
|
||||||
|
src: `import { db } from './x.js';\nexport async function f() { const { companies } = await import('@mosaicstack/db'); await db.insert(companies).values({}); }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E12 literal dynamic import, namespace binding',
|
||||||
|
src: `import { db } from './x.js';\nexport async function f() { const m = await import('@mosaicstack/db'); await db.insert(m.companies).values({}); }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E13 createRequire outside its register',
|
||||||
|
src: `import { createRequire } from 'node:module';\nconst require = createRequire(import.meta.url);\nexport const pg = require('postgres');`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E14 factory capability laundered through re-export',
|
||||||
|
src: `import * as dbns from './evasion-mid2.js';\nexport async function f(t: string) { await dbns.createDb('u').execute('DELETE FROM ' + t); }`,
|
||||||
|
extras: [
|
||||||
|
{
|
||||||
|
rel: 'packages/db/src/evasion-mid2.ts',
|
||||||
|
src: `export { createDb } from '@mosaicstack/db';`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E15 driver default laundered through re-export',
|
||||||
|
src: `import pg from './evasion-mid3.js';\nexport async function f(t: string) { const c = pg('u'); await c.unsafe('TRUNCATE ' + t); }`,
|
||||||
|
extras: [
|
||||||
|
{
|
||||||
|
rel: 'packages/db/src/evasion-mid3.ts',
|
||||||
|
src: `export { default } from 'postgres';`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E16 concatenated dynamic-import specifier',
|
||||||
|
src: `export async function f() { const m = await import('@mosaicstack/' + 'db'); return m; }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E17 computed-member write call',
|
||||||
|
src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db['insert'](companies).values({}); }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E18 spaced member access write',
|
||||||
|
src: `import { companies } from '@mosaicstack/db';\nimport { db } from './x.js';\nexport async function f() { await db . insert (companies).values({}); }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E19 computed-member execution call',
|
||||||
|
src: `export class R { constructor(private client: { query(s: string): Promise<unknown> }) {}\n async f(t: string) { await this.client['query']('TRUNCATE ' + t); } }`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E20 schema-qualified table in SQL span',
|
||||||
|
src: `export const q = 'DELETE FROM public.hierarchy_grants WHERE role = $1';`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E21 comment interposed in SQL span',
|
||||||
|
src: `export const q = 'DELETE FROM /* audit */ hierarchy_grants WHERE role = $1';`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E22 COPY statement in SQL span',
|
||||||
|
src: `export const q = 'COPY hierarchy_grants FROM STDIN';`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E23 escaped-backtick-quoted table in SQL span',
|
||||||
|
src: 'export const q = `DELETE FROM \\`hierarchy_grants\\` WHERE role = 1`;',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [
|
const CLEAN_CONTROLS: Array<{ name: string; src: string }> = [
|
||||||
{
|
{
|
||||||
@@ -773,9 +1056,20 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
for (const e of EVASIONS) {
|
for (const e of EVASIONS) {
|
||||||
const rel = 'packages/db/src/evasion-sample.ts';
|
const rel = 'packages/db/src/evasion-sample.ts';
|
||||||
const synthetic: FileFacts = { rel, ...lexSource(e.src) };
|
const synthetic: FileFacts = { rel, ...lexSource(e.src) };
|
||||||
const synthSet = new Set([...fileSet, rel, barrel.rel]);
|
const extraFacts: FileFacts[] = (e.extras ?? []).map((x) => ({
|
||||||
const synthConduits = computeSchemaConduits([...files, barrel, synthetic], synthSet);
|
rel: x.rel,
|
||||||
const v = analyzeFile(synthetic, synthConduits, synthSet);
|
...lexSource(x.src),
|
||||||
|
}));
|
||||||
|
const synthFiles = [...files, barrel, ...extraFacts, synthetic];
|
||||||
|
const synthSet = new Set([...fileSet, rel, barrel.rel, ...extraFacts.map((x) => x.rel)]);
|
||||||
|
const synthCap = computeCapabilityConduits(synthFiles, synthSet);
|
||||||
|
const synthCtx: AnalysisCtx = {
|
||||||
|
fileSet: synthSet,
|
||||||
|
conduits: computeSchemaConduits(synthFiles, synthSet),
|
||||||
|
driverConduits: synthCap.driver,
|
||||||
|
factoryConduits: synthCap.factory,
|
||||||
|
};
|
||||||
|
const v = analyzeFile(synthetic, synthCtx);
|
||||||
expect(v.length, `evasion not caught: ${e.name}`).toBeGreaterThan(0);
|
expect(v.length, `evasion not caught: ${e.name}`).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -784,9 +1078,16 @@ describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
|||||||
for (const c of CLEAN_CONTROLS) {
|
for (const c of CLEAN_CONTROLS) {
|
||||||
const rel = 'packages/db/src/clean-sample.ts';
|
const rel = 'packages/db/src/clean-sample.ts';
|
||||||
const synthetic: FileFacts = { rel, ...lexSource(c.src) };
|
const synthetic: FileFacts = { rel, ...lexSource(c.src) };
|
||||||
|
const synthFiles = [...files, synthetic];
|
||||||
const synthSet = new Set([...fileSet, rel]);
|
const synthSet = new Set([...fileSet, rel]);
|
||||||
const synthConduits = computeSchemaConduits([...files, synthetic], synthSet);
|
const synthCap = computeCapabilityConduits(synthFiles, synthSet);
|
||||||
const v = analyzeFile(synthetic, synthConduits, synthSet);
|
const synthCtx: AnalysisCtx = {
|
||||||
|
fileSet: synthSet,
|
||||||
|
conduits: computeSchemaConduits(synthFiles, synthSet),
|
||||||
|
driverConduits: synthCap.driver,
|
||||||
|
factoryConduits: synthCap.factory,
|
||||||
|
};
|
||||||
|
const v = analyzeFile(synthetic, synthCtx);
|
||||||
expect(
|
expect(
|
||||||
v,
|
v,
|
||||||
`false positive on ${c.name}: ${v.map((x) => `${x.prong}:${x.detail}`).join('; ')}`,
|
`false positive on ${c.name}: ${v.map((x) => `${x.prong}:${x.detail}`).join('; ')}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user