@vantageos/backend-doctor 0.3.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.
@@ -0,0 +1,619 @@
1
+ // R-56 — a credential is held only as a one-way digest of a high-entropy secret,
2
+ // a refresh token is single-use, every bearer is revocable and a bearer minted from
3
+ // a session is bounded, and a verification token is matched in full.
4
+ //
5
+ // Standard bacc54a §4 R-56 and its Detector spec, read literally. Four clauses,
6
+ // one site kind each (the tag opens the site's note):
7
+ // (1) at rest. A `ctx.db.insert|patch|replace` object whose key is a credential
8
+ // (`code`, `token`, `secret`, `apiKey`, `password`, `refreshToken`, …) and whose
9
+ // value holds no one-way digest is plaintext at rest. A digest of a value
10
+ // that traces to a chosen secret — a door argument named like a secret
11
+ // (`callerToken`, `secret`, …) or an environment secret — that reaches a
12
+ // stored row is an offline-checkable copy of that secret. A digest of a
13
+ // value the backend generated, or of an identity, is conformant.
14
+ // (2) refresh. A table named like a refresh-token store, read by a hash index in
15
+ // some function, with no function in the tree that reads it by hash AND marks
16
+ // the token consumed or revoked (or deletes it), leaves a stolen token
17
+ // replayable for its whole life.
18
+ // (3a) revocable. A table whose inserted rows carry `tokenHash` and `expiresAt`
19
+ // holds bearers; if no function in the tree reads that table and revokes
20
+ // (a revoked flag or timestamp) or deletes a row of it, no bearer can be
21
+ // withdrawn.
22
+ // (3b) bounded. The function that inserts such a row, into a table with no code
23
+ // or refresh-token linkage, holds neither a revoke-prior (a patch/delete on
24
+ // the same table) nor a cap (a read of the table whose result a refusal
25
+ // bounds). A row that links the grant it was exchanged from
26
+ // (`refreshTokenHash`, `codeHash`, `familyId`, …) belongs to a family that
27
+ // clause 2 bounds, and a refresh-token table is clause 2's.
28
+ // (4) verification. A request-supplied commit/hash/token identifier matched
29
+ // against stored text by `.includes` / `.startsWith` / `.indexOf` with no
30
+ // full-length check (a 40- or 64-digit pattern, or a length compared with 40
31
+ // or 64) lets a one-character prefix verify; (4e) a response body that
32
+ // echoes stored content (`hint`, `noteExcerpt`, `excerpt`, `got`, …) from a
33
+ // function that verifies a token.
34
+ //
35
+ // NOT DECIDED HERE, stated. The entropy of a secret generated outside the backend;
36
+ // the binding of a minted bearer to a session's verified claims rather than a
37
+ // body-declared value. A control reads that a digest is stored, not how strong the
38
+ // input was.
39
+ import * as ts from "typescript";
40
+ import { lineOf, parseFile, unwrap, walk } from "./_ast.js";
41
+ import { decide, emptyScan, isJudgeable, } from "./_scan.js";
42
+ export const R56_ID = "R-56 credentials held as digests of generated secrets, refresh tokens single-use, bearers revocable and session-minted bearers bounded, verification tokens matched in full";
43
+ const R56_CHECK = "a credential-named column holds a digest of a generated value; a refresh-token table read by hash has a consuming door; a bearer table has a revocation door and its mint revokes prior bearers or caps them (or exchanges a code or refresh token); a request-supplied commit identifier is length-checked before it is matched";
44
+ export const R56_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa §4 R-56";
45
+ const CRED_KEY = /^(code|token|secret|apiKey|api_key|password|passphrase|refreshToken|accessToken|bearer|rawToken|authorizationCode|clientSecret|privateKey|sessionToken|otp)$/i;
46
+ const SECRET_ARG = /^(callerToken|masterSecret|masterToken|secret|password|passphrase|apiKey|adminSecret|bearerSecret)$/i;
47
+ const DIGEST_FN = /^(sha(1|224|256|384|512)\w*|hash\w*|digest\w*|hmac\w*|md5\w*|createHash|bcrypt\w*|argon\w*|scrypt\w*|pbkdf2\w*)$/i;
48
+ const REVOKED_KEY = /^(revoked|revokedAt|consumedAt|usedAt|used|consumed|deletedAt)$/i;
49
+ const FAMILY_KEY = /^(refreshTokenHash|codeHash|authorizationCodeHash|familyId|grantId|parentTokenHash)$/i;
50
+ const ECHO_KEY = /^(hint|noteExcerpt|excerpt|got|preview|snippet)$/i;
51
+ const CAP_WORDS = /cap|limit|max|too many|exceed/i;
52
+ const ID_NAME = /(sha|commit|hash|digest)/i;
53
+ const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
54
+ function unitsOf(p) {
55
+ const out = [];
56
+ for (const st of p.sf.statements) {
57
+ if (ts.isFunctionDeclaration(st) && st.name && st.body)
58
+ out.push({ name: st.name.text, rel: p.rel, line: lineOf(st), node: st });
59
+ else if (ts.isVariableStatement(st))
60
+ for (const d of st.declarationList.declarations)
61
+ if (ts.isIdentifier(d.name) && d.initializer)
62
+ out.push({
63
+ name: d.name.text,
64
+ rel: p.rel,
65
+ line: lineOf(d),
66
+ node: d.initializer,
67
+ });
68
+ if (ts.isExpressionStatement(st) && ts.isCallExpression(st.expression)) {
69
+ const c = unwrap(st.expression.expression);
70
+ if (ts.isPropertyAccessExpression(c) && c.name.text === "route") {
71
+ const cfg = st.expression.arguments[0];
72
+ let path = "route";
73
+ let method = "";
74
+ if (cfg && ts.isObjectLiteralExpression(cfg))
75
+ for (const pr of cfg.properties)
76
+ if (ts.isPropertyAssignment(pr) && ts.isIdentifier(pr.name)) {
77
+ if (pr.name.text === "path" &&
78
+ ts.isStringLiteralLike(pr.initializer))
79
+ path = pr.initializer.text;
80
+ if (pr.name.text === "method" &&
81
+ ts.isStringLiteralLike(pr.initializer))
82
+ method = pr.initializer.text;
83
+ }
84
+ out.push({
85
+ name: `${method ? `${method} ` : ""}${path}`,
86
+ rel: p.rel,
87
+ line: lineOf(st),
88
+ node: st,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ // ---- small readers ---------------------------------------------------------------
96
+ function strArg(c, i = 0) {
97
+ const a = c.arguments[i];
98
+ return a && ts.isStringLiteralLike(a) ? a.text : undefined;
99
+ }
100
+ function methodOf(c) {
101
+ const e = unwrap(c.expression);
102
+ if (ts.isPropertyAccessExpression(e))
103
+ return e.name.text;
104
+ if (ts.isIdentifier(e))
105
+ return e.text;
106
+ return undefined;
107
+ }
108
+ function isDigestCall(c) {
109
+ const e = unwrap(c.expression);
110
+ if (ts.isIdentifier(e))
111
+ return DIGEST_FN.test(e.text);
112
+ if (ts.isPropertyAccessExpression(e))
113
+ return e.name.text === "digest" || DIGEST_FN.test(e.name.text);
114
+ return false;
115
+ }
116
+ function holdsDigest(e) {
117
+ let hit = false;
118
+ walk(e, (x) => {
119
+ if (!hit && ts.isCallExpression(x) && isDigestCall(x))
120
+ hit = true;
121
+ });
122
+ return hit;
123
+ }
124
+ function localInits(unit) {
125
+ const m = new Map();
126
+ walk(unit, (x) => {
127
+ if (ts.isVariableDeclaration(x) && ts.isIdentifier(x.name) && x.initializer)
128
+ m.set(x.name.text, x.initializer);
129
+ });
130
+ return m;
131
+ }
132
+ function isDigestValue(e, inits, depth = 0) {
133
+ if (holdsDigest(e))
134
+ return true;
135
+ const x = unwrap(e);
136
+ if (ts.isIdentifier(x) && depth < 4) {
137
+ const init = inits.get(x.text);
138
+ return init !== undefined && isDigestValue(init, inits, depth + 1);
139
+ }
140
+ return false;
141
+ }
142
+ function isLiteral(e) {
143
+ const x = unwrap(e);
144
+ return (ts.isStringLiteralLike(x) ||
145
+ ts.isNumericLiteral(x) ||
146
+ x.kind === ts.SyntaxKind.TrueKeyword ||
147
+ x.kind === ts.SyntaxKind.FalseKeyword ||
148
+ x.kind === ts.SyntaxKind.NullKeyword ||
149
+ (ts.isIdentifier(x) && x.text === "undefined"));
150
+ }
151
+ function dbWrites(unit) {
152
+ const out = [];
153
+ walk(unit, (x) => {
154
+ if (!ts.isCallExpression(x))
155
+ return;
156
+ const e = unwrap(x.expression);
157
+ if (!ts.isPropertyAccessExpression(e))
158
+ return;
159
+ const m = e.name.text;
160
+ if (!/^(insert|patch|replace|delete)$/.test(m))
161
+ return;
162
+ if (!/\bdb\b/.test(e.expression.getText()))
163
+ return;
164
+ const objArg = m === "insert" ? x.arguments[1] : x.arguments[1];
165
+ const obj = objArg && ts.isObjectLiteralExpression(unwrap(objArg))
166
+ ? unwrap(objArg)
167
+ : undefined;
168
+ out.push({
169
+ kind: m,
170
+ ...(m === "insert" ? { table: strArg(x, 0) } : {}),
171
+ ...(obj ? { obj } : {}),
172
+ node: x,
173
+ });
174
+ });
175
+ return out;
176
+ }
177
+ function objectKeys(o) {
178
+ const out = [];
179
+ for (const p of o.properties) {
180
+ if (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) {
181
+ const n = p.name;
182
+ if (ts.isIdentifier(n) || ts.isStringLiteralLike(n))
183
+ out.push(n.text);
184
+ }
185
+ if (ts.isSpreadAssignment(p)) {
186
+ // `...(cond ? { agentId } : {})` — read the literal keys inside the spread
187
+ walk(p.expression, (x) => {
188
+ if (ts.isObjectLiteralExpression(x))
189
+ out.push(...objectKeys(x));
190
+ });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+ /** Tables a unit queries: `ctx.db.query("T")`. */
196
+ function queriedTables(unit) {
197
+ const out = new Set();
198
+ walk(unit, (x) => {
199
+ if (ts.isCallExpression(x) && methodOf(x) === "query") {
200
+ const t = strArg(x);
201
+ if (t)
202
+ out.add(t);
203
+ }
204
+ });
205
+ return out;
206
+ }
207
+ /** A query chain `ctx.db.query("T")…withIndex(name, cb)` that selects by a hash. */
208
+ function readsByHash(unit) {
209
+ const out = new Set();
210
+ walk(unit, (x) => {
211
+ if (!ts.isCallExpression(x) || methodOf(x) !== "withIndex")
212
+ return;
213
+ const idx = strArg(x, 0) ?? "";
214
+ const cbText = x.arguments[1]?.getText() ?? "";
215
+ if (!/hash/i.test(idx) && !/hash/i.test(cbText))
216
+ return;
217
+ let t;
218
+ let cur = x;
219
+ for (;;) {
220
+ const c = unwrap(cur);
221
+ if (ts.isCallExpression(c)) {
222
+ if (methodOf(c) === "query") {
223
+ t = strArg(c);
224
+ break;
225
+ }
226
+ cur =
227
+ unwrap(c.expression).expression ?? c;
228
+ if (cur === c)
229
+ break;
230
+ }
231
+ else if (ts.isPropertyAccessExpression(c))
232
+ cur = c.expression;
233
+ else
234
+ break;
235
+ }
236
+ if (t)
237
+ out.add(t);
238
+ });
239
+ return out;
240
+ }
241
+ function marksRevokedOrDeletes(unit) {
242
+ return dbWrites(unit).some((w) => w.kind === "delete" ||
243
+ ((w.kind === "patch" || w.kind === "replace") &&
244
+ w.obj !== undefined &&
245
+ objectKeys(w.obj).some((k) => REVOKED_KEY.test(k))));
246
+ }
247
+ export function scanR56(files) {
248
+ const scan = emptyScan();
249
+ const ps = files
250
+ .filter((f) => isJudgeable(f) && isConvexFile(f))
251
+ .map(parseFile);
252
+ const units = ps.flatMap(unitsOf);
253
+ scan.units = units.length;
254
+ // ---- clause 1 --------------------------------------------------------------
255
+ for (const u of units) {
256
+ const inits = localInits(u.node);
257
+ const writes = dbWrites(u.node).filter((w) => w.kind !== "delete" && w.obj);
258
+ // secret seeds: args.<SECRET_ARG>, a destructured SECRET_ARG, process.env.*SECRET*
259
+ const secretSrc = new Map();
260
+ walk(u.node, (x) => {
261
+ if (ts.isPropertyAccessExpression(x)) {
262
+ const base = x.expression.getText();
263
+ if (/^args$/.test(base) && SECRET_ARG.test(x.name.text))
264
+ secretSrc.set(`${base}.${x.name.text}`, `${base}.${x.name.text}`);
265
+ if (/process\.env$/.test(base) &&
266
+ /SECRET|TOKEN|PASSWORD|KEY/.test(x.name.text))
267
+ secretSrc.set(`${base}.${x.name.text}`, `${base}.${x.name.text}`);
268
+ }
269
+ if (ts.isVariableDeclaration(x) &&
270
+ ts.isObjectBindingPattern(x.name) &&
271
+ x.initializer?.getText() === "args")
272
+ for (const el of x.name.elements)
273
+ if (ts.isIdentifier(el.name) && SECRET_ARG.test(el.name.text))
274
+ secretSrc.set(el.name.text, `args.${el.name.text}`);
275
+ });
276
+ const tainted = new Map();
277
+ const mentionsSecret = (e) => {
278
+ let found;
279
+ walk(e, (x) => {
280
+ if (found)
281
+ return;
282
+ if (ts.isPropertyAccessExpression(x)) {
283
+ const k = x.getText();
284
+ if (secretSrc.has(k))
285
+ found = secretSrc.get(k);
286
+ }
287
+ if (ts.isIdentifier(x)) {
288
+ if (secretSrc.has(x.text))
289
+ found = secretSrc.get(x.text);
290
+ else if (tainted.has(x.text))
291
+ found = tainted.get(x.text);
292
+ }
293
+ });
294
+ return found;
295
+ };
296
+ for (let pass = 0; pass < 4; pass++)
297
+ for (const [name, init] of inits) {
298
+ if (tainted.has(name))
299
+ continue;
300
+ const s = mentionsSecret(init);
301
+ if (s && !holdsDigest(init))
302
+ tainted.set(name, s);
303
+ }
304
+ // variables that hold a digest OF a secret
305
+ const digestOfSecret = new Map();
306
+ for (const [name, init] of inits) {
307
+ let src;
308
+ walk(init, (x) => {
309
+ if (src || !ts.isCallExpression(x) || !isDigestCall(x))
310
+ return;
311
+ for (const a of x.arguments) {
312
+ const s = mentionsSecret(a);
313
+ if (s)
314
+ src = s;
315
+ }
316
+ });
317
+ if (src)
318
+ digestOfSecret.set(name, src);
319
+ }
320
+ for (const w of writes) {
321
+ const obj = w.obj;
322
+ for (const p of obj.properties) {
323
+ let key;
324
+ let value;
325
+ if (ts.isPropertyAssignment(p) &&
326
+ (ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name))) {
327
+ key = p.name.text;
328
+ value = p.initializer;
329
+ }
330
+ else if (ts.isShorthandPropertyAssignment(p)) {
331
+ key = p.name.text;
332
+ value = p.name;
333
+ }
334
+ if (key === undefined || value === undefined)
335
+ continue;
336
+ // (1) a digest of a chosen secret reaches a stored row
337
+ let viaSecret;
338
+ walk(value, (x) => {
339
+ if (viaSecret)
340
+ return;
341
+ if (ts.isIdentifier(x) && digestOfSecret.has(x.text))
342
+ viaSecret = digestOfSecret.get(x.text);
343
+ if (ts.isCallExpression(x) && isDigestCall(x))
344
+ for (const a of x.arguments) {
345
+ const s = mentionsSecret(a);
346
+ if (s)
347
+ viaSecret = s;
348
+ }
349
+ });
350
+ if (viaSecret) {
351
+ scan.violate.push({
352
+ rel: u.rel,
353
+ n: lineOf(p),
354
+ note: `${u.name}: (1) persists ${key} as a digest of ${viaSecret}, a chosen secret — an unsalted digest of a chosen secret is an offline-checkable copy of it`,
355
+ });
356
+ continue;
357
+ }
358
+ if (!CRED_KEY.test(key))
359
+ continue;
360
+ if (isLiteral(value))
361
+ continue;
362
+ if (isDigestValue(value, inits)) {
363
+ scan.conform.push({
364
+ rel: u.rel,
365
+ n: lineOf(p),
366
+ note: `${u.name}: (1) stores ${key} as a digest`,
367
+ });
368
+ continue;
369
+ }
370
+ const origin = /^args\./.test(unwrap(value).getText())
371
+ ? unwrap(value).getText()
372
+ : ts.isIdentifier(unwrap(value)) &&
373
+ inits.has(unwrap(value).text)
374
+ ? `a value built in this function (${inits
375
+ .get(unwrap(value).text)
376
+ ?.getText()
377
+ .replace(/\s+/g, " ")
378
+ .slice(0, 40)})`
379
+ : unwrap(value).getText().slice(0, 40);
380
+ scan.violate.push({
381
+ rel: u.rel,
382
+ n: lineOf(p),
383
+ note: `${u.name}: (1) stores the credential ${key} without a digest — ${origin}; a code, token or key is held only as a digest of itself`,
384
+ });
385
+ }
386
+ }
387
+ }
388
+ // ---- clause 2: refresh tables ----------------------------------------------
389
+ const lookups = new Map();
390
+ const consumers = new Map();
391
+ for (const u of units) {
392
+ const byHash = readsByHash(u.node);
393
+ for (const t of byHash) {
394
+ if (!/refresh/i.test(t))
395
+ continue;
396
+ (lookups.get(t) ?? lookups.set(t, []).get(t))?.push(u);
397
+ if (marksRevokedOrDeletes(u.node))
398
+ (consumers.get(t) ?? consumers.set(t, []).get(t))?.push(u);
399
+ }
400
+ }
401
+ for (const [t, ls] of lookups) {
402
+ const cs = consumers.get(t) ?? [];
403
+ if (cs.length === 0) {
404
+ const readers = ls.filter((l) => !marksRevokedOrDeletes(l.node));
405
+ for (const l of readers)
406
+ scan.violate.push({
407
+ rel: l.rel,
408
+ n: l.line,
409
+ note: `${l.name}: (2) ${t} is read by hash here and no function in the tree reads it by hash and marks the token consumed or revoked (or deletes it); a stolen refresh token stays replayable for its whole life`,
410
+ });
411
+ }
412
+ else {
413
+ scan.conform.push({
414
+ rel: cs[0].rel,
415
+ n: cs[0].line,
416
+ note: `${cs[0].name}: (2) ${t} is consumed by hash (${cs.map((c) => c.name).join(", ")})`,
417
+ });
418
+ }
419
+ }
420
+ const mints = [];
421
+ for (const u of units)
422
+ for (const w of dbWrites(u.node)) {
423
+ if (w.kind !== "insert" || !w.table || !w.obj)
424
+ continue;
425
+ const keys = objectKeys(w.obj);
426
+ if (keys.includes("tokenHash") &&
427
+ keys.includes("expiresAt") &&
428
+ !/refresh/i.test(w.table))
429
+ mints.push({ unit: u, table: w.table, keys, line: lineOf(w.node) });
430
+ }
431
+ const revokers = new Map();
432
+ for (const u of units) {
433
+ if (!marksRevokedOrDeletes(u.node))
434
+ continue;
435
+ for (const t of queriedTables(u.node))
436
+ (revokers.get(t) ?? revokers.set(t, []).get(t))?.push(u);
437
+ }
438
+ const family = new Set(mints
439
+ .filter((m) => m.keys.some((k) => FAMILY_KEY.test(k)))
440
+ .map((m) => m.table));
441
+ const seenMint = new Set();
442
+ for (const m of mints) {
443
+ const key = `${m.unit.rel}:${m.unit.name}:${m.table}`;
444
+ if (seenMint.has(key))
445
+ continue;
446
+ seenMint.add(key);
447
+ const rs = revokers.get(m.table) ?? [];
448
+ let ok = true;
449
+ if (rs.length === 0) {
450
+ ok = false;
451
+ scan.violate.push({
452
+ rel: m.unit.rel,
453
+ n: m.line,
454
+ note: `${m.unit.name}: (3a) ${m.table} holds bearers (rows with tokenHash and expiresAt) and no function in the tree revokes or deletes a row of it; no bearer can be withdrawn`,
455
+ });
456
+ }
457
+ if (!family.has(m.table)) {
458
+ const reads = queriedTables(m.unit.node).has(m.table);
459
+ const revokePrior = reads && marksRevokedOrDeletes(m.unit.node);
460
+ let cap = false;
461
+ if (reads) {
462
+ walk(m.unit.node, (x) => {
463
+ if (cap || !ts.isThrowStatement(x))
464
+ return;
465
+ if (CAP_WORDS.test(x.getText()))
466
+ cap = true;
467
+ for (let up = x.parent; up && up !== m.unit.node; up = up.parent)
468
+ if (ts.isIfStatement(up) &&
469
+ /\.length|count/i.test(up.expression.getText()) &&
470
+ /\d/.test(up.expression.getText()))
471
+ cap = true;
472
+ });
473
+ }
474
+ if (!revokePrior && !cap) {
475
+ ok = false;
476
+ scan.violate.push({
477
+ rel: m.unit.rel,
478
+ n: m.line,
479
+ note: `${m.unit.name}: (3b) mints into ${m.table} with neither a revoke-prior nor a cap on a principal's live bearers, and no code or refresh-token exchange links it to a family`,
480
+ });
481
+ }
482
+ }
483
+ if (ok)
484
+ scan.conform.push({
485
+ rel: m.unit.rel,
486
+ n: m.line,
487
+ note: `${m.unit.name}: (3) ${m.table} has a revocation door (${rs.map((r) => r.name).join(", ")}) and its mint is bounded${family.has(m.table) ? " by its family (clause 2)" : " (revoke-prior or cap)"}`,
488
+ });
489
+ }
490
+ // ---- clause 4: verification tokens ------------------------------------------
491
+ for (const u of units) {
492
+ // request-derived identifiers: params named request/req/args, bodies, and
493
+ // bindings destructured from them
494
+ const tainted = new Set(["args", "request", "req", "body"]);
495
+ for (let pass = 0; pass < 4; pass++)
496
+ walk(u.node, (x) => {
497
+ if (!ts.isVariableDeclaration(x) || !x.initializer)
498
+ return;
499
+ const init = x.initializer.getText();
500
+ const fromReq = /\.json\(\)|\bargs\b|\brequest\b|\breq\b|\bbody\b/.test(init) ||
501
+ [...tainted].some((t) => new RegExp(`\\b${t}\\b`).test(init));
502
+ if (!fromReq)
503
+ return;
504
+ const add = (b) => {
505
+ if (ts.isIdentifier(b))
506
+ tainted.add(b.text);
507
+ else
508
+ for (const e of b.elements)
509
+ if (!ts.isOmittedExpression(e))
510
+ add(e.name);
511
+ };
512
+ add(x.name);
513
+ });
514
+ const idents = new Set();
515
+ walk(u.node, (x) => {
516
+ if (ts.isIdentifier(x) && tainted.has(x.text) && ID_NAME.test(x.text))
517
+ idents.add(x.text);
518
+ });
519
+ if (idents.size === 0)
520
+ continue;
521
+ // substring matches of such an identifier
522
+ const hits = [];
523
+ walk(u.node, (x) => {
524
+ if (!ts.isCallExpression(x))
525
+ return;
526
+ const m = methodOf(x);
527
+ if (m !== "includes" && m !== "startsWith" && m !== "indexOf")
528
+ return;
529
+ const e = unwrap(x.expression);
530
+ if (!ts.isPropertyAccessExpression(e))
531
+ return;
532
+ const arg = x.arguments[0];
533
+ if (!arg)
534
+ return;
535
+ walk(arg, (y) => {
536
+ if (ts.isIdentifier(y) && idents.has(y.text))
537
+ hits.push({ id: y.text, method: m, node: x });
538
+ });
539
+ });
540
+ const guarded = (id) => {
541
+ let g = false;
542
+ walk(u.node, (x) => {
543
+ if (g)
544
+ return;
545
+ if (ts.isCallExpression(x) && methodOf(x) === "test") {
546
+ const re = unwrap(x.expression);
547
+ if (ts.isPropertyAccessExpression(re) &&
548
+ ts.isRegularExpressionLiteral(re.expression) &&
549
+ /\{(40|64)(,\d*)?\}/.test(re.expression.text) &&
550
+ new RegExp(`\\b${id}\\b`).test(x.arguments.map((a) => a.getText()).join(" ")))
551
+ g = true;
552
+ }
553
+ if (ts.isBinaryExpression(x)) {
554
+ const both = `${x.left.getText()} ${x.right.getText()}`;
555
+ if (new RegExp(`\\b${id}\\b`).test(both) &&
556
+ /\.length/.test(both) &&
557
+ /\b(40|64)\b/.test(both) &&
558
+ both.length < 200)
559
+ g = true;
560
+ }
561
+ });
562
+ return g;
563
+ };
564
+ const reported = new Set();
565
+ for (const h of hits) {
566
+ if (reported.has(h.id))
567
+ continue;
568
+ reported.add(h.id);
569
+ if (guarded(h.id))
570
+ scan.conform.push({
571
+ rel: u.rel,
572
+ n: lineOf(h.node),
573
+ note: `${u.name}: (4) matches ${h.id} after a full-length (40 or 64 digit) check`,
574
+ });
575
+ else
576
+ scan.violate.push({
577
+ rel: u.rel,
578
+ n: lineOf(h.node),
579
+ note: `${u.name}: (4) matches the request-supplied ${h.id} against stored text with .${h.method}() and no full-length (40 or 64 digit) check; a one-character prefix that occurs anywhere in the text verifies`,
580
+ });
581
+ }
582
+ if (hits.length > 0 || [...idents].some((i) => /sha|commit/i.test(i))) {
583
+ const echoes = [];
584
+ walk(u.node, (x) => {
585
+ if (!ts.isCallExpression(x))
586
+ return;
587
+ const callee = x.expression.getText();
588
+ if (!/Response/.test(callee))
589
+ return;
590
+ walk(x, (y) => {
591
+ if (ts.isPropertyAssignment(y) &&
592
+ ts.isIdentifier(y.name) &&
593
+ ECHO_KEY.test(y.name.text) &&
594
+ !isLiteral(y.initializer))
595
+ echoes.push(`${y.name.text} (line ${lineOf(y)})`);
596
+ });
597
+ });
598
+ if (echoes.length)
599
+ scan.violate.push({
600
+ rel: u.rel,
601
+ n: u.line,
602
+ note: `${u.name}: (4e) a verification response discloses stored content — ${[...new Set(echoes)].slice(0, 6).join(", ")}; a refused verification names a reason code and nothing stored`,
603
+ });
604
+ }
605
+ }
606
+ return scan;
607
+ }
608
+ export const R56 = {
609
+ id: R56_ID,
610
+ run: (tree) => {
611
+ const s = scanR56(tree.files);
612
+ const n = tree.files.filter((f) => isJudgeable(f) && isConvexFile(f)).length;
613
+ const empty = `R-56: no applicable site — ${n} convex file(s) read (${s.units} unit(s)); none stores a credential-named column, reads a refresh-token table by hash, inserts a bearer row (tokenHash and expiresAt), or matches a request-supplied commit identifier. NOT a clean bill.`;
614
+ return decide("R-56", R56_ID, R56_CHECK, s, empty);
615
+ },
616
+ };
617
+ export const SOURCE_CREDENTIAL_HYGIENE_DETECTORS = [
618
+ { detector: R56, ruleId: "R-56", standardPin: R56_STANDARD_PIN },
619
+ ];
@@ -15,6 +15,11 @@
15
15
  // doctor-owned control: own section, own sidecar entry, drives the exit code.
16
16
  // CR-2 is the next free number of that family. Upstreaming it as R-N is a
17
17
  // separate change to the standard document in its own repository.
18
+ // UPSTREAMED: the standard at bacc54a (fixtures/backend-standard-bacc54a.md)
19
+ // defines this rule as R-57. The key stays CR-2 because the vantage-peers
20
+ // deploy-gate baseline reads CR-2; R-57 is listed in src/not-checkable.ts with
21
+ // that reason so one defect is never counted as two rules. Renaming the key to
22
+ // R-57 is a follow-up that moves that baseline in the same delivery.
18
23
  //
19
24
  // WHAT IS A SITE. One site per function that DEFINES a primitive: the innermost
20
25
  // named function holding the deciding node (a helper, an MCP tool handler, or a
@@ -61,7 +66,7 @@
61
66
  // nowhere (neither here nor through R-53), never counted as a pass. An empty
62
67
  // population is could-not-judge, never a clean bill.
63
68
  //
64
- // CEILING 0. CR-2 is mechanical (fixtures/rule-classes-2825122.json): any
69
+ // CEILING 0. CR-2 is mechanical (fixtures/rule-classes-bacc54a.json): any
65
70
  // violation is a mechanical violation, counted in mechanical_rule_counts with a
66
71
  // per-module split, and gives a non-zero exit.
67
72
  import * as ts from "typescript";
@@ -0,0 +1,11 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare const R55_ID = "R-55 a read narrows by every predicate before it bounds the result; an aggregate is computed over the narrowed set";
4
+ export declare const R55_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa \u00A74 R-55";
5
+ export interface R55Scan extends Scan {
6
+ /** functions read that hold a bounded read. */
7
+ bounded: number;
8
+ }
9
+ export declare function scanR55(files: SrcFile[]): R55Scan;
10
+ export declare const R55: Detector;
11
+ export declare const SOURCE_NARROW_THEN_BOUND_DETECTORS: SourceDetectorEntry[];