@vantageos/backend-doctor 0.2.0 → 0.3.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 +48 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +62 -17
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.js +291 -0
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.js +455 -26
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +27 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/package.json +1 -1
|
@@ -60,7 +60,14 @@ export interface LocalTaint {
|
|
|
60
60
|
* RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
|
|
61
61
|
* own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
|
|
62
62
|
* exhaustion is `cnj`, never a guess. */
|
|
63
|
-
export declare function analyzeLocalTaint(h: Handler, local: string, isResolver?:
|
|
63
|
+
export declare function analyzeLocalTaint(h: Handler, local: string, isResolver?: ResolverTest, principalOf?: PrincipalTest): LocalTaint;
|
|
64
|
+
/** Is `expr` the principal @vantageos/cloud-identity resolved and the door
|
|
65
|
+
* refused unless ok (package-principal.ts)? Returns the reason, else undefined. */
|
|
66
|
+
export type PrincipalTest = (expr: string) => string | undefined;
|
|
67
|
+
/** Is `name` an identity resolver? `call` is the whole `await name(…)` text,
|
|
68
|
+
* joined across the lines it spans, so a resolver anchored on one of its call
|
|
69
|
+
* arguments can read that argument. */
|
|
70
|
+
export type ResolverTest = (name: string, call: string) => boolean;
|
|
64
71
|
export declare const R6_GUARD_HELPERS: Readonly<Record<string, GuardHelperKind>>;
|
|
65
72
|
interface HelperDef {
|
|
66
73
|
rel: string;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { itemCount, siteCount, withCount } from "../../violation-count.js";
|
|
2
|
+
import { parseFile } from "./_ast.js";
|
|
3
|
+
import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, SCOPE_REFINERS, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
|
|
4
|
+
import { IDENTITY_PACKAGE } from "./identity-by-id.js";
|
|
5
|
+
import { packagePrincipals, } from "./package-principal.js";
|
|
6
|
+
import { masterOnlyWrapperCredit, possessionGatedCredit } from "./r6-shapes.js";
|
|
2
7
|
/** The standard both are pinned against (short hash a9d7877b), §15/§18. */
|
|
3
8
|
export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
|
|
4
9
|
function emptyScan() {
|
|
@@ -63,12 +68,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
63
68
|
.map((x) => `${x.rel}:${x.n} (${x.note})`)
|
|
64
69
|
.join("; ");
|
|
65
70
|
if (nV > 0) {
|
|
66
|
-
return {
|
|
71
|
+
return withCount({
|
|
67
72
|
id,
|
|
68
73
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
69
74
|
state: "non-conforming",
|
|
70
75
|
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
|
|
71
|
-
};
|
|
76
|
+
}, siteCount(scan.violate));
|
|
72
77
|
}
|
|
73
78
|
if (nJ > 0) {
|
|
74
79
|
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
|
|
@@ -147,7 +152,6 @@ export function splitMutationHandlers(f) {
|
|
|
147
152
|
const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
|
|
148
153
|
const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
|
|
149
154
|
const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
|
|
150
|
-
const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
|
|
151
155
|
// ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
|
|
152
156
|
//
|
|
153
157
|
// Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
|
|
@@ -288,8 +292,21 @@ function destructuredRhs(raw, local) {
|
|
|
288
292
|
* RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
|
|
289
293
|
* own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
|
|
290
294
|
* exhaustion is `cnj`, never a guess. */
|
|
291
|
-
export function analyzeLocalTaint(h, local, isResolver) {
|
|
292
|
-
return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver);
|
|
295
|
+
export function analyzeLocalTaint(h, local, isResolver, principalOf) {
|
|
296
|
+
return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver, principalOf);
|
|
297
|
+
}
|
|
298
|
+
/** The RHS starting on `codeLines[i]`, extended over the following lines until
|
|
299
|
+
* an opening `await <name>(` is balanced (bounded; unbalanced -> the line alone).
|
|
300
|
+
* Used ONLY to recognise a resolver call that spans lines — never to widen the
|
|
301
|
+
* scope test of an ordinary RHS. */
|
|
302
|
+
function joinedAwaitedCall(rhs, codeLines, i) {
|
|
303
|
+
if (!/^\s*await\s+[A-Za-z_$][\w$]*\s*\(/.test(rhs))
|
|
304
|
+
return rhs;
|
|
305
|
+
let text = rhs;
|
|
306
|
+
const depthOf = (t) => (t.match(/\(/g) ?? []).length - (t.match(/\)/g) ?? []).length;
|
|
307
|
+
for (let j = i + 1; depthOf(text) > 0 && j < codeLines.length && j <= i + 40; j++)
|
|
308
|
+
text += `\n${codeLines[j].raw}`;
|
|
309
|
+
return depthOf(text) > 0 ? rhs : text;
|
|
293
310
|
}
|
|
294
311
|
/** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
|
|
295
312
|
* call but `;`): the whole value is that function's return. */
|
|
@@ -310,7 +327,11 @@ function wholeAwaitedCall(rhs) {
|
|
|
310
327
|
}
|
|
311
328
|
return null;
|
|
312
329
|
}
|
|
313
|
-
function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
330
|
+
function analyzeLocalTaintInner(h, local, depth, visited, isResolver, principalOf) {
|
|
331
|
+
// `local` IS an alias of a checked package principal (`const { principal }
|
|
332
|
+
// = who` after `if (!who.ok) throw`): the verified identity under a name.
|
|
333
|
+
if (principalOf?.(local))
|
|
334
|
+
return { fromArgs: 0, fromScope: 1, matched: 1, cnj: 0, fromResolver: 1 };
|
|
314
335
|
let fromArgs = 0;
|
|
315
336
|
let fromScope = 0;
|
|
316
337
|
let matched = 0;
|
|
@@ -319,7 +340,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
|
319
340
|
const esc = local.replace(/[$]/g, "\\$&");
|
|
320
341
|
const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
|
|
321
342
|
const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
|
|
322
|
-
const classify = (rhs) => {
|
|
343
|
+
const classify = (rhs, call = rhs) => {
|
|
323
344
|
// A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
|
|
324
345
|
// (even `orgSlug`) never counts as scope; the identifier's own provenance
|
|
325
346
|
// decides, or the scan refuses (defect 3, Eta pole 2).
|
|
@@ -334,7 +355,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
|
334
355
|
}
|
|
335
356
|
const next = new Set(visited);
|
|
336
357
|
next.add(local);
|
|
337
|
-
const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver);
|
|
358
|
+
const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver, principalOf);
|
|
338
359
|
if (sub.matched === 0)
|
|
339
360
|
cnj++; // the referenced local has no resolvable assignment.
|
|
340
361
|
else if (sub.fromScope > 0 && sub.fromArgs === 0)
|
|
@@ -348,8 +369,14 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
|
348
369
|
// The whole value is the return of a function the tree shows to be an
|
|
349
370
|
// identity RESOLVER (it returns the verified scope itself and refuses an
|
|
350
371
|
// absent identity first): scope-derived, whatever arguments it was handed.
|
|
351
|
-
const called = isResolver ? wholeAwaitedCall(
|
|
352
|
-
if (called && isResolver?.(called)) {
|
|
372
|
+
const called = isResolver ? wholeAwaitedCall(call) : null;
|
|
373
|
+
if (called && isResolver?.(called, call)) {
|
|
374
|
+
fromScope++;
|
|
375
|
+
fromResolver++;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// A member read of a checked package principal (`who.principal.orgId`).
|
|
379
|
+
if (principalOf?.(rhs)) {
|
|
353
380
|
fromScope++;
|
|
354
381
|
fromResolver++;
|
|
355
382
|
return;
|
|
@@ -367,10 +394,9 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
|
367
394
|
else
|
|
368
395
|
fromArgs++;
|
|
369
396
|
};
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const raw = l.raw;
|
|
397
|
+
const codeLines = h.lines.filter(code);
|
|
398
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
399
|
+
const raw = codeLines[i].raw;
|
|
374
400
|
// The handler's own parameter destructured — the binding IS the argument.
|
|
375
401
|
if (boundFromHandlerParam(raw, local)) {
|
|
376
402
|
matched++;
|
|
@@ -390,7 +416,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
|
390
416
|
continue;
|
|
391
417
|
matched++;
|
|
392
418
|
const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
|
|
393
|
-
classify(rhs);
|
|
419
|
+
classify(rhs, joinedAwaitedCall(rhs, codeLines, i));
|
|
394
420
|
}
|
|
395
421
|
return fromResolver > 0
|
|
396
422
|
? { fromArgs, fromScope, matched, cnj, fromResolver }
|
|
@@ -459,6 +485,50 @@ export const R6_GUARD_HELPERS = {
|
|
|
459
485
|
requireFleetMaster: "master-only",
|
|
460
486
|
requireServiceAccount: "master-only",
|
|
461
487
|
};
|
|
488
|
+
// ---- SCOPE REFINERS at the handler (a local assigned from one) ------------
|
|
489
|
+
//
|
|
490
|
+
// `const scope = await resolveVerifiedPerson(ctx, transportScope, args.verifiedPerson, …)`
|
|
491
|
+
// read as caller-arg-derived (fromScope=0) because an argument is handed to the
|
|
492
|
+
// call — the false R-6 accusation at vantage-peers 45e40c8 missions.ts:232. A
|
|
493
|
+
// SCOPE_REFINERS call (guard-helper-flow.ts) is scope-derived when its
|
|
494
|
+
// definition is READABLE in the scanned tree and the scope it is handed, at
|
|
495
|
+
// `argIndex`, is itself a scope-derived local. Its other arguments never taint
|
|
496
|
+
// its return. (A resolver such as `requireAuthenticatedCaller` stays judged on
|
|
497
|
+
// its BODY by judgeResolverFunction, which now reads a refiner of a pure scope
|
|
498
|
+
// as pure — recurringTasks.ts:216.)
|
|
499
|
+
/** Top-level comma-separated arguments of the first `name(…)` call in `call`. */
|
|
500
|
+
function callArguments(call, name) {
|
|
501
|
+
const at = call.search(new RegExp(`\\b${name}\\s*\\(`));
|
|
502
|
+
if (at === -1)
|
|
503
|
+
return null;
|
|
504
|
+
const open = call.indexOf("(", at);
|
|
505
|
+
const out = [];
|
|
506
|
+
let depth = 0;
|
|
507
|
+
let cur = "";
|
|
508
|
+
for (let i = open; i < call.length; i++) {
|
|
509
|
+
const ch = call[i];
|
|
510
|
+
if ("([{".includes(ch)) {
|
|
511
|
+
depth++;
|
|
512
|
+
if (depth === 1)
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
else if (")]}".includes(ch)) {
|
|
516
|
+
depth--;
|
|
517
|
+
if (depth === 0) {
|
|
518
|
+
if (cur.trim())
|
|
519
|
+
out.push(cur.trim());
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
else if (ch === "," && depth === 1) {
|
|
524
|
+
out.push(cur.trim());
|
|
525
|
+
cur = "";
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
cur += ch;
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
462
532
|
const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
|
|
463
533
|
/** Index every definition of an allowlisted helper in the tree. */
|
|
464
534
|
export function indexGuardHelpers(files) {
|
|
@@ -712,6 +782,176 @@ function boundaryMemberRhs(writes, loc) {
|
|
|
712
782
|
}
|
|
713
783
|
return out.length ? out : null;
|
|
714
784
|
}
|
|
785
|
+
// ---- WHICH boundary field reaches a write from an ARGUMENT -----------------
|
|
786
|
+
//
|
|
787
|
+
// The last-resort accusation ("a boundary field taken from a caller argument
|
|
788
|
+
// with no identity/resource guard") used to fire on ANY handler line carrying a
|
|
789
|
+
// boundary token. vantage-peers 45e40c8 tasks.complete was accused for
|
|
790
|
+
// `createdBy: fixPatternCreatedBy` where `fixPatternCreatedBy = task.assignedTo`
|
|
791
|
+
// and `task = await ctx.db.get(args.taskId)` — a value read off the TARGET
|
|
792
|
+
// resource, server-side, never the caller's argument. The accusation now needs
|
|
793
|
+
// the boundary field's value, AT A WRITE, to come from the caller's arguments:
|
|
794
|
+
// - `field: args.x` directly, or a local whose assignments read `args` (one
|
|
795
|
+
// hop through renames, member reads and destructuring, bounded);
|
|
796
|
+
// - a local object handed to the write (`patch(id, obj)`) carrying the field
|
|
797
|
+
// in its literal or through `obj.field = …`;
|
|
798
|
+
// - `args` (or a rest/alias of it) spread or passed whole, when the args
|
|
799
|
+
// validator declares a boundary field the write does not override.
|
|
800
|
+
// NOT the argument: a value read from the database (`ctx.db.get`/`.query`),
|
|
801
|
+
// the return of an identity resolver, or anything with no `args` in its
|
|
802
|
+
// provenance (`lastActedBy`, a server-computed actor). A local whose assignment
|
|
803
|
+
// cannot be found stays an argument (the old, conservative reading).
|
|
804
|
+
const R6_BOUNDARY_FIELD = "(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)";
|
|
805
|
+
function boundaryFromArgument(h, writes, isResolver, principalOf) {
|
|
806
|
+
const codeLines = h.lines.filter(code);
|
|
807
|
+
const handlerAt = codeLines.findIndex((l) => /\bhandler\b/.test(l.raw));
|
|
808
|
+
const validator = codeLines
|
|
809
|
+
.slice(0, handlerAt === -1 ? 0 : handlerAt)
|
|
810
|
+
.map((l) => l.raw)
|
|
811
|
+
.join("\n");
|
|
812
|
+
const validatorFields = new Set([
|
|
813
|
+
...validator.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*:`, "g")),
|
|
814
|
+
].map((m) => m[1]));
|
|
815
|
+
const body = codeLines.slice(Math.max(handlerAt, 0)).map((l) => l.raw);
|
|
816
|
+
const bodyText = body.join("\n");
|
|
817
|
+
const esc = (n) => n.replace(/[$]/g, "\\$&");
|
|
818
|
+
// assignments of `local` in the handler body: RHS texts (multi-line object
|
|
819
|
+
// literals and awaited calls joined), plus whether a handler param binds it.
|
|
820
|
+
const assignments = (local) => {
|
|
821
|
+
const out = [];
|
|
822
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${esc(local)}\\s*(?::[^=\\n]*)?=(?!=)`);
|
|
823
|
+
for (let i = 0; i < body.length; i++) {
|
|
824
|
+
const raw = body[i];
|
|
825
|
+
if (boundFromHandlerParam(raw, local))
|
|
826
|
+
return "param";
|
|
827
|
+
const dr = destructuredRhs(raw, local);
|
|
828
|
+
if (dr !== null) {
|
|
829
|
+
out.push(dr);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (!assign.test(raw))
|
|
833
|
+
continue;
|
|
834
|
+
let rhs = raw.slice(raw.indexOf("=") + 1);
|
|
835
|
+
const depth = (t) => (t.match(/[({[]/g) ?? []).length - (t.match(/[)}\]]/g) ?? []).length;
|
|
836
|
+
for (let j = i + 1; depth(rhs) > 0 && j < body.length && j <= i + 60; j++)
|
|
837
|
+
rhs += `\n${body[j]}`;
|
|
838
|
+
out.push(rhs);
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
};
|
|
842
|
+
const argFed = (local, seen) => {
|
|
843
|
+
if (local === "args")
|
|
844
|
+
return true;
|
|
845
|
+
if (principalOf?.(local))
|
|
846
|
+
return false; // an alias of a checked principal
|
|
847
|
+
if (seen.has(local) || seen.size > 6)
|
|
848
|
+
return false;
|
|
849
|
+
const next = new Set(seen).add(local);
|
|
850
|
+
const rhss = assignments(local);
|
|
851
|
+
if (rhss === "param")
|
|
852
|
+
return true;
|
|
853
|
+
if (rhss.length === 0)
|
|
854
|
+
return true; // not found: conservative, as before.
|
|
855
|
+
return rhss.some((rhs) => valueFromArgs(rhs, next));
|
|
856
|
+
};
|
|
857
|
+
const valueFromArgs = (expr, seen) => {
|
|
858
|
+
const e = expr
|
|
859
|
+
.trim()
|
|
860
|
+
.replace(/;+\s*$/, "")
|
|
861
|
+
.trim();
|
|
862
|
+
if (/^(?:await\s+)?ctx\.db\.(?:get|query)\b/.test(e))
|
|
863
|
+
return false; // a stored row
|
|
864
|
+
const called = wholeAwaitedCall(e);
|
|
865
|
+
if (called && isResolver(called, e))
|
|
866
|
+
return false; // the verified scope
|
|
867
|
+
if (principalOf?.(e))
|
|
868
|
+
return false; // a checked package principal
|
|
869
|
+
const root = /^([A-Za-z_$][\w$]*)(?:\s*(?:\.|\?\.|\[|$|\s+as\b|!))/.exec(e);
|
|
870
|
+
if (root &&
|
|
871
|
+
/^[A-Za-z_$][\w$]*(?:(?:\.|\?\.)[A-Za-z_$][\w$]*)*(?:\s+as\s+[\w<>[\]|. ]+|!)?$/.test(e))
|
|
872
|
+
return argFed(root[1], seen); // a local, or a member read of one
|
|
873
|
+
if (/\bargs\b/.test(e))
|
|
874
|
+
return true;
|
|
875
|
+
// any other expression: argument-fed only through a local it names.
|
|
876
|
+
for (const m of e.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.|\?\.)/g))
|
|
877
|
+
if (m[1] !== "ctx" && argFed(m[1], seen))
|
|
878
|
+
return true;
|
|
879
|
+
return false;
|
|
880
|
+
};
|
|
881
|
+
const objectCarries = (text, seen) => {
|
|
882
|
+
// `field: <value>` up to the next top-level `,` / `}` / line end.
|
|
883
|
+
for (const m of text.matchAll(new RegExp(`(?:^|[{,\\s])(${R6_BOUNDARY_FIELD})\\s*:\\s*([^,}\\n]+)`, "gm")))
|
|
884
|
+
if (valueFromArgs(m[2], seen))
|
|
885
|
+
return true;
|
|
886
|
+
for (const m of text.matchAll(new RegExp(`(?:^|[{,])\\s*(${R6_BOUNDARY_FIELD})\\s*(?=[,}]|$)`, "gm")))
|
|
887
|
+
if (argFed(m[1], seen))
|
|
888
|
+
return true;
|
|
889
|
+
// spreads: `...args`, or `...x` with x an alias/rest of args.
|
|
890
|
+
for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
|
|
891
|
+
const x = m[1];
|
|
892
|
+
if (x === "args" || restOfArgs(x)) {
|
|
893
|
+
const pulled = x === "args" ? new Set() : pulledFromArgs(x);
|
|
894
|
+
const overridden = new Set([
|
|
895
|
+
...text.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*[:,}]`, "g")),
|
|
896
|
+
].map((k) => k[1]));
|
|
897
|
+
if ([...validatorFields].some((fld) => !pulled.has(fld) && !overridden.has(fld)))
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
else if (localObjectCarries(x, seen))
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
return false;
|
|
904
|
+
};
|
|
905
|
+
const restOfArgs = (x) => new RegExp(`\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).test(bodyText) ||
|
|
906
|
+
new RegExp(`\\b(?:const|let|var)\\s+${esc(x)}\\s*=\\s*args\\s*;?\\s*$`, "m").test(bodyText);
|
|
907
|
+
const pulledFromArgs = (x) => {
|
|
908
|
+
const m = new RegExp(`\\{([^}]*)\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).exec(bodyText);
|
|
909
|
+
const out = new Set();
|
|
910
|
+
if (m)
|
|
911
|
+
for (const part of m[1].split(",")) {
|
|
912
|
+
const key = part.split(":")[0].split("=")[0].trim();
|
|
913
|
+
if (key)
|
|
914
|
+
out.add(key);
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
};
|
|
918
|
+
// A local object handed to a write: its literal, and every `obj.field = v`.
|
|
919
|
+
const localObjectCarries = (obj, seen) => {
|
|
920
|
+
if (seen.has(`obj:${obj}`))
|
|
921
|
+
return false;
|
|
922
|
+
const next = new Set(seen).add(`obj:${obj}`);
|
|
923
|
+
const rhss = assignments(obj);
|
|
924
|
+
if (rhss !== "param")
|
|
925
|
+
for (const rhs of rhss)
|
|
926
|
+
if (rhs.trim().startsWith("{") && objectCarries(rhs, next))
|
|
927
|
+
return true;
|
|
928
|
+
const memberSet = new RegExp(`\\b${esc(obj)}\\s*(?:\\.\\s*(${R6_BOUNDARY_FIELD})|\\[\\s*["'\`](${R6_BOUNDARY_FIELD})["'\`]\\s*\\])\\s*=(?!=)\\s*([^;\\n]+)`, "g");
|
|
929
|
+
for (const m of bodyText.matchAll(memberSet))
|
|
930
|
+
if (valueFromArgs(m[3], next))
|
|
931
|
+
return true;
|
|
932
|
+
return false;
|
|
933
|
+
};
|
|
934
|
+
for (const w of writes) {
|
|
935
|
+
if (new RegExp(`\\b${R6_BOUNDARY_FIELD}\\s*:\\s*args\\b`).test(w))
|
|
936
|
+
return true;
|
|
937
|
+
if (objectCarries(w, new Set()))
|
|
938
|
+
return true;
|
|
939
|
+
// the object handed to the write: its last top-level argument.
|
|
940
|
+
const m = /ctx\.db\.(?:insert|patch|replace)\s*\(([\s\S]*)\)\s*;?\s*$/.exec(w.trim());
|
|
941
|
+
const last = m ? m[1].split(",").pop()?.trim() : undefined;
|
|
942
|
+
if (last === "args") {
|
|
943
|
+
if (validatorFields.size > 0)
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
else if (last && /^[A-Za-z_$][\w$]*$/.test(last)) {
|
|
947
|
+
if (restOfArgs(last) && validatorFields.size > 0)
|
|
948
|
+
return true;
|
|
949
|
+
if (localObjectCarries(last, new Set()))
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
715
955
|
export function scanR6(files, rootIsConvex = false) {
|
|
716
956
|
const scan = emptyScan();
|
|
717
957
|
const helperIndex = indexGuardHelpers(files);
|
|
@@ -721,6 +961,11 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
721
961
|
continue;
|
|
722
962
|
// One shape for every argument read: `args["x"]` → `args.x`.
|
|
723
963
|
const f = { ...raw, lines: normalizeLines(raw.lines) };
|
|
964
|
+
// Parsed once per file, and only when the file names the identity
|
|
965
|
+
// package: the checked-principal read (package-principal.ts) needs it.
|
|
966
|
+
const parsed = f.lines.some((l) => l.raw.includes(IDENTITY_PACKAGE))
|
|
967
|
+
? parseFile(f)
|
|
968
|
+
: undefined;
|
|
724
969
|
for (const h of splitMutationHandlers(f)) {
|
|
725
970
|
// An internalMutation has NO client caller: its arguments come from
|
|
726
971
|
// server code (a cron, a scheduler hop, another function), so "a
|
|
@@ -742,8 +987,6 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
742
987
|
guard = true;
|
|
743
988
|
if (R6_SCOPE_RESOLUTION.test(l.raw))
|
|
744
989
|
resolvesScope = true;
|
|
745
|
-
if (R6_BOUNDARY_ARG.test(l.raw))
|
|
746
|
-
boundaryArg = true;
|
|
747
990
|
}
|
|
748
991
|
if (!writeLine)
|
|
749
992
|
continue; // not a write handler — R-6 does not govern it.
|
|
@@ -766,12 +1009,71 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
766
1009
|
return fileSrc;
|
|
767
1010
|
};
|
|
768
1011
|
const resolvedBy = new Map();
|
|
769
|
-
const isResolver = (name) => {
|
|
1012
|
+
const isResolver = (name, call) => {
|
|
770
1013
|
const d = fns.resolver(name, f.rel);
|
|
771
|
-
if (d)
|
|
772
|
-
resolvedBy.set(name,
|
|
773
|
-
|
|
1014
|
+
if (d) {
|
|
1015
|
+
resolvedBy.set(name, `identity resolver ${name}() (definition ${d.rel}:${d.f.line}), which returns the verified scope itself and refuses an absent identity on every path`);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
const spec = SCOPE_REFINERS[name];
|
|
1019
|
+
if (!spec)
|
|
1020
|
+
return false;
|
|
1021
|
+
const def = fns.lookup(name, f.rel);
|
|
1022
|
+
const arg = callArguments(call, name)?.[spec.argIndex];
|
|
1023
|
+
if (!def || !arg || !/^[A-Za-z_$][\w$]*$/.test(arg) || arg === "args")
|
|
1024
|
+
return false;
|
|
1025
|
+
const t = analyzeLocalTaint(h, arg, isResolver);
|
|
1026
|
+
if (!(t.fromScope > 0 && t.fromArgs === 0))
|
|
1027
|
+
return false;
|
|
1028
|
+
resolvedBy.set(name, `named scope refiner ${name}() (definition ${def.rel}:${def.f.line}) handed the scope-derived local "${arg}"`);
|
|
1029
|
+
return true;
|
|
774
1030
|
};
|
|
1031
|
+
// The principal @vantageos/cloud-identity resolved, refused unless ok
|
|
1032
|
+
// before the write (package-principal.ts): a verified-identity value.
|
|
1033
|
+
const principals = parsed
|
|
1034
|
+
? packagePrincipals(parsed, h.name, writeLine)
|
|
1035
|
+
: undefined;
|
|
1036
|
+
const principalOf = principals
|
|
1037
|
+
? (expr) => {
|
|
1038
|
+
const why = principals.principalOf(expr);
|
|
1039
|
+
if (why)
|
|
1040
|
+
resolvedBy.set(`principal:${why}`, why);
|
|
1041
|
+
return why;
|
|
1042
|
+
}
|
|
1043
|
+
: undefined;
|
|
1044
|
+
// The boundary-from-argument test reads the WRITE's provenance, never a
|
|
1045
|
+
// boundary token anywhere in the handler (see boundaryFromArgument).
|
|
1046
|
+
boundaryArg = boundaryFromArgument(h, writes, isResolver, principalOf);
|
|
1047
|
+
// An awaited call, before the first write, to a function judged an
|
|
1048
|
+
// identity resolver (it refuses an absent identity on every path and
|
|
1049
|
+
// returns the verified scope) IS an identity guard, whatever arguments
|
|
1050
|
+
// it is handed — vantage-peers 45e40c8 tasks.complete resolves its
|
|
1051
|
+
// caller through requireAuthenticatedCaller before writing.
|
|
1052
|
+
let resolverGuard;
|
|
1053
|
+
{
|
|
1054
|
+
const codeLines = h.lines.filter(code);
|
|
1055
|
+
for (let i = 0; i < codeLines.length && !resolverGuard; i++) {
|
|
1056
|
+
if (codeLines[i].n >= writeLine)
|
|
1057
|
+
break;
|
|
1058
|
+
const m = /\bawait\s+([A-Za-z_$][\w$]*)\s*\(/.exec(codeLines[i].raw);
|
|
1059
|
+
if (!m || m[1] === "ctx")
|
|
1060
|
+
continue;
|
|
1061
|
+
const from = codeLines[i].raw.slice(m.index);
|
|
1062
|
+
const call = joinedAwaitedCall(from, codeLines, i);
|
|
1063
|
+
resolvedBy.clear();
|
|
1064
|
+
if (isResolver(m[1], call))
|
|
1065
|
+
resolverGuard = `${[...resolvedBy.values()].join(", ")} called at line ${codeLines[i].n}`;
|
|
1066
|
+
}
|
|
1067
|
+
resolvedBy.clear();
|
|
1068
|
+
}
|
|
1069
|
+
// Resolving the caller proves WHO writes, not that a boundary ARGUMENT
|
|
1070
|
+
// is bound to them: the credit stands only when no argument reaches a
|
|
1071
|
+
// boundary field at a write (a hollow master-only helper that returns the
|
|
1072
|
+
// scope must not excuse `orchestratorId: args.orchestratorId`).
|
|
1073
|
+
if (resolverGuard && !boundaryArg)
|
|
1074
|
+
guard = true;
|
|
1075
|
+
else
|
|
1076
|
+
resolverGuard = undefined;
|
|
775
1077
|
const credits = [];
|
|
776
1078
|
// A boundary written straight from an argument a BINDING VALIDATOR
|
|
777
1079
|
// bound to the verified scope before the write: credited only when
|
|
@@ -794,10 +1096,19 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
794
1096
|
}
|
|
795
1097
|
let bound;
|
|
796
1098
|
for (const loc of boundaryLocals) {
|
|
1099
|
+
const rhs = boundaryMemberRhs(writes, loc);
|
|
1100
|
+
// Every write of `loc` reads the checked package principal
|
|
1101
|
+
// (`orgId: who.principal.orgId`, `orgId: p.orgId` for an alias p).
|
|
1102
|
+
const viaPrincipal = rhs && principals ? rhs.map((x) => principals.principalOf(x)) : [];
|
|
1103
|
+
if (viaPrincipal.length > 0 && viaPrincipal.every((x) => x)) {
|
|
1104
|
+
anyDerived = true;
|
|
1105
|
+
resolvesScope = true;
|
|
1106
|
+
credits.push(`\`${rhs?.[0]}\` is ${viaPrincipal[0]}`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
797
1109
|
// A value a refusing predicate bound to the caller's scope before
|
|
798
1110
|
// the write (`if (!P(scope, mission.orgId)) throw`), written
|
|
799
1111
|
// unchanged: derived from the verified identity through the target.
|
|
800
|
-
const rhs = boundaryMemberRhs(writes, loc);
|
|
801
1112
|
if (rhs) {
|
|
802
1113
|
bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
|
|
803
1114
|
const by = rhs.map((x) => bound?.get(x));
|
|
@@ -809,10 +1120,16 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
809
1120
|
}
|
|
810
1121
|
}
|
|
811
1122
|
resolvedBy.clear();
|
|
812
|
-
const a = analyzeLocalTaint(h, loc, isResolver);
|
|
1123
|
+
const a = analyzeLocalTaint(h, loc, isResolver, principalOf);
|
|
813
1124
|
if (a.fromResolver) {
|
|
814
1125
|
resolvesScope = true;
|
|
815
|
-
|
|
1126
|
+
const by = [...resolvedBy.entries()];
|
|
1127
|
+
const returned = by.filter(([k]) => !k.startsWith("principal:"));
|
|
1128
|
+
const read = by.filter(([k]) => k.startsWith("principal:"));
|
|
1129
|
+
if (returned.length > 0)
|
|
1130
|
+
credits.push(`local "${loc}" is the return of ${returned.map(([, v]) => v).join(", ")}`);
|
|
1131
|
+
if (read.length > 0)
|
|
1132
|
+
credits.push(`local "${loc}" reads ${read.map(([, v]) => v).join(", ")}`);
|
|
816
1133
|
}
|
|
817
1134
|
if (a.fromScope > 0) {
|
|
818
1135
|
anyDerived = true;
|
|
@@ -933,7 +1250,7 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
933
1250
|
scan.conform.push({
|
|
934
1251
|
rel: f.rel,
|
|
935
1252
|
n: writeLine,
|
|
936
|
-
note: `${h.name}: boundary derived from a verified identity / target-resource guard`,
|
|
1253
|
+
note: `${h.name}: boundary derived from a verified identity / target-resource guard${resolverGuard ? ` — ${resolverGuard}` : ""}`,
|
|
937
1254
|
});
|
|
938
1255
|
}
|
|
939
1256
|
else if (boundaryArg) {
|
|
@@ -944,11 +1261,23 @@ export function scanR6(files, rootIsConvex = false) {
|
|
|
944
1261
|
});
|
|
945
1262
|
}
|
|
946
1263
|
else {
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1264
|
+
// Two shapes read in full from the AST before deferring: a FILE-LOCAL
|
|
1265
|
+
// master-only wrapper dominating the write, and a possession-gated
|
|
1266
|
+
// write of the one row a hashed argument selects (r6-shapes.ts).
|
|
1267
|
+
const credit = masterOnlyWrapperCredit(raw, h.name) ??
|
|
1268
|
+
possessionGatedCredit(raw, h.name);
|
|
1269
|
+
if (credit)
|
|
1270
|
+
scan.conform.push({
|
|
1271
|
+
rel: f.rel,
|
|
1272
|
+
n: writeLine,
|
|
1273
|
+
note: `${h.name}: ${credit}`,
|
|
1274
|
+
});
|
|
1275
|
+
else
|
|
1276
|
+
scan.cnj.push({
|
|
1277
|
+
rel: f.rel,
|
|
1278
|
+
n: writeLine,
|
|
1279
|
+
note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
|
|
1280
|
+
});
|
|
952
1281
|
}
|
|
953
1282
|
// A violation at a site calling a listed helper whose definition was
|
|
954
1283
|
// read and NOT honoured says why, so the reader is not left believing
|
|
@@ -1033,12 +1362,12 @@ const R16_CHECK = "a refusal throws a ConvexError with a stable UPPER_SNAKE_CASE
|
|
|
1033
1362
|
/** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
|
|
1034
1363
|
export function classifyR6Live(id, tool, outcome) {
|
|
1035
1364
|
if (outcome.kind === "accepted") {
|
|
1036
|
-
return {
|
|
1365
|
+
return withCount({
|
|
1037
1366
|
id,
|
|
1038
1367
|
anchor: `live:${tool}`,
|
|
1039
1368
|
state: "non-conforming",
|
|
1040
1369
|
verdict: `R-6 (live): tool "${tool}" ACCEPTED a write with no verified identity/boundary — fail-open.`,
|
|
1041
|
-
};
|
|
1370
|
+
}, itemCount(1));
|
|
1042
1371
|
}
|
|
1043
1372
|
if (/RBAC|AUTH|ORG|unauthori|denied|required|forbidden/i.test(outcome.message)) {
|
|
1044
1373
|
return {
|
|
@@ -1070,12 +1399,12 @@ export function classifyR16Live(id, payload) {
|
|
|
1070
1399
|
};
|
|
1071
1400
|
}
|
|
1072
1401
|
if (R16_REFUSAL_KW.test(payload)) {
|
|
1073
|
-
return {
|
|
1402
|
+
return withCount({
|
|
1074
1403
|
id,
|
|
1075
1404
|
anchor: "live:error-contract",
|
|
1076
1405
|
state: "non-conforming",
|
|
1077
1406
|
verdict: `R-16 (live): a triggered refusal carried NO structured code — opaque error contract (${payload.slice(0, 80)}).`,
|
|
1078
|
-
};
|
|
1407
|
+
}, itemCount(1));
|
|
1079
1408
|
}
|
|
1080
1409
|
const reason = `R-16 (live): could not trigger a classifiable refusal — the served response is neither a coded refusal nor a recognisable one. NOT a clean bill.`;
|
|
1081
1410
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
1
2
|
import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// R-19 — a table holding personal data declares a retention period AND exposes
|
|
@@ -300,11 +301,21 @@ export function scanR20(files) {
|
|
|
300
301
|
note: "background job declares no per-run bound (.take/_CAP/limit) — an unbounded run does not ship",
|
|
301
302
|
});
|
|
302
303
|
}
|
|
304
|
+
else if (hasJustificationTokenInText(f.lines.map((l) => l.raw).join("\n"))) {
|
|
305
|
+
// judgement retired (task k179jnhx, §23): a bound-but-unlogged job is
|
|
306
|
+
// flagged; the FILE's carried JUSTIFIÉ:/JUSTIFIED: token is the written
|
|
307
|
+
// justification — decided by presence, never a person's opinion of it.
|
|
308
|
+
scan.conform.push({
|
|
309
|
+
rel: f.rel,
|
|
310
|
+
n: boundLine,
|
|
311
|
+
note: "background job bounds its run; no outcome log (console.*) found, but the file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
303
314
|
else {
|
|
304
|
-
scan.
|
|
315
|
+
scan.violate.push({
|
|
305
316
|
rel: f.rel,
|
|
306
317
|
n: boundLine,
|
|
307
|
-
note: "background job bounds its run but no outcome log (console.*) found — observability
|
|
318
|
+
note: "background job bounds its run but no outcome log (console.*) found, and no written JUSTIFIÉ:/JUSTIFIED: token in the file — observability owes a fix or a recorded justification",
|
|
308
319
|
});
|
|
309
320
|
}
|
|
310
321
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
1
2
|
import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
3
|
// An ADMIN-PROVISIONABLE resource: a config/template row an admin creates —
|
|
3
4
|
// profiles, clients, tenants, templates, mappings, filter rules, allowlists.
|
|
@@ -114,6 +115,7 @@ export function scanR43(files) {
|
|
|
114
115
|
const scan = emptyScan();
|
|
115
116
|
const dels = deletePaths(files);
|
|
116
117
|
const { creates, optOutFiles } = adminCreates(files);
|
|
118
|
+
const textByRel = new Map(files.map((f) => [f.rel, f.lines.map((l) => l.raw).join("\n")]));
|
|
117
119
|
for (const c of creates.values()) {
|
|
118
120
|
const del = dels.get(c.table);
|
|
119
121
|
if (del) {
|
|
@@ -130,11 +132,21 @@ export function scanR43(files) {
|
|
|
130
132
|
note: `admin resource ${c.table} has no delete/GC path but carries a written out-of-scope reason (allow-no-delete-path) in ${c.rel}`,
|
|
131
133
|
});
|
|
132
134
|
}
|
|
135
|
+
else if (hasJustificationTokenInText(textByRel.get(c.rel) ?? "")) {
|
|
136
|
+
// judgement retired (task k179jnhx, §23): the file's carried
|
|
137
|
+
// JUSTIFIÉ:/JUSTIFIED: token IS the written out-of-scope reason,
|
|
138
|
+
// decided by presence, never by a person's opinion of soundness.
|
|
139
|
+
scan.conform.push({
|
|
140
|
+
rel: c.rel,
|
|
141
|
+
n: c.n,
|
|
142
|
+
note: `admin resource ${c.table} has no delete/GC path, but ${c.rel} carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
133
145
|
else {
|
|
134
|
-
scan.
|
|
146
|
+
scan.violate.push({
|
|
135
147
|
rel: c.rel,
|
|
136
148
|
n: c.n,
|
|
137
|
-
note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason — admin-resource lifecycle asymmetry
|
|
149
|
+
note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason (allow-no-delete-path / JUSTIFIÉ: / JUSTIFIED:) — admin-resource lifecycle asymmetry owes a fix or a recorded justification`,
|
|
138
150
|
});
|
|
139
151
|
}
|
|
140
152
|
}
|