@vantageos/backend-doctor 0.2.0 → 0.4.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 +57 -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/credential-hygiene.d.ts +11 -0
- package/dist/detectors/source/credential-hygiene.js +619 -0
- 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 +666 -0
- package/dist/detectors/source/narrow-then-bound.d.ts +11 -0
- package/dist/detectors/source/narrow-then-bound.js +571 -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/service-account-claim.d.ts +15 -0
- package/dist/detectors/source/service-account-claim.js +491 -0
- 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 +12 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +55 -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/backend-standard-bacc54a.md +785 -0
- package/fixtures/rule-classes-bacc54a.json +244 -0
- package/package.json +1 -1
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// R-54 — a door the fleet service account can reach acts only inside an
|
|
2
|
+
// organisation the transport verified for this call; a call that carries no
|
|
3
|
+
// verified claim is refused, never served as master.
|
|
4
|
+
//
|
|
5
|
+
// Standard bacc54a §4 R-54 and its Detector spec, read literally:
|
|
6
|
+
// red (A) a public door that resolves its caller through the organisation-scope
|
|
7
|
+
// resolver (so the service account resolves to master) and declares no
|
|
8
|
+
// claim argument (verifiedActor / verifiedOrg / verifiedPerson);
|
|
9
|
+
// red (B) a door that declares a claim argument but has no refusal for the
|
|
10
|
+
// claimless service-account call: its claim is optional, and the absence
|
|
11
|
+
// of the claim leaves the call master;
|
|
12
|
+
// green a claim argument together with a refusal for the claimless
|
|
13
|
+
// service-account call, or one of three listed exits:
|
|
14
|
+
// (a) service-account-only by purpose (requireServiceAccount, or
|
|
15
|
+
// requireResolvedCaller with masterOnly AND mcpBoundOnly);
|
|
16
|
+
// (b) not reachable by the service account: the callers resolve only
|
|
17
|
+
// through a verified session (requireOrgAdmin and its siblings, or
|
|
18
|
+
// a bare getUserIdentity read) and the door never reaches the
|
|
19
|
+
// organisation-scope resolver;
|
|
20
|
+
// (c) `fleet-wide: <reason of eight characters or more>` in the door's
|
|
21
|
+
// source, stating why a whole-fleet reach is its purpose;
|
|
22
|
+
// could-not-judge an action whose scope arrives through a run call, a
|
|
23
|
+
// resolver-shaped callee defined outside the scanned tree, a claim
|
|
24
|
+
// argument spread from a validator the scan cannot find, and a door
|
|
25
|
+
// registered through a wrapper whose context is not read here. Never a
|
|
26
|
+
// readable door.
|
|
27
|
+
//
|
|
28
|
+
// WHAT A REFUSAL IS. A `throw` (or a `return` of an object that says it refuses)
|
|
29
|
+
// whose PATH CONDITION holds both the service-account state and the claim's
|
|
30
|
+
// absence: every enclosing `if` of the throw contributes its condition (its
|
|
31
|
+
// negation for an `else`), split on `&&` (on `||` for a negation). The atoms are
|
|
32
|
+
// read by shape, not by spelling:
|
|
33
|
+
// service-account state `scope.isMaster`, `isMcpBoundMaster(scope)`,
|
|
34
|
+
// `scope.masterSource === "service-account"`, a
|
|
35
|
+
// `serviceAccount`-named value;
|
|
36
|
+
// claim absence `X === undefined`, `X == null`, `!X`, `typeof X ===
|
|
37
|
+
// "undefined"` — or the negation of `X !== undefined`,
|
|
38
|
+
// `X != null`, `X` — where X is CLAIM-DERIVED: it names
|
|
39
|
+
// verifiedActor / verifiedOrg / verifiedPerson, a
|
|
40
|
+
// parameter called `claim` or `verified*`, or a local
|
|
41
|
+
// initialised from a claim-derived value (to a fixpoint).
|
|
42
|
+
// A path that also requires the caller NOT to be the service account (`!isMaster`)
|
|
43
|
+
// refuses the other population, so it is not a claimless-service-account refusal.
|
|
44
|
+
// A claim that is mandatory for every caller (absence alone refuses) is bound.
|
|
45
|
+
//
|
|
46
|
+
// WHAT IS NOT READ. R-54 clause 2 (the claim confines every row the door reads or
|
|
47
|
+
// writes) is not decided here: which rows a handler touches, and against which
|
|
48
|
+
// tenant, is R-6 / R-52 / R-53 clause 2 (the target check). This detector decides
|
|
49
|
+
// that a verified claim exists to confine to, and that its absence is refused.
|
|
50
|
+
import * as ts from "typescript";
|
|
51
|
+
import { calledNames, indexFunctions, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
|
|
52
|
+
import { decide, emptyScan, isJudgeable, } from "./_scan.js";
|
|
53
|
+
export const R54_ID = "R-54 a door the service account can reach acts inside a transport-verified organisation; a claimless call is refused, never served as master";
|
|
54
|
+
const R54_CHECK = "a door resolving its caller through the organisation-scope resolver declares a verified-claim argument and refuses the call that is the service account and carries no claim, or is service-account-only, session-only, or declared fleet-wide";
|
|
55
|
+
export const R54_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa §4 R-54";
|
|
56
|
+
const CLAIMS = ["verifiedActor", "verifiedOrg", "verifiedPerson"];
|
|
57
|
+
const PUBLIC_BUILDERS = new Set([
|
|
58
|
+
"query",
|
|
59
|
+
"mutation",
|
|
60
|
+
"action",
|
|
61
|
+
]);
|
|
62
|
+
const SCOPE_RESOLVER = "withOrgScope";
|
|
63
|
+
/** `fleet-wide: <reason>` — the reason must be a real sentence, not a token. */
|
|
64
|
+
export const FLEET_WIDE_MARKER = /fleet-wide:\s*(\S[^\n]{7,})/;
|
|
65
|
+
/** A callee name that looks like it resolves a caller / scope. */
|
|
66
|
+
const RESOLVER_SHAPED = /^(require|resolve|get|with|assert|check)[A-Za-z]*(Scope|Caller|Principal|Identity|Reader|Actor|Auth)[A-Za-z]*$/;
|
|
67
|
+
/** Session-only gates: the caller is a verified session, never the service account. */
|
|
68
|
+
const SESSION_GATES = new Set([
|
|
69
|
+
"requireOrgAdmin",
|
|
70
|
+
"requireOperatorAdminToCreateOrg",
|
|
71
|
+
"resolveOperatorAdmin",
|
|
72
|
+
]);
|
|
73
|
+
const SERVICE_STATE = /\bisMaster\b|\bisMcpBoundMaster\b|masterSource\s*===?\s*["']service-account["']|\bisServiceAccount\b|serviceAccount/i;
|
|
74
|
+
const REFUSAL_RETURN = /refus|denied|unauthori[sz]ed|RBAC/i;
|
|
75
|
+
// ---- args ------------------------------------------------------------------
|
|
76
|
+
/** Top-level `const NAME = { … }` / `v.object({ … })` initialiser text, tree-wide. */
|
|
77
|
+
function topLevelConsts(ps) {
|
|
78
|
+
const m = new Map();
|
|
79
|
+
for (const p of ps)
|
|
80
|
+
for (const st of p.sf.statements)
|
|
81
|
+
if (ts.isVariableStatement(st))
|
|
82
|
+
for (const d of st.declarationList.declarations)
|
|
83
|
+
if (ts.isIdentifier(d.name) && d.initializer)
|
|
84
|
+
m.set(d.name.text, d.initializer.getText(p.sf));
|
|
85
|
+
return m;
|
|
86
|
+
}
|
|
87
|
+
/** The registration's config object literal (`{ args, handler, … }`). */
|
|
88
|
+
function configOf(r) {
|
|
89
|
+
for (let x = r.handler.parent; x; x = x.parent)
|
|
90
|
+
if (ts.isObjectLiteralExpression(x))
|
|
91
|
+
return x;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
function readArgs(r, p, consts) {
|
|
95
|
+
const out = { claims: [], unresolved: [] };
|
|
96
|
+
const cfg = configOf(r);
|
|
97
|
+
const prop = cfg?.properties.find((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "args");
|
|
98
|
+
if (!prop || !ts.isPropertyAssignment(prop))
|
|
99
|
+
return out;
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const visit = (text, depth) => {
|
|
102
|
+
for (const c of CLAIMS)
|
|
103
|
+
if (new RegExp(`\\b${c}\\s*:`).test(text) && !out.claims.includes(c))
|
|
104
|
+
out.claims.push(c);
|
|
105
|
+
for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
|
|
106
|
+
const n = m[1];
|
|
107
|
+
if (seen.has(n))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(n);
|
|
110
|
+
const body = consts.get(n);
|
|
111
|
+
if (body !== undefined && depth < 4)
|
|
112
|
+
visit(body, depth + 1);
|
|
113
|
+
else
|
|
114
|
+
out.unresolved.push(n);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const init = unwrap(prop.initializer);
|
|
118
|
+
if (ts.isIdentifier(init)) {
|
|
119
|
+
const body = consts.get(init.text);
|
|
120
|
+
if (body === undefined)
|
|
121
|
+
out.unresolved.push(init.text);
|
|
122
|
+
else
|
|
123
|
+
visit(body, 1);
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
visit(init.getText(p.sf), 0);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function closureOf(handler, idx) {
|
|
130
|
+
const nodes = [handler];
|
|
131
|
+
const calls = new Set();
|
|
132
|
+
const undefinedCalls = new Set();
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const queue = [handler];
|
|
135
|
+
while (queue.length) {
|
|
136
|
+
const n = queue.pop();
|
|
137
|
+
for (const c of calledNames(n)) {
|
|
138
|
+
calls.add(c);
|
|
139
|
+
if (seen.has(c))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(c);
|
|
142
|
+
const defs = idx.get(c);
|
|
143
|
+
if (defs === undefined) {
|
|
144
|
+
undefinedCalls.add(c);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const d of defs) {
|
|
148
|
+
nodes.push(d.fn);
|
|
149
|
+
queue.push(d.fn);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { nodes, calls, undefinedCalls };
|
|
154
|
+
}
|
|
155
|
+
// ---- claim refusal -----------------------------------------------------------
|
|
156
|
+
const isClaimName = (n) => /^verified(Actor|Org|Person)$/.test(n) || /^(claim|verified\w*)$/i.test(n);
|
|
157
|
+
/** A callee that turns a claim into a value which is undefined exactly when the
|
|
158
|
+
* claim is absent (`resolveVerifiedOrg`, `resolveDoorVerifiedOrg`). A resolver
|
|
159
|
+
* that returns a SCOPE whatever the claim (`resolveVerifiedPerson` hands back the
|
|
160
|
+
* transport scope when no person is carried) is not one: its result is never
|
|
161
|
+
* undefined, so testing it says nothing about the claim. */
|
|
162
|
+
const CLAIM_RESOLVER = /^resolve(Door)?Verified(Org|Actor)$|Claim$/;
|
|
163
|
+
/** Is `e` the claim itself, or a value that is undefined exactly when the claim
|
|
164
|
+
* is absent? The claim; a property / element chain on it; a ternary, `??`, `||`
|
|
165
|
+
* or `&&` with a claim-derived operand; a claim resolver called on it. A value
|
|
166
|
+
* merely COMPUTED FROM the claim (a row read by its agent ID) is not: its absence
|
|
167
|
+
* is not the claim's absence. */
|
|
168
|
+
function isClaimValue(e, tainted) {
|
|
169
|
+
const x = unwrap(e);
|
|
170
|
+
if (ts.isIdentifier(x))
|
|
171
|
+
return tainted.has(x.text) || isClaimName(x.text);
|
|
172
|
+
if (ts.isPropertyAccessExpression(x))
|
|
173
|
+
return (/^verified(Actor|Org|Person)$/.test(x.name.text) ||
|
|
174
|
+
isClaimValue(x.expression, tainted));
|
|
175
|
+
if (ts.isElementAccessExpression(x))
|
|
176
|
+
return isClaimValue(x.expression, tainted);
|
|
177
|
+
if (ts.isConditionalExpression(x))
|
|
178
|
+
return (isClaimValue(x.whenTrue, tainted) || isClaimValue(x.whenFalse, tainted));
|
|
179
|
+
if (ts.isBinaryExpression(x)) {
|
|
180
|
+
const op = x.operatorToken.kind;
|
|
181
|
+
if (op === ts.SyntaxKind.QuestionQuestionToken ||
|
|
182
|
+
op === ts.SyntaxKind.BarBarToken ||
|
|
183
|
+
op === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
184
|
+
return isClaimValue(x.left, tainted) || isClaimValue(x.right, tainted);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (ts.isCallExpression(x)) {
|
|
188
|
+
const callee = unwrap(x.expression);
|
|
189
|
+
const name = ts.isIdentifier(callee)
|
|
190
|
+
? callee.text
|
|
191
|
+
: ts.isPropertyAccessExpression(callee)
|
|
192
|
+
? callee.name.text
|
|
193
|
+
: undefined;
|
|
194
|
+
return (name !== undefined &&
|
|
195
|
+
CLAIM_RESOLVER.test(name) &&
|
|
196
|
+
x.arguments.some((a) => isClaimValue(a, tainted)));
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
function claimDerivedLocals(fn) {
|
|
201
|
+
const tainted = new Set();
|
|
202
|
+
if (ts.isFunctionLike(fn) &&
|
|
203
|
+
"parameters" in fn &&
|
|
204
|
+
Array.isArray(fn.parameters))
|
|
205
|
+
for (const prm of fn.parameters)
|
|
206
|
+
if (ts.isIdentifier(prm.name) && isClaimName(prm.name.text))
|
|
207
|
+
tainted.add(prm.name.text);
|
|
208
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
209
|
+
let grew = false;
|
|
210
|
+
walk(fn, (x) => {
|
|
211
|
+
if (!ts.isVariableDeclaration(x) || !x.initializer)
|
|
212
|
+
return;
|
|
213
|
+
const names = [];
|
|
214
|
+
if (ts.isIdentifier(x.name))
|
|
215
|
+
names.push(x.name.text);
|
|
216
|
+
else if (ts.isObjectBindingPattern(x.name))
|
|
217
|
+
for (const e of x.name.elements)
|
|
218
|
+
if (ts.isIdentifier(e.name))
|
|
219
|
+
names.push(e.name.text);
|
|
220
|
+
const fromDestructure = ts.isObjectBindingPattern(x.name) &&
|
|
221
|
+
x.name.elements.some((e) => ts.isIdentifier(e.name) &&
|
|
222
|
+
/^verified(Actor|Org|Person)$/.test(e.name.text));
|
|
223
|
+
if (fromDestructure || isClaimValue(x.initializer, tainted))
|
|
224
|
+
for (const n of names)
|
|
225
|
+
if (!tainted.has(n)) {
|
|
226
|
+
tainted.add(n);
|
|
227
|
+
grew = true;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
if (!grew)
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
return tainted;
|
|
234
|
+
}
|
|
235
|
+
/** Classify ONE atom that holds with truth value `holds` in the path. */
|
|
236
|
+
function classify(atom, holds, claimDerived) {
|
|
237
|
+
const a = unwrap(atom);
|
|
238
|
+
if (ts.isPrefixUnaryExpression(a) &&
|
|
239
|
+
a.operator === ts.SyntaxKind.ExclamationToken)
|
|
240
|
+
return classify(a.operand, !holds, claimDerived);
|
|
241
|
+
if (ts.isBinaryExpression(a)) {
|
|
242
|
+
const op = a.operatorToken.kind;
|
|
243
|
+
const eq = op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
244
|
+
op === ts.SyntaxKind.EqualsEqualsToken;
|
|
245
|
+
const ne = op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
|
246
|
+
op === ts.SyntaxKind.ExclamationEqualsToken;
|
|
247
|
+
if (eq || ne) {
|
|
248
|
+
const l = unwrap(a.left);
|
|
249
|
+
const r = unwrap(a.right);
|
|
250
|
+
const nullish = (e) => (ts.isIdentifier(e) && e.text === "undefined") ||
|
|
251
|
+
e.kind === ts.SyntaxKind.NullKeyword;
|
|
252
|
+
const bool = (e) => e.kind === ts.SyntaxKind.TrueKeyword
|
|
253
|
+
? true
|
|
254
|
+
: e.kind === ts.SyntaxKind.FalseKeyword
|
|
255
|
+
? false
|
|
256
|
+
: undefined;
|
|
257
|
+
if (bool(r) !== undefined || bool(l) !== undefined) {
|
|
258
|
+
const lit = (bool(r) ?? bool(l));
|
|
259
|
+
const subject = bool(r) !== undefined ? l : r;
|
|
260
|
+
return classify(subject, (eq ? holds : !holds) === lit, claimDerived);
|
|
261
|
+
}
|
|
262
|
+
if (nullish(r) || nullish(l)) {
|
|
263
|
+
const subject = nullish(r) ? l : r;
|
|
264
|
+
if (claimDerived(subject)) {
|
|
265
|
+
// X === undefined holds => absent; X !== undefined holds => present
|
|
266
|
+
const absent = eq ? holds : !holds;
|
|
267
|
+
return absent ? "claim-absent" : "claim-present";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (ts.isPropertyAccessExpression(l) &&
|
|
271
|
+
l.name.text === "masterSource" &&
|
|
272
|
+
ts.isStringLiteralLike(r) &&
|
|
273
|
+
r.text === "service-account") {
|
|
274
|
+
const is = eq ? holds : !holds;
|
|
275
|
+
return is ? "service" : "not-service";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return "other";
|
|
279
|
+
}
|
|
280
|
+
const text = a.getText();
|
|
281
|
+
if (claimDerived(a))
|
|
282
|
+
return holds ? "claim-present" : "claim-absent";
|
|
283
|
+
if (SERVICE_STATE.test(text))
|
|
284
|
+
return holds ? "service" : "not-service";
|
|
285
|
+
return "other";
|
|
286
|
+
}
|
|
287
|
+
/** Split `e` into the atoms that ALL hold when `e` is true (&&) or when it is
|
|
288
|
+
* false (||); anything else is one opaque atom. */
|
|
289
|
+
function atomsOf(e, holds) {
|
|
290
|
+
const x = unwrap(e);
|
|
291
|
+
if (ts.isBinaryExpression(x)) {
|
|
292
|
+
const op = x.operatorToken.kind;
|
|
293
|
+
if (holds && op === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
294
|
+
return [...atomsOf(x.left, true), ...atomsOf(x.right, true)];
|
|
295
|
+
if (!holds && op === ts.SyntaxKind.BarBarToken)
|
|
296
|
+
return [...atomsOf(x.left, false), ...atomsOf(x.right, false)];
|
|
297
|
+
}
|
|
298
|
+
if (ts.isPrefixUnaryExpression(x) &&
|
|
299
|
+
x.operator === ts.SyntaxKind.ExclamationToken)
|
|
300
|
+
return atomsOf(x.operand, !holds);
|
|
301
|
+
return [{ atom: x, holds }];
|
|
302
|
+
}
|
|
303
|
+
function pathFacts(t, fn, claimDerived) {
|
|
304
|
+
const facts = new Set();
|
|
305
|
+
for (let child = t, up = t.parent; up && child !== fn; child = up, up = up.parent) {
|
|
306
|
+
if (ts.isIfStatement(up)) {
|
|
307
|
+
const inThen = up.thenStatement === child;
|
|
308
|
+
const inElse = up.elseStatement === child;
|
|
309
|
+
if (!inThen && !inElse)
|
|
310
|
+
continue;
|
|
311
|
+
for (const { atom, holds } of atomsOf(up.expression, inThen))
|
|
312
|
+
facts.add(classify(atom, holds, claimDerived));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return facts;
|
|
316
|
+
}
|
|
317
|
+
function refusesClaimless(closure) {
|
|
318
|
+
for (const fn of closure.nodes) {
|
|
319
|
+
const tainted = claimDerivedLocals(fn);
|
|
320
|
+
const claimDerived = (e) => isClaimValue(e, tainted);
|
|
321
|
+
let found = false;
|
|
322
|
+
walk(fn, (x) => {
|
|
323
|
+
if (found)
|
|
324
|
+
return;
|
|
325
|
+
const refusal = ts.isThrowStatement(x) ||
|
|
326
|
+
(ts.isReturnStatement(x) &&
|
|
327
|
+
x.expression !== undefined &&
|
|
328
|
+
REFUSAL_RETURN.test(x.expression.getText()));
|
|
329
|
+
if (!refusal)
|
|
330
|
+
return;
|
|
331
|
+
const facts = pathFacts(x, fn, claimDerived);
|
|
332
|
+
// The path must say "no claim", optionally "and the caller is the service
|
|
333
|
+
// account", and nothing else: a refusal that also needs some other
|
|
334
|
+
// condition (a flag, a name, a present claim) refuses a narrower call.
|
|
335
|
+
if (!facts.has("claim-absent"))
|
|
336
|
+
return;
|
|
337
|
+
for (const f of facts)
|
|
338
|
+
if (f !== "claim-absent" && f !== "service")
|
|
339
|
+
return;
|
|
340
|
+
found = true;
|
|
341
|
+
});
|
|
342
|
+
if (found)
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
|
|
348
|
+
function statementOf(n) {
|
|
349
|
+
let x = n;
|
|
350
|
+
while (x.parent && !ts.isSourceFile(x.parent))
|
|
351
|
+
x = x.parent;
|
|
352
|
+
return x;
|
|
353
|
+
}
|
|
354
|
+
export function scanR54(files) {
|
|
355
|
+
const scan = emptyScan();
|
|
356
|
+
scan.notApplicable = [];
|
|
357
|
+
scan.doors = 0;
|
|
358
|
+
const ps = files
|
|
359
|
+
.filter((f) => isJudgeable(f) && isConvexFile(f))
|
|
360
|
+
.map(parseFile);
|
|
361
|
+
const idx = indexFunctions(ps);
|
|
362
|
+
const consts = topLevelConsts(ps);
|
|
363
|
+
for (const p of ps) {
|
|
364
|
+
for (const r of registrations(p)) {
|
|
365
|
+
if (!PUBLIC_BUILDERS.has(r.builder))
|
|
366
|
+
continue;
|
|
367
|
+
scan.doors++;
|
|
368
|
+
const site = (note) => ({
|
|
369
|
+
rel: r.rel,
|
|
370
|
+
n: r.line,
|
|
371
|
+
note: `${r.name}: ${note}`,
|
|
372
|
+
});
|
|
373
|
+
const stmt = statementOf(r.handler);
|
|
374
|
+
const fullText = p.sf.text.slice(stmt.getFullStart(), stmt.getEnd());
|
|
375
|
+
const closure = closureOf(r.handler, idx);
|
|
376
|
+
const args = readArgs(r, p, consts);
|
|
377
|
+
const marker = FLEET_WIDE_MARKER.exec(fullText);
|
|
378
|
+
if (marker) {
|
|
379
|
+
scan.conform.push(site(`exit (c): declared fleet-wide — ${marker[1].trim().slice(0, 160)}`));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const serviceOnly = closure.calls.has("requireServiceAccount") ||
|
|
383
|
+
closureHasMasterOnlyResolved(closure);
|
|
384
|
+
if (serviceOnly) {
|
|
385
|
+
scan.conform.push(site("exit (a): service-account-only by purpose (requireServiceAccount / requireResolvedCaller masterOnly+mcpBoundOnly)"));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const reaches = closure.calls.has(SCOPE_RESOLVER);
|
|
389
|
+
if (!reaches) {
|
|
390
|
+
const session = [...closure.calls].some((c) => SESSION_GATES.has(c)) ||
|
|
391
|
+
closureReadsIdentity(closure);
|
|
392
|
+
if (session) {
|
|
393
|
+
scan.conform.push(site("exit (b): callers resolve only through a verified session; the door never reaches the organisation-scope resolver"));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (r.builder === "action" && closureCallsRun(closure)) {
|
|
397
|
+
scan.cnj.push(site("caller scope arrives through a run call (ctx.runQuery / ctx.runMutation); its resolution is not read here"));
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const foreign = [...closure.undefinedCalls].filter((c) => RESOLVER_SHAPED.test(c));
|
|
401
|
+
if (foreign.length) {
|
|
402
|
+
scan.cnj.push(site(`caller resolution delegated to ${foreign.join(", ")}, defined outside the scanned tree`));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
scan.notApplicable.push(`${r.rel}:${r.name}`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// The door reaches the scope resolver: the service account is master here.
|
|
409
|
+
if (args.claims.length === 0) {
|
|
410
|
+
if (args.unresolved.length) {
|
|
411
|
+
scan.cnj.push(site(`args spread from ${args.unresolved.join(", ")}, which the scan cannot find; a claim argument may be among them`));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
scan.violate.push(site("(A) resolves its caller through the organisation-scope resolver (the service account is master here) and declares no claim argument (verifiedActor / verifiedOrg / verifiedPerson)"));
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (refusesClaimless(closure)) {
|
|
418
|
+
scan.conform.push(site(`claim-bound: accepts ${args.claims.join(", ")} and refuses the claimless service-account call`));
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
scan.violate.push(site(`(B) the claim is optional: accepts ${args.claims.join(", ")} but no branch refuses the claimless service-account call, so the absence of a claim leaves the call master`));
|
|
422
|
+
}
|
|
423
|
+
// A door registered through a wrapper is not read: say so, never skip it.
|
|
424
|
+
for (const st of p.sf.statements) {
|
|
425
|
+
if (!ts.isVariableStatement(st))
|
|
426
|
+
continue;
|
|
427
|
+
for (const d of st.declarationList.declarations) {
|
|
428
|
+
if (!d.initializer || !ts.isIdentifier(d.name))
|
|
429
|
+
continue;
|
|
430
|
+
const init = unwrap(d.initializer);
|
|
431
|
+
if (!ts.isCallExpression(init))
|
|
432
|
+
continue;
|
|
433
|
+
const callee = init.expression;
|
|
434
|
+
if (!ts.isIdentifier(callee))
|
|
435
|
+
continue;
|
|
436
|
+
if (/^(query|mutation|action|internal\w+|httpAction)$/.test(callee.text))
|
|
437
|
+
continue;
|
|
438
|
+
const cfg = init.arguments[0];
|
|
439
|
+
if (!cfg || !ts.isObjectLiteralExpression(cfg))
|
|
440
|
+
continue;
|
|
441
|
+
const hasHandler = cfg.properties.some((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "handler");
|
|
442
|
+
if (!hasHandler)
|
|
443
|
+
continue;
|
|
444
|
+
scan.cnj.push({
|
|
445
|
+
rel: p.rel,
|
|
446
|
+
n: lineOf(d),
|
|
447
|
+
note: `${d.name.text}: registered through the wrapper ${callee.text}; the caller resolution lives in the wrapper's context, which is not read here`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return scan;
|
|
453
|
+
}
|
|
454
|
+
/** `ctx.runQuery(...)` / `ctx.runMutation(...)` are method calls, which
|
|
455
|
+
* calledNames (plain identifiers only) does not see. */
|
|
456
|
+
function closureCallsRun(c) {
|
|
457
|
+
return c.nodes.some((n) => /\.run(Query|Mutation)\s*\(/.test(n.getText()));
|
|
458
|
+
}
|
|
459
|
+
/** `ctx.auth.getUserIdentity()` is a method call; calledNames sees plain
|
|
460
|
+
* identifiers only, so read the closure's text for the identity read. */
|
|
461
|
+
function closureReadsIdentity(c) {
|
|
462
|
+
return c.nodes.some((n) => /\.getUserIdentity\s*\(/.test(n.getText()));
|
|
463
|
+
}
|
|
464
|
+
function closureHasMasterOnlyResolved(c) {
|
|
465
|
+
for (const n of c.nodes) {
|
|
466
|
+
let hit = false;
|
|
467
|
+
walk(n, (x) => {
|
|
468
|
+
if (ts.isCallExpression(x) &&
|
|
469
|
+
ts.isIdentifier(unwrap(x.expression)) &&
|
|
470
|
+
unwrap(x.expression).text === "requireResolvedCaller") {
|
|
471
|
+
const t = x.arguments.map((a) => a.getText()).join(",");
|
|
472
|
+
if (/masterOnly/.test(t) && /mcpBoundOnly/.test(t))
|
|
473
|
+
hit = true;
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
if (hit)
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
export const R54 = {
|
|
482
|
+
id: R54_ID,
|
|
483
|
+
run: (tree) => {
|
|
484
|
+
const s = scanR54(tree.files);
|
|
485
|
+
const empty = `R-54: no applicable site — ${s.doors} public door(s) read in ${tree.files.filter((f) => isJudgeable(f) && isConvexFile(f)).length} convex file(s); ${s.notApplicable.length} resolve no caller at all (CR-1 / R-6 govern them) and none reaches the organisation-scope resolver or an exit. NOT a clean bill.`;
|
|
486
|
+
return decide("R-54", R54_ID, R54_CHECK, s, empty);
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
export const SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS = [
|
|
490
|
+
{ detector: R54, ruleId: "R-54", standardPin: R54_STANDARD_PIN },
|
|
491
|
+
];
|
|
@@ -131,20 +131,33 @@ export function scanR18(files) {
|
|
|
131
131
|
// them into one mutation "would game the regex without changing atomicity"
|
|
132
132
|
// (okfBundleNode importOkfBundle shape).
|
|
133
133
|
// An action that would be flagged non-conforming may carry an `atomicity-exception:`
|
|
134
|
-
// marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
134
|
+
// marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions
|
|
135
|
+
// (superseded on the disposition axis by task k179jnhx, §23 — judgement retired
|
|
136
|
+
// as an acceptance kind, 2026-09-02):
|
|
137
|
+
// COND 1 — originally "the disposition is COULD-NOT-JUDGE, never conform: a
|
|
138
|
+
// marker records a CLAIM that reaches a human reviewer" (Eta ruling
|
|
139
|
+
// 2026-08-31, mirroring the R-51 marker #141). The standard's §23 rewrite
|
|
140
|
+
// retires that disposition: a well-formed, COMPLETE claim (COND 2 + COND 3
|
|
141
|
+
// both satisfied — the shape named, and its checkable sub-conditions all
|
|
142
|
+
// present) IS the written justification the marker was always meant to
|
|
143
|
+
// record, exactly as JUSTIFIÉ:/JUSTIFIED: is elsewhere — so it now decides
|
|
144
|
+
// mechanically to PRESENT, never could-not-judge. An INCOMPLETE claim still
|
|
145
|
+
// buys nothing (COND 3, unchanged): the site stays non-conforming until the
|
|
146
|
+
// claim is well-formed.
|
|
147
|
+
// COND 2 — the marker MUST name which shape it claims, because the three are
|
|
148
|
+
// checked differently and the check must know which sub-conditions to ask for.
|
|
140
149
|
// COND 3 — the dangerous shape is (2): "unbounded loop, each write independently
|
|
141
150
|
// atomic" is a sentence anyone can write about a split invariant. So shape (2)
|
|
142
151
|
// is only honoured when the region ALSO names the loop (for/for-of/.forEach/.map)
|
|
143
152
|
// AND states the independence (no invariant spans two writes). A shape-(2) marker
|
|
144
153
|
// that names neither the loop nor the independence is INCOMPLETE — it does not buy
|
|
145
|
-
//
|
|
146
|
-
// A well-formed marker →
|
|
147
|
-
//
|
|
154
|
+
// present; the site stays non-conforming until the claim is well-formed.
|
|
155
|
+
// A well-formed, COMPLETE marker → present (the claim itself is the recorded
|
|
156
|
+
// justification; the control decides on the claim's structural completeness,
|
|
157
|
+
// never on a person's opinion of whether it is sound).
|
|
158
|
+
// No marker, an unknown shape, or an incomplete claim → non-conforming.
|
|
159
|
+
// could-not-judge is reserved for a site the instrument could not read (never
|
|
160
|
+
// reached here — every site below either parsed a marker or did not).
|
|
148
161
|
// ---------------------------------------------------------------------------
|
|
149
162
|
const ACTION_OPEN = /export\s+const\s+(\w+)\s*=\s*action\s*\(/;
|
|
150
163
|
const NEXT_EXPORT = /export\s+const\s+\w+\s*=/;
|
|
@@ -232,16 +245,16 @@ export function scanR29(files) {
|
|
|
232
245
|
? shapeReingest
|
|
233
246
|
: false;
|
|
234
247
|
if (shape && shapeComplete) {
|
|
235
|
-
// COND 1: a well-formed
|
|
236
|
-
//
|
|
237
|
-
scan.
|
|
248
|
+
// COND 1 (§23 rewrite, task k179jnhx): a well-formed, COMPLETE marker
|
|
249
|
+
// is itself the written justification — decided mechanically, present.
|
|
250
|
+
scan.conform.push({
|
|
238
251
|
rel: f.rel,
|
|
239
252
|
n: firstMut || openLine,
|
|
240
|
-
note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries
|
|
253
|
+
note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries a COMPLETE atomicity-exception marker claiming shape "${shape}" — ${shape === SHAPE_PER_ITEM_LOOP
|
|
241
254
|
? "the region names its loop and states the per-item writes are independently atomic (no invariant spans two)"
|
|
242
255
|
: shape === SHAPE_GATE_REINGEST
|
|
243
256
|
? "a leading gate runMutation, a supersede/dedup keyed on a stable namespace, and a recovering re-insert loop, with the convergence stated explicitly — a re-ingest of the same stable key converges on one current set"
|
|
244
|
-
: "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}.
|
|
257
|
+
: "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. The marker's structural completeness IS the written justification (§23) — decided by the claim's presence, never a person's opinion of it`,
|
|
245
258
|
});
|
|
246
259
|
}
|
|
247
260
|
else if (shape === SHAPE_GATE_REINGEST) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
2
|
/** The vendored-fixture basename used as the anchor file for every doc site. */
|
|
3
|
-
export declare const STANDARD_FILE = "backend-standard-
|
|
3
|
+
export declare const STANDARD_FILE = "backend-standard-bacc54a.md";
|
|
4
4
|
export declare const VENDORED_STANDARD_PATH: string;
|
|
5
5
|
/** The standard all four are pinned against (short hash a9d7877b). */
|
|
6
6
|
export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
|
|
3
3
|
// R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
|
|
4
4
|
// and the oracle detectors (which read the CSV), these read the VENDORED standard
|
|
5
|
-
// fixture `fixtures/backend-standard-
|
|
5
|
+
// fixture `fixtures/backend-standard-bacc54a.md` — the material at the pin. The
|
|
6
6
|
// engine's contract types are DERIVED from engine-types.ts, never re-spelled.
|
|
7
7
|
//
|
|
8
8
|
// THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { itemCount, withCount } from "../../violation-count.js";
|
|
15
16
|
/** The vendored-fixture basename used as the anchor file for every doc site. */
|
|
16
|
-
export const STANDARD_FILE = "backend-standard-
|
|
17
|
+
export const STANDARD_FILE = "backend-standard-bacc54a.md";
|
|
17
18
|
/** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
|
|
18
19
|
* Resolved locally (not via coverage.ts) so this module carries no import cycle
|
|
19
20
|
* with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
|
|
@@ -136,12 +137,12 @@ export function decideDoc(ruleId, id, check, scan, emptyReason) {
|
|
|
136
137
|
.map((x) => `${STANDARD_FILE}:${x.n} (${x.note})`)
|
|
137
138
|
.join("; ");
|
|
138
139
|
if (nV > 0) {
|
|
139
|
-
return {
|
|
140
|
+
return withCount({
|
|
140
141
|
id,
|
|
141
142
|
anchor: `${STANDARD_FILE}:${scan.violate[0].n}`,
|
|
142
143
|
state: "non-conforming",
|
|
143
144
|
verdict: `${ruleId}: ${nV} STATIC violation(s) in the standard document (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform: ${fmt(scan.conform)}.` : ""}`,
|
|
144
|
-
};
|
|
145
|
+
}, itemCount(nV));
|
|
145
146
|
}
|
|
146
147
|
if (nJ > 0) {
|
|
147
148
|
const reason = `${ruleId}: static half clean (${nC} conform); ${nJ} site(s) owe a human/gate verdict — ${fmt(scan.cnj)}. This is NOT a clean bill: GREEN is the gate half surfaced per site, not the detection running. ${breakdown}`;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { readFileSync } from "node:fs";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { itemCount, withCount } from "../../violation-count.js";
|
|
27
28
|
import { readStandard, STANDARD_FILE, toLines } from "./_doc.js";
|
|
28
29
|
/** prefix → { the sibling fixture basename, its recorded upstream blob hash }.
|
|
29
30
|
* The blob hash is the PINS.txt record — a vendored document that cannot be
|
|
@@ -154,12 +155,12 @@ export function decideR41(r) {
|
|
|
154
155
|
const deadList = r.dead
|
|
155
156
|
.map((c) => `${c.id} (cited ${site(c)}) not defined in ${SIBLING[c.prefix].file}`)
|
|
156
157
|
.join("; ");
|
|
157
|
-
return {
|
|
158
|
+
return withCount({
|
|
158
159
|
id: R41_ID,
|
|
159
160
|
anchor: site(r.dead[0]),
|
|
160
161
|
state: "non-conforming",
|
|
161
162
|
verdict: `R-41: ${r.dead.length} DEAD POINTER(S) — a cited frontier id resolves to no definition in its sibling (a dead pointer is a red, not a note). ${counts} Dead: ${deadList}. Cited: ${citedList}.`,
|
|
162
|
-
};
|
|
163
|
+
}, itemCount(r.dead.length));
|
|
163
164
|
}
|
|
164
165
|
return {
|
|
165
166
|
id: R41_ID,
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
// (non-conforming / could-not-judge / present / absent) — no fifth state,
|
|
38
38
|
// no second mechanism: the detector reads the tree the engine hands it.
|
|
39
39
|
import * as ts from "typescript";
|
|
40
|
+
import { siteCount, withCount } from "../../violation-count.js";
|
|
40
41
|
/** The standard this detector is pinned against (the re-pinned instrument pin). */
|
|
41
42
|
export const R46_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §16 R-46";
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
@@ -456,12 +457,12 @@ export function decideR46(cov) {
|
|
|
456
457
|
}
|
|
457
458
|
if (missing.length > 0) {
|
|
458
459
|
const first = missing[0];
|
|
459
|
-
return {
|
|
460
|
+
return withCount({
|
|
460
461
|
id: R46_ID,
|
|
461
462
|
anchor: `${first.rel}:${first.line}`,
|
|
462
463
|
state: "non-conforming",
|
|
463
464
|
verdict: `R-46: ${missing.length} returns validator(s) enumerate a stored document but OMIT a schema-declared field with NO written projection reason (${R46_CHECK}). ${breakdown}\n=== coverage inventory ===\n${inventory}\n=== end inventory ===`,
|
|
464
|
-
};
|
|
465
|
+
}, siteCount(missing));
|
|
465
466
|
}
|
|
466
467
|
if (cov.findings.length > 0) {
|
|
467
468
|
const first = cov.findings[0];
|