@vantageos/backend-doctor 0.1.14 → 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 +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- 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 +71 -18
- 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 +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- 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 +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- 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.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- 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.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- 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.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- 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/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// R-6 — the principal @vantageos/cloud-identity resolved, once the door has
|
|
2
|
+
// refused the unresolved branch, is a VERIFIED-IDENTITY value.
|
|
3
|
+
//
|
|
4
|
+
// `resolveActingPrincipal(credential, lookups, door)` and
|
|
5
|
+
// `resolvePersonPrincipal(…)` return `{ ok: true, principal } | { ok: false,
|
|
6
|
+
// refusal }`: the package resolves the person by ID in the organisation its
|
|
7
|
+
// credential is bound to (standard 2825122 R-53). Their definitions live in the
|
|
8
|
+
// package, never in the scanned tree, so R-6's in-tree resolver judgement
|
|
9
|
+
// (judgeResolverFunction) cannot read them, and a door writing
|
|
10
|
+
// `orgId: who.principal.orgId` read as a laundered caller argument —
|
|
11
|
+
// vantage-peers `startBinding` at sigma/start-binding-by-id ae58c95.
|
|
12
|
+
//
|
|
13
|
+
// What is credited, read with the parser on the handler's DIRECT statements
|
|
14
|
+
// before its first write (a statement that ends at or after the write line is
|
|
15
|
+
// not read):
|
|
16
|
+
// - `const R = await F(…)` where F is the file's own named (or namespace)
|
|
17
|
+
// import of `resolveActingPrincipal` / `resolvePersonPrincipal` from
|
|
18
|
+
// @vantageos/cloud-identity (aliases resolved). `let`/`var`, a call that is
|
|
19
|
+
// not awaited, a local function, or an import from any other module: not
|
|
20
|
+
// credited.
|
|
21
|
+
// - `if (!R.ok) <exit>` (or `R.ok === false`, `R.ok !== true`), no `else`,
|
|
22
|
+
// whose branch ALWAYS leaves the handler: a `throw`/`return` statement, or
|
|
23
|
+
// a block whose last statement is one. A branch that logs and continues, an
|
|
24
|
+
// empty block, or a branch that exits on only one of its paths: not
|
|
25
|
+
// credited.
|
|
26
|
+
// - the principal is then `R.principal` and any member read of it, and an
|
|
27
|
+
// ALIAS bound by `const P = R.principal` or `const { principal[: P] } = R`
|
|
28
|
+
// (no default value), and any member read of the alias.
|
|
29
|
+
// The principal of an R that is not checked, or of a different R than the one
|
|
30
|
+
// checked, is never credited. Nothing here credits `R` itself, `R.refusal…`, or
|
|
31
|
+
// an expression that mixes the principal with anything else
|
|
32
|
+
// (`R.principal.orgId ?? args.orgId`).
|
|
33
|
+
//
|
|
34
|
+
// Declared limits: a refusal inside a nested block of the handler (a branch, a
|
|
35
|
+
// try, a loop) is not read — the door is then judged as before this change; a
|
|
36
|
+
// nested-scope shadow of R or P before the write is not modelled (the text taint
|
|
37
|
+
// read of R-6 still sees the shadowing assignment and keeps it accused).
|
|
38
|
+
import * as ts from "typescript";
|
|
39
|
+
import { dotted, lineOf, unwrap } from "./_ast.js";
|
|
40
|
+
import { handlerBodyOf } from "./guard-helper-flow.js";
|
|
41
|
+
import { importsOf, PACKAGE_RESOLVERS, pkgExportOf, refusesOnNotOk, } from "./identity-by-id.js";
|
|
42
|
+
const NONE = { principalOf: () => undefined };
|
|
43
|
+
/** Does `e` read the handler's `args`, directly or through a `const` it names?
|
|
44
|
+
* A local whose declaration is not among `locals` (a parameter, a closure
|
|
45
|
+
* variable) is not followed; `args` itself is the caller's input. */
|
|
46
|
+
function readsArgs(e, locals, seen) {
|
|
47
|
+
let hit = false;
|
|
48
|
+
const visit = (n) => {
|
|
49
|
+
if (hit)
|
|
50
|
+
return;
|
|
51
|
+
if (ts.isIdentifier(n)) {
|
|
52
|
+
const parent = n.parent;
|
|
53
|
+
const nameOnly = parent &&
|
|
54
|
+
((ts.isPropertyAccessExpression(parent) && parent.name === n) ||
|
|
55
|
+
(ts.isPropertyAssignment(parent) && parent.name === n));
|
|
56
|
+
if (!nameOnly) {
|
|
57
|
+
if (n.text === "args")
|
|
58
|
+
hit = true;
|
|
59
|
+
else if (!seen.has(n.text) && locals.has(n.text)) {
|
|
60
|
+
seen.add(n.text);
|
|
61
|
+
if (readsArgs(locals.get(n.text), locals, seen))
|
|
62
|
+
hit = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
n.forEachChild(visit);
|
|
67
|
+
};
|
|
68
|
+
visit(e);
|
|
69
|
+
return hit;
|
|
70
|
+
}
|
|
71
|
+
/** A statement that leaves the handler on every path. */
|
|
72
|
+
function alwaysExits(st) {
|
|
73
|
+
if (ts.isThrowStatement(st) || ts.isReturnStatement(st))
|
|
74
|
+
return true;
|
|
75
|
+
if (!ts.isBlock(st) || st.statements.length === 0)
|
|
76
|
+
return false;
|
|
77
|
+
const last = st.statements[st.statements.length - 1];
|
|
78
|
+
return ts.isThrowStatement(last) || ts.isReturnStatement(last);
|
|
79
|
+
}
|
|
80
|
+
const endLineOf = (n) => n.getSourceFile().getLineAndCharacterOfPosition(n.getEnd()).line + 1;
|
|
81
|
+
/** Read handler `handlerName` of `p` before `beforeLine` (the first write). */
|
|
82
|
+
export function packagePrincipals(p, handlerName, beforeLine) {
|
|
83
|
+
const { imports } = importsOf(p);
|
|
84
|
+
if (imports.named.size === 0 && imports.namespaces.size === 0)
|
|
85
|
+
return NONE;
|
|
86
|
+
const body = handlerBodyOf(p.sf, handlerName);
|
|
87
|
+
if (!body)
|
|
88
|
+
return NONE;
|
|
89
|
+
const results = new Map();
|
|
90
|
+
const locals = new Map(); // const name -> initializer
|
|
91
|
+
const aliases = new Map(); // alias -> result local
|
|
92
|
+
for (const st of body.statements) {
|
|
93
|
+
if (endLineOf(st) >= beforeLine)
|
|
94
|
+
break;
|
|
95
|
+
if (ts.isVariableStatement(st)) {
|
|
96
|
+
if ((st.declarationList.flags & ts.NodeFlags.Const) === 0)
|
|
97
|
+
continue;
|
|
98
|
+
for (const d of st.declarationList.declarations) {
|
|
99
|
+
if (!d.initializer)
|
|
100
|
+
continue;
|
|
101
|
+
const init = d.initializer;
|
|
102
|
+
if (ts.isIdentifier(d.name)) {
|
|
103
|
+
const awaited = ts.isAwaitExpression(init)
|
|
104
|
+
? unwrap(init.expression)
|
|
105
|
+
: undefined;
|
|
106
|
+
const exp = awaited && ts.isCallExpression(awaited)
|
|
107
|
+
? pkgExportOf(awaited.expression, imports)
|
|
108
|
+
: undefined;
|
|
109
|
+
if (exp && PACKAGE_RESOLVERS.has(exp)) {
|
|
110
|
+
// The credential is the identity claim: one the caller's
|
|
111
|
+
// arguments reach is a forged claim, never a verified one.
|
|
112
|
+
const cred = awaited && ts.isCallExpression(awaited) ? awaited.arguments[0] : undefined;
|
|
113
|
+
if (!cred || readsArgs(cred, locals, new Set()))
|
|
114
|
+
continue;
|
|
115
|
+
results.set(d.name.text, { resolver: exp, line: lineOf(st) });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
locals.set(d.name.text, init);
|
|
119
|
+
const path = dotted(init);
|
|
120
|
+
const m = path ? /^([A-Za-z_$][\w$]*)\.principal$/.exec(path) : null;
|
|
121
|
+
if (m && results.has(m[1]))
|
|
122
|
+
aliases.set(d.name.text, m[1]);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ts.isObjectBindingPattern(d.name)) {
|
|
126
|
+
const from = unwrap(init);
|
|
127
|
+
if (!ts.isIdentifier(from) || !results.has(from.text))
|
|
128
|
+
continue;
|
|
129
|
+
for (const e of d.name.elements) {
|
|
130
|
+
if (e.dotDotDotToken || e.initializer)
|
|
131
|
+
continue;
|
|
132
|
+
if (!ts.isIdentifier(e.name))
|
|
133
|
+
continue;
|
|
134
|
+
const key = e.propertyName ?? e.name;
|
|
135
|
+
if (ts.isIdentifier(key) && key.text === "principal")
|
|
136
|
+
aliases.set(e.name.text, from.text);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!ts.isIfStatement(st) || st.elseStatement)
|
|
143
|
+
continue;
|
|
144
|
+
if (!alwaysExits(st.thenStatement))
|
|
145
|
+
continue;
|
|
146
|
+
for (const [local, r] of results)
|
|
147
|
+
if (r.checkLine === undefined && refusesOnNotOk(st.expression, local))
|
|
148
|
+
r.checkLine = lineOf(st);
|
|
149
|
+
}
|
|
150
|
+
const why = (local) => {
|
|
151
|
+
const r = results.get(local);
|
|
152
|
+
if (!r || r.checkLine === undefined)
|
|
153
|
+
return undefined;
|
|
154
|
+
return `the principal @vantageos/cloud-identity ${r.resolver}() resolved into "${local}" (line ${r.line}), refused on !${local}.ok at line ${r.checkLine} before the write`;
|
|
155
|
+
};
|
|
156
|
+
if (![...results.values()].some((r) => r.checkLine !== undefined))
|
|
157
|
+
return NONE;
|
|
158
|
+
return {
|
|
159
|
+
principalOf(expr) {
|
|
160
|
+
const e = expr
|
|
161
|
+
.trim()
|
|
162
|
+
.replace(/;+\s*$/, "")
|
|
163
|
+
.replace(/\s+/g, "")
|
|
164
|
+
.replace(/\?\./g, ".");
|
|
165
|
+
const m = /^([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)$/.exec(e);
|
|
166
|
+
if (!m)
|
|
167
|
+
return undefined;
|
|
168
|
+
const [, head, rest] = m;
|
|
169
|
+
const aliased = aliases.get(head);
|
|
170
|
+
if (aliased !== undefined)
|
|
171
|
+
return why(aliased);
|
|
172
|
+
if (rest === ".principal" || rest.startsWith(".principal."))
|
|
173
|
+
return why(head);
|
|
174
|
+
return undefined;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { type Registration, type SrcFile } from "./_ast.js";
|
|
3
|
+
/** Judge a function as a master-only wrapper (shape 1). */
|
|
4
|
+
export declare function isMasterOnlyWrapper(fn: ts.FunctionLikeDeclaration): boolean;
|
|
5
|
+
/** Shape 1 at a handler: an awaited top-level call, before the first write, to a
|
|
6
|
+
* FILE-LOCAL master-only wrapper. Returns the credit text or undefined. */
|
|
7
|
+
export declare function masterOnlyWrapperCredit(f: SrcFile, handlerName: string): string | undefined;
|
|
8
|
+
/** Shape 2 at a handler. Returns the credit text or undefined. */
|
|
9
|
+
export declare function possessionGatedCredit(f: SrcFile, handlerName: string): string | undefined;
|
|
10
|
+
export interface PossessionGate {
|
|
11
|
+
rowVar: string;
|
|
12
|
+
table: string;
|
|
13
|
+
field: string;
|
|
14
|
+
hashLine: number;
|
|
15
|
+
rowLine: number;
|
|
16
|
+
writes: number;
|
|
17
|
+
}
|
|
18
|
+
/** The possession gate of a registration, or undefined when the shape is not
|
|
19
|
+
* held in full. */
|
|
20
|
+
export declare function possessionGate(reg: Registration): PossessionGate | undefined;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Two write shapes R-6 used to defer (could-not-judge) although each is readable
|
|
2
|
+
// in full from the handler and the file it sits in. Both are judged here on the
|
|
3
|
+
// AST; neither is credited from a comment marker.
|
|
4
|
+
//
|
|
5
|
+
// 1. MASTER-ONLY FILE-LOCAL WRAPPER. The handler's first statements call a
|
|
6
|
+
// function defined in the SAME file whose body is exactly:
|
|
7
|
+
// const scope = await withOrgScope(ctx); // fail-closed resolver
|
|
8
|
+
// if (!scope.isMaster) throw …; // refuses every non-master
|
|
9
|
+
// [return scope;]
|
|
10
|
+
// Every path through it refuses unless the verified caller is the fleet
|
|
11
|
+
// master, so the write that follows is master-only. A master-only write on a
|
|
12
|
+
// table with no member boundary has no boundary an argument could steer: R-6
|
|
13
|
+
// conforms. ANY other statement in the wrapper (an early return, a second
|
|
14
|
+
// branch, a conjunction in the master test, an `allowNoIdentityMaster: true`
|
|
15
|
+
// option) leaves the shape unrecognised and the site stays where it was.
|
|
16
|
+
//
|
|
17
|
+
// 2. POSSESSION-GATED WRITE. The handler hashes an ARGUMENT
|
|
18
|
+
// (`const h = await sha256Hex(args.key)`), selects ONE row through a unique
|
|
19
|
+
// index on a hash field (`.withIndex(…, q => q.eq("keyHash", h)).unique()`),
|
|
20
|
+
// and every write it performs is a patch/replace/delete of THAT row's `_id` —
|
|
21
|
+
// no insert, no scheduled or run effect, no other row. The caller can only
|
|
22
|
+
// reach a row whose secret it already holds; the write carries no boundary
|
|
23
|
+
// the caller could choose. A handler that hash-matches and then writes ANY
|
|
24
|
+
// other row is not this shape. `@credential` / `allow-no-caller-resolution:`
|
|
25
|
+
// markers above the registration are reported as corroboration only.
|
|
26
|
+
//
|
|
27
|
+
// Origin: VP main 11a7057 — seven requireMasterScope sites (errorMonitor.ts
|
|
28
|
+
// 537/565, githubRepoMapping.ts 303/337, issues.ts 183/210/270) and
|
|
29
|
+
// licenses.ts:140 activate, proven safe by vantage-peers sigma/r6-abstentions
|
|
30
|
+
// @08aa83e convex/__tests__/r6AbstentionsResolved.test.ts (10/10).
|
|
31
|
+
import * as ts from "typescript";
|
|
32
|
+
import { dotted, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
|
|
33
|
+
const HASH_CALLEE = /sha256|sha512|hash|digest/i;
|
|
34
|
+
/** A resolver whose `isMaster: true` comes only from a verified identity unless
|
|
35
|
+
* the call opts into `allowNoIdentityMaster` (vantage-peers convex/lib/auth.ts
|
|
36
|
+
* `withOrgScope`: the no-identity branch returns a non-master scope). */
|
|
37
|
+
const FAIL_CLOSED_SCOPE = new Set(["withOrgScope"]);
|
|
38
|
+
// Keyed on the file OBJECT: one parse per file per run, and two different files
|
|
39
|
+
// that share a path (a fixture rewritten between runs) never share a parse.
|
|
40
|
+
const cache = new WeakMap();
|
|
41
|
+
function parsedOf(f) {
|
|
42
|
+
let p = cache.get(f);
|
|
43
|
+
if (!p) {
|
|
44
|
+
p = parseFile(f);
|
|
45
|
+
cache.set(f, p);
|
|
46
|
+
}
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
function refusesNonMasterOn(c0, scopeVar) {
|
|
50
|
+
const c = unwrap(c0);
|
|
51
|
+
if (ts.isPrefixUnaryExpression(c) &&
|
|
52
|
+
c.operator === ts.SyntaxKind.ExclamationToken) {
|
|
53
|
+
const o = unwrap(c.operand);
|
|
54
|
+
return (ts.isPropertyAccessExpression(o) &&
|
|
55
|
+
o.name.text === "isMaster" &&
|
|
56
|
+
dotted(o.expression) === scopeVar);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function throwsOnly(s) {
|
|
61
|
+
if (ts.isThrowStatement(s))
|
|
62
|
+
return true;
|
|
63
|
+
return (ts.isBlock(s) &&
|
|
64
|
+
s.statements.length > 0 &&
|
|
65
|
+
ts.isThrowStatement(s.statements[s.statements.length - 1]) &&
|
|
66
|
+
s.statements.every((x) => ts.isThrowStatement(x) || ts.isVariableStatement(x)));
|
|
67
|
+
}
|
|
68
|
+
/** Judge a function as a master-only wrapper (shape 1). */
|
|
69
|
+
export function isMasterOnlyWrapper(fn) {
|
|
70
|
+
const body = fn.body;
|
|
71
|
+
if (!body || !ts.isBlock(body))
|
|
72
|
+
return false;
|
|
73
|
+
const st = body.statements;
|
|
74
|
+
if (st.length < 2 || st.length > 3)
|
|
75
|
+
return false;
|
|
76
|
+
const s0 = st[0];
|
|
77
|
+
if (!ts.isVariableStatement(s0))
|
|
78
|
+
return false;
|
|
79
|
+
const decls = s0.declarationList.declarations;
|
|
80
|
+
if (decls.length !== 1 || !ts.isIdentifier(decls[0].name))
|
|
81
|
+
return false;
|
|
82
|
+
const scopeVar = decls[0].name.text;
|
|
83
|
+
const init = decls[0].initializer && unwrap(decls[0].initializer);
|
|
84
|
+
if (!init || !ts.isCallExpression(init))
|
|
85
|
+
return false;
|
|
86
|
+
const callee = unwrap(init.expression);
|
|
87
|
+
if (!ts.isIdentifier(callee) || !FAIL_CLOSED_SCOPE.has(callee.text))
|
|
88
|
+
return false;
|
|
89
|
+
if (init.arguments
|
|
90
|
+
.slice(1)
|
|
91
|
+
.some((a) => /allowNoIdentityMaster\s*:\s*(?!false\b)/.test(a.getText())))
|
|
92
|
+
return false;
|
|
93
|
+
const s1 = st[1];
|
|
94
|
+
if (!ts.isIfStatement(s1) ||
|
|
95
|
+
s1.elseStatement !== undefined ||
|
|
96
|
+
!refusesNonMasterOn(s1.expression, scopeVar) ||
|
|
97
|
+
!throwsOnly(s1.thenStatement))
|
|
98
|
+
return false;
|
|
99
|
+
if (st.length === 3) {
|
|
100
|
+
const s2 = st[2];
|
|
101
|
+
if (!ts.isReturnStatement(s2))
|
|
102
|
+
return false;
|
|
103
|
+
if (s2.expression && dotted(s2.expression) !== scopeVar)
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
function registrationNamed(p, name) {
|
|
109
|
+
return registrations(p).find((r) => r.name === name);
|
|
110
|
+
}
|
|
111
|
+
function localFunctions(p) {
|
|
112
|
+
const out = new Map();
|
|
113
|
+
for (const st of p.sf.statements) {
|
|
114
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body)
|
|
115
|
+
out.set(st.name.text, st);
|
|
116
|
+
else if (ts.isVariableStatement(st))
|
|
117
|
+
for (const d of st.declarationList.declarations) {
|
|
118
|
+
const i = d.initializer && unwrap(d.initializer);
|
|
119
|
+
if (ts.isIdentifier(d.name) &&
|
|
120
|
+
i &&
|
|
121
|
+
(ts.isArrowFunction(i) || ts.isFunctionExpression(i)))
|
|
122
|
+
out.set(d.name.text, i);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
const WRITE = /\bdb\.(patch|replace|delete|insert)$/;
|
|
128
|
+
const EFFECT = /\b(scheduler\.(runAfter|runAt)|runMutation|runAction)$/;
|
|
129
|
+
/** Shape 1 at a handler: an awaited top-level call, before the first write, to a
|
|
130
|
+
* FILE-LOCAL master-only wrapper. Returns the credit text or undefined. */
|
|
131
|
+
export function masterOnlyWrapperCredit(f, handlerName) {
|
|
132
|
+
const p = parsedOf(f);
|
|
133
|
+
const reg = registrationNamed(p, handlerName);
|
|
134
|
+
const body = reg?.handler.body;
|
|
135
|
+
if (!reg || !body || !ts.isBlock(body))
|
|
136
|
+
return undefined;
|
|
137
|
+
const locals = localFunctions(p);
|
|
138
|
+
for (const st of body.statements) {
|
|
139
|
+
let firstWrite = false;
|
|
140
|
+
walk(st, (x) => {
|
|
141
|
+
if (ts.isCallExpression(x)) {
|
|
142
|
+
const c = dotted(x.expression) ?? "";
|
|
143
|
+
if (WRITE.test(c) || EFFECT.test(c))
|
|
144
|
+
firstWrite = true;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (firstWrite)
|
|
148
|
+
return undefined;
|
|
149
|
+
const e = ts.isExpressionStatement(st)
|
|
150
|
+
? unwrap(st.expression)
|
|
151
|
+
: ts.isVariableStatement(st) &&
|
|
152
|
+
st.declarationList.declarations.length === 1 &&
|
|
153
|
+
st.declarationList.declarations[0].initializer
|
|
154
|
+
? unwrap(st.declarationList.declarations[0].initializer)
|
|
155
|
+
: undefined;
|
|
156
|
+
if (!e || !ts.isCallExpression(e))
|
|
157
|
+
continue;
|
|
158
|
+
const callee = unwrap(e.expression);
|
|
159
|
+
if (!ts.isIdentifier(callee))
|
|
160
|
+
continue;
|
|
161
|
+
const fn = locals.get(callee.text);
|
|
162
|
+
if (fn && isMasterOnlyWrapper(fn))
|
|
163
|
+
return `master-only: ${callee.text}() (file-local, ${f.rel}:${lineOf(fn)}) resolves the caller through a fail-closed withOrgScope and refuses every non-master on every path, called at line ${lineOf(st)} before the first write — a master-only write has no member boundary an argument could steer`;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/** Shape 2 at a handler. Returns the credit text or undefined. */
|
|
168
|
+
export function possessionGatedCredit(f, handlerName) {
|
|
169
|
+
const p = parsedOf(f);
|
|
170
|
+
const reg = registrationNamed(p, handlerName);
|
|
171
|
+
const body = reg?.handler.body;
|
|
172
|
+
if (!reg || !body)
|
|
173
|
+
return undefined;
|
|
174
|
+
const g = possessionGate(reg);
|
|
175
|
+
if (!g)
|
|
176
|
+
return undefined;
|
|
177
|
+
const markers = [];
|
|
178
|
+
const start = reg.line;
|
|
179
|
+
const lines = p.sf.text.split("\n");
|
|
180
|
+
for (let i = Math.max(0, start - 15); i < start - 1; i++) {
|
|
181
|
+
if (/@credential\b/.test(lines[i]))
|
|
182
|
+
markers.push("@credential");
|
|
183
|
+
if (/allow-no-caller-resolution:/.test(lines[i]))
|
|
184
|
+
markers.push("allow-no-caller-resolution");
|
|
185
|
+
}
|
|
186
|
+
return `possession-gated: the argument is hashed (line ${g.hashLine}) and matched through a unique index on ${g.table}.${g.field} (line ${g.rowLine}); every write (${g.writes}) is that one row's _id — no other row, no insert, no effect${markers.length ? ` (corroborated by marker(s) ${[...new Set(markers)].join(", ")}, never the reason)` : ""}`;
|
|
187
|
+
}
|
|
188
|
+
/** The possession gate of a registration, or undefined when the shape is not
|
|
189
|
+
* held in full. */
|
|
190
|
+
export function possessionGate(reg) {
|
|
191
|
+
const body = reg.handler.body;
|
|
192
|
+
if (!body)
|
|
193
|
+
return undefined;
|
|
194
|
+
const hashVars = new Map();
|
|
195
|
+
const rows = new Map();
|
|
196
|
+
walk(body, (x) => {
|
|
197
|
+
if (!ts.isVariableDeclaration(x) ||
|
|
198
|
+
!ts.isIdentifier(x.name) ||
|
|
199
|
+
!x.initializer)
|
|
200
|
+
return;
|
|
201
|
+
const init = unwrap(x.initializer);
|
|
202
|
+
if (ts.isCallExpression(init)) {
|
|
203
|
+
const c = dotted(init.expression) ?? "";
|
|
204
|
+
if (HASH_CALLEE.test(c.split(".").pop() ?? "") &&
|
|
205
|
+
init.arguments.some((a) => /\bargs\.\w+/.test(a.getText()))) {
|
|
206
|
+
hashVars.set(x.name.text, lineOf(x));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// ctx.db.query("T").withIndex(_, q => q.eq("f", h)).unique()
|
|
210
|
+
const outer = unwrap(init.expression);
|
|
211
|
+
if (ts.isPropertyAccessExpression(outer) &&
|
|
212
|
+
outer.name.text === "unique" &&
|
|
213
|
+
init.arguments.length === 0) {
|
|
214
|
+
let table;
|
|
215
|
+
let eq;
|
|
216
|
+
let eqCount = 0;
|
|
217
|
+
walk(init, (y) => {
|
|
218
|
+
if (!ts.isCallExpression(y))
|
|
219
|
+
return;
|
|
220
|
+
const yc = unwrap(y.expression);
|
|
221
|
+
if (!ts.isPropertyAccessExpression(yc))
|
|
222
|
+
return;
|
|
223
|
+
if (yc.name.text === "query" &&
|
|
224
|
+
y.arguments[0] &&
|
|
225
|
+
ts.isStringLiteralLike(y.arguments[0]))
|
|
226
|
+
table = y.arguments[0].text;
|
|
227
|
+
if (yc.name.text === "eq" &&
|
|
228
|
+
y.arguments.length === 2 &&
|
|
229
|
+
ts.isStringLiteralLike(y.arguments[0])) {
|
|
230
|
+
eqCount++;
|
|
231
|
+
eq = {
|
|
232
|
+
field: y.arguments[0].text,
|
|
233
|
+
value: y.arguments[1].getText(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (table && eq && eqCount === 1 && /hash/i.test(eq.field))
|
|
238
|
+
rows.set(x.name.text, {
|
|
239
|
+
table,
|
|
240
|
+
field: `${eq.field}=${eq.value}`,
|
|
241
|
+
line: lineOf(x),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
let gate;
|
|
247
|
+
for (const [rowVar, r] of rows) {
|
|
248
|
+
const [field, value] = r.field.split("=");
|
|
249
|
+
const hashLine = hashVars.get(value);
|
|
250
|
+
if (hashLine === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
gate = {
|
|
253
|
+
rowVar,
|
|
254
|
+
table: r.table,
|
|
255
|
+
field,
|
|
256
|
+
hashLine,
|
|
257
|
+
rowLine: r.line,
|
|
258
|
+
writes: 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (!gate)
|
|
262
|
+
return undefined;
|
|
263
|
+
let ok = true;
|
|
264
|
+
let writes = 0;
|
|
265
|
+
walk(body, (x) => {
|
|
266
|
+
if (!ts.isCallExpression(x))
|
|
267
|
+
return;
|
|
268
|
+
const c = dotted(x.expression) ?? "";
|
|
269
|
+
if (EFFECT.test(c))
|
|
270
|
+
ok = false;
|
|
271
|
+
const m = WRITE.exec(c);
|
|
272
|
+
if (!m)
|
|
273
|
+
return;
|
|
274
|
+
writes++;
|
|
275
|
+
if (m[1] === "insert")
|
|
276
|
+
ok = false;
|
|
277
|
+
else if (!x.arguments[0] ||
|
|
278
|
+
dotted(x.arguments[0]) !== `${gate?.rowVar}._id`)
|
|
279
|
+
ok = false;
|
|
280
|
+
});
|
|
281
|
+
if (!ok || writes === 0)
|
|
282
|
+
return undefined;
|
|
283
|
+
gate.writes = writes;
|
|
284
|
+
return gate;
|
|
285
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Detector } from "../../engine-types.js";
|
|
2
2
|
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare const READ_BOUND_TOKEN: RegExp;
|
|
3
4
|
export declare function scanR31(files: SrcFile[]): Scan;
|
|
4
5
|
export declare const R31: Detector;
|
|
5
6
|
/** In the form the orchestrator spreads into REGISTRY. */
|