@vantageos/backend-doctor 0.4.2 → 0.4.3
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/dist/build-stamp.json +2 -2
- package/dist/detectors/source/contract.js +1 -1
- package/dist/detectors/source/guard-helper-flow.d.ts +5 -1
- package/dist/detectors/source/guard-helper-flow.js +274 -10
- package/dist/detectors/source/identity.js +1 -1
- package/dist/detectors/source/local-identity.d.ts +5 -0
- package/dist/detectors/source/local-identity.js +448 -6
- package/dist/trigger.js +1 -1
- package/package.json +1 -1
package/dist/build-stamp.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Same kind as the six in security.ts: each reads the REAL backend SOURCE the
|
|
4
4
|
// engine hands it in `run(tree)` — it does NOT consult the oracle CSV. Every
|
|
5
5
|
// predicate below is derived from the ACTUAL convex source at
|
|
6
|
-
//
|
|
6
|
+
// vantage-peers convex/, NEVER from the standard's §15 prose. Where
|
|
7
7
|
// the standard's illustration disagrees with the source, the SOURCE wins and the
|
|
8
8
|
// detector records the stale line (see R-11's stale note).
|
|
9
9
|
//
|
|
@@ -91,10 +91,14 @@ export declare function summarizeValidator(fn: ts.FunctionLikeDeclaration, o: {
|
|
|
91
91
|
key: string;
|
|
92
92
|
depth: number;
|
|
93
93
|
}): ValidatorSummary;
|
|
94
|
+
/** Exported for its tests: the definition-side judgement above. */
|
|
95
|
+
export declare function isFleetStandingPredicate(fn: ts.FunctionLikeDeclaration): boolean;
|
|
94
96
|
/** Judge a function as a REFUSING PREDICATE (shape 2 above): parameter
|
|
95
97
|
* `identityIdx` receives the caller's verified scope, `boundIdx` the value the
|
|
96
98
|
* caller then writes. */
|
|
97
|
-
export declare function judgePredicateFunction(fn: ts.FunctionLikeDeclaration, identityIdx: number, boundIdx: number
|
|
99
|
+
export declare function judgePredicateFunction(fn: ts.FunctionLikeDeclaration, identityIdx: number, boundIdx: number,
|
|
100
|
+
/** names seen from `fn`'s file, to credit a fleet-standing predicate */
|
|
101
|
+
resolve?: CallResolver): HelperVerdict;
|
|
98
102
|
/** What a mutation handler's own body declares and refuses before `beforeLine`. */
|
|
99
103
|
export interface HandlerGuards {
|
|
100
104
|
/** Top-level `const` locals: whether bound straight to an identity read, or
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// carrying return/throw/break/continue, a `finally`, an expression-bodied
|
|
31
31
|
// arrow) makes the verdict COULD-NOT-JUDGE — never honoured.
|
|
32
32
|
import * as ts from "typescript";
|
|
33
|
+
import { IDENTITY_PACKAGE } from "./identity-by-id.js";
|
|
33
34
|
class Undecidable extends Error {
|
|
34
35
|
}
|
|
35
36
|
const isFunctionLike = (n) => ts.isFunctionDeclaration(n) ||
|
|
@@ -251,7 +252,7 @@ class Walker {
|
|
|
251
252
|
const v = this.v;
|
|
252
253
|
if (!v)
|
|
253
254
|
return s;
|
|
254
|
-
if (positiveMasterTest(cond, v.pure))
|
|
255
|
+
if (positiveMasterTest(cond, v.pure, v.resolve))
|
|
255
256
|
return { ...s, guarded: true, master: true };
|
|
256
257
|
if (this.posBinds(cond, s.tainted))
|
|
257
258
|
return { ...s, guarded: true, bound: true };
|
|
@@ -297,7 +298,7 @@ class Walker {
|
|
|
297
298
|
const c = unwrap(cond);
|
|
298
299
|
if (ts.isPrefixUnaryExpression(c) &&
|
|
299
300
|
c.operator === ts.SyntaxKind.ExclamationToken)
|
|
300
|
-
return positiveMasterTest(c.operand, pure);
|
|
301
|
+
return positiveMasterTest(c.operand, pure, this.v?.resolve);
|
|
301
302
|
if (ts.isBinaryExpression(c) &&
|
|
302
303
|
c.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
303
304
|
return this.negMaster(c.left, pure) || this.negMaster(c.right, pure);
|
|
@@ -321,7 +322,7 @@ class Walker {
|
|
|
321
322
|
// An un-awaited async predicate is a Promise: always truthy.
|
|
322
323
|
if (!awaitedCall(cond) && returnsPromise(d.fn))
|
|
323
324
|
return false;
|
|
324
|
-
return judgePredicateFunction(d.fn, i, j).verdict === "honoured";
|
|
325
|
+
return judgePredicateFunction(d.fn, i, j, d.resolve).verdict === "honoured";
|
|
325
326
|
}
|
|
326
327
|
/** A statement `[await] Q(…)` whose callee the tree defines: Q, walked as a
|
|
327
328
|
* validator with the identity and target this call hands it, either binds
|
|
@@ -451,7 +452,7 @@ class Walker {
|
|
|
451
452
|
: this.negBinds(cond, cs.tainted)),
|
|
452
453
|
master: p.master ||
|
|
453
454
|
(holds
|
|
454
|
-
? positiveMasterTest(cond, v.pure)
|
|
455
|
+
? positiveMasterTest(cond, v.pure, v.resolve)
|
|
455
456
|
: this.negMaster(cond, v.pure)),
|
|
456
457
|
};
|
|
457
458
|
if (alwaysThrows(a))
|
|
@@ -918,21 +919,282 @@ export function summarizeValidator(fn, o) {
|
|
|
918
919
|
}
|
|
919
920
|
/** `cond` is a POSITIVE test of `isMaster` on the identity (alone or as a
|
|
920
921
|
* conjunct): the arm it guards runs for the fleet master only. */
|
|
921
|
-
function positiveMasterTest(cond, tainted) {
|
|
922
|
+
function positiveMasterTest(cond, tainted, resolve) {
|
|
922
923
|
const c = unwrap(cond);
|
|
923
924
|
if (ts.isBinaryExpression(c) &&
|
|
924
925
|
c.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
925
|
-
return (positiveMasterTest(c.left, tainted) ||
|
|
926
|
-
positiveMasterTest(c.right, tainted));
|
|
926
|
+
return (positiveMasterTest(c.left, tainted, resolve) ||
|
|
927
|
+
positiveMasterTest(c.right, tainted, resolve));
|
|
927
928
|
if (ts.isBinaryExpression(c) &&
|
|
928
929
|
(c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
929
930
|
c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
|
|
930
931
|
c.right.kind === ts.SyntaxKind.TrueKeyword)
|
|
931
|
-
return positiveMasterTest(c.left, tainted);
|
|
932
|
+
return positiveMasterTest(c.left, tainted, resolve);
|
|
933
|
+
if (resolve && fleetStandingCall(c, tainted, resolve))
|
|
934
|
+
return true;
|
|
932
935
|
return (ts.isPropertyAccessExpression(c) &&
|
|
933
936
|
c.name.text === "isMaster" &&
|
|
934
937
|
dependsOnIdentity(c.expression, tainted));
|
|
935
938
|
}
|
|
939
|
+
// ---------------------------------------------------------------------------
|
|
940
|
+
// THE FLEET STANDING. vantage-peers 6b44c8f (convex/lib/auth.ts) decides
|
|
941
|
+
// "fleet" on `hasFleetStanding(scope)`: a standing `withOrgScope` attaches from
|
|
942
|
+
// what @vantageos/cloud-identity resolved (a `fleet` principal, the
|
|
943
|
+
// `operator-reader` person the package judged an org admin, or the explicit
|
|
944
|
+
// `internal` marker). Read on its definition, that call is the same fleet
|
|
945
|
+
// carve-out as a positive `scope.isMaster` test — never on its name.
|
|
946
|
+
//
|
|
947
|
+
// `F(<identity>)` is credited when F resolves in the tree and its definition:
|
|
948
|
+
// - takes ONE plain parameter P, handed the identity itself (not a value built
|
|
949
|
+
// from it), and makes no call;
|
|
950
|
+
// - reads nothing off P but `P.standing` (directly or through a const);
|
|
951
|
+
// - can be truthy only through `<standing>.principal… === <literal>` (the kind
|
|
952
|
+
// of the principal the package produced, or its absence for `internal`);
|
|
953
|
+
// every other return is a falsy literal, and at least one return reads
|
|
954
|
+
// `<standing>.principal.kind`;
|
|
955
|
+
// - returns the principal's ABSENCE (`<standing>.principal === undefined` /
|
|
956
|
+
// `null`) only inside a branch selected by the grant `internal`: the
|
|
957
|
+
// `case "internal":` clause of a `switch (<standing>.grant)` that no other
|
|
958
|
+
// case falls into, or the then-arm of `if (<standing>.grant === "internal")`
|
|
959
|
+
// (the else-arm of `!==`). An absence returned under `default:`, another
|
|
960
|
+
// case, an unrelated branch, or a concise arrow body makes every grant that
|
|
961
|
+
// carries no principal read as fleet, so the predicate is credited nothing.
|
|
962
|
+
// The guard-clause form (`if (<standing>.grant !== "internal") return false;`
|
|
963
|
+
// then the absence test) is NOT credited either: only then/else arms and
|
|
964
|
+
// `case` clauses are read. Fail-closed by design; write the `case` or `if`
|
|
965
|
+
// form to be credited (Eta on #68, probe V6);
|
|
966
|
+
// - lives in a file that imports @vantageos/cloud-identity AND produces that
|
|
967
|
+
// principal: a `principal: { …, kind: x.kind }` built from a parameter whose
|
|
968
|
+
// declared type is a name imported from the package.
|
|
969
|
+
// A local boolean named the same (reads `isMaster`, returns `true`, reads only
|
|
970
|
+
// `standing.grant`), a body in a file with no package producer, or a name with
|
|
971
|
+
// no definition in the tree is credited nothing.
|
|
972
|
+
// ---------------------------------------------------------------------------
|
|
973
|
+
const standingMemo = new WeakMap();
|
|
974
|
+
function fleetStandingCall(c, tainted, resolve) {
|
|
975
|
+
if (!ts.isCallExpression(c) || !ts.isIdentifier(c.expression))
|
|
976
|
+
return false;
|
|
977
|
+
if (c.arguments.length !== 1)
|
|
978
|
+
return false;
|
|
979
|
+
const a = unwrap(c.arguments[0]);
|
|
980
|
+
if (!ts.isIdentifier(a) || !tainted.has(a.text))
|
|
981
|
+
return false;
|
|
982
|
+
const d = resolve(c.expression.text);
|
|
983
|
+
if (!d)
|
|
984
|
+
return false;
|
|
985
|
+
let v = standingMemo.get(d.fn);
|
|
986
|
+
if (v === undefined) {
|
|
987
|
+
v = isFleetStandingPredicate(d.fn);
|
|
988
|
+
standingMemo.set(d.fn, v);
|
|
989
|
+
}
|
|
990
|
+
return v;
|
|
991
|
+
}
|
|
992
|
+
const EQ_OPS = new Set([
|
|
993
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
994
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
995
|
+
]);
|
|
996
|
+
const NE_OPS = new Set([
|
|
997
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
998
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
999
|
+
]);
|
|
1000
|
+
const isLiteralValue = (e) => {
|
|
1001
|
+
const u = unwrap(e);
|
|
1002
|
+
return (ts.isStringLiteralLike(u) ||
|
|
1003
|
+
u.kind === ts.SyntaxKind.NullKeyword ||
|
|
1004
|
+
(ts.isIdentifier(u) && u.text === "undefined"));
|
|
1005
|
+
};
|
|
1006
|
+
/** Exported for its tests: the definition-side judgement above. */
|
|
1007
|
+
export function isFleetStandingPredicate(fn) {
|
|
1008
|
+
if (fn.parameters.length !== 1)
|
|
1009
|
+
return false;
|
|
1010
|
+
const pn = fn.parameters[0].name;
|
|
1011
|
+
if (!ts.isIdentifier(pn) || !fn.body)
|
|
1012
|
+
return false;
|
|
1013
|
+
const P = pn.text;
|
|
1014
|
+
const sf = fn.getSourceFile();
|
|
1015
|
+
if (!packageProducesPrincipal(sf))
|
|
1016
|
+
return false;
|
|
1017
|
+
// locals bound to `P.standing`
|
|
1018
|
+
const standingLocals = new Set();
|
|
1019
|
+
const isStanding = (e) => {
|
|
1020
|
+
const u = unwrap(e);
|
|
1021
|
+
if (ts.isIdentifier(u))
|
|
1022
|
+
return standingLocals.has(u.text);
|
|
1023
|
+
return (ts.isPropertyAccessExpression(u) &&
|
|
1024
|
+
u.name.text === "standing" &&
|
|
1025
|
+
ts.isIdentifier(unwrap(u.expression)) &&
|
|
1026
|
+
unwrap(u.expression).text === P);
|
|
1027
|
+
};
|
|
1028
|
+
let ok = true;
|
|
1029
|
+
walkOwn(fn.body, (x) => {
|
|
1030
|
+
if (ts.isVariableDeclaration(x)) {
|
|
1031
|
+
if (ts.isIdentifier(x.name) && x.initializer && isStanding(x.initializer))
|
|
1032
|
+
standingLocals.add(x.name.text);
|
|
1033
|
+
else
|
|
1034
|
+
ok = false;
|
|
1035
|
+
}
|
|
1036
|
+
if (ts.isCallExpression(x) || ts.isNewExpression(x))
|
|
1037
|
+
ok = false;
|
|
1038
|
+
if (ts.isBinaryExpression(x) &&
|
|
1039
|
+
x.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1040
|
+
x.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
|
|
1041
|
+
ok = false;
|
|
1042
|
+
// nothing read off P but `standing`
|
|
1043
|
+
if (ts.isIdentifier(x) &&
|
|
1044
|
+
x.text === P &&
|
|
1045
|
+
x.parent !== fn.parameters[0] &&
|
|
1046
|
+
!(x.parent &&
|
|
1047
|
+
ts.isPropertyAccessExpression(x.parent) &&
|
|
1048
|
+
x.parent.expression === x &&
|
|
1049
|
+
x.parent.name.text === "standing"))
|
|
1050
|
+
ok = false;
|
|
1051
|
+
});
|
|
1052
|
+
if (!ok)
|
|
1053
|
+
return false;
|
|
1054
|
+
// `<standing>.principal` / `<standing>.principal.kind` against a literal
|
|
1055
|
+
const principalSide = (e) => {
|
|
1056
|
+
const u = unwrap(e);
|
|
1057
|
+
if (!ts.isPropertyAccessExpression(u))
|
|
1058
|
+
return undefined;
|
|
1059
|
+
if (u.name.text === "principal" && isStanding(u.expression))
|
|
1060
|
+
return "principal";
|
|
1061
|
+
const inner = unwrap(u.expression);
|
|
1062
|
+
if (u.name.text === "kind" &&
|
|
1063
|
+
ts.isPropertyAccessExpression(inner) &&
|
|
1064
|
+
inner.name.text === "principal" &&
|
|
1065
|
+
isStanding(inner.expression))
|
|
1066
|
+
return "kind";
|
|
1067
|
+
return undefined;
|
|
1068
|
+
};
|
|
1069
|
+
// `<standing>.grant` compared with the literal "internal"
|
|
1070
|
+
const isGrant = (g) => {
|
|
1071
|
+
const x = unwrap(g);
|
|
1072
|
+
return (ts.isPropertyAccessExpression(x) &&
|
|
1073
|
+
x.name.text === "grant" &&
|
|
1074
|
+
isStanding(x.expression));
|
|
1075
|
+
};
|
|
1076
|
+
const isInternal = (l) => {
|
|
1077
|
+
const x = unwrap(l);
|
|
1078
|
+
return ts.isStringLiteralLike(x) && x.text === "internal";
|
|
1079
|
+
};
|
|
1080
|
+
const isInternalTest = (e, ops) => {
|
|
1081
|
+
const u = unwrap(e);
|
|
1082
|
+
if (!ts.isBinaryExpression(u) || !ops.has(u.operatorToken.kind))
|
|
1083
|
+
return false;
|
|
1084
|
+
return ((isGrant(u.left) && isInternal(u.right)) ||
|
|
1085
|
+
(isGrant(u.right) && isInternal(u.left)));
|
|
1086
|
+
};
|
|
1087
|
+
const endsBranch = (sts) => {
|
|
1088
|
+
const last = sts[sts.length - 1];
|
|
1089
|
+
return (last !== undefined &&
|
|
1090
|
+
(ts.isReturnStatement(last) ||
|
|
1091
|
+
ts.isThrowStatement(last) ||
|
|
1092
|
+
ts.isBreakStatement(last)));
|
|
1093
|
+
};
|
|
1094
|
+
/** Is `at` inside a branch that only the grant `internal` selects? */
|
|
1095
|
+
const underInternal = (at) => {
|
|
1096
|
+
for (let c = at, p = at.parent; p && c !== fn.body;) {
|
|
1097
|
+
if (ts.isCaseClause(p)) {
|
|
1098
|
+
const sw = p.parent.parent;
|
|
1099
|
+
const ix = p.parent.clauses.indexOf(p);
|
|
1100
|
+
const prev = ix > 0 ? p.parent.clauses[ix - 1] : undefined;
|
|
1101
|
+
if (ts.isSwitchStatement(sw) &&
|
|
1102
|
+
isGrant(sw.expression) &&
|
|
1103
|
+
isInternal(p.expression) &&
|
|
1104
|
+
// no other case falls into this one
|
|
1105
|
+
(prev === undefined || endsBranch(prev.statements)))
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
if (ts.isIfStatement(p)) {
|
|
1109
|
+
if (p.thenStatement === c && isInternalTest(p.expression, EQ_OPS))
|
|
1110
|
+
return true;
|
|
1111
|
+
if (p.elseStatement === c && isInternalTest(p.expression, NE_OPS))
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
c = p;
|
|
1115
|
+
p = p.parent;
|
|
1116
|
+
}
|
|
1117
|
+
return false;
|
|
1118
|
+
};
|
|
1119
|
+
let readsKind = false;
|
|
1120
|
+
const truthyOk = (e, at) => {
|
|
1121
|
+
if (isFalsyLiteral(e))
|
|
1122
|
+
return true;
|
|
1123
|
+
const u = unwrap(e);
|
|
1124
|
+
if (!ts.isBinaryExpression(u) || !EQ_OPS.has(u.operatorToken.kind))
|
|
1125
|
+
return false;
|
|
1126
|
+
const side = (isLiteralValue(u.right) && principalSide(u.left)) ||
|
|
1127
|
+
(isLiteralValue(u.left) && principalSide(u.right));
|
|
1128
|
+
if (!side)
|
|
1129
|
+
return false;
|
|
1130
|
+
if (side === "kind")
|
|
1131
|
+
readsKind = true;
|
|
1132
|
+
// the principal's absence is fleet standing for `internal` alone
|
|
1133
|
+
if (side === "principal" && (at === undefined || !underInternal(at)))
|
|
1134
|
+
return false;
|
|
1135
|
+
return true;
|
|
1136
|
+
};
|
|
1137
|
+
if (!ts.isBlock(fn.body))
|
|
1138
|
+
return truthyOk(fn.body, undefined) && readsKind;
|
|
1139
|
+
let returnsAll = true;
|
|
1140
|
+
walkOwn(fn.body, (x) => {
|
|
1141
|
+
if (ts.isReturnStatement(x) && !truthyOk(x.expression, x))
|
|
1142
|
+
returnsAll = false;
|
|
1143
|
+
});
|
|
1144
|
+
return returnsAll && readsKind;
|
|
1145
|
+
}
|
|
1146
|
+
/** The file imports @vantageos/cloud-identity and builds a standing principal
|
|
1147
|
+
* from a value typed with one of the package's names:
|
|
1148
|
+
* `principal: { …, kind: <param>.kind }` where `<param>: <PackageName>`. */
|
|
1149
|
+
function packageProducesPrincipal(sf) {
|
|
1150
|
+
const fromPackage = new Set();
|
|
1151
|
+
for (const st of sf.statements) {
|
|
1152
|
+
if (!ts.isImportDeclaration(st) ||
|
|
1153
|
+
!ts.isStringLiteral(st.moduleSpecifier) ||
|
|
1154
|
+
st.moduleSpecifier.text !== IDENTITY_PACKAGE)
|
|
1155
|
+
continue;
|
|
1156
|
+
const b = st.importClause?.namedBindings;
|
|
1157
|
+
if (b && ts.isNamedImports(b))
|
|
1158
|
+
for (const e of b.elements)
|
|
1159
|
+
fromPackage.add(e.name.text);
|
|
1160
|
+
}
|
|
1161
|
+
if (fromPackage.size === 0)
|
|
1162
|
+
return false;
|
|
1163
|
+
let found = false;
|
|
1164
|
+
const visit = (x) => {
|
|
1165
|
+
if (found)
|
|
1166
|
+
return;
|
|
1167
|
+
if (ts.isPropertyAssignment(x) &&
|
|
1168
|
+
ts.isIdentifier(x.name) &&
|
|
1169
|
+
x.name.text === "kind" &&
|
|
1170
|
+
x.parent?.parent &&
|
|
1171
|
+
ts.isPropertyAssignment(x.parent.parent) &&
|
|
1172
|
+
ts.isIdentifier(x.parent.parent.name) &&
|
|
1173
|
+
x.parent.parent.name.text === "principal") {
|
|
1174
|
+
const v = unwrap(x.initializer);
|
|
1175
|
+
const root = ts.isPropertyAccessExpression(v) && v.name.text === "kind"
|
|
1176
|
+
? unwrap(v.expression)
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (root && ts.isIdentifier(root)) {
|
|
1179
|
+
// the parameter it reads, typed with a package name
|
|
1180
|
+
for (let f = x.parent; f; f = f.parent) {
|
|
1181
|
+
if (!ts.isFunctionLike(f))
|
|
1182
|
+
continue;
|
|
1183
|
+
const prm = f.parameters.find((q) => ts.isIdentifier(q.name) && q.name.text === root.text);
|
|
1184
|
+
if (prm?.type &&
|
|
1185
|
+
ts.isTypeReferenceNode(prm.type) &&
|
|
1186
|
+
ts.isIdentifier(prm.type.typeName) &&
|
|
1187
|
+
fromPackage.has(prm.type.typeName.text))
|
|
1188
|
+
found = true;
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
x.forEachChild(visit);
|
|
1194
|
+
};
|
|
1195
|
+
visit(sf);
|
|
1196
|
+
return found;
|
|
1197
|
+
}
|
|
936
1198
|
/** `e` can only be truthy when the bound value EQUALS an identity-derived one,
|
|
937
1199
|
* or lies UNDER an identity-derived prefix (`ns.startsWith(`${ownPrefix}/`)`).
|
|
938
1200
|
* A conjunction binds when either conjunct does; a disjunction only when BOTH
|
|
@@ -986,7 +1248,9 @@ const isFalsyLiteral = (e) => {
|
|
|
986
1248
|
/** Judge a function as a REFUSING PREDICATE (shape 2 above): parameter
|
|
987
1249
|
* `identityIdx` receives the caller's verified scope, `boundIdx` the value the
|
|
988
1250
|
* caller then writes. */
|
|
989
|
-
export function judgePredicateFunction(fn, identityIdx, boundIdx
|
|
1251
|
+
export function judgePredicateFunction(fn, identityIdx, boundIdx,
|
|
1252
|
+
/** names seen from `fn`'s file, to credit a fleet-standing predicate */
|
|
1253
|
+
resolve) {
|
|
990
1254
|
if (!fn.body || !ts.isBlock(fn.body))
|
|
991
1255
|
return {
|
|
992
1256
|
verdict: "could-not-judge",
|
|
@@ -1020,7 +1284,7 @@ export function judgePredicateFunction(fn, identityIdx, boundIdx) {
|
|
|
1020
1284
|
return null;
|
|
1021
1285
|
const u = unwrap(e);
|
|
1022
1286
|
if (u.kind === ts.SyntaxKind.TrueKeyword) {
|
|
1023
|
-
if (cond && positiveMasterTest(cond, tainted))
|
|
1287
|
+
if (cond && positiveMasterTest(cond, tainted, resolve))
|
|
1024
1288
|
return null;
|
|
1025
1289
|
return {
|
|
1026
1290
|
verdict: "refused",
|
|
@@ -704,7 +704,7 @@ function predicateBoundValues(h, f, writeLine, fns, src) {
|
|
|
704
704
|
const d = fns.lookup(r.predicate, f.rel);
|
|
705
705
|
if (!d)
|
|
706
706
|
continue;
|
|
707
|
-
const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j));
|
|
707
|
+
const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j, fns.callResolver(d.rel)));
|
|
708
708
|
if (v.verdict === "honoured")
|
|
709
709
|
out.set(value, `${r.predicate}() (definition ${d.rel}:${d.f.line})`);
|
|
710
710
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
1
2
|
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
3
|
import { type Site, type SrcFile } from "./_scan.js";
|
|
3
4
|
export declare const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
|
|
@@ -18,6 +19,10 @@ export interface LocalIdentityScan {
|
|
|
18
19
|
exempt: Site[];
|
|
19
20
|
cnj: Site[];
|
|
20
21
|
}
|
|
22
|
+
export type DoorCheck = (call: ts.CallExpression) => boolean;
|
|
23
|
+
/** Every reference to `name` in the tree (outside its declaration, imports and
|
|
24
|
+
* exports) is a call whose result goes into a call argument; at least one. */
|
|
25
|
+
export type CallersForward = (name: string) => boolean;
|
|
21
26
|
export declare function scanLocalIdentity(files: SrcFile[]): LocalIdentityScan;
|
|
22
27
|
export declare function localIdentityResult(scan: LocalIdentityScan): DetectorResult;
|
|
23
28
|
/** The rendered section: EVERY violation, exemption and abstention is named. */
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
// per-module split, and gives a non-zero exit.
|
|
72
72
|
import * as ts from "typescript";
|
|
73
73
|
import { siteCount, withCount } from "../../violation-count.js";
|
|
74
|
-
import { calleeName, dotted, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
|
|
74
|
+
import { calleeName, dotted, indexFunctions, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
|
|
75
75
|
import { isConvexOrMcp, isJudgeable, } from "./_scan.js";
|
|
76
76
|
import { conformantMappingReads, IDENTITY_PACKAGE, scanR53Detailed, } from "./identity-by-id.js";
|
|
77
77
|
export const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
|
|
@@ -301,8 +301,435 @@ const isNormaliserCall = (e) => {
|
|
|
301
301
|
const u = unwrap(e);
|
|
302
302
|
return ts.isCallExpression(u) && NAME_NORMALISER.test(calleeName(u) ?? "");
|
|
303
303
|
};
|
|
304
|
+
// ---- forwarding a verified claim ------------------------------------------
|
|
305
|
+
//
|
|
306
|
+
// A function that copies a verified-claim field (`oauthCtx.fromAllowList`) into
|
|
307
|
+
// the arguments of a backend call decides nothing from it: the door it calls
|
|
308
|
+
// does the deciding. That is the CONFORMING shape — the claim travels to the
|
|
309
|
+
// one place allowed to judge it. vantage-peers 9e224b2 `rosterClaimArgs`
|
|
310
|
+
// (mcp-server/src/tools.ts:1849) needed an `allow-local-identity:` marker only
|
|
311
|
+
// because the read was taken for a filter.
|
|
312
|
+
//
|
|
313
|
+
// Credited ONLY when, mechanically:
|
|
314
|
+
// - the call it reaches is a BACKEND DOOR (`doorCheckIn`:
|
|
315
|
+
// `convex|client|<x>Client .query/.mutation/.action`, `<ctx>.runQuery/
|
|
316
|
+
// runMutation/runAction`, on a receiver whose BINDING is a parameter, a
|
|
317
|
+
// package import, a constructed client or a judged client wrapper — never
|
|
318
|
+
// its name alone, Eta probe V5). A claim handed to a function defined in the tree,
|
|
319
|
+
// to an unknown helper, or to a storage write is NOT forwarded: that callee
|
|
320
|
+
// is where the decision would move (`etaAdmits(rosterClaimArgs(ctx), who)`),
|
|
321
|
+
// and nothing judges it here (Eta on #68, probe V2);
|
|
322
|
+
// - the read's value reaches a call ARGUMENT (or an object literal that is a
|
|
323
|
+
// call argument) through nothing but parentheses, casts, non-null, and a
|
|
324
|
+
// `?? <literal | [] | {}>` / `|| <…>` fallback — any other use (a `.filter`,
|
|
325
|
+
// `.includes`, a comparison, a local binding) is not a forward;
|
|
326
|
+
// - or that object literal is RETURNED by a named function (a declaration, or
|
|
327
|
+
// a `const` bound to an arrow / function expression) and EVERY reference to
|
|
328
|
+
// that name in the tree is a call whose result is itself handed to a
|
|
329
|
+
// backend door's argument — directly, spread into the arguments, or spread into an object
|
|
330
|
+
// literal that is a call argument. A function nobody calls, a callback, a
|
|
331
|
+
// property-assigned arrow, or one caller that keeps the result (to compare
|
|
332
|
+
// it, to bind it, to return it) credits nothing: a tool does not clear a
|
|
333
|
+
// site whose consumer nobody judged;
|
|
334
|
+
// - the receiver is not the caller's own `args` (a client-supplied list is not
|
|
335
|
+
// a verified claim);
|
|
336
|
+
// - the function holding it makes NO identity decision: no equality between
|
|
337
|
+
// two non-literal values, no equality reading a name or slug field, no
|
|
338
|
+
// membership test (`includes`/`has`/`indexOf`/`some`/`every`) on a
|
|
339
|
+
// non-literal, and no refusing `if` (then-arm returns or throws) whose
|
|
340
|
+
// condition reads a field of the object the claim is read off. Any one of
|
|
341
|
+
// these keeps the read red.
|
|
342
|
+
const FALLBACK_OPS = new Set([
|
|
343
|
+
ts.SyntaxKind.QuestionQuestionToken,
|
|
344
|
+
ts.SyntaxKind.BarBarToken,
|
|
345
|
+
]);
|
|
346
|
+
const isInertFallback = (e) => {
|
|
347
|
+
const u = unwrap(e);
|
|
348
|
+
return (isLiteral(u) ||
|
|
349
|
+
(ts.isArrayLiteralExpression(u) && u.elements.length === 0) ||
|
|
350
|
+
(ts.isObjectLiteralExpression(u) && u.properties.length === 0));
|
|
351
|
+
};
|
|
352
|
+
const rootIdentifier = (e) => {
|
|
353
|
+
let x = unwrap(e);
|
|
354
|
+
for (;;) {
|
|
355
|
+
const r = propRead(x);
|
|
356
|
+
if (r)
|
|
357
|
+
x = unwrap(r.obj);
|
|
358
|
+
else if (ts.isCallExpression(x))
|
|
359
|
+
return undefined;
|
|
360
|
+
else
|
|
361
|
+
return ts.isIdentifier(x) ? x : undefined;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
/** The calls allowed to judge a forwarded claim: a Convex door reached
|
|
365
|
+
* through a client (`convex.query/mutation/action`, `client.…`,
|
|
366
|
+
* `<x>Client.…`) or from a function context (`<ctx>.runQuery/runMutation/
|
|
367
|
+
* runAction`). Never a function defined in the tree, never a storage write
|
|
368
|
+
* (`ctx.db.patch` / `insert`), never an unknown helper: none of those judges
|
|
369
|
+
* the claim, so handing it to one is not forwarding it.
|
|
370
|
+
*
|
|
371
|
+
* The door is recognised by its receiver's BINDING, never by its name alone
|
|
372
|
+
* (Eta on #68, probe V5: a local `const etaClient = { query: <decides> }`
|
|
373
|
+
* named like a client). The receiver must be a plain identifier that resolves,
|
|
374
|
+
* in its own file, to one of:
|
|
375
|
+
* - a parameter of an enclosing function (the caller hands the client in);
|
|
376
|
+
* - an import from a PACKAGE (a non-relative specifier);
|
|
377
|
+
* - a `const` built by a known client constructor (`new ConvexHttpClient(…)`,
|
|
378
|
+
* `new ConvexClient(…)`, `new ConvexReactClient(…)`);
|
|
379
|
+
* - a `const` built by a CLIENT WRAPPER `W(<receiver>, …)` whose first
|
|
380
|
+
* argument itself qualifies, where every definition of `W` in the tree takes
|
|
381
|
+
* the client as its first parameter and returns only that parameter or
|
|
382
|
+
* `new Proxy(<that parameter>, …)` (vantage-peers `withVerifiedActor`).
|
|
383
|
+
* A `W` with no definition in the tree credits nothing.
|
|
384
|
+
* Anything else is refused: an object literal, an arrow or function, a class,
|
|
385
|
+
* a `let`, a loop variable, a relative (tree) import, an unresolved name, or a
|
|
386
|
+
* property-access receiver (`ctx.db.query`, `deps.client.query`).
|
|
387
|
+
* NOT JUDGED (declared): the handler of a `new Proxy(<client>, handler)`
|
|
388
|
+
* wrapper; such a handler could decide, and is credited as a pass-through. */
|
|
389
|
+
const CLIENT_DOOR_METHODS = new Set([
|
|
390
|
+
"query",
|
|
391
|
+
"mutation",
|
|
392
|
+
"action",
|
|
393
|
+
]);
|
|
394
|
+
const CTX_DOOR_METHODS = new Set([
|
|
395
|
+
"runQuery",
|
|
396
|
+
"runMutation",
|
|
397
|
+
"runAction",
|
|
398
|
+
]);
|
|
399
|
+
const CLIENT_RECEIVER = /^(convex|client|[A-Za-z_$][\w$]*Client)$/;
|
|
400
|
+
const CLIENT_CONSTRUCTORS = new Set([
|
|
401
|
+
"ConvexHttpClient",
|
|
402
|
+
"ConvexClient",
|
|
403
|
+
"ConvexReactClient",
|
|
404
|
+
]);
|
|
405
|
+
/** Does a binding name (identifier or destructuring pattern) bind `name`? */
|
|
406
|
+
function bindsName(b, name) {
|
|
407
|
+
if (ts.isIdentifier(b))
|
|
408
|
+
return b.text === name;
|
|
409
|
+
const els = b.elements;
|
|
410
|
+
return els.some((e) => !ts.isOmittedExpression(e) && bindsName(e.name, name));
|
|
411
|
+
}
|
|
412
|
+
/** The declaration `id` resolves to, by lexical scope, in its own file. */
|
|
413
|
+
function bindingOf(id) {
|
|
414
|
+
const name = id.text;
|
|
415
|
+
for (let n = id.parent; n; n = n.parent) {
|
|
416
|
+
if (ts.isFunctionLike(n)) {
|
|
417
|
+
if (n.parameters.some((prm) => bindsName(prm.name, name)))
|
|
418
|
+
return { kind: "param" };
|
|
419
|
+
if (ts.isFunctionExpression(n) && n.name?.text === name)
|
|
420
|
+
return { kind: "other" };
|
|
421
|
+
}
|
|
422
|
+
if ((ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) &&
|
|
423
|
+
n.initializer &&
|
|
424
|
+
ts.isVariableDeclarationList(n.initializer) &&
|
|
425
|
+
n.initializer.declarations.some((d) => bindsName(d.name, name)))
|
|
426
|
+
return { kind: "other" };
|
|
427
|
+
if (ts.isCatchClause(n) &&
|
|
428
|
+
n.variableDeclaration &&
|
|
429
|
+
bindsName(n.variableDeclaration.name, name))
|
|
430
|
+
return { kind: "other" };
|
|
431
|
+
const stmts = ts.isBlock(n) ||
|
|
432
|
+
ts.isSourceFile(n) ||
|
|
433
|
+
ts.isModuleBlock(n) ||
|
|
434
|
+
ts.isCaseClause(n) ||
|
|
435
|
+
ts.isDefaultClause(n)
|
|
436
|
+
? n.statements
|
|
437
|
+
: undefined;
|
|
438
|
+
if (!stmts)
|
|
439
|
+
continue;
|
|
440
|
+
for (const st of stmts) {
|
|
441
|
+
if (ts.isVariableStatement(st)) {
|
|
442
|
+
const isConst = (st.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
443
|
+
for (const d of st.declarationList.declarations) {
|
|
444
|
+
if (!bindsName(d.name, name))
|
|
445
|
+
continue;
|
|
446
|
+
return isConst && ts.isIdentifier(d.name)
|
|
447
|
+
? { kind: "const", init: d.initializer }
|
|
448
|
+
: { kind: "other" };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if ((ts.isFunctionDeclaration(st) ||
|
|
452
|
+
ts.isClassDeclaration(st) ||
|
|
453
|
+
ts.isEnumDeclaration(st) ||
|
|
454
|
+
ts.isModuleDeclaration(st)) &&
|
|
455
|
+
st.name &&
|
|
456
|
+
ts.isIdentifier(st.name) &&
|
|
457
|
+
st.name.text === name)
|
|
458
|
+
return { kind: "other" };
|
|
459
|
+
if (ts.isImportDeclaration(st) &&
|
|
460
|
+
ts.isStringLiteral(st.moduleSpecifier)) {
|
|
461
|
+
const c = st.importClause;
|
|
462
|
+
const b = c?.namedBindings;
|
|
463
|
+
const hit = c?.name?.text === name ||
|
|
464
|
+
(b !== undefined &&
|
|
465
|
+
(ts.isNamespaceImport(b)
|
|
466
|
+
? b.name.text === name
|
|
467
|
+
: b.elements.some((e) => e.name.text === name)));
|
|
468
|
+
if (hit)
|
|
469
|
+
return { kind: "import", spec: st.moduleSpecifier.text };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
/** Own return expressions of `fn` (nested functions excluded); a concise
|
|
476
|
+
* arrow body counts as its one return. */
|
|
477
|
+
function ownReturns(fn) {
|
|
478
|
+
if (!fn.body)
|
|
479
|
+
return [];
|
|
480
|
+
if (!ts.isBlock(fn.body))
|
|
481
|
+
return [fn.body];
|
|
482
|
+
const out = [];
|
|
483
|
+
const visit = (x) => {
|
|
484
|
+
if (ts.isFunctionLike(x) || ts.isClassLike(x))
|
|
485
|
+
return;
|
|
486
|
+
if (ts.isReturnStatement(x))
|
|
487
|
+
out.push(x.expression);
|
|
488
|
+
ts.forEachChild(x, visit);
|
|
489
|
+
};
|
|
490
|
+
ts.forEachChild(fn.body, visit);
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
/** `fn` takes the client first and hands back only that client, bare or behind
|
|
494
|
+
* `new Proxy(<client>, …)`. */
|
|
495
|
+
function isClientWrapper(fn) {
|
|
496
|
+
const p0 = fn.parameters[0]?.name;
|
|
497
|
+
if (!p0 || !ts.isIdentifier(p0))
|
|
498
|
+
return false;
|
|
499
|
+
const isP0 = (e) => {
|
|
500
|
+
if (!e)
|
|
501
|
+
return false;
|
|
502
|
+
const u = unwrap(e);
|
|
503
|
+
return ts.isIdentifier(u) && u.text === p0.text;
|
|
504
|
+
};
|
|
505
|
+
const rets = ownReturns(fn);
|
|
506
|
+
return (rets.length > 0 &&
|
|
507
|
+
rets.every((r) => {
|
|
508
|
+
if (isP0(r))
|
|
509
|
+
return true;
|
|
510
|
+
const u = r && unwrap(r);
|
|
511
|
+
return (!!u &&
|
|
512
|
+
ts.isNewExpression(u) &&
|
|
513
|
+
ts.isIdentifier(u.expression) &&
|
|
514
|
+
u.expression.text === "Proxy" &&
|
|
515
|
+
isP0(u.arguments?.[0]));
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
/** The door check over a parsed tree (client wrappers resolve by name). */
|
|
519
|
+
function doorCheckIn(parsed) {
|
|
520
|
+
const idx = indexFunctions(parsed);
|
|
521
|
+
const wrapperMemo = new Map();
|
|
522
|
+
const wrapperOk = (name) => {
|
|
523
|
+
let v = wrapperMemo.get(name);
|
|
524
|
+
if (v === undefined) {
|
|
525
|
+
const defs = idx.get(name) ?? [];
|
|
526
|
+
v = defs.length > 0 && defs.every((d) => isClientWrapper(d.fn));
|
|
527
|
+
wrapperMemo.set(name, v);
|
|
528
|
+
}
|
|
529
|
+
return v;
|
|
530
|
+
};
|
|
531
|
+
const clientBinding = (id, depth) => {
|
|
532
|
+
if (depth > 4)
|
|
533
|
+
return false;
|
|
534
|
+
const b = bindingOf(id);
|
|
535
|
+
if (!b)
|
|
536
|
+
return false;
|
|
537
|
+
if (b.kind === "param")
|
|
538
|
+
return true;
|
|
539
|
+
if (b.kind === "import")
|
|
540
|
+
return !b.spec.startsWith(".") && !b.spec.startsWith("/");
|
|
541
|
+
if (b.kind !== "const" || !b.init)
|
|
542
|
+
return false;
|
|
543
|
+
const init = unwrap(b.init);
|
|
544
|
+
if (ts.isNewExpression(init) &&
|
|
545
|
+
ts.isIdentifier(init.expression) &&
|
|
546
|
+
CLIENT_CONSTRUCTORS.has(init.expression.text))
|
|
547
|
+
return true;
|
|
548
|
+
if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) {
|
|
549
|
+
const a0 = init.arguments[0] && unwrap(init.arguments[0]);
|
|
550
|
+
return (!!a0 &&
|
|
551
|
+
ts.isIdentifier(a0) &&
|
|
552
|
+
clientBinding(a0, depth + 1) &&
|
|
553
|
+
wrapperOk(init.expression.text));
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
};
|
|
557
|
+
return (call) => {
|
|
558
|
+
const c = unwrap(call.expression);
|
|
559
|
+
if (!ts.isPropertyAccessExpression(c))
|
|
560
|
+
return false;
|
|
561
|
+
const m = c.name.text;
|
|
562
|
+
const recv = unwrap(c.expression);
|
|
563
|
+
if (!ts.isIdentifier(recv))
|
|
564
|
+
return false;
|
|
565
|
+
if (CTX_DOOR_METHODS.has(m))
|
|
566
|
+
return clientBinding(recv, 0);
|
|
567
|
+
if (!CLIENT_DOOR_METHODS.has(m))
|
|
568
|
+
return false;
|
|
569
|
+
return CLIENT_RECEIVER.test(recv.text) && clientBinding(recv, 0);
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/** Climb from `n` through wrappers and object-literal nesting; true when it
|
|
573
|
+
* lands in a BACKEND DOOR call's arguments (`isBackendDoor`). `inObject` reports whether an object literal was
|
|
574
|
+
* climbed (the caller of a returned literal needs one). */
|
|
575
|
+
function climbToArgument(n, isBackendDoor, onReturn) {
|
|
576
|
+
let cur = n;
|
|
577
|
+
let inObject = false;
|
|
578
|
+
for (let p = cur.parent; p; cur = p, p = p.parent) {
|
|
579
|
+
if (ts.isParenthesizedExpression(p) ||
|
|
580
|
+
ts.isNonNullExpression(p) ||
|
|
581
|
+
ts.isAsExpression(p) ||
|
|
582
|
+
ts.isSatisfiesExpression(p) ||
|
|
583
|
+
ts.isAwaitExpression(p))
|
|
584
|
+
continue;
|
|
585
|
+
if (ts.isBinaryExpression(p) &&
|
|
586
|
+
FALLBACK_OPS.has(p.operatorToken.kind) &&
|
|
587
|
+
p.left === cur &&
|
|
588
|
+
isInertFallback(p.right))
|
|
589
|
+
continue;
|
|
590
|
+
if ((ts.isPropertyAssignment(p) && p.initializer === cur) ||
|
|
591
|
+
(ts.isSpreadAssignment(p) && p.expression === cur)) {
|
|
592
|
+
p = p.parent; // the object literal
|
|
593
|
+
inObject = true;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (ts.isSpreadElement(p) && p.expression === cur) {
|
|
597
|
+
if (p.parent && ts.isCallExpression(p.parent))
|
|
598
|
+
return (isBackendDoor(p.parent) && p.parent.arguments.some((a) => a === p));
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
if (ts.isCallExpression(p))
|
|
602
|
+
return isBackendDoor(p) && p.arguments.some((a) => a === cur);
|
|
603
|
+
if (!onReturn)
|
|
604
|
+
return false;
|
|
605
|
+
if (ts.isReturnStatement(p) && p.expression === cur) {
|
|
606
|
+
let fn = p.parent;
|
|
607
|
+
while (fn && !ts.isFunctionLike(fn))
|
|
608
|
+
fn = fn.parent;
|
|
609
|
+
return !!fn && onReturn(fn, inObject);
|
|
610
|
+
}
|
|
611
|
+
if (ts.isArrowFunction(p) && p.body === cur)
|
|
612
|
+
return onReturn(p, inObject);
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
/** The name a function is called by: a declaration's, or the `const` an arrow /
|
|
618
|
+
* function expression initialises. */
|
|
619
|
+
function calledName(fn) {
|
|
620
|
+
if (ts.isFunctionDeclaration(fn))
|
|
621
|
+
return fn.name?.text;
|
|
622
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
623
|
+
fn.parent &&
|
|
624
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
625
|
+
fn.parent.initializer === fn &&
|
|
626
|
+
ts.isIdentifier(fn.parent.name))
|
|
627
|
+
return fn.parent.name.text;
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
function callersForwardIn(parsed, isBackendDoor) {
|
|
631
|
+
const memo = new Map();
|
|
632
|
+
return (name) => {
|
|
633
|
+
const hit = memo.get(name);
|
|
634
|
+
if (hit !== undefined)
|
|
635
|
+
return hit;
|
|
636
|
+
let calls = 0;
|
|
637
|
+
let ok = true;
|
|
638
|
+
for (const p of parsed) {
|
|
639
|
+
if (!ok)
|
|
640
|
+
break;
|
|
641
|
+
if (!p.sf.text.includes(name))
|
|
642
|
+
continue;
|
|
643
|
+
walk(p.sf, (x) => {
|
|
644
|
+
if (!ok || !ts.isIdentifier(x) || x.text !== name)
|
|
645
|
+
return;
|
|
646
|
+
const par = x.parent;
|
|
647
|
+
if ((ts.isFunctionDeclaration(par) && par.name === x) ||
|
|
648
|
+
(ts.isVariableDeclaration(par) && par.name === x) ||
|
|
649
|
+
ts.isImportSpecifier(par) ||
|
|
650
|
+
ts.isExportSpecifier(par) ||
|
|
651
|
+
ts.isImportClause(par) ||
|
|
652
|
+
(ts.isPropertyAccessExpression(par) && par.name === x) ||
|
|
653
|
+
(ts.isPropertyAssignment(par) && par.name === x))
|
|
654
|
+
return;
|
|
655
|
+
if (ts.isCallExpression(par) &&
|
|
656
|
+
par.expression === x &&
|
|
657
|
+
climbToArgument(par, isBackendDoor)) {
|
|
658
|
+
calls++;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
ok = false;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const v = ok && calls > 0;
|
|
665
|
+
memo.set(name, v);
|
|
666
|
+
return v;
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/** Does the read `n` (receiver `obj`) only travel into a call's arguments? */
|
|
670
|
+
function forwardsOnly(n, obj, callersForward, isBackendDoor) {
|
|
671
|
+
if (rootIdentifier(obj)?.text === "args")
|
|
672
|
+
return false;
|
|
673
|
+
return climbToArgument(n, isBackendDoor, (fn, inObject) => {
|
|
674
|
+
if (!inObject)
|
|
675
|
+
return false;
|
|
676
|
+
const name = calledName(fn);
|
|
677
|
+
return name !== undefined && callersForward(name);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const NAME_OR_SLUG = new Set([
|
|
681
|
+
...NAME_FIELDS,
|
|
682
|
+
"slug",
|
|
683
|
+
"orgSlug",
|
|
684
|
+
"clerkOrgSlug",
|
|
685
|
+
]);
|
|
686
|
+
/** The first node of `owner` that decides on identity, or undefined. */
|
|
687
|
+
function decidesOnIdentity(owner,
|
|
688
|
+
/** the root identifier the forwarded claim is read off (`mapping`) */
|
|
689
|
+
root) {
|
|
690
|
+
let hit;
|
|
691
|
+
walk(owner, (x) => {
|
|
692
|
+
if (hit)
|
|
693
|
+
return;
|
|
694
|
+
// a refusing gate that judges a field of the very object the claim is
|
|
695
|
+
// forwarded from (`if (!mapping || !mapping.isActive) return …`): the
|
|
696
|
+
// function admits on that object, it does not merely forward it
|
|
697
|
+
if (root !== undefined && ts.isIfStatement(x) && exits(x.thenStatement)) {
|
|
698
|
+
let reads = false;
|
|
699
|
+
walk(x.expression, (y) => {
|
|
700
|
+
const r = propRead(y);
|
|
701
|
+
if (r && rootIdentifier(r.obj)?.text === root)
|
|
702
|
+
reads = true;
|
|
703
|
+
});
|
|
704
|
+
if (reads) {
|
|
705
|
+
hit = x.expression;
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (ts.isBinaryExpression(x) && EQ.has(x.operatorToken.kind)) {
|
|
710
|
+
const named = [x.left, x.right].some((s) => {
|
|
711
|
+
const r = propReadResolved(s);
|
|
712
|
+
return ((r !== undefined && NAME_OR_SLUG.has(r.name)) ||
|
|
713
|
+
(ts.isIdentifier(unwrap(s)) &&
|
|
714
|
+
NAME_OR_SLUG.has(unwrap(s).text)));
|
|
715
|
+
});
|
|
716
|
+
if (named || (!isLiteral(x.left) && !isLiteral(x.right)))
|
|
717
|
+
hit = x;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (ts.isCallExpression(x)) {
|
|
721
|
+
const c = unwrap(x.expression);
|
|
722
|
+
if (ts.isPropertyAccessExpression(c) &&
|
|
723
|
+
MEMBER_TEST.has(c.name.text) &&
|
|
724
|
+
x.arguments[0] &&
|
|
725
|
+
!isLiteral(x.arguments[0]))
|
|
726
|
+
hit = x;
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
return hit;
|
|
730
|
+
}
|
|
304
731
|
/** The primitives this module adds beside R-53's, one hit per deciding node. */
|
|
305
|
-
function ownHits(p, okReads) {
|
|
732
|
+
function ownHits(p, okReads, callersForward, isBackendDoor) {
|
|
306
733
|
const out = [];
|
|
307
734
|
const jwt = jwtBindings(p);
|
|
308
735
|
walk(p.sf, (x) => {
|
|
@@ -389,10 +816,23 @@ function ownHits(p, okReads) {
|
|
|
389
816
|
});
|
|
390
817
|
}
|
|
391
818
|
}
|
|
392
|
-
// scope: a read of an allow-list field
|
|
819
|
+
// scope: a read of an allow-list field — unless it only FORWARDS the
|
|
820
|
+
// verified claim into call arguments from a function that decides nothing
|
|
393
821
|
const rd = propRead(x);
|
|
394
|
-
if (rd && SCOPE_PROPS.has(rd.name))
|
|
395
|
-
|
|
822
|
+
if (rd && SCOPE_PROPS.has(rd.name)) {
|
|
823
|
+
const fwd = forwardsOnly(x, rd.obj, callersForward, isBackendDoor);
|
|
824
|
+
const decision = fwd
|
|
825
|
+
? decidesOnIdentity(ownerOf(x, p.rel).node, rootIdentifier(rd.obj)?.text)
|
|
826
|
+
: undefined;
|
|
827
|
+
if (!fwd)
|
|
828
|
+
out.push({ node: x, kind: "scope", why: `reads \`${snippet(x)}\`` });
|
|
829
|
+
else if (decision)
|
|
830
|
+
out.push({
|
|
831
|
+
node: x,
|
|
832
|
+
kind: "scope",
|
|
833
|
+
why: `reads \`${snippet(x)}\` (forwarded, but the same function also decides: \`${snippet(decision)}\`)`,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
396
836
|
// membership: a gate on a role / master field
|
|
397
837
|
if (ts.isIfStatement(x) && exits(x.thenStatement)) {
|
|
398
838
|
const r = readsProp(x.expression, ROLE_PROPS);
|
|
@@ -545,8 +985,10 @@ export function scanLocalIdentity(files) {
|
|
|
545
985
|
const o = r.atOwner ? ownerAtDecl(node, p.rel, r.n) : ownerOf(node, p.rel);
|
|
546
986
|
note(o, r.kind, r.why);
|
|
547
987
|
}
|
|
988
|
+
const isBackendDoor = doorCheckIn(parsed);
|
|
989
|
+
const callersForward = callersForwardIn(parsed, isBackendDoor);
|
|
548
990
|
for (const p of parsed)
|
|
549
|
-
for (const h of ownHits(p, okReads))
|
|
991
|
+
for (const h of ownHits(p, okReads, callersForward, isBackendDoor))
|
|
550
992
|
note(ownerOf(h.node, p.rel), h.kind, `${h.why}, line ${lineOf(h.node)}`);
|
|
551
993
|
for (const { owner, why } of [...sites.values()].sort((a, b) => a.owner.rel.localeCompare(b.owner.rel) || a.owner.n - b.owner.n)) {
|
|
552
994
|
const site = { rel: owner.rel, n: owner.n };
|
package/dist/trigger.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// D6 — THE PATH-TRIGGER. Decides, given a list of changed paths, whether
|
|
2
2
|
// backend-doctor MUST run. The backend-path shapes are DERIVED from what a
|
|
3
|
-
// Convex backend actually IS (grounded against the real
|
|
3
|
+
// Convex backend actually IS (grounded against the real vantage-peers repository
|
|
4
4
|
// layout, read-only):
|
|
5
5
|
//
|
|
6
6
|
// SHAPE (a) SCHEMA — convex/schema.ts (the defineSchema file).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantageos/backend-doctor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|