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.
@@ -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' }. One shallow pass; deterministic; bounded by the function body.
1409
- function collectReqDerived(fnNode) {
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 === 'VariableDeclarator' &&
1419
- n.id?.type === 'Identifier' &&
1420
- n.init?.type === 'MemberExpression'
1421
- ) {
1422
- const name = reqParamName(n.init, null);
1423
- if (name) map.set(n.id.name, name);
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 (a.type === 'NumericLiteral' && (a.value === 401 || a.value === 403))
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
- if (
2109
- p.value?.type === 'NumericLiteral' &&
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]?.type === 'NumericLiteral' &&
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]?.type === 'NumericLiteral' &&
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 GUARD_NAME.test(name ?? '') || Boolean(scan?.guardSignals.deniesWithStatus);
2826
+ return (
2827
+ GUARD_NAME.test(name ?? '') ||
2828
+ Boolean(scan?.guardSignals?.deniesWithStatus) ||
2829
+ Boolean(scan?.assertedGuard)
2830
+ );
2464
2831
  }