@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.
- package/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- 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);
|