@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,661 @@
|
|
|
1
|
+
// CR-2 — no application, no MCP server and no starter defines its own identity
|
|
2
|
+
// primitive. Identity lives ONLY in @vantageos/cloud-identity.
|
|
3
|
+
//
|
|
4
|
+
// Operator order, 2026-10-09, verbatim: "No application, no MCP server, and no
|
|
5
|
+
// starter defines its own primitive for: resolving a caller to a tenant,
|
|
6
|
+
// asserting membership of an organisation, filtering rows by a caller's scope,
|
|
7
|
+
// or validating a master credential." The instrument detects it; a reviewer's
|
|
8
|
+
// judgement is not the mechanism.
|
|
9
|
+
//
|
|
10
|
+
// WHY A DOCTOR-OWNED ID (CR-2) AND NOT R-54. The pinned standard
|
|
11
|
+
// (fixtures/backend-standard-2825122.md) numbers R-1..R-53. An R- id the
|
|
12
|
+
// vendored standard does not define makes coverage.ts report an UNKNOWN rule and
|
|
13
|
+
// makes rule-class.ts refuse to classify it ("the pinned standard states no
|
|
14
|
+
// build kind"), which nulls the mechanical count. CR-1 set the precedent for a
|
|
15
|
+
// doctor-owned control: own section, own sidecar entry, drives the exit code.
|
|
16
|
+
// CR-2 is the next free number of that family. Upstreaming it as R-N is a
|
|
17
|
+
// separate change to the standard document in its own repository.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS A SITE. One site per function that DEFINES a primitive: the innermost
|
|
20
|
+
// named function holding the deciding node (a helper, an MCP tool handler, or a
|
|
21
|
+
// Convex registration whose own handler does it). A file-level statement is
|
|
22
|
+
// owned by `<module>`. Each site names every primitive kind it holds, each with
|
|
23
|
+
// its line.
|
|
24
|
+
//
|
|
25
|
+
// THE FIVE KINDS (any one is a violation):
|
|
26
|
+
// tenant resolves a caller to a tenant or agent — R-53's (d) "resolves
|
|
27
|
+
// identity itself" (an unsanctioned getUserIdentity() read, a
|
|
28
|
+
// client_org_mapping read keyed on a credential claim), R-53's doors
|
|
29
|
+
// whose principal is resolved "by the handler itself", plus any
|
|
30
|
+
// client_org_mapping read outside R-53's org-ID adapter ruling;
|
|
31
|
+
// membership asserts membership of an organisation or a role — a gate (an
|
|
32
|
+
// `if` that throws/returns, a conditional) reading a role/master
|
|
33
|
+
// field, a read of a membership table, R-53's (c) "continues as
|
|
34
|
+
// master when unresolved";
|
|
35
|
+
// scope filters rows by a caller's scope — a read of an allow-list field
|
|
36
|
+
// (allowedOrchestrators, fromAllowList, …) or a membership test
|
|
37
|
+
// over a caller's tenant / name / scope list;
|
|
38
|
+
// credential validates a credential — timingSafeEqual, a token hash
|
|
39
|
+
// (createHash / subtle.digest on a token, secret or key), JWT
|
|
40
|
+
// verification (jose, jsonwebtoken);
|
|
41
|
+
// name identifies an agent by NAME — R-53's (d) "compares names to
|
|
42
|
+
// admit", R-53 clause 1's recipient-by-name sites, an index or
|
|
43
|
+
// filter keyed on a name field, a comparison through a name
|
|
44
|
+
// normaliser.
|
|
45
|
+
// R-53's classifications are REUSED (scanR53Detailed), never re-derived; only
|
|
46
|
+
// the kinds R-53 does not carry are detected here.
|
|
47
|
+
//
|
|
48
|
+
// No package-call exit. A function that calls the package AND compares names
|
|
49
|
+
// locally still defines a local primitive: the order bans the primitive, not
|
|
50
|
+
// the absence of the package. What the package resolves is admitted by R-53's
|
|
51
|
+
// own sanction rules (a getUserIdentity() value that reaches only a package
|
|
52
|
+
// resolver; the org-ID adapter read) — those are not sites here either.
|
|
53
|
+
//
|
|
54
|
+
// EXEMPT, REPORTED: a file of @vantageos/cloud-identity itself, and a site
|
|
55
|
+
// carrying `allow-local-identity: <reason of 8+ characters>` in its leading
|
|
56
|
+
// comment or its body (the CR-1 `allow-no-caller-resolution:` convention). An
|
|
57
|
+
// exempt site is listed by file:line with its reason; it is never counted as a
|
|
58
|
+
// pass silently.
|
|
59
|
+
//
|
|
60
|
+
// COULD-NOT-JUDGE: a file with a syntax error is reported by name and judged
|
|
61
|
+
// nowhere (neither here nor through R-53), never counted as a pass. An empty
|
|
62
|
+
// population is could-not-judge, never a clean bill.
|
|
63
|
+
//
|
|
64
|
+
// CEILING 0. CR-2 is mechanical (fixtures/rule-classes-2825122.json): any
|
|
65
|
+
// violation is a mechanical violation, counted in mechanical_rule_counts with a
|
|
66
|
+
// per-module split, and gives a non-zero exit.
|
|
67
|
+
import * as ts from "typescript";
|
|
68
|
+
import { siteCount, withCount } from "../../violation-count.js";
|
|
69
|
+
import { calleeName, dotted, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
|
|
70
|
+
import { isConvexOrMcp, isJudgeable, } from "./_scan.js";
|
|
71
|
+
import { conformantMappingReads, IDENTITY_PACKAGE, scanR53Detailed, } from "./identity-by-id.js";
|
|
72
|
+
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";
|
|
73
|
+
export const KIND = {
|
|
74
|
+
tenant: "resolves a caller to a tenant or agent",
|
|
75
|
+
membership: "asserts membership or role",
|
|
76
|
+
scope: "filters by the caller's scope",
|
|
77
|
+
credential: "validates a credential",
|
|
78
|
+
name: "identifies an agent by name",
|
|
79
|
+
};
|
|
80
|
+
/** The written justification, read FROM THE TARGET SOURCE at the site. */
|
|
81
|
+
export const EXEMPT_MARKER = /allow-local-identity:\s*(\S[^\n]{7,})/;
|
|
82
|
+
/** A file of the identity package itself. */
|
|
83
|
+
const PACKAGE_PATH = /(^|\/)(node_modules\/@vantageos\/cloud-identity|packages\/cloud-identity)\//;
|
|
84
|
+
const SOURCE_EXT = /\.[cm]?[tj]sx?$/;
|
|
85
|
+
const TOOL_CALL = /^(defineTool|tool|registerTool)$/;
|
|
86
|
+
/** The innermost NAMED function holding `n`. A Convex registration's own
|
|
87
|
+
* `handler` is owned by the registration (`export const NAME = mutation(…)`),
|
|
88
|
+
* at the same line R-53 reports its doors. */
|
|
89
|
+
function ownerOf(n, rel) {
|
|
90
|
+
let viaHandler = false;
|
|
91
|
+
for (let x = n.parent; x; x = x.parent) {
|
|
92
|
+
if (ts.isFunctionDeclaration(x) && x.name)
|
|
93
|
+
return { rel, n: lineOf(x), name: x.name.text, node: x };
|
|
94
|
+
if (ts.isMethodDeclaration(x) && ts.isIdentifier(x.name)) {
|
|
95
|
+
if (x.name.text === "handler") {
|
|
96
|
+
viaHandler = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
return { rel, n: lineOf(x), name: x.name.text, node: x };
|
|
100
|
+
}
|
|
101
|
+
if ((ts.isArrowFunction(x) || ts.isFunctionExpression(x)) && x.parent) {
|
|
102
|
+
const par = x.parent;
|
|
103
|
+
if (ts.isVariableDeclaration(par) && ts.isIdentifier(par.name))
|
|
104
|
+
return { rel, n: lineOf(par), name: par.name.text, node: par };
|
|
105
|
+
if (ts.isPropertyAssignment(par) && ts.isIdentifier(par.name)) {
|
|
106
|
+
if (par.name.text === "handler") {
|
|
107
|
+
viaHandler = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
return { rel, n: lineOf(par), name: par.name.text, node: par };
|
|
111
|
+
}
|
|
112
|
+
if (ts.isCallExpression(par) &&
|
|
113
|
+
TOOL_CALL.test((dotted(par.expression) ?? "").split(".").pop() ?? "")) {
|
|
114
|
+
const label = par.arguments.find((a) => ts.isStringLiteralLike(a) ||
|
|
115
|
+
(ts.isIdentifier(a) && /_TOOL_NAME$/.test(a.text)));
|
|
116
|
+
return {
|
|
117
|
+
rel,
|
|
118
|
+
n: lineOf(par),
|
|
119
|
+
name: `tool ${label ? label.getText() : "<anonymous>"}`,
|
|
120
|
+
node: par,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// a registration reached by climbing out of its handler
|
|
125
|
+
if (viaHandler &&
|
|
126
|
+
ts.isVariableDeclaration(x) &&
|
|
127
|
+
ts.isIdentifier(x.name) &&
|
|
128
|
+
x.initializer &&
|
|
129
|
+
ts.isCallExpression(unwrap(x.initializer)))
|
|
130
|
+
return { rel, n: lineOf(x), name: x.name.text, node: x };
|
|
131
|
+
}
|
|
132
|
+
let top = n;
|
|
133
|
+
while (top.parent && !ts.isSourceFile(top.parent))
|
|
134
|
+
top = top.parent;
|
|
135
|
+
return { rel, n: lineOf(n), name: "<module>", node: top };
|
|
136
|
+
}
|
|
137
|
+
/** The deepest node starting on line `n` of `p` (for a site given by line). */
|
|
138
|
+
function nodeAtLine(p, n) {
|
|
139
|
+
let hit;
|
|
140
|
+
walk(p.sf, (x) => {
|
|
141
|
+
if (x !== p.sf && lineOf(x) === n)
|
|
142
|
+
hit = x;
|
|
143
|
+
});
|
|
144
|
+
return hit;
|
|
145
|
+
}
|
|
146
|
+
/** The marker's reason at an owner: its leading comment, or its own text. */
|
|
147
|
+
function markerOf(o) {
|
|
148
|
+
let host = o.node;
|
|
149
|
+
if (ts.isVariableDeclaration(host) && host.parent?.parent)
|
|
150
|
+
host = host.parent.parent; // the VariableStatement carries the comment
|
|
151
|
+
return EXEMPT_MARKER.exec(host.getFullText())?.[1].trim();
|
|
152
|
+
}
|
|
153
|
+
// ---- the kinds R-53 does not carry ----------------------------------------
|
|
154
|
+
const snippet = (n) => n.getText().replace(/\s+/g, " ").slice(0, 80);
|
|
155
|
+
/** Credential-validation callees, whatever module they come from. */
|
|
156
|
+
const CREDENTIAL_CALLS = new Set([
|
|
157
|
+
"timingSafeEqual",
|
|
158
|
+
"jwtVerify",
|
|
159
|
+
"jwtDecrypt",
|
|
160
|
+
"compactVerify",
|
|
161
|
+
"flattenedVerify",
|
|
162
|
+
"generalVerify",
|
|
163
|
+
"createRemoteJWKSet",
|
|
164
|
+
"createLocalJWKSet",
|
|
165
|
+
"importJWK",
|
|
166
|
+
"importSPKI",
|
|
167
|
+
"importX509",
|
|
168
|
+
]);
|
|
169
|
+
const JWT_MODULES = new Set(["jose", "jsonwebtoken"]);
|
|
170
|
+
const SECRETISH = /token|secret|bearer|password|passphrase|credential|api_?key|apikey|signature/i;
|
|
171
|
+
const ROLE_PROPS = new Set([
|
|
172
|
+
"role",
|
|
173
|
+
"roles",
|
|
174
|
+
"orgRole",
|
|
175
|
+
"org_role",
|
|
176
|
+
"isMaster",
|
|
177
|
+
"masterSource",
|
|
178
|
+
"isAdmin",
|
|
179
|
+
"isOwner",
|
|
180
|
+
"isMember",
|
|
181
|
+
"membership",
|
|
182
|
+
"memberships",
|
|
183
|
+
]);
|
|
184
|
+
const MEMBERSHIP_TABLE = /member/i;
|
|
185
|
+
const SCOPE_PROPS = new Set([
|
|
186
|
+
"allowedOrchestrators",
|
|
187
|
+
"fromAllowList",
|
|
188
|
+
"allowList",
|
|
189
|
+
"allowedNamespaces",
|
|
190
|
+
"namespacePrefixes",
|
|
191
|
+
"allowedTenants",
|
|
192
|
+
"allowedOrgs",
|
|
193
|
+
]);
|
|
194
|
+
const SCOPE_LIST = /^(tenants|tenantIds|names|roster|scopes|orgs|orgIds|orgSlugs|allowed\w*|\w*allowList)$/i;
|
|
195
|
+
const MEMBER_TEST = new Set([
|
|
196
|
+
"includes",
|
|
197
|
+
"has",
|
|
198
|
+
"indexOf",
|
|
199
|
+
"some",
|
|
200
|
+
"every",
|
|
201
|
+
]);
|
|
202
|
+
const NAME_FIELDS = new Set([
|
|
203
|
+
"name",
|
|
204
|
+
"normalizedName",
|
|
205
|
+
"recipient",
|
|
206
|
+
"from",
|
|
207
|
+
"assignedTo",
|
|
208
|
+
"createdBy",
|
|
209
|
+
"lastAssignedTo",
|
|
210
|
+
"orchestrator",
|
|
211
|
+
"orchestratorId",
|
|
212
|
+
"callerOrchestrator",
|
|
213
|
+
"agentName",
|
|
214
|
+
]);
|
|
215
|
+
const NAME_NORMALISER = /^normali[sz]e\w*(Id|Name)$/;
|
|
216
|
+
const EQ = new Set([
|
|
217
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
218
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
219
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
220
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
221
|
+
]);
|
|
222
|
+
function isLiteral(e) {
|
|
223
|
+
const u = unwrap(e);
|
|
224
|
+
return (ts.isStringLiteralLike(u) ||
|
|
225
|
+
ts.isNumericLiteral(u) ||
|
|
226
|
+
u.kind === ts.SyntaxKind.NullKeyword ||
|
|
227
|
+
u.kind === ts.SyntaxKind.TrueKeyword ||
|
|
228
|
+
u.kind === ts.SyntaxKind.FalseKeyword ||
|
|
229
|
+
(ts.isIdentifier(u) && u.text === "undefined"));
|
|
230
|
+
}
|
|
231
|
+
/** Does `n` read a property named in `props` (a value read, not a key)? */
|
|
232
|
+
function readsProp(n, props) {
|
|
233
|
+
let hit;
|
|
234
|
+
walk(n, (x) => {
|
|
235
|
+
const r = ts.isExpression(x) ? propReadResolved(x) : undefined;
|
|
236
|
+
if (!hit && r && props.has(r.name))
|
|
237
|
+
hit = x;
|
|
238
|
+
});
|
|
239
|
+
return hit;
|
|
240
|
+
}
|
|
241
|
+
function exits(s) {
|
|
242
|
+
let hit = false;
|
|
243
|
+
walk(s, (y) => {
|
|
244
|
+
if (ts.isThrowStatement(y) || ts.isReturnStatement(y))
|
|
245
|
+
hit = true;
|
|
246
|
+
}, true);
|
|
247
|
+
return hit;
|
|
248
|
+
}
|
|
249
|
+
/** Bindings a file imports from jose / jsonwebtoken. */
|
|
250
|
+
function jwtBindings(p) {
|
|
251
|
+
const out = new Set();
|
|
252
|
+
for (const st of p.sf.statements) {
|
|
253
|
+
if (!ts.isImportDeclaration(st))
|
|
254
|
+
continue;
|
|
255
|
+
if (!JWT_MODULES.has(st.moduleSpecifier.text))
|
|
256
|
+
continue;
|
|
257
|
+
const c = st.importClause;
|
|
258
|
+
if (c?.name)
|
|
259
|
+
out.add(c.name.text);
|
|
260
|
+
const b = c?.namedBindings;
|
|
261
|
+
if (b && ts.isNamespaceImport(b))
|
|
262
|
+
out.add(b.name.text);
|
|
263
|
+
else if (b)
|
|
264
|
+
for (const e of b.elements)
|
|
265
|
+
out.add(e.name.text);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/** The field a `q.eq(<field>, K)` / `q.eq(q.field("f"), K)` keys on. */
|
|
270
|
+
function eqField(c) {
|
|
271
|
+
const f = c.arguments[0] && unwrap(c.arguments[0]);
|
|
272
|
+
if (!f)
|
|
273
|
+
return undefined;
|
|
274
|
+
if (ts.isStringLiteralLike(f))
|
|
275
|
+
return f.text;
|
|
276
|
+
if (ts.isCallExpression(f) &&
|
|
277
|
+
calleeName(f) === "field" &&
|
|
278
|
+
f.arguments[0] &&
|
|
279
|
+
ts.isStringLiteralLike(f.arguments[0]))
|
|
280
|
+
return f.arguments[0].text;
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
/** Inside a `.withIndex(…)` / `.filter(…)` / `.withSearchIndex(…)` callback? */
|
|
284
|
+
function inQueryKey(n) {
|
|
285
|
+
for (let x = n.parent; x; x = x.parent) {
|
|
286
|
+
if (ts.isCallExpression(x) &&
|
|
287
|
+
/^(withIndex|filter|withSearchIndex)$/.test(calleeName(x) ?? "") &&
|
|
288
|
+
ts.isPropertyAccessExpression(unwrap(x.expression)))
|
|
289
|
+
return true;
|
|
290
|
+
if (ts.isBlock(x) || ts.isSourceFile(x))
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const isNormaliserCall = (e) => {
|
|
296
|
+
const u = unwrap(e);
|
|
297
|
+
return ts.isCallExpression(u) && NAME_NORMALISER.test(calleeName(u) ?? "");
|
|
298
|
+
};
|
|
299
|
+
/** The primitives this module adds beside R-53's, one hit per deciding node. */
|
|
300
|
+
function ownHits(p, okReads) {
|
|
301
|
+
const out = [];
|
|
302
|
+
const jwt = jwtBindings(p);
|
|
303
|
+
walk(p.sf, (x) => {
|
|
304
|
+
// credential
|
|
305
|
+
if (ts.isCallExpression(x)) {
|
|
306
|
+
const name = calleeName(x) ?? "";
|
|
307
|
+
const head = (dotted(x.expression) ?? "").split(".")[0];
|
|
308
|
+
const chain = (() => {
|
|
309
|
+
let c = x;
|
|
310
|
+
while (c.parent &&
|
|
311
|
+
(ts.isPropertyAccessExpression(c.parent) ||
|
|
312
|
+
ts.isCallExpression(c.parent)))
|
|
313
|
+
c = c.parent;
|
|
314
|
+
return c.getText();
|
|
315
|
+
})();
|
|
316
|
+
const ownerName = ownerOf(x, p.rel).name;
|
|
317
|
+
if (CREDENTIAL_CALLS.has(name))
|
|
318
|
+
out.push({
|
|
319
|
+
node: x,
|
|
320
|
+
kind: "credential",
|
|
321
|
+
why: `\`${name}()\``,
|
|
322
|
+
});
|
|
323
|
+
else if (jwt.has(head) && /verify|decode|jwks|import/i.test(name))
|
|
324
|
+
out.push({
|
|
325
|
+
node: x,
|
|
326
|
+
kind: "credential",
|
|
327
|
+
why: `JWT verification \`${snippet(x.expression)}()\``,
|
|
328
|
+
});
|
|
329
|
+
else if ((name === "createHash" ||
|
|
330
|
+
name === "createHmac" ||
|
|
331
|
+
/\bsubtle\.digest$/.test(dotted(x.expression) ?? "")) &&
|
|
332
|
+
(SECRETISH.test(chain) || SECRETISH.test(ownerName)))
|
|
333
|
+
out.push({
|
|
334
|
+
node: x,
|
|
335
|
+
kind: "credential",
|
|
336
|
+
why: `token hashing \`${snippet(x)}\``,
|
|
337
|
+
});
|
|
338
|
+
// membership table read
|
|
339
|
+
const a0 = x.arguments[0];
|
|
340
|
+
if (name === "query" &&
|
|
341
|
+
/\bdb\.query$/.test(dotted(x.expression) ?? "") &&
|
|
342
|
+
a0 &&
|
|
343
|
+
ts.isStringLiteralLike(a0)) {
|
|
344
|
+
if (MEMBERSHIP_TABLE.test(a0.text))
|
|
345
|
+
out.push({
|
|
346
|
+
node: x,
|
|
347
|
+
kind: "membership",
|
|
348
|
+
why: `reads the membership table \`${a0.text}\``,
|
|
349
|
+
});
|
|
350
|
+
if (a0.text === "client_org_mapping" && !okReads.has(x))
|
|
351
|
+
out.push({
|
|
352
|
+
node: x,
|
|
353
|
+
kind: "tenant",
|
|
354
|
+
why: "reads `client_org_mapping` itself, outside the org-ID adapter ruling",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// scope: membership test over a caller's list
|
|
358
|
+
const callee = unwrap(x.expression);
|
|
359
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
360
|
+
MEMBER_TEST.has(callee.name.text) &&
|
|
361
|
+
x.arguments[0] &&
|
|
362
|
+
!isLiteral(x.arguments[0])) {
|
|
363
|
+
const recv = unwrap(callee.expression);
|
|
364
|
+
const last = propReadResolved(recv)?.name ??
|
|
365
|
+
(ts.isIdentifier(recv) ? recv.text : "");
|
|
366
|
+
if (SCOPE_LIST.test(last) && !SCOPE_PROPS.has(last))
|
|
367
|
+
out.push({
|
|
368
|
+
node: x,
|
|
369
|
+
kind: "scope",
|
|
370
|
+
why: `\`${snippet(x)}\``,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
// name: an index / filter keyed on a name field with a non-literal
|
|
374
|
+
if (name === "eq" &&
|
|
375
|
+
x.arguments.length === 2 &&
|
|
376
|
+
!isLiteral(x.arguments[1]) &&
|
|
377
|
+
inQueryKey(x)) {
|
|
378
|
+
const f = eqField(x);
|
|
379
|
+
if (f !== undefined && NAME_FIELDS.has(f))
|
|
380
|
+
out.push({
|
|
381
|
+
node: x,
|
|
382
|
+
kind: "name",
|
|
383
|
+
why: `selects rows by the name field \`${f}\` (\`${snippet(x)}\`)`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// scope: a read of an allow-list field
|
|
388
|
+
const rd = propRead(x);
|
|
389
|
+
if (rd && SCOPE_PROPS.has(rd.name))
|
|
390
|
+
out.push({ node: x, kind: "scope", why: `reads \`${snippet(x)}\`` });
|
|
391
|
+
// membership: a gate on a role / master field
|
|
392
|
+
if (ts.isIfStatement(x) && exits(x.thenStatement)) {
|
|
393
|
+
const r = readsProp(x.expression, ROLE_PROPS);
|
|
394
|
+
if (r)
|
|
395
|
+
out.push({
|
|
396
|
+
node: x.expression,
|
|
397
|
+
kind: "membership",
|
|
398
|
+
why: `gates on \`${snippet(r)}\``,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (ts.isConditionalExpression(x)) {
|
|
402
|
+
const r = readsProp(x.condition, ROLE_PROPS);
|
|
403
|
+
if (r)
|
|
404
|
+
out.push({
|
|
405
|
+
node: x.condition,
|
|
406
|
+
kind: "membership",
|
|
407
|
+
why: `branches on \`${snippet(r)}\``,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// name: a comparison through a name normaliser
|
|
411
|
+
if (ts.isBinaryExpression(x) &&
|
|
412
|
+
EQ.has(x.operatorToken.kind) &&
|
|
413
|
+
!isLiteral(x.left) &&
|
|
414
|
+
!isLiteral(x.right) &&
|
|
415
|
+
(isNormaliserCall(x.left) || isNormaliserCall(x.right)))
|
|
416
|
+
out.push({
|
|
417
|
+
node: x,
|
|
418
|
+
kind: "name",
|
|
419
|
+
why: `compares through a name normaliser (\`${snippet(x)}\`)`,
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/** R-53's classifications that ARE local identity primitives, mapped to kinds. */
|
|
425
|
+
function reuseR53(files) {
|
|
426
|
+
const d = scanR53Detailed(files);
|
|
427
|
+
const sites = [];
|
|
428
|
+
for (const s of d.scan.violate) {
|
|
429
|
+
const layer = /: \(d\) local identity layer — (.*)$/.exec(s.note);
|
|
430
|
+
if (layer) {
|
|
431
|
+
for (const why of layer[1].split("; ")) {
|
|
432
|
+
if (/^compares names to admit/.test(why))
|
|
433
|
+
sites.push({
|
|
434
|
+
...at(s),
|
|
435
|
+
kind: "name",
|
|
436
|
+
why: `R-53 (d): ${why}`,
|
|
437
|
+
atOwner: true,
|
|
438
|
+
});
|
|
439
|
+
else if (/^resolves identity itself/.test(why))
|
|
440
|
+
sites.push({
|
|
441
|
+
...at(s),
|
|
442
|
+
kind: "tenant",
|
|
443
|
+
why: `R-53 (d): ${why}`,
|
|
444
|
+
atOwner: true,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const door = /\(a\) principal resolved by the handler itself[^|]*/.exec(s.note);
|
|
450
|
+
if (door) {
|
|
451
|
+
sites.push({
|
|
452
|
+
...at(s),
|
|
453
|
+
kind: "tenant",
|
|
454
|
+
why: `R-53 door ${door[0].trim()}`,
|
|
455
|
+
atOwner: true,
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (/: \(1\) recipient resolved by name — /.test(s.note)) {
|
|
460
|
+
sites.push({
|
|
461
|
+
...at(s),
|
|
462
|
+
kind: "name",
|
|
463
|
+
why: `R-53 clause 1: ${s.note.replace(/^[^:]*: \(1\) /, "").slice(0, 160)}`,
|
|
464
|
+
atOwner: false,
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (/: \(c\) continues as master when /.test(s.note))
|
|
469
|
+
sites.push({
|
|
470
|
+
...at(s),
|
|
471
|
+
kind: "membership",
|
|
472
|
+
why: `R-53 (c): ${s.note.replace(/^[^:]*: \(c\) /, "")}`,
|
|
473
|
+
atOwner: false,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return { sites, cnj: d.scan.cnj };
|
|
477
|
+
}
|
|
478
|
+
const at = (s) => ({ rel: s.rel, n: s.n });
|
|
479
|
+
// ---- the scan -------------------------------------------------------------
|
|
480
|
+
/** Syntactic diagnostics of one file, through the public transpile API. */
|
|
481
|
+
function syntaxErrors(f) {
|
|
482
|
+
const out = ts.transpileModule(sourceOf(f), {
|
|
483
|
+
fileName: f.rel,
|
|
484
|
+
reportDiagnostics: true,
|
|
485
|
+
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
486
|
+
});
|
|
487
|
+
const errs = (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error);
|
|
488
|
+
if (errs.length === 0)
|
|
489
|
+
return undefined;
|
|
490
|
+
const d = errs[0];
|
|
491
|
+
const line = d.file && d.start !== undefined
|
|
492
|
+
? d.file.getLineAndCharacterOfPosition(d.start).line + 1
|
|
493
|
+
: 0;
|
|
494
|
+
return `line ${line}: ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`;
|
|
495
|
+
}
|
|
496
|
+
export function scanLocalIdentity(files) {
|
|
497
|
+
const population = files.filter((f) => (isConvexOrMcp(f) || PACKAGE_PATH.test(f.rel)) &&
|
|
498
|
+
isJudgeable(f) &&
|
|
499
|
+
SOURCE_EXT.test(f.rel));
|
|
500
|
+
const scan = {
|
|
501
|
+
files: population.length,
|
|
502
|
+
violate: [],
|
|
503
|
+
exempt: [],
|
|
504
|
+
cnj: [],
|
|
505
|
+
};
|
|
506
|
+
const judged = [];
|
|
507
|
+
for (const f of population) {
|
|
508
|
+
const err = syntaxErrors(f);
|
|
509
|
+
if (err)
|
|
510
|
+
scan.cnj.push({
|
|
511
|
+
rel: f.rel,
|
|
512
|
+
n: 0,
|
|
513
|
+
note: `unparsable (${err}) — no site in this file was judged; NOT a pass`,
|
|
514
|
+
});
|
|
515
|
+
else
|
|
516
|
+
judged.push(f);
|
|
517
|
+
}
|
|
518
|
+
const parsed = judged.map(parseFile);
|
|
519
|
+
const byRel = new Map(parsed.map((p) => [p.rel, p]));
|
|
520
|
+
const okReads = conformantMappingReads(parsed);
|
|
521
|
+
// owner key -> owner + reasons
|
|
522
|
+
const sites = new Map();
|
|
523
|
+
const note = (o, kind, why) => {
|
|
524
|
+
const key = `${o.rel}:${o.n}`;
|
|
525
|
+
const e = sites.get(key) ?? { owner: o, why: [] };
|
|
526
|
+
const line = `${KIND[kind]} — ${why}`;
|
|
527
|
+
if (!e.why.includes(line))
|
|
528
|
+
e.why.push(line);
|
|
529
|
+
sites.set(key, e);
|
|
530
|
+
};
|
|
531
|
+
const reused = reuseR53(judged);
|
|
532
|
+
scan.cnj.push(...reused.cnj);
|
|
533
|
+
for (const r of reused.sites) {
|
|
534
|
+
const p = byRel.get(r.rel);
|
|
535
|
+
const node = p && nodeAtLine(p, r.n);
|
|
536
|
+
if (!p || !node)
|
|
537
|
+
continue;
|
|
538
|
+
// a (d) layer / door site already sits on its owner's line; the node
|
|
539
|
+
// starting there IS the owner's declaration, so own it directly.
|
|
540
|
+
const o = r.atOwner ? ownerAtDecl(node, p.rel, r.n) : ownerOf(node, p.rel);
|
|
541
|
+
note(o, r.kind, r.why);
|
|
542
|
+
}
|
|
543
|
+
for (const p of parsed)
|
|
544
|
+
for (const h of ownHits(p, okReads))
|
|
545
|
+
note(ownerOf(h.node, p.rel), h.kind, `${h.why}, line ${lineOf(h.node)}`);
|
|
546
|
+
for (const { owner, why } of [...sites.values()].sort((a, b) => a.owner.rel.localeCompare(b.owner.rel) || a.owner.n - b.owner.n)) {
|
|
547
|
+
const site = { rel: owner.rel, n: owner.n };
|
|
548
|
+
const body = `${owner.name}: ${why.join("; ")}`;
|
|
549
|
+
if (PACKAGE_PATH.test(owner.rel)) {
|
|
550
|
+
scan.exempt.push({
|
|
551
|
+
...site,
|
|
552
|
+
note: `EXEMPT (package) — inside ${IDENTITY_PACKAGE} itself. ${body}`,
|
|
553
|
+
});
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const reason = markerOf(owner);
|
|
557
|
+
if (reason !== undefined)
|
|
558
|
+
scan.exempt.push({
|
|
559
|
+
...site,
|
|
560
|
+
note: `EXEMPT (marker) — "${reason.slice(0, 120)}". ${body}`,
|
|
561
|
+
});
|
|
562
|
+
else
|
|
563
|
+
scan.violate.push({ ...site, note: body });
|
|
564
|
+
}
|
|
565
|
+
return scan;
|
|
566
|
+
}
|
|
567
|
+
/** The owner whose declaration starts on line `n` at or above `node`. */
|
|
568
|
+
function ownerAtDecl(node, rel, n) {
|
|
569
|
+
for (let x = node; x; x = x.parent) {
|
|
570
|
+
if (lineOf(x) !== n)
|
|
571
|
+
break;
|
|
572
|
+
if (ts.isFunctionDeclaration(x) && x.name)
|
|
573
|
+
return { rel, n, name: x.name.text, node: x };
|
|
574
|
+
if ((ts.isVariableDeclaration(x) || ts.isPropertyAssignment(x)) &&
|
|
575
|
+
ts.isIdentifier(x.name))
|
|
576
|
+
return { rel, n, name: x.name.text, node: x };
|
|
577
|
+
if (ts.isMethodDeclaration(x) && ts.isIdentifier(x.name))
|
|
578
|
+
return { rel, n, name: x.name.text, node: x };
|
|
579
|
+
}
|
|
580
|
+
// the declaration may start on the line as a descendant's parent chain did
|
|
581
|
+
// not reach it (a VariableStatement whose first token is `export`)
|
|
582
|
+
let found;
|
|
583
|
+
walk(node.getSourceFile(), (x) => {
|
|
584
|
+
if (found || lineOf(x) !== n)
|
|
585
|
+
return;
|
|
586
|
+
if (ts.isFunctionDeclaration(x) && x.name)
|
|
587
|
+
found = { rel, n, name: x.name.text, node: x };
|
|
588
|
+
else if (ts.isVariableDeclaration(x) && ts.isIdentifier(x.name))
|
|
589
|
+
found = { rel, n, name: x.name.text, node: x };
|
|
590
|
+
else if (ts.isPropertyAssignment(x) && ts.isIdentifier(x.name))
|
|
591
|
+
found = { rel, n, name: x.name.text, node: x };
|
|
592
|
+
});
|
|
593
|
+
return found ?? ownerOf(node, rel);
|
|
594
|
+
}
|
|
595
|
+
// ---- verdict + rendering --------------------------------------------------
|
|
596
|
+
const list = (xs) => xs.map((s) => `${s.rel}:${s.n} (${s.note})`).join("; ");
|
|
597
|
+
export function localIdentityResult(scan) {
|
|
598
|
+
const nV = scan.violate.length;
|
|
599
|
+
const nE = scan.exempt.length;
|
|
600
|
+
const nJ = scan.cnj.length;
|
|
601
|
+
const counts = `[files=${scan.files} violations=${nV} exempt=${nE} could-not-judge=${nJ}]`;
|
|
602
|
+
const tail = `${nE ? ` Exempt (reported, not a pass by silence): ${list(scan.exempt)}.` : ""}${nJ ? ` could-not-judge (not decided): ${list(scan.cnj)}.` : ""}`;
|
|
603
|
+
if (nV > 0)
|
|
604
|
+
return withCount({
|
|
605
|
+
id: CR2_ID,
|
|
606
|
+
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
607
|
+
state: "non-conforming",
|
|
608
|
+
verdict: `CR-2: ${nV} site(s) define a local identity primitive instead of calling ${IDENTITY_PACKAGE}. ${counts} Violations: ${list(scan.violate)}.${tail}`,
|
|
609
|
+
}, siteCount(scan.violate));
|
|
610
|
+
if (nJ > 0) {
|
|
611
|
+
const reason = `CR-2: 0 violations, but ${nJ} file(s) could not be judged. ${counts}${tail} This is NOT a clean bill.`;
|
|
612
|
+
return {
|
|
613
|
+
id: CR2_ID,
|
|
614
|
+
anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`,
|
|
615
|
+
state: "could-not-judge",
|
|
616
|
+
verdict: reason,
|
|
617
|
+
reason,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (scan.files === 0) {
|
|
621
|
+
const reason = "CR-2: no applicable file — no convex/ or mcp-server/src source was read. NOT a clean bill.";
|
|
622
|
+
return {
|
|
623
|
+
id: CR2_ID,
|
|
624
|
+
anchor: "local-identity:no-applicable-file",
|
|
625
|
+
state: "could-not-judge",
|
|
626
|
+
verdict: reason,
|
|
627
|
+
reason,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
id: CR2_ID,
|
|
632
|
+
anchor: scan.exempt[0]
|
|
633
|
+
? `${scan.exempt[0].rel}:${scan.exempt[0].n}`
|
|
634
|
+
: "local-identity:none",
|
|
635
|
+
state: "present",
|
|
636
|
+
verdict: `CR-2: 0 violations across ${scan.files} file(s); ${nE} exempt. ${counts}${tail}`,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** The rendered section: EVERY violation, exemption and abstention is named. */
|
|
640
|
+
export function localIdentitySection(scan) {
|
|
641
|
+
const out = [
|
|
642
|
+
`LOCAL IDENTITY (CR-2) — DOCTOR-OWNED mechanical control, NOT one of the numbered rules in the pinned standard. Identity lives only in ${IDENTITY_PACKAGE}; ceiling 0. It drives the exit code.`,
|
|
643
|
+
` files read: ${scan.files}`,
|
|
644
|
+
];
|
|
645
|
+
for (const [label, xs] of [
|
|
646
|
+
["VIOLATION", scan.violate],
|
|
647
|
+
["EXEMPT", scan.exempt],
|
|
648
|
+
["COULD-NOT-JUDGE", scan.cnj],
|
|
649
|
+
]) {
|
|
650
|
+
out.push(` ${label} (${xs.length}):`);
|
|
651
|
+
for (const s of xs)
|
|
652
|
+
out.push(` ${s.rel}:${s.n} — ${s.note}`);
|
|
653
|
+
}
|
|
654
|
+
out.push(` CR-2 VERDICT: ${scan.violate.length} violation(s) — ${scan.violate.length ? "MECHANICAL, the gate refuses" : "none"}; ${scan.exempt.length} exempt; ${scan.cnj.length} could-not-judge.`);
|
|
655
|
+
return out.join("\n");
|
|
656
|
+
}
|
|
657
|
+
/** CR-2 as an engine Detector. */
|
|
658
|
+
export const CR2 = {
|
|
659
|
+
id: CR2_ID,
|
|
660
|
+
run: (tree) => localIdentityResult(scanLocalIdentity(tree.files)),
|
|
661
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Parsed } from "./_ast.js";
|
|
2
|
+
/** The checked package principals of one handler. */
|
|
3
|
+
export interface PackagePrincipals {
|
|
4
|
+
/** `expr` (a source expression) when it IS the checked principal or a member
|
|
5
|
+
* read of it: a description of why, else undefined. */
|
|
6
|
+
principalOf(expr: string): string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Read handler `handlerName` of `p` before `beforeLine` (the first write). */
|
|
9
|
+
export declare function packagePrincipals(p: Parsed, handlerName: string, beforeLine: number): PackagePrincipals;
|