sparda-mcp 0.67.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/package.json +3 -2
- package/src/commands/claude-hook.js +105 -0
- package/src/commands/gate.js +28 -2
- package/src/commands/prove.js +12 -1
- package/src/commands/remove.js +3 -0
- package/src/detect.js +34 -0
- package/src/index.js +24 -2
- package/src/server/stdio.js +1 -1
- package/src/ubg/apocalypse.js +89 -10
- package/src/ubg/compile.js +2 -0
- package/src/ubg/express.js +39 -1
- package/src/ubg/extract.js +393 -26
- package/src/ubg/nestjs.js +262 -11
- package/src/ubg/resolve.js +58 -5
- package/src/ubg/strapi.js +510 -0
- package/src/ubg/translate.js +3 -0
package/src/ubg/extract.js
CHANGED
|
@@ -363,6 +363,243 @@ function collectEffectClients(body) {
|
|
|
363
363
|
return labeled;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
// Persistence-client packages: a binding imported from one, or built (`new`/call) from such an
|
|
367
|
+
// import, is a PROVEN database handle — by provenance, not by its name (ADR-068). Used to close
|
|
368
|
+
// the opaque-write hole: an UNKNOWN method on a proven handle (`db.nukeEverything(...)`) must be
|
|
369
|
+
// treated as a write, not ignored, or an unguarded custom write passes invisible.
|
|
370
|
+
const DB_PACKAGES = new Set([
|
|
371
|
+
'knex',
|
|
372
|
+
'pg',
|
|
373
|
+
'pg-pool',
|
|
374
|
+
'mysql',
|
|
375
|
+
'mysql2',
|
|
376
|
+
'mysql2/promise',
|
|
377
|
+
'mongoose',
|
|
378
|
+
'mongodb',
|
|
379
|
+
'sequelize',
|
|
380
|
+
'typeorm',
|
|
381
|
+
'drizzle-orm',
|
|
382
|
+
'@prisma/client',
|
|
383
|
+
'better-sqlite3',
|
|
384
|
+
'@libsql/client',
|
|
385
|
+
'postgres',
|
|
386
|
+
'sqlite3',
|
|
387
|
+
'@mikro-orm/core',
|
|
388
|
+
'objection',
|
|
389
|
+
'kysely',
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
// Methods on a DB handle that are clearly READS — never flagged as an opaque write.
|
|
393
|
+
const DB_KNOWN_READ = new Set([
|
|
394
|
+
'select',
|
|
395
|
+
'find',
|
|
396
|
+
'findone',
|
|
397
|
+
'findmany',
|
|
398
|
+
'findunique',
|
|
399
|
+
'findfirst',
|
|
400
|
+
'findall',
|
|
401
|
+
'first',
|
|
402
|
+
'get',
|
|
403
|
+
'getone',
|
|
404
|
+
'getmany',
|
|
405
|
+
'count',
|
|
406
|
+
'exists',
|
|
407
|
+
'all',
|
|
408
|
+
'aggregate',
|
|
409
|
+
'scan',
|
|
410
|
+
'list',
|
|
411
|
+
'fetch',
|
|
412
|
+
'read',
|
|
413
|
+
'describe',
|
|
414
|
+
'explain',
|
|
415
|
+
'has',
|
|
416
|
+
'pluck',
|
|
417
|
+
'stream',
|
|
418
|
+
'cursor',
|
|
419
|
+
'iterate',
|
|
420
|
+
'tolist',
|
|
421
|
+
'toarray',
|
|
422
|
+
]);
|
|
423
|
+
// Plumbing / chaining / promise / introspection / wiring on a DB handle — never an effect. Also
|
|
424
|
+
// parks the ambiguous raw-SQL methods (`raw`/`query`/`execute`) OUT of the opaque-write fallback:
|
|
425
|
+
// their literal-vs-dynamic read/write split is a separate follow-on (ADR-068), and firing them
|
|
426
|
+
// blindly would flood reads. So V1 catches the custom-named write (`db.archiveAll()`), not raw SQL.
|
|
427
|
+
const DB_NON_EFFECT = new Set([
|
|
428
|
+
'then',
|
|
429
|
+
'catch',
|
|
430
|
+
'finally',
|
|
431
|
+
'tostring',
|
|
432
|
+
'valueof',
|
|
433
|
+
'tosql',
|
|
434
|
+
'toquery',
|
|
435
|
+
'clone',
|
|
436
|
+
'as',
|
|
437
|
+
'ref',
|
|
438
|
+
'unref',
|
|
439
|
+
'on',
|
|
440
|
+
'off',
|
|
441
|
+
'once',
|
|
442
|
+
'emit',
|
|
443
|
+
'addlistener',
|
|
444
|
+
'removelistener',
|
|
445
|
+
'destroy',
|
|
446
|
+
'end',
|
|
447
|
+
'connect',
|
|
448
|
+
'disconnect',
|
|
449
|
+
'close',
|
|
450
|
+
'release',
|
|
451
|
+
'begin',
|
|
452
|
+
'commit',
|
|
453
|
+
'rollback',
|
|
454
|
+
'savepoint',
|
|
455
|
+
'transaction',
|
|
456
|
+
'pipe',
|
|
457
|
+
'listen',
|
|
458
|
+
'use',
|
|
459
|
+
'ping',
|
|
460
|
+
'authenticate',
|
|
461
|
+
'sync',
|
|
462
|
+
'define',
|
|
463
|
+
'model',
|
|
464
|
+
'prepare',
|
|
465
|
+
'unprepare',
|
|
466
|
+
'bind',
|
|
467
|
+
'escape',
|
|
468
|
+
'format',
|
|
469
|
+
'migrate',
|
|
470
|
+
'seed',
|
|
471
|
+
'schema',
|
|
472
|
+
'fn',
|
|
473
|
+
'client',
|
|
474
|
+
'pool',
|
|
475
|
+
'raw',
|
|
476
|
+
'query',
|
|
477
|
+
'execute',
|
|
478
|
+
'exec',
|
|
479
|
+
'command',
|
|
480
|
+
'sql',
|
|
481
|
+
'template',
|
|
482
|
+
'with',
|
|
483
|
+
'withschema',
|
|
484
|
+
'table',
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
// Auth packages whose guard middleware PROVABLY DENIES (401/403/throw) on missing/invalid auth.
|
|
488
|
+
// A catalog of VERIFIED published facts (versioned, auditable) — NOT a name guess (ADR-069): the
|
|
489
|
+
// claim is "`passport.authenticate()` denies", checkable by anyone reading passport once. This is
|
|
490
|
+
// what lets an opaque npm auth guard count as VERIFIED instead of merely asserted, so a real app
|
|
491
|
+
// on passport/express-jwt can legitimately reach PROVEN. Innate immunity: know the known pathogens.
|
|
492
|
+
const AUTH_GUARD_PACKAGES = new Set([
|
|
493
|
+
'passport',
|
|
494
|
+
'express-jwt',
|
|
495
|
+
'express-oauth2-jwt-bearer',
|
|
496
|
+
'express-openid-connect',
|
|
497
|
+
'connect-ensure-login',
|
|
498
|
+
'express-basic-auth',
|
|
499
|
+
'@clerk/clerk-sdk-node',
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
const objectOptionIsFalse = (objNode, key) =>
|
|
503
|
+
objNode?.type === 'ObjectExpression' &&
|
|
504
|
+
objNode.properties.some(
|
|
505
|
+
(p) =>
|
|
506
|
+
p.type === 'ObjectProperty' &&
|
|
507
|
+
p.key?.type === 'Identifier' &&
|
|
508
|
+
p.key.name === key &&
|
|
509
|
+
p.value.type === 'BooleanLiteral' &&
|
|
510
|
+
p.value.value === false,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Is this call a known auth-lib DENY-form middleware, per the provenance map `pkgOf`
|
|
514
|
+
// (localName → package)? Deny-FORM precision keeps the catalog honest — a form that does NOT
|
|
515
|
+
// auto-deny is abstained on (stays asserted, the safe direction), never falsely verified:
|
|
516
|
+
// • passport.authenticate(strategy, opts?) denies UNLESS a custom callback fn is passed
|
|
517
|
+
// (then the deny logic is the user's, not passport's default 401).
|
|
518
|
+
// • express-jwt expressjwt(opts) throws UNLESS `credentialsRequired: false`.
|
|
519
|
+
export function authDenyCall(node, pkgOf) {
|
|
520
|
+
if (node?.type !== 'CallExpression' || !pkgOf) return false;
|
|
521
|
+
const callee = node.callee;
|
|
522
|
+
if (
|
|
523
|
+
callee.type === 'MemberExpression' &&
|
|
524
|
+
callee.object.type === 'Identifier' &&
|
|
525
|
+
callee.property.type === 'Identifier' &&
|
|
526
|
+
callee.property.name === 'authenticate' &&
|
|
527
|
+
pkgOf.get(callee.object.name) === 'passport'
|
|
528
|
+
) {
|
|
529
|
+
const hasCallback = node.arguments.some(
|
|
530
|
+
(a) => a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression',
|
|
531
|
+
);
|
|
532
|
+
return !hasCallback;
|
|
533
|
+
}
|
|
534
|
+
if (callee.type === 'Identifier') {
|
|
535
|
+
const pkg = pkgOf.get(callee.name);
|
|
536
|
+
if (!pkg || !AUTH_GUARD_PACKAGES.has(pkg)) return false;
|
|
537
|
+
if (pkg === 'express-jwt')
|
|
538
|
+
return !objectOptionIsFalse(node.arguments[0], 'credentialsRequired');
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Provenance for auth guards: the import map (localName → package) plus the local const names
|
|
545
|
+
// bound to a deny-form auth call (`const requireAuth = passport.authenticate('jwt')`). Same shape
|
|
546
|
+
// as collectDbHandles/collectEffectClients — provenance, never a name test.
|
|
547
|
+
export function collectAuthGuards(body) {
|
|
548
|
+
const pkgOf = new Map();
|
|
549
|
+
for (const node of body)
|
|
550
|
+
if (node.type === 'ImportDeclaration')
|
|
551
|
+
for (const s of node.specifiers) pkgOf.set(s.local.name, node.source.value);
|
|
552
|
+
const denyBindings = new Set();
|
|
553
|
+
for (const node of body) {
|
|
554
|
+
if (node.type !== 'VariableDeclaration') continue;
|
|
555
|
+
for (const d of node.declarations)
|
|
556
|
+
if (d.id.type === 'Identifier' && authDenyCall(d.init, pkgOf))
|
|
557
|
+
denyBindings.add(d.id.name);
|
|
558
|
+
}
|
|
559
|
+
return { pkgOf, denyBindings };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Vars/params bound to a database handle, by IMPORT PROVENANCE (same shape as
|
|
563
|
+
// collectEffectClients): imported from a DB package, or `new X()`/`X(...)`/alias of a labeled
|
|
564
|
+
// binding. Deliberately provenance-only — never a name test — so `const db = notARealDb` is NOT
|
|
565
|
+
// a handle, and a handle named `store` still IS one.
|
|
566
|
+
function collectDbHandles(body) {
|
|
567
|
+
const labeled = new Set();
|
|
568
|
+
const isLabeled = (n) => n?.type === 'Identifier' && labeled.has(n.name);
|
|
569
|
+
for (const node of body) {
|
|
570
|
+
if (node.type === 'ImportDeclaration') {
|
|
571
|
+
if (DB_PACKAGES.has(node.source.value))
|
|
572
|
+
for (const s of node.specifiers) labeled.add(s.local.name);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (node.type !== 'VariableDeclaration') continue;
|
|
576
|
+
for (const d of node.declarations) {
|
|
577
|
+
const init = d.init;
|
|
578
|
+
if (!init || d.id.type !== 'Identifier') continue;
|
|
579
|
+
const requirePkg =
|
|
580
|
+
init.type === 'CallExpression' &&
|
|
581
|
+
init.callee.type === 'Identifier' &&
|
|
582
|
+
init.callee.name === 'require' &&
|
|
583
|
+
init.arguments[0]?.type === 'StringLiteral'
|
|
584
|
+
? init.arguments[0].value
|
|
585
|
+
: null;
|
|
586
|
+
if (
|
|
587
|
+
(requirePkg && DB_PACKAGES.has(requirePkg)) ||
|
|
588
|
+
(init.type === 'CallExpression' &&
|
|
589
|
+
init.callee.type === 'CallExpression' &&
|
|
590
|
+
init.callee.callee?.type === 'Identifier' &&
|
|
591
|
+
init.callee.callee.name === 'require' &&
|
|
592
|
+
DB_PACKAGES.has(init.callee.arguments?.[0]?.value)) ||
|
|
593
|
+
(init.type === 'NewExpression' && isLabeled(init.callee)) ||
|
|
594
|
+
(init.type === 'CallExpression' && isLabeled(init.callee)) ||
|
|
595
|
+
isLabeled(init)
|
|
596
|
+
)
|
|
597
|
+
labeled.add(d.id.name);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return labeled;
|
|
601
|
+
}
|
|
602
|
+
|
|
366
603
|
// TypeORM repository provenance — like the effect-client label, but for ORM repositories, so a
|
|
367
604
|
// `repo.save()` / `this.userRepo.save()` resolves to a db_write with its entity table even though
|
|
368
605
|
// the table is nowhere in the call. Two sources: (1) a class constructor param that is an injected
|
|
@@ -503,6 +740,8 @@ export function parseModule(absFile) {
|
|
|
503
740
|
|
|
504
741
|
for (const node of facts.ast.program.body) collectTopLevel(node, facts, absFile, false);
|
|
505
742
|
facts.effectClients = collectEffectClients(facts.ast.program.body);
|
|
743
|
+
facts.dbHandles = collectDbHandles(facts.ast.program.body);
|
|
744
|
+
facts.authGuards = collectAuthGuards(facts.ast.program.body);
|
|
506
745
|
return facts;
|
|
507
746
|
}
|
|
508
747
|
|
|
@@ -1194,7 +1433,7 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
1194
1433
|
isolation: 'default',
|
|
1195
1434
|
tryId: null,
|
|
1196
1435
|
catchOf: null,
|
|
1197
|
-
reqDerived: collectReqDerived(fnNode),
|
|
1436
|
+
reqDerived: collectReqDerived(fnNode, env.reqDerivedSeed),
|
|
1198
1437
|
// symbolic `this.<field>` bindings, supplied by the caller when this body is a
|
|
1199
1438
|
// CLASS METHOD reached through `new X(req.params.collection)` — lets a
|
|
1200
1439
|
// `this.knex(this.collection)` inside a service resolve to `:collection`.
|
|
@@ -1202,6 +1441,7 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
1202
1441
|
// effect-labeled bindings of the owning module (import-provenance), so a call on an
|
|
1203
1442
|
// SDK client is recognized by origin. Absent → the path/command catalog still applies.
|
|
1204
1443
|
effectClients: env.effectClients ?? null,
|
|
1444
|
+
dbHandles: env.dbHandles ?? null,
|
|
1205
1445
|
// TypeORM repository provenance: class-injected repo fields (env.repoFields, from the owning
|
|
1206
1446
|
// class) merged with local `getRepository(Entity)` vars found in THIS body → receiver → table.
|
|
1207
1447
|
repoTables: mergeRepoTables(env.repoFields, collectRepoVars(fnNode)),
|
|
@@ -1353,6 +1593,38 @@ function callAssertsOwnership(node) {
|
|
|
1353
1593
|
return false;
|
|
1354
1594
|
}
|
|
1355
1595
|
|
|
1596
|
+
// Does this `if` assert caller-ownership by FETCH-THEN-COMPARE — the most common hand-rolled
|
|
1597
|
+
// authorization check: `if (row.ownerId !== req.user.id) return res.sendStatus(403)`. The scope
|
|
1598
|
+
// lives in a value comparison + deny, NOT in a `where` clause, so `whereOwnerScoped` abstains and
|
|
1599
|
+
// O7 false-positives. This is the deterministic half of the generate-and-check loop (ADR-074): a
|
|
1600
|
+
// proposed ownership witness ("scope is proven by comparing <field> to the principal, gating a
|
|
1601
|
+
// deny") is VERIFIED here against the AST. SOUND both ways, adversarially tested:
|
|
1602
|
+
// • the compared-against value must be the caller's VERIFIED identity (`valueIsIdentity` rejects
|
|
1603
|
+
// `req.body.ownerId` — an attacker-controlled spoof — even though it is named like an owner);
|
|
1604
|
+
// • the branch must actually DENY (a throw or a 401/403 response) — a compare that only logs
|
|
1605
|
+
// proves nothing and is rejected.
|
|
1606
|
+
// Advisory-only (feeds O7, never a hard rule), so it can never create a false PROVEN; at worst a
|
|
1607
|
+
// mis-clear drops an advisory that was already non-gating.
|
|
1608
|
+
function ifAssertsOwnership(node) {
|
|
1609
|
+
if (node.type !== 'IfStatement') return false;
|
|
1610
|
+
let denies = false;
|
|
1611
|
+
walkNodes(node.consequent, (c) => {
|
|
1612
|
+
if (c.type === 'ThrowStatement') denies = true;
|
|
1613
|
+
if (c.type === 'CallExpression' && deniedStatusOf(c)) denies = true;
|
|
1614
|
+
});
|
|
1615
|
+
if (!denies) return false;
|
|
1616
|
+
let owns = false;
|
|
1617
|
+
walkNodes(node.test, (b) => {
|
|
1618
|
+
if (b.type !== 'BinaryExpression' || !/^[=!]==?$/.test(b.operator)) return;
|
|
1619
|
+
// one side is the caller's verified identity, the OTHER is a fetched field (a member access
|
|
1620
|
+
// like `row.ownerId`). Order-agnostic. `valueIsIdentity` is the honesty gate — request input
|
|
1621
|
+
// (`req.body.*`, `req.params.*`) is never identity, so a spoofable compare is rejected.
|
|
1622
|
+
if (valueIsIdentity(b.left) && b.right?.type === 'MemberExpression') owns = true;
|
|
1623
|
+
if (valueIsIdentity(b.right) && b.left?.type === 'MemberExpression') owns = true;
|
|
1624
|
+
});
|
|
1625
|
+
return owns;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1356
1628
|
// does this `where` target a specific object by a bare `id` key (the BOLA shape)?
|
|
1357
1629
|
function whereHasIdKey(whereNode) {
|
|
1358
1630
|
if (whereNode?.type !== 'ObjectExpression') return false;
|
|
@@ -1384,7 +1656,7 @@ function optionValueOf(arg, name) {
|
|
|
1384
1656
|
return null;
|
|
1385
1657
|
}
|
|
1386
1658
|
|
|
1387
|
-
function reqParamName(node, reqDerived, thisSymbols) {
|
|
1659
|
+
export function reqParamName(node, reqDerived, thisSymbols) {
|
|
1388
1660
|
node = unwrapTS(node);
|
|
1389
1661
|
if (node?.type === 'Identifier') return reqDerived?.get(node.name) ?? null;
|
|
1390
1662
|
if (node?.type !== 'MemberExpression') return null;
|
|
@@ -1405,22 +1677,72 @@ function reqParamName(node, reqDerived, thisSymbols) {
|
|
|
1405
1677
|
}
|
|
1406
1678
|
|
|
1407
1679
|
// map local vars assigned straight from a request member: `const c = req.params.collection`
|
|
1408
|
-
// → { c: ':collection' }.
|
|
1409
|
-
|
|
1680
|
+
// → { c: ':collection' }. Also follows the two commonest value-flow shapes the direct-member
|
|
1681
|
+
// case misses — OBJECT DESTRUCTURING (`const { title, body } = req.body` → title/body carry
|
|
1682
|
+
// the request taint, the dominant handler idiom) and IDENTIFIER ALIAS (`const b = req.body;
|
|
1683
|
+
// const c = b`). One source-order pass — earlier bindings are in the map when a later one
|
|
1684
|
+
// references them; deterministic; bounded by the function body. UNDER-approximates on
|
|
1685
|
+
// purpose (a missed alias is a false-negative on an advisory taint tag, never a hidden
|
|
1686
|
+
// write) — see valueTainted.
|
|
1687
|
+
//
|
|
1688
|
+
// `seed` (interprocedural, ADR-066): request-derived PARAMETER bindings supplied by the
|
|
1689
|
+
// caller — when a handler calls `saveItem(req.body)`, the resolver seeds `saveItem`'s param
|
|
1690
|
+
// as request-derived so a write inside it taints. Seeded first so a local re-binding in the
|
|
1691
|
+
// body can still override. MUST-analysis: the caller only seeds a param it PROVED tainted.
|
|
1692
|
+
export function collectReqDerived(fnNode, seed = null) {
|
|
1410
1693
|
const map = new Map();
|
|
1694
|
+
if (seed) for (const [k, v] of seed) map.set(k, v);
|
|
1695
|
+
// non-null request-taint marker if `init` is request-derived: a req member (`req.body`),
|
|
1696
|
+
// or an identifier already tracked as request-derived (`b` after `const b = req.body`).
|
|
1697
|
+
const reqSourceOf = (init) => {
|
|
1698
|
+
const node = unwrapTS(init);
|
|
1699
|
+
if (!node) return null;
|
|
1700
|
+
if (node.type === 'Identifier') return map.get(node.name) ?? null;
|
|
1701
|
+
if (node.type === 'MemberExpression') return reqParamName(node, map);
|
|
1702
|
+
return null;
|
|
1703
|
+
};
|
|
1411
1704
|
const walk = (n) => {
|
|
1412
1705
|
if (!n || typeof n !== 'object') return;
|
|
1413
1706
|
if (Array.isArray(n)) {
|
|
1414
1707
|
for (const x of n) walk(x);
|
|
1415
1708
|
return;
|
|
1416
1709
|
}
|
|
1417
|
-
if (
|
|
1418
|
-
n.type === '
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1710
|
+
if (n.type === 'VariableDeclarator') {
|
|
1711
|
+
if (n.id?.type === 'Identifier' && n.init?.type === 'MemberExpression') {
|
|
1712
|
+
const name = reqParamName(n.init, map);
|
|
1713
|
+
if (name) map.set(n.id.name, name);
|
|
1714
|
+
} else if (n.id?.type === 'Identifier' && n.init?.type === 'Identifier') {
|
|
1715
|
+
// alias: `const c = b` where b is already request-derived
|
|
1716
|
+
const src = map.get(n.init.name);
|
|
1717
|
+
if (src) map.set(n.id.name, src);
|
|
1718
|
+
} else if (n.id?.type === 'ObjectPattern' && reqSourceOf(n.init)) {
|
|
1719
|
+
// destructure: `const { title, body: b, ...rest } = req.body` — each binding is
|
|
1720
|
+
// request-derived; a named binding carries its KEY as the symbolic leaf (`:title`).
|
|
1721
|
+
const src = reqSourceOf(n.init);
|
|
1722
|
+
for (const prop of n.id.properties) {
|
|
1723
|
+
if (prop.type === 'RestElement' && prop.argument?.type === 'Identifier') {
|
|
1724
|
+
map.set(prop.argument.name, src); // rest carries the whole source taint
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (prop.type !== 'ObjectProperty') continue;
|
|
1728
|
+
const key =
|
|
1729
|
+
prop.key?.type === 'Identifier'
|
|
1730
|
+
? prop.key.name
|
|
1731
|
+
: prop.key?.type === 'StringLiteral'
|
|
1732
|
+
? prop.key.value
|
|
1733
|
+
: null;
|
|
1734
|
+
// local binding: `{ title }` (value=title), `{ title: t }` (value=t),
|
|
1735
|
+
// `{ title = 'x' }` (value=AssignmentPattern → left)
|
|
1736
|
+
const val = prop.value;
|
|
1737
|
+
const local =
|
|
1738
|
+
val?.type === 'Identifier'
|
|
1739
|
+
? val.name
|
|
1740
|
+
: val?.type === 'AssignmentPattern' && val.left?.type === 'Identifier'
|
|
1741
|
+
? val.left.name
|
|
1742
|
+
: null;
|
|
1743
|
+
if (key && local) map.set(local, `:${key}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1424
1746
|
}
|
|
1425
1747
|
for (const k of Object.keys(n)) {
|
|
1426
1748
|
if (
|
|
@@ -1556,6 +1878,11 @@ function visit(node, out, ctx) {
|
|
|
1556
1878
|
// can only ever downgrade a critical to an advisory, never verify a guard.
|
|
1557
1879
|
if (node.type === 'ThrowStatement') out.credentialSignals.denies4xxOrThrows = true;
|
|
1558
1880
|
|
|
1881
|
+
// G1b (O7/BOLA only): fetch-then-compare ownership — `if (row.ownerId !== req.user.id) deny`.
|
|
1882
|
+
// The deterministic witness verifier (ADR-074), clears the same advisory as G1 (callAssertsOwnership),
|
|
1883
|
+
// sound both ways. Runs on every IfStatement, so it must be in `visit`, not `inspectCall`.
|
|
1884
|
+
if (ifAssertsOwnership(node)) out.ownerAsserted = true;
|
|
1885
|
+
|
|
1559
1886
|
// try/catch — try-effects are compensable by catch-effects (SBIR §2.2)
|
|
1560
1887
|
if (node.type === 'TryStatement') {
|
|
1561
1888
|
const tryLine = node.loc?.start.line ?? 0;
|
|
@@ -1575,8 +1902,7 @@ function visit(node, out, ctx) {
|
|
|
1575
1902
|
out.guardSignals.deniesWithStatus = true;
|
|
1576
1903
|
else if (n === 'HttpException' || n === 'HttpError')
|
|
1577
1904
|
for (const a of node.arguments)
|
|
1578
|
-
if (
|
|
1579
|
-
out.guardSignals.deniesWithStatus = true;
|
|
1905
|
+
if (isDenyStatusArg(a)) out.guardSignals.deniesWithStatus = true;
|
|
1580
1906
|
// any error/response CONSTRUCTED with an unauthorized/forbidden code or a 401/403
|
|
1581
1907
|
// status — `new DubApiError({ code: "unauthorized" })`, `new Response(_, { status:
|
|
1582
1908
|
// 403 })`. A general REST/Next idiom, so a resolved auth wrapper reads as a denial.
|
|
@@ -2009,6 +2335,33 @@ function inspectCall(node, out, ctx) {
|
|
|
2009
2335
|
line,
|
|
2010
2336
|
});
|
|
2011
2337
|
}
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// ---- opaque write on a PROVEN persistence handle (ADR-068 — the effect-bias inversion).
|
|
2342
|
+
// Reached only AFTER every known ORM/SDK/HTTP/fs handler has declined. An UNKNOWN method called
|
|
2343
|
+
// DIRECTLY on a database handle (`db.archiveAll(...)`) can't be read — but the safe direction is
|
|
2344
|
+
// INVERTED here vs everywhere else: a missed write is a HIDDEN HOLE (an unguarded custom write
|
|
2345
|
+
// goes invisible), while an over-flagged read is only a surfaceable false positive. So treat it
|
|
2346
|
+
// as a write. Marked `opaque` with a null table, so it fires ONLY the guard obligation (O1) and
|
|
2347
|
+
// NEVER fabricates column/atomicity precision (O2/O3 skip a table-less write). Gated hard to
|
|
2348
|
+
// avoid a flood: the receiver must be a handle IDENTIFIER (builder continuations sit on a CALL
|
|
2349
|
+
// receiver, ORM verbs are handled above), and the method must be neither a known read nor
|
|
2350
|
+
// plumbing. Provenance-based (dbHandles), never a name test — so this can only RAISE a finding.
|
|
2351
|
+
if (
|
|
2352
|
+
ctx.dbHandles &&
|
|
2353
|
+
callee.object.type === 'Identifier' &&
|
|
2354
|
+
ctx.dbHandles.has(callee.object.name) &&
|
|
2355
|
+
!DB_KNOWN_READ.has(methodLower) &&
|
|
2356
|
+
!DB_NON_EFFECT.has(methodLower)
|
|
2357
|
+
) {
|
|
2358
|
+
pushEffect(out, ctx, {
|
|
2359
|
+
effectType: 'db_write',
|
|
2360
|
+
op: 'unknown',
|
|
2361
|
+
table: null,
|
|
2362
|
+
opaque: true,
|
|
2363
|
+
line,
|
|
2364
|
+
});
|
|
2012
2365
|
}
|
|
2013
2366
|
}
|
|
2014
2367
|
|
|
@@ -2105,11 +2458,8 @@ function isDenyOptions(arg) {
|
|
|
2105
2458
|
for (const p of arg.properties) {
|
|
2106
2459
|
if (p.type !== 'ObjectProperty' || p.key?.type !== 'Identifier') continue;
|
|
2107
2460
|
if (p.key.name === 'status' || p.key.name === 'statusCode') {
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
(p.value.value === 401 || p.value.value === 403)
|
|
2111
|
-
)
|
|
2112
|
-
return true;
|
|
2461
|
+
// { status: 403 } OR { status: HttpStatus.FORBIDDEN } (named constant, ADR-073)
|
|
2462
|
+
if (isDenyStatusArg(p.value)) return true;
|
|
2113
2463
|
} else if (p.key.name === 'code') {
|
|
2114
2464
|
if (
|
|
2115
2465
|
p.value?.type === 'StringLiteral' &&
|
|
@@ -2160,8 +2510,19 @@ function statusIn4xx(node) {
|
|
|
2160
2510
|
return false;
|
|
2161
2511
|
}
|
|
2162
2512
|
|
|
2513
|
+
// A deny STATUS argument: the numeric 401/403, OR the named HTTP constant `X.FORBIDDEN` /
|
|
2514
|
+
// `X.UNAUTHORIZED` (http-status-codes' `StatusCodes.FORBIDDEN`, Nest's `HttpStatus.FORBIDDEN`, …).
|
|
2515
|
+
// The status NAME is a conserved denial signal regardless of spelling — recognizing it is honest
|
|
2516
|
+
// (FORBIDDEN is 403 by definition, not a fuzzy name), and closes a huge real gap: a guard that
|
|
2517
|
+
// throws `new HttpException(_, StatusCodes.FORBIDDEN)` (ghostfolio does this on 91 permission guards)
|
|
2518
|
+
// used to read asserted only because SPARDA saw the constant, not a literal 403 (ADR-073).
|
|
2519
|
+
const isDenyStatusArg = (a) =>
|
|
2520
|
+
(a?.type === 'NumericLiteral' && (a.value === 401 || a.value === 403)) ||
|
|
2521
|
+
(a?.type === 'MemberExpression' &&
|
|
2522
|
+
a.property?.type === 'Identifier' &&
|
|
2523
|
+
(a.property.name === 'FORBIDDEN' || a.property.name === 'UNAUTHORIZED'));
|
|
2524
|
+
|
|
2163
2525
|
function deniedStatusOf(node) {
|
|
2164
|
-
const isDeny = (v) => v === 401 || v === 403;
|
|
2165
2526
|
// NextResponse.json(_, { status: 401 }) / res.json(_, { status: 403 }) — the deny is
|
|
2166
2527
|
// in the init object, not a .status() chain. A first-class Next/Web-Response idiom.
|
|
2167
2528
|
if (
|
|
@@ -2171,13 +2532,12 @@ function deniedStatusOf(node) {
|
|
|
2171
2532
|
node.arguments.some(isDenyOptions)
|
|
2172
2533
|
)
|
|
2173
2534
|
return true;
|
|
2174
|
-
// res.sendStatus(401) / res.status(401)... — a direct deny status
|
|
2535
|
+
// res.sendStatus(401) / res.status(401)... — a direct deny status (numeric or named constant)
|
|
2175
2536
|
if (
|
|
2176
2537
|
node.type === 'CallExpression' &&
|
|
2177
2538
|
node.callee?.type === 'MemberExpression' &&
|
|
2178
2539
|
node.callee.property?.name === 'sendStatus' &&
|
|
2179
|
-
node.arguments[0]
|
|
2180
|
-
isDeny(node.arguments[0].value)
|
|
2540
|
+
isDenyStatusArg(node.arguments[0])
|
|
2181
2541
|
)
|
|
2182
2542
|
return true;
|
|
2183
2543
|
let cur = node;
|
|
@@ -2186,8 +2546,7 @@ function deniedStatusOf(node) {
|
|
|
2186
2546
|
cur.type === 'CallExpression' &&
|
|
2187
2547
|
cur.callee?.type === 'MemberExpression' &&
|
|
2188
2548
|
cur.callee.property?.name === 'status' &&
|
|
2189
|
-
cur.arguments[0]
|
|
2190
|
-
isDeny(cur.arguments[0].value)
|
|
2549
|
+
isDenyStatusArg(cur.arguments[0])
|
|
2191
2550
|
)
|
|
2192
2551
|
return true;
|
|
2193
2552
|
cur =
|
|
@@ -2458,7 +2817,15 @@ function literalPairsOf(clause) {
|
|
|
2458
2817
|
return pairs;
|
|
2459
2818
|
}
|
|
2460
2819
|
|
|
2461
|
-
// middleware classifier: name smell OR observed 401/403 denial → guard
|
|
2820
|
+
// middleware classifier: name smell OR observed 401/403 denial → guard. `assertedGuard`
|
|
2821
|
+
// is an extractor's explicit "this chain step gates by name, unverified" flag (ADR-063,
|
|
2822
|
+
// the principal-injection param decorators `@GetUser`/`@Principal` whose bare names carry
|
|
2823
|
+
// no GUARD_NAME token) — honored here so a marked step reads as an ASSERTED guard without
|
|
2824
|
+
// widening GUARD_NAME globally (which would misclassify a plain `getUser` middleware).
|
|
2462
2825
|
export function isGuardLike(name, scan) {
|
|
2463
|
-
return
|
|
2826
|
+
return (
|
|
2827
|
+
GUARD_NAME.test(name ?? '') ||
|
|
2828
|
+
Boolean(scan?.guardSignals?.deniesWithStatus) ||
|
|
2829
|
+
Boolean(scan?.assertedGuard)
|
|
2830
|
+
);
|
|
2464
2831
|
}
|