@cosmicdrift/kumiko-guards 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +16 -0
  3. package/package.json +40 -0
  4. package/src/_lib/baseline-compare.ts +56 -0
  5. package/src/_lib/generic-reason.ts +39 -0
  6. package/src/_lib/guard-kit.ts +534 -0
  7. package/src/_lib/handler-name-forms.ts +29 -0
  8. package/src/_lib/ignore-tag.ts +24 -0
  9. package/src/_lib/primitives-access.ts +19 -0
  10. package/src/_lib/roots.ts +304 -0
  11. package/src/_lib/scan-lines.ts +25 -0
  12. package/src/_lib/scan-scope.ts +152 -0
  13. package/src/_lib/security-baseline-cli.ts +54 -0
  14. package/src/_lib/security-baseline.ts +325 -0
  15. package/src/_lib/sql-inventory.ts +267 -0
  16. package/src/guard-access-denied-test.ts +135 -0
  17. package/src/guard-admin-api.ts +134 -0
  18. package/src/guard-cross-feature-imports.ts +244 -0
  19. package/src/guard-direct-entity-writes.ts +387 -0
  20. package/src/guard-direct-fetch.ts +154 -0
  21. package/src/guard-escape-hatch-declared.ts +520 -0
  22. package/src/guard-fake-tests.ts +137 -0
  23. package/src/guard-html-escape.ts +345 -0
  24. package/src/guard-no-custom-primitives.ts +196 -0
  25. package/src/guard-no-date-api.ts +186 -0
  26. package/src/guard-no-direct-fs.ts +232 -0
  27. package/src/guard-no-direct-process-env.ts +126 -0
  28. package/src/guard-no-inline-styles.ts +58 -0
  29. package/src/guard-no-logic-in-views.ts +147 -0
  30. package/src/guard-no-raw-hooks.ts +76 -0
  31. package/src/guard-open-to-all-reason.ts +112 -0
  32. package/src/guard-pre-es-patterns.ts +199 -0
  33. package/src/guard-primitives-discipline.ts +330 -0
  34. package/src/guard-raw-classname.ts +111 -0
  35. package/src/guard-raw-interactive-elements.ts +154 -0
  36. package/src/guard-raw-sql.ts +89 -0
  37. package/src/guard-renderer-boundaries.ts +157 -0
  38. package/src/guard-restricted-symbols.ts +138 -0
  39. package/src/guard-silent-skip.ts +186 -0
  40. package/src/guard-tailwind-scan-surface.ts +588 -0
  41. package/src/guard-tenant-escalation.ts +312 -0
  42. package/src/guard-thin-wrappers.ts +422 -0
  43. package/src/guard-unsafe-json-parse.ts +86 -0
  44. package/src/index.ts +29 -0
  45. package/src/run-guards.ts +78 -0
  46. package/src/run-repo-checks.ts +22 -0
  47. package/src/run-ui-guards.ts +25 -0
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: escape-hatch declarations for the cross-tenant/system-identity
4
+ * surface (ctx.db.raw / ctx.systemDb.unsafeRaw / ctx.queryAs|writeAs(system)).
5
+ *
6
+ * R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
7
+ * `r.systemScope()` feature.
8
+ * R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
9
+ * systemScope and outside a handler/hook that lexically declares
10
+ * `escapeHatch`.
11
+ * R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
12
+ * with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
13
+ * call, a `*Job` function, or a handler/hook that lexically declares
14
+ * `escapeHatch`.
15
+ * R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, or a declared
16
+ * `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, given a
17
+ * placeholder reason literal — hard-fails everywhere, not baselined.
18
+ * R5 unsafe-all-tenants-outside-declared-scope: an `unsafeAllTenants: true`
19
+ * or `unsafeAllTenants: { reason: "..." }` option, passed directly as a
20
+ * call argument, outside systemScope, `.job.ts`, an `r.job(...)` call,
21
+ * a `*Job` function, or a scope that already declares `escapeHatch`.
22
+ *
23
+ * escapeHatch (R2/R3) is recognized only as a direct, literal `escapeHatch`
24
+ * property (object literal, or ternary of two object literals) either in the
25
+ * same object literal as an inline `handler` function, or in an options object
26
+ * passed to `r.hook`/`writeHandler`/`queryHandler`/`streamHandler` alongside
27
+ * the handler function argument. Referenced-by-variable functions, spread
28
+ * options, computed/string keys, and non-literal escapeHatch values are
29
+ * conservatively not recognized (miss, don't falsely clear).
30
+ *
31
+ * Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
32
+ * job (access-declarations.ts), not this guard's. Known false-negatives:
33
+ * multi-hop aliasing, `ctx["db"]` through an intermediate variable, and a
34
+ * TenantDb/system-identity handed to another function across file
35
+ * boundaries — all conservative (miss, don't falsely flag). R5 additionally
36
+ * misses `unsafeAllTenants` given via an identifier, a ternary, `false`, or
37
+ * `undefined`, and an options object passed by variable reference or spread
38
+ * rather than as a literal call argument — all conservative (miss, don't
39
+ * falsely flag).
40
+ *
41
+ * Usage:
42
+ * bun guards/guard-escape-hatch-declared.ts
43
+ * Baseline: bun guards/run-guards.ts --write-security-baseline
44
+ */
45
+ import * as path from "node:path";
46
+ import { type Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
47
+ import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
48
+ import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
49
+
50
+ const SCAN: ScanSpec = {
51
+ scope: "source",
52
+ extensions: ["ts"],
53
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
54
+ };
55
+ const EXCLUDE = /(__tests__\/|\.test\.tsx?$|\.d\.ts$)/;
56
+
57
+ const DB_RECEIVER_NAMES = new Set(["db", "dbOutsideTransaction"]);
58
+ const SYSTEM_IDENTITY_RE = /^(SYSTEM(_[A-Z0-9_]+)?|system(User|Actor)?)$/;
59
+ const CTX_IDENTIFIER_NAMES = new Set(["ctx", "context", "handlerCtx", "handlerContext"]);
60
+
61
+ export type EscapeHatchFinding = {
62
+ file: string;
63
+ line: number;
64
+ rule:
65
+ | "raw-outside-system-scope"
66
+ | "unsafe-raw-outside-system-scope"
67
+ | "system-identity-outside-declared-scope"
68
+ | "unsafe-all-tenants-outside-declared-scope";
69
+ message: string;
70
+ };
71
+
72
+ export type GenericReasonFinding = {
73
+ file: string;
74
+ line: number;
75
+ message: string;
76
+ };
77
+
78
+ function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
79
+ return files.filter((sf) => !EXCLUDE.test(sf.getFilePath()));
80
+ }
81
+
82
+ export function systemScopeDirs(files: readonly SourceFile[]): string[] {
83
+ const dirs: string[] = [];
84
+ for (const sf of scannableFiles(files)) {
85
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
86
+ const expr = call.getExpression();
87
+ if (
88
+ expr.isKind(SyntaxKind.PropertyAccessExpression) &&
89
+ expr.getName() === "systemScope" &&
90
+ call.getArguments().length === 0
91
+ ) {
92
+ dirs.push(path.dirname(sf.getFilePath()));
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ return dirs;
98
+ }
99
+
100
+ function isInSystemScope(filePath: string, dirs: readonly string[]): boolean {
101
+ return dirs.some((dir) => filePath.startsWith(`${dir}/`));
102
+ }
103
+
104
+ function isDirectTenantDbAccess(node: Node): boolean {
105
+ if (node.isKind(SyntaxKind.PropertyAccessExpression)) {
106
+ return DB_RECEIVER_NAMES.has(node.getName());
107
+ }
108
+ if (node.isKind(SyntaxKind.ElementAccessExpression)) {
109
+ const arg = node.getArgumentExpression();
110
+ if (arg?.isKind(SyntaxKind.StringLiteral)) {
111
+ return DB_RECEIVER_NAMES.has(arg.getLiteralValue());
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+
117
+ function isTenantDbExpression(node: Node): boolean {
118
+ if (isDirectTenantDbAccess(node)) return true;
119
+ if (!node.isKind(SyntaxKind.Identifier)) return false;
120
+ const decls = node.getSymbol()?.getDeclarations() ?? [];
121
+ for (const decl of decls) {
122
+ if (decl.isKind(SyntaxKind.VariableDeclaration)) {
123
+ if (decl.getTypeNode()?.getText() === "TenantDb") return true;
124
+ const init = decl.getInitializer();
125
+ if (init && isDirectTenantDbAccess(init)) return true;
126
+ } else if (decl.isKind(SyntaxKind.BindingElement)) {
127
+ const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
128
+ if (!DB_RECEIVER_NAMES.has(propName)) continue;
129
+ // Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
130
+ // stays a deliberate false-negative, not worth the ambiguity.
131
+ const owner = decl.getParent()?.getParent();
132
+ const init = owner?.isKind(SyntaxKind.VariableDeclaration)
133
+ ? owner.getInitializer()
134
+ : undefined;
135
+ if (init?.isKind(SyntaxKind.Identifier) && CTX_IDENTIFIER_NAMES.has(init.getText())) {
136
+ return true;
137
+ }
138
+ } else if (decl.isKind(SyntaxKind.Parameter)) {
139
+ if (decl.getTypeNode()?.getText() === "TenantDb") return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ function findRawFindings(
146
+ sf: SourceFile,
147
+ root: string,
148
+ systemDirs: readonly string[],
149
+ ): EscapeHatchFinding[] {
150
+ if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
151
+ const out: EscapeHatchFinding[] = [];
152
+ for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
153
+ if (pa.getName() !== "raw") continue;
154
+ if (!isTenantDbExpression(pa.getExpression())) continue;
155
+ out.push({
156
+ file: path.relative(root, sf.getFilePath()),
157
+ line: pa.getStartLineNumber(),
158
+ rule: "raw-outside-system-scope",
159
+ message:
160
+ "ctx.db.raw used outside a systemScope feature — a tenant-scoped raw escape needs a systemScope feature, or route the actual query through the framework's tenant filtering.",
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function findUnsafeRawFindings(
167
+ sf: SourceFile,
168
+ root: string,
169
+ systemDirs: readonly string[],
170
+ ): EscapeHatchFinding[] {
171
+ if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
172
+ const out: EscapeHatchFinding[] = [];
173
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
174
+ const expr = call.getExpression();
175
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
176
+ if (expr.getName() !== "unsafeRaw") continue;
177
+ if (isInsideEscapeHatchDeclaredFunction(call)) continue;
178
+ out.push({
179
+ file: path.relative(root, sf.getFilePath()),
180
+ line: call.getStartLineNumber(),
181
+ rule: "unsafe-raw-outside-system-scope",
182
+ message:
183
+ 'unsafeRaw(...) used outside a systemScope feature and outside a handler/hook declaring escapeHatch — declare { escapeHatch: { reason: "..." } } on the handler or hook, declare the feature systemScope, or use ctx.systemDb.acknowledgeCrossTenant(reason) for a scoped read.',
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function isSystemIdentityExpression(node: Node): boolean {
190
+ if (node.isKind(SyntaxKind.CallExpression)) {
191
+ const calleeText = node.getExpression().getText();
192
+ return calleeText.endsWith("createSystemUser") || calleeText.endsWith("systemUserOf");
193
+ }
194
+ if (!node.isKind(SyntaxKind.Identifier)) return false;
195
+ if (SYSTEM_IDENTITY_RE.test(node.getText())) return true;
196
+ const decls = node.getSymbol()?.getDeclarations() ?? [];
197
+ for (const decl of decls) {
198
+ if (!decl.isKind(SyntaxKind.VariableDeclaration)) continue;
199
+ const init = decl.getInitializer();
200
+ if (init && isSystemIdentityExpression(init)) return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ function hasJobScopeAncestor(call: Node): boolean {
206
+ let ancestor: Node | undefined = call.getParent();
207
+ while (ancestor) {
208
+ if (ancestor.isKind(SyntaxKind.CallExpression)) {
209
+ const calleeExpr = ancestor.getExpression();
210
+ if (
211
+ calleeExpr.isKind(SyntaxKind.PropertyAccessExpression) &&
212
+ calleeExpr.getName() === "job"
213
+ ) {
214
+ return true;
215
+ }
216
+ }
217
+ if (
218
+ ancestor.isKind(SyntaxKind.FunctionDeclaration) ||
219
+ ancestor.isKind(SyntaxKind.MethodDeclaration)
220
+ ) {
221
+ if (ancestor.getName()?.endsWith("Job")) return true;
222
+ }
223
+ if (ancestor.isKind(SyntaxKind.ArrowFunction)) {
224
+ const parent = ancestor.getParent();
225
+ if (parent?.isKind(SyntaxKind.VariableDeclaration) && parent.getName().endsWith("Job")) {
226
+ return true;
227
+ }
228
+ }
229
+ ancestor = ancestor.getParent();
230
+ }
231
+ return false;
232
+ }
233
+
234
+ function unwrapParenthesized(node: Node): Node {
235
+ let current = node;
236
+ while (current.isKind(SyntaxKind.ParenthesizedExpression)) {
237
+ current = current.getExpression();
238
+ }
239
+ return current;
240
+ }
241
+
242
+ function isEscapeHatchDeclarationValue(node: Node | undefined): boolean {
243
+ if (!node) return false;
244
+ const value = unwrapParenthesized(node);
245
+ if (value.isKind(SyntaxKind.ObjectLiteralExpression)) return true;
246
+ if (value.isKind(SyntaxKind.ConditionalExpression)) {
247
+ const whenTrue = unwrapParenthesized(value.getWhenTrue());
248
+ const whenFalse = unwrapParenthesized(value.getWhenFalse());
249
+ return (
250
+ whenTrue.isKind(SyntaxKind.ObjectLiteralExpression) &&
251
+ whenFalse.isKind(SyntaxKind.ObjectLiteralExpression)
252
+ );
253
+ }
254
+ return false;
255
+ }
256
+
257
+ function objectDeclaresEscapeHatch(obj: ObjectLiteralExpression): boolean {
258
+ return obj.getProperties().some((prop) => {
259
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
260
+ const nameNode = prop.getNameNode();
261
+ if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "escapeHatch") {
262
+ return false;
263
+ }
264
+ return isEscapeHatchDeclarationValue(prop.getInitializer());
265
+ });
266
+ }
267
+
268
+ const ESCAPE_HATCH_CALL_METHODS = new Set([
269
+ "hook",
270
+ "writeHandler",
271
+ "queryHandler",
272
+ "streamHandler",
273
+ ]);
274
+
275
+ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
276
+ const parent = fn.getParent();
277
+ if (!parent) return false;
278
+
279
+ if (fn.isKind(SyntaxKind.MethodDeclaration)) {
280
+ const nameNode = fn.getNameNode();
281
+ return (
282
+ nameNode.isKind(SyntaxKind.Identifier) &&
283
+ nameNode.getText() === "handler" &&
284
+ parent.isKind(SyntaxKind.ObjectLiteralExpression) &&
285
+ objectDeclaresEscapeHatch(parent)
286
+ );
287
+ }
288
+
289
+ if (!fn.isKind(SyntaxKind.ArrowFunction) && !fn.isKind(SyntaxKind.FunctionExpression)) {
290
+ return false;
291
+ }
292
+
293
+ if (parent.isKind(SyntaxKind.PropertyAssignment)) {
294
+ const nameNode = parent.getNameNode();
295
+ const obj = parent.getParent();
296
+ return (
297
+ nameNode.isKind(SyntaxKind.Identifier) &&
298
+ nameNode.getText() === "handler" &&
299
+ parent.getInitializer() === fn &&
300
+ obj.isKind(SyntaxKind.ObjectLiteralExpression) &&
301
+ objectDeclaresEscapeHatch(obj)
302
+ );
303
+ }
304
+
305
+ if (parent.isKind(SyntaxKind.CallExpression)) {
306
+ const args = parent.getArguments();
307
+ if (!args.includes(fn)) return false;
308
+ const callee = parent.getExpression();
309
+ if (
310
+ !callee.isKind(SyntaxKind.PropertyAccessExpression) ||
311
+ !ESCAPE_HATCH_CALL_METHODS.has(callee.getName())
312
+ ) {
313
+ return false;
314
+ }
315
+ return args.some(
316
+ (arg) =>
317
+ arg !== fn &&
318
+ arg.isKind(SyntaxKind.ObjectLiteralExpression) &&
319
+ objectDeclaresEscapeHatch(arg),
320
+ );
321
+ }
322
+
323
+ return false;
324
+ }
325
+
326
+ function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
327
+ let ancestor: Node | undefined = node.getParent();
328
+ while (ancestor) {
329
+ if (
330
+ ancestor.isKind(SyntaxKind.ArrowFunction) ||
331
+ ancestor.isKind(SyntaxKind.FunctionExpression) ||
332
+ ancestor.isKind(SyntaxKind.MethodDeclaration)
333
+ ) {
334
+ if (isEscapeHatchDeclaredFunction(ancestor)) return true;
335
+ }
336
+ ancestor = ancestor.getParent();
337
+ }
338
+ return false;
339
+ }
340
+
341
+ function isAllowedEscapeHatchCall(
342
+ call: Node,
343
+ sf: SourceFile,
344
+ systemDirs: readonly string[],
345
+ ): boolean {
346
+ const filePath = sf.getFilePath();
347
+ if (isInSystemScope(filePath, systemDirs)) return true;
348
+ if (filePath.endsWith(".job.ts")) return true;
349
+ return hasJobScopeAncestor(call) || isInsideEscapeHatchDeclaredFunction(call);
350
+ }
351
+
352
+ function findSystemIdentityFindings(
353
+ sf: SourceFile,
354
+ root: string,
355
+ systemDirs: readonly string[],
356
+ ): EscapeHatchFinding[] {
357
+ const out: EscapeHatchFinding[] = [];
358
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
359
+ const expr = call.getExpression();
360
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
361
+ const methodName = expr.getName();
362
+ if (methodName !== "queryAs" && methodName !== "writeAs") continue;
363
+ const firstArg = call.getArguments()[0];
364
+ if (!firstArg || !isSystemIdentityExpression(firstArg)) continue;
365
+ if (isAllowedEscapeHatchCall(call, sf, systemDirs)) continue;
366
+ out.push({
367
+ file: path.relative(root, sf.getFilePath()),
368
+ line: call.getStartLineNumber(),
369
+ rule: "system-identity-outside-declared-scope",
370
+ message: `${methodName}(...) called with a system identity outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, or declare { escapeHatch: { reason: "..." } } on the handler or hook.`,
371
+ });
372
+ }
373
+ return out;
374
+ }
375
+
376
+ function isUnsafeAllTenantsDeclarationValue(node: Node): boolean {
377
+ if (node.isKind(SyntaxKind.TrueKeyword)) return true;
378
+ return node.isKind(SyntaxKind.ObjectLiteralExpression);
379
+ }
380
+
381
+ function findUnsafeAllTenantsFindings(
382
+ sf: SourceFile,
383
+ root: string,
384
+ systemDirs: readonly string[],
385
+ ): EscapeHatchFinding[] {
386
+ const out: EscapeHatchFinding[] = [];
387
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
388
+ const nameNode = prop.getNameNode();
389
+ if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "unsafeAllTenants") {
390
+ continue;
391
+ }
392
+ const initializer = prop.getInitializer();
393
+ if (!initializer) continue;
394
+ const value = unwrapParenthesized(initializer);
395
+ if (!isUnsafeAllTenantsDeclarationValue(value)) continue;
396
+ const obj = prop.getParent();
397
+ if (!obj.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
398
+ const call = obj.getParent();
399
+ if (!call?.isKind(SyntaxKind.CallExpression)) continue;
400
+ if (!call.getArguments().includes(obj)) continue;
401
+ if (objectDeclaresEscapeHatch(obj)) continue;
402
+ if (isAllowedEscapeHatchCall(call, sf, systemDirs)) continue;
403
+ out.push({
404
+ file: path.relative(root, sf.getFilePath()),
405
+ line: prop.getStartLineNumber(),
406
+ rule: "unsafe-all-tenants-outside-declared-scope",
407
+ message:
408
+ 'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, or declare { escapeHatch: { reason: "..." } } alongside it.',
409
+ });
410
+ }
411
+ return out;
412
+ }
413
+
414
+ export function findEscapeHatchFindings(
415
+ files: readonly SourceFile[],
416
+ root: string,
417
+ ): EscapeHatchFinding[] {
418
+ const systemDirs = systemScopeDirs(files);
419
+ const out: EscapeHatchFinding[] = [];
420
+ for (const sf of scannableFiles(files)) {
421
+ out.push(...findRawFindings(sf, root, systemDirs));
422
+ out.push(...findUnsafeRawFindings(sf, root, systemDirs));
423
+ out.push(...findSystemIdentityFindings(sf, root, systemDirs));
424
+ out.push(...findUnsafeAllTenantsFindings(sf, root, systemDirs));
425
+ }
426
+ return out;
427
+ }
428
+
429
+ const GENERIC_REASON_METHODS = new Set(["acknowledgeCrossTenant", "unsafeRaw"]);
430
+
431
+ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReasonFinding[] {
432
+ const out: GenericReasonFinding[] = [];
433
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
434
+ const expr = call.getExpression();
435
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
436
+ const methodName = expr.getName();
437
+ if (!GENERIC_REASON_METHODS.has(methodName)) continue;
438
+ const reasonText = literalReasonText(call.getArguments()[0]);
439
+ if (reasonText === undefined || !isGenericReason(reasonText)) continue;
440
+ out.push({
441
+ file: path.relative(root, sf.getFilePath()),
442
+ line: call.getStartLineNumber(),
443
+ message: `${methodName}("${reasonText}") uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
444
+ });
445
+ }
446
+ return out;
447
+ }
448
+
449
+ const REASON_OBJECT_PROPERTY_NAMES = ["escapeHatch", "unsafeAllTenants"] as const;
450
+
451
+ function findGenericReasonObjectProperty(
452
+ sf: SourceFile,
453
+ root: string,
454
+ propertyName: string,
455
+ ): GenericReasonFinding[] {
456
+ const out: GenericReasonFinding[] = [];
457
+ for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
458
+ if (pa.getName() !== propertyName) continue;
459
+ const init = pa.getInitializer();
460
+ if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
461
+ const reasonProp = init.getProperty("reason");
462
+ if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
463
+ const reasonText = literalReasonText(reasonProp.getInitializer());
464
+ if (reasonText === undefined) continue;
465
+ // Empty/whitespace is the boot validator's job — no double-check.
466
+ if (reasonText.trim() === "") continue;
467
+ if (!isGenericReason(reasonText)) continue;
468
+ out.push({
469
+ file: path.relative(root, sf.getFilePath()),
470
+ line: pa.getStartLineNumber(),
471
+ message: `${propertyName}: { reason: "${reasonText}" } uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
472
+ });
473
+ }
474
+ return out;
475
+ }
476
+
477
+ export function findGenericReasonCalls(
478
+ files: readonly SourceFile[],
479
+ root: string,
480
+ ): GenericReasonFinding[] {
481
+ const out: GenericReasonFinding[] = [];
482
+ for (const sf of scannableFiles(files)) {
483
+ out.push(...findGenericReasonMethodCalls(sf, root));
484
+ for (const propertyName of REASON_OBJECT_PROPERTY_NAMES) {
485
+ out.push(...findGenericReasonObjectProperty(sf, root, propertyName));
486
+ }
487
+ }
488
+ return out;
489
+ }
490
+
491
+ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
492
+ return {
493
+ name: "Escape-Hatch-Declared Guard",
494
+ scan: SCAN,
495
+ security: true,
496
+ hint: 'Escape-Hatch deklarieren (r.systemScope() auf der Feature-Definition, .job.ts/r.job(...) fuer Jobs, oder { escapeHatch: { reason: "..." } } auf dem Handler oder Hook) oder den ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants-Zugriff entfernen. Baseline nach bewusster Reduktion: `bun guards/run-guards.ts --write-security-baseline`',
497
+ run(files) {
498
+ const violations: GuardViolation[] = [
499
+ ...findGenericReasonCalls(files, opts.root).map((f) => ({
500
+ file: f.file,
501
+ line: f.line,
502
+ message: f.message,
503
+ neverFrozen: true,
504
+ })),
505
+ ...findEscapeHatchFindings(files, opts.root).map((f) => ({
506
+ file: f.file,
507
+ line: f.line,
508
+ message: f.message,
509
+ })),
510
+ ];
511
+ return { violations };
512
+ },
513
+ };
514
+ }
515
+
516
+ export const guard = createEscapeHatchGuard({ root: process.cwd() });
517
+
518
+ if (import.meta.main) {
519
+ runStandalone(guard);
520
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: tests must actually assert something. Flags:
4
+ * (a) Always-true assertions: expect(true).toBe(true), expect(1).toBe(1)
5
+ * (b) Test bodies with zero expect() calls
6
+ *
7
+ * Both are silent failures — they count as "passing" but prove nothing. In
8
+ * integration tests this is particularly toxic, since the whole point is to
9
+ * prove wiring against the real stack.
10
+ *
11
+ * Usage:
12
+ * bun guards/guard-fake-tests.ts
13
+ */
14
+
15
+ import * as path from "node:path";
16
+ import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
17
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
18
+
19
+ const ROOT = process.cwd();
20
+
21
+ const SCAN: ScanSpec = {
22
+ scope: "tests",
23
+ extensions: ["ts"],
24
+ kinds: ["framework", "library"],
25
+ };
26
+
27
+ interface Violation {
28
+ file: string;
29
+ line: number;
30
+ kind: "tautology" | "no-expect";
31
+ detail: string;
32
+ }
33
+
34
+ const TAUTOLOGIES = [
35
+ /^expect\(\s*true\s*\)\.toBe\(\s*true\s*\)$/,
36
+ /^expect\(\s*false\s*\)\.toBe\(\s*false\s*\)$/,
37
+ /^expect\(\s*1\s*\)\.toBe\(\s*1\s*\)$/,
38
+ /^expect\(\s*['"]\w+['"]\s*\)\.toBe\(\s*['"]\w+['"]\s*\)$/,
39
+ ];
40
+
41
+ function isTestCall(call: CallExpression): boolean {
42
+ const expr = call.getExpression();
43
+ const text = expr.getText();
44
+ return text === "it" || text === "test";
45
+ }
46
+
47
+ // Helpers that internally call `expect(...)` and assert something —
48
+ // treating a call to one of these as equivalent to a direct `expect()` in
49
+ // the body. Extend this list only when a new wrapper provides a real
50
+ // assertion (not a silent-noop alias), otherwise it re-opens the
51
+ // "no expect" escape hatch.
52
+ const ASSERTION_HELPER_NAMES = new Set([
53
+ "expectError",
54
+ "expectSuccess",
55
+ "expectErrorIncludes",
56
+ // expectTypeOf — Vitest's compile-time type assertion. Failures show up as
57
+ // tsc errors (e.g. TS2344 "Type 'X' does not satisfy ..."), not as runtime
58
+ // expect failures, but they ARE real assertions on the type level.
59
+ "expectTypeOf",
60
+ ]);
61
+
62
+ function countExpects(node: Node): number {
63
+ let n = 0;
64
+ node.forEachDescendant((d) => {
65
+ if (d.isKind(SyntaxKind.CallExpression)) {
66
+ const name = d.getExpression().getText();
67
+ if (name === "expect" || name.startsWith("expect.")) {
68
+ n++;
69
+ } else if (ASSERTION_HELPER_NAMES.has(name)) {
70
+ n++;
71
+ }
72
+ }
73
+ });
74
+ return n;
75
+ }
76
+
77
+ function scanFile(sf: SourceFile): Violation[] {
78
+ const violations: Violation[] = [];
79
+ const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
80
+
81
+ for (const call of calls) {
82
+ // (a) tautology: full expression text matches a known trivial pattern
83
+ const text = call.getText().replace(/\s+/g, " ").trim();
84
+ if (TAUTOLOGIES.some((re) => re.test(text))) {
85
+ violations.push({
86
+ file: path.relative(ROOT, sf.getFilePath()),
87
+ line: call.getStartLineNumber(),
88
+ kind: "tautology",
89
+ detail: text.slice(0, 60),
90
+ });
91
+ continue;
92
+ }
93
+
94
+ // (b) no-expect: it/test with empty expect count
95
+ if (!isTestCall(call)) continue;
96
+ const args = call.getArguments();
97
+ if (args.length < 2) continue;
98
+ const body = args[1];
99
+ if (!body) continue;
100
+ const isArrowOrFn =
101
+ body.isKind(SyntaxKind.ArrowFunction) || body.isKind(SyntaxKind.FunctionExpression);
102
+ if (!isArrowOrFn) continue;
103
+ const nExpect = countExpects(body);
104
+ if (nExpect === 0) {
105
+ const nameArg = args[0]?.getText().slice(0, 50) ?? "<anonymous>";
106
+ violations.push({
107
+ file: path.relative(ROOT, sf.getFilePath()),
108
+ line: call.getStartLineNumber(),
109
+ kind: "no-expect",
110
+ detail: nameArg,
111
+ });
112
+ }
113
+ }
114
+ return violations;
115
+ }
116
+
117
+ export const guard: AstGuard = {
118
+ name: "Fake-Test Guard",
119
+ scan: SCAN,
120
+ hint: "Test ohne expect() oder mit Tautologie — echtes Verhalten prüfen, nicht Existenz.",
121
+ run(files) {
122
+ const violations: Array<{ file: string; line: number; message: string }> = [];
123
+ for (const sf of files) {
124
+ for (const v of scanFile(sf)) {
125
+ const label = v.kind === "tautology" ? "TAUTOLOGY" : "NO EXPECT";
126
+ violations.push({
127
+ file: v.file,
128
+ line: v.line,
129
+ message: `${label}: ${v.detail}`,
130
+ });
131
+ }
132
+ }
133
+ return { violations };
134
+ },
135
+ };
136
+
137
+ if (import.meta.main) runStandalone(guard);