@vantageos/backend-doctor 0.3.0 → 0.4.2
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 +15 -6
- package/dist/build-stamp.json +2 -2
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +1 -1
- package/dist/detectors/source/contract.js +9 -1
- package/dist/detectors/source/credential-hygiene.d.ts +11 -0
- package/dist/detectors/source/credential-hygiene.js +619 -0
- package/dist/detectors/source/isolation-read.d.ts +2 -0
- package/dist/detectors/source/isolation-read.js +557 -0
- package/dist/detectors/source/local-identity.js +6 -1
- 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/service-account-claim.d.ts +15 -0
- package/dist/detectors/source/service-account-claim.js +491 -0
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +2 -2
- package/dist/not-checkable.js +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +29 -1
- package/dist/rule-class.js +2 -2
- package/fixtures/backend-standard-bacc54a.md +785 -0
- package/fixtures/{rule-classes-2825122.json → rule-classes-bacc54a.json} +20 -4
- package/package.json +1 -1
|
@@ -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
|
+
];
|