@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,312 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: tenant / privilege-escalation safety. Four complementary checks.
|
|
4
|
+
*
|
|
5
|
+
* A) **Role-input write handlers need an escalation test.** Any
|
|
6
|
+
* `defineWriteHandler` whose Zod schema takes a `role`/`roles` field lets
|
|
7
|
+
* the caller choose a role — the membership-role escalation surface that
|
|
8
|
+
* let a Tenant-Admin invite "SystemAdmin" and become platform admin. Such
|
|
9
|
+
* a handler MUST have a test asserting a reserved/global role is rejected:
|
|
10
|
+
* a test file that references the handler name (any of its kebab/camel/
|
|
11
|
+
* colon-segment forms) AND a reserved-role literal. Matching is repo-wide
|
|
12
|
+
* because a feature's escalation test legitimately lives in another
|
|
13
|
+
* feature's __tests__ (e.g. user:update is covered by auth's multi-roles).
|
|
14
|
+
*
|
|
15
|
+
* B) **tenantIdOverride handlers must use crossTenantOverrideDenied.** A
|
|
16
|
+
* payload `tenantIdOverride` on a TenantAdmin-reachable handler is the
|
|
17
|
+
* cross-tenant escape hatch; the SystemAdmin gate must go through the
|
|
18
|
+
* shared framework helper, not an inline `roles.includes("SystemAdmin")`
|
|
19
|
+
* check that the next handler forgets.
|
|
20
|
+
*
|
|
21
|
+
* C) **Membership-derived JWT mints must strip reserved roles.** Command-time
|
|
22
|
+
* validation rejects reserved roles from a membership, but a projection
|
|
23
|
+
* rebuild replays stored membership events through the apply path, not the
|
|
24
|
+
* handler — so a forbidden role can be resurrected into the projection. Any
|
|
25
|
+
* file that mints a session (`kind: "auth-session"` / a `SessionUser`
|
|
26
|
+
* literal) from a membership source (`membership.roles`/`chosen.roles`/
|
|
27
|
+
* `invitationRole`) MUST call `stripForbiddenMembershipRoles`, the
|
|
28
|
+
* read-time backstop.
|
|
29
|
+
*
|
|
30
|
+
* D) **TenantAdmin-reachable writes on a global user row need a membership
|
|
31
|
+
* check.** A `ctx.db.raw` read deliberately bypasses the auto-tenant-
|
|
32
|
+
* filter because User status is global. Combined with `access.admin`
|
|
33
|
+
* (which includes the tenant-scoped TenantAdmin) and a `userId` payload,
|
|
34
|
+
* the handler acts on *someone else's* account across tenant lines, so it
|
|
35
|
+
* must gate on the target's membership in the caller's own tenant — the
|
|
36
|
+
* isSystemAdminActor + tenantMembershipsTable shape lift-restriction.
|
|
37
|
+
* write.ts uses. restrict-account.write.ts shipped without it.
|
|
38
|
+
*
|
|
39
|
+
* All four are tripwires: false-negatives (a weak name match) are tolerated, a
|
|
40
|
+
* false-positive on the clean repo is not. Detection stays conservative.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* bun guards/guard-tenant-escalation.ts
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import * as path from "node:path";
|
|
47
|
+
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
48
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
49
|
+
import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
|
|
50
|
+
|
|
51
|
+
const ROOT = process.cwd();
|
|
52
|
+
|
|
53
|
+
const SCAN: ScanSpec = {
|
|
54
|
+
scope: "source",
|
|
55
|
+
extensions: ["ts"],
|
|
56
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const TEST_FILE = /\.test\.ts$/;
|
|
60
|
+
|
|
61
|
+
// Quoted forms — a test "rejects this role" assertion carries the literal.
|
|
62
|
+
const RESERVED_ROLE_LITERALS = ['"SystemAdmin"', '"system"', '"all"', '"anonymous"'];
|
|
63
|
+
|
|
64
|
+
type RoleHandler = { name: string; file: string; line: number };
|
|
65
|
+
|
|
66
|
+
// A `schema:` property that references an outlined const (`schema:
|
|
67
|
+
// CancelInvitationSchema`) has no `role:`/`roles:` text of its own — the field
|
|
68
|
+
// lives in the const's declaration. Resolving the identifier to its
|
|
69
|
+
// declaration covers that ~43% of write handlers (#1556-adjacent bug: a
|
|
70
|
+
// text-only check silently skips them). Falls back to the property's own text
|
|
71
|
+
// when the initializer isn't a resolvable identifier.
|
|
72
|
+
function resolvedSchemaText(schemaProp: Node): string {
|
|
73
|
+
const init = schemaProp.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
74
|
+
if (init?.getKind() === SyntaxKind.Identifier) {
|
|
75
|
+
const decl = init.asKindOrThrow(SyntaxKind.Identifier).getSymbol()?.getValueDeclaration();
|
|
76
|
+
if (decl) return decl.getText();
|
|
77
|
+
}
|
|
78
|
+
return schemaProp.getText();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findRoleInputHandlers(files: readonly SourceFile[]): RoleHandler[] {
|
|
82
|
+
const out: RoleHandler[] = [];
|
|
83
|
+
for (const sf of files) {
|
|
84
|
+
if (TEST_FILE.test(sf.getFilePath())) continue;
|
|
85
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
86
|
+
if (call.getExpression().getText() !== "defineWriteHandler") continue;
|
|
87
|
+
const arg = call.getArguments()[0];
|
|
88
|
+
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
89
|
+
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
90
|
+
const name = literalStringOf(obj.getProperty("name"));
|
|
91
|
+
const schema = obj.getProperty("schema");
|
|
92
|
+
if (!name || !schema) continue;
|
|
93
|
+
// `role:`/`roles:` zod field anywhere in the schema expression.
|
|
94
|
+
if (!/\broles?\s*:/.test(resolvedSchemaText(schema))) continue;
|
|
95
|
+
out.push({
|
|
96
|
+
name,
|
|
97
|
+
file: sf.getFilePath(),
|
|
98
|
+
line: call.getStartLineNumber(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function reservedRoleTestTexts(files: readonly SourceFile[]): string[] {
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
for (const sf of files) {
|
|
108
|
+
if (!TEST_FILE.test(sf.getFilePath())) continue;
|
|
109
|
+
const text = sf.getFullText();
|
|
110
|
+
if (RESERVED_ROLE_LITERALS.some((r) => text.includes(r))) out.push(text);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function findUntestedRoleHandlers(files: readonly SourceFile[]): RoleHandler[] {
|
|
116
|
+
const handlers = findRoleInputHandlers(files);
|
|
117
|
+
const tests = reservedRoleTestTexts(files);
|
|
118
|
+
return handlers.filter((h) => {
|
|
119
|
+
const forms = nameForms(h.name);
|
|
120
|
+
// Word boundary instead of a raw includes(): a raw substring match on
|
|
121
|
+
// the short forms (e.g. "create" from "user:create") would match any
|
|
122
|
+
// test file that happens to contain "createTestStack()"/
|
|
123
|
+
// "createSourceFile()" AND "SystemAdmin" somewhere — not real coverage
|
|
124
|
+
// of the combination. \b keeps the documented file-wide (not
|
|
125
|
+
// block-wide) matching intent while still requiring the name as its
|
|
126
|
+
// own word.
|
|
127
|
+
return !tests.some((t) => forms.some((f) => mentionsAsWord(t, f)));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type OverrideHandler = { file: string; line: number };
|
|
132
|
+
|
|
133
|
+
export function findOverrideHandlersMissingHelper(files: readonly SourceFile[]): OverrideHandler[] {
|
|
134
|
+
const out: OverrideHandler[] = [];
|
|
135
|
+
for (const sf of files) {
|
|
136
|
+
if (TEST_FILE.test(sf.getFilePath())) continue;
|
|
137
|
+
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
138
|
+
if (pa.getName() !== "tenantIdOverride") continue;
|
|
139
|
+
if (pa.getInitializer()?.getText().startsWith("z.") !== true) continue;
|
|
140
|
+
// Scope the check to the enclosing defineWriteHandler(...) call, not
|
|
141
|
+
// the whole file — a file with two override handlers where only one
|
|
142
|
+
// calls crossTenantOverrideDenied must still flag the other.
|
|
143
|
+
const handlerCall = pa.getFirstAncestor(
|
|
144
|
+
(a) =>
|
|
145
|
+
a.getKind() === SyntaxKind.CallExpression &&
|
|
146
|
+
a.asKindOrThrow(SyntaxKind.CallExpression).getExpression().getText() ===
|
|
147
|
+
"defineWriteHandler",
|
|
148
|
+
);
|
|
149
|
+
const scopeText = handlerCall?.getText() ?? sf.getFullText();
|
|
150
|
+
if (scopeText.includes("crossTenantOverrideDenied")) continue;
|
|
151
|
+
out.push({ file: sf.getFilePath(), line: pa.getStartLineNumber() });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A session built for a JWT — either the auth-session result shape or a typed
|
|
158
|
+
// SessionUser literal.
|
|
159
|
+
const SESSION_MINT = /kind:\s*"auth-session"|:\s*SessionUser\s*=\s*\{/;
|
|
160
|
+
// Roles read from a tenant membership (DB projection / invitation), as opposed
|
|
161
|
+
// to globalRoles or a compile-time constant. The merge lands in a `mergedRoles`
|
|
162
|
+
// variable, so the source lives in the file body, not the `roles:` literal —
|
|
163
|
+
// hence a file-level check.
|
|
164
|
+
const MEMBERSHIP_SOURCE = /\b(?:membership\.roles|chosen\.roles|invitationRole)\b/;
|
|
165
|
+
// A bare mention instead of a call test (`STRIP_FN + "("`) is deliberate
|
|
166
|
+
// here, not sloppy: the real login.write.ts handler doesn't call
|
|
167
|
+
// stripForbiddenMembershipRoles directly, but indirectly via
|
|
168
|
+
// buildSessionRoles() — a direct call test would false-positive there.
|
|
169
|
+
// This tolerates the false negative from the guard's own contract (dead
|
|
170
|
+
// imports/comments count too) but avoids the worse false positive against
|
|
171
|
+
// the real call path.
|
|
172
|
+
const STRIP_FN = "stripForbiddenMembershipRoles";
|
|
173
|
+
|
|
174
|
+
type MintSite = { file: string; line: number };
|
|
175
|
+
|
|
176
|
+
function lineOfMatch(text: string, re: RegExp): number {
|
|
177
|
+
const m = re.exec(text);
|
|
178
|
+
return m ? text.slice(0, m.index).split("\n").length : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function findMembershipMintsMissingStrip(files: readonly SourceFile[]): MintSite[] {
|
|
182
|
+
const out: MintSite[] = [];
|
|
183
|
+
for (const sf of files) {
|
|
184
|
+
if (TEST_FILE.test(sf.getFilePath())) continue;
|
|
185
|
+
const text = sf.getFullText();
|
|
186
|
+
if (!SESSION_MINT.test(text)) continue;
|
|
187
|
+
if (!MEMBERSHIP_SOURCE.test(text)) continue;
|
|
188
|
+
if (text.includes(STRIP_FN)) continue;
|
|
189
|
+
out.push({
|
|
190
|
+
file: sf.getFilePath(),
|
|
191
|
+
line: lineOfMatch(text, MEMBERSHIP_SOURCE),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The two halves of the cross-tenant gate lift-restriction.write.ts uses:
|
|
198
|
+
// SystemAdmin skips, everyone else needs an active membership row for the
|
|
199
|
+
// target in the caller's own tenant. Either token counts as "gated" — the
|
|
200
|
+
// combination is what the reviewer reads, a mention is what the guard can
|
|
201
|
+
// see without following the call graph.
|
|
202
|
+
const MEMBERSHIP_GATE = /\b(?:isSystemAdminActor|tenantMembershipsTable)\b/;
|
|
203
|
+
// A bare-mention match on `denyIfTargetOutsideAdminTenant` isn't enough — the
|
|
204
|
+
// helper returns `Promise<WriteFailure | undefined>` instead of throwing, so
|
|
205
|
+
// `await denyIfTargetOutsideAdminTenant(...)` with no binding has no gate at
|
|
206
|
+
// all despite passing a mention check. Require the return value to actually
|
|
207
|
+
// be consumed, matching the real lift-restriction.write.ts call form.
|
|
208
|
+
const CROSS_TENANT_HELPER_GATE = /(?:=|return)\s*await\s+denyIfTargetOutsideAdminTenant\s*\(/;
|
|
209
|
+
// `userId` in the payload means the handler targets an account other than the
|
|
210
|
+
// caller's own; a self-service handler reads event.user.id instead.
|
|
211
|
+
const TARGETS_OTHER_USER = /\buserId\s*:/;
|
|
212
|
+
// engine/config-helpers `access` presets whose roles are platform-wide actors
|
|
213
|
+
// (system / SystemAdmin) with no TenantAdmin in them. Deliberately does NOT
|
|
214
|
+
// exempt a bare SYSTEM_ROLE mention: `[SYSTEM_ROLE, "TenantAdmin"]` (the
|
|
215
|
+
// un-helpered access.withSystem form) is tenant-reachable, and no write
|
|
216
|
+
// handler needs the exemption today.
|
|
217
|
+
const PLATFORM_WIDE_ACCESS = /\baccess\.(?:systemAdmin|system|privileged)\b/;
|
|
218
|
+
|
|
219
|
+
type GlobalUserWrite = { name: string; file: string; line: number };
|
|
220
|
+
|
|
221
|
+
export function findGlobalUserWritesMissingMembershipCheck(
|
|
222
|
+
files: readonly SourceFile[],
|
|
223
|
+
): GlobalUserWrite[] {
|
|
224
|
+
const out: GlobalUserWrite[] = [];
|
|
225
|
+
for (const sf of files) {
|
|
226
|
+
if (TEST_FILE.test(sf.getFilePath())) continue;
|
|
227
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
228
|
+
if (call.getExpression().getText() !== "defineWriteHandler") continue;
|
|
229
|
+
const arg = call.getArguments()[0];
|
|
230
|
+
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
231
|
+
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
232
|
+
// `name` is only used for the violation message, not the security
|
|
233
|
+
// check itself — a factory-built handler with a non-literal name
|
|
234
|
+
// expression (`name: enable ? "enable" : "disable"`) must not skip
|
|
235
|
+
// Check D just because literalStringOf() can't resolve it.
|
|
236
|
+
const name = literalStringOf(obj.getProperty("name"));
|
|
237
|
+
const schema = obj.getProperty("schema");
|
|
238
|
+
const accessProp = obj.getProperty("access");
|
|
239
|
+
// No `access` key at all = deny-all (engine/access.ts: undefined
|
|
240
|
+
// returns false), so there is nothing to reach across a tenant.
|
|
241
|
+
if (!schema || !accessProp) continue;
|
|
242
|
+
// Presets that resolve to platform-wide actors only (engine/
|
|
243
|
+
// config-helpers `access`): no tenant boundary exists to cross.
|
|
244
|
+
// Everything else — access.admin, and the openToAll handlers that
|
|
245
|
+
// gate on isAdminActor at runtime (#1556's shape) — is reachable by
|
|
246
|
+
// a tenant-scoped TenantAdmin.
|
|
247
|
+
if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) continue;
|
|
248
|
+
if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) continue;
|
|
249
|
+
const body = call.getText();
|
|
250
|
+
// Must actually touch a *user* table — otherwise a handler reading
|
|
251
|
+
// ctx.db.raw against an unrelated global table (plans, audit log,
|
|
252
|
+
// invitations) gets flagged with a "reads a global user row" message
|
|
253
|
+
// that doesn't apply to it (false-positive on clean code).
|
|
254
|
+
if (!body.includes("ctx.db.raw") || !/\buserTable\b/.test(body)) continue;
|
|
255
|
+
if (MEMBERSHIP_GATE.test(body) || CROSS_TENANT_HELPER_GATE.test(body)) continue;
|
|
256
|
+
out.push({
|
|
257
|
+
name: name ?? path.basename(sf.getFilePath()),
|
|
258
|
+
file: sf.getFilePath(),
|
|
259
|
+
line: call.getStartLineNumber(),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const guard: AstGuard = {
|
|
267
|
+
name: "Tenant-Escalation Guard",
|
|
268
|
+
scan: SCAN,
|
|
269
|
+
security: true,
|
|
270
|
+
hint: "Role-input handlers need an escalation test (assert a reserved role is rejected); tenantIdOverride handlers must call crossTenantOverrideDenied; membership-derived JWT mints must call stripForbiddenMembershipRoles; TenantAdmin-reachable ctx.db.raw user writes must check the target's membership.",
|
|
271
|
+
run(files) {
|
|
272
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
273
|
+
|
|
274
|
+
for (const h of findUntestedRoleHandlers(files)) {
|
|
275
|
+
violations.push({
|
|
276
|
+
file: path.relative(ROOT, h.file),
|
|
277
|
+
line: h.line,
|
|
278
|
+
message: `role-input write handler "${h.name}" has no escalation test — add a test asserting a reserved/global role (SystemAdmin/system/all/anonymous) is rejected.`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const o of findOverrideHandlersMissingHelper(files)) {
|
|
283
|
+
violations.push({
|
|
284
|
+
file: path.relative(ROOT, o.file),
|
|
285
|
+
line: o.line,
|
|
286
|
+
message:
|
|
287
|
+
"handler exposes tenantIdOverride but never calls crossTenantOverrideDenied — route the SystemAdmin gate through the shared helper.",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const m of findMembershipMintsMissingStrip(files)) {
|
|
292
|
+
violations.push({
|
|
293
|
+
file: path.relative(ROOT, m.file),
|
|
294
|
+
line: m.line,
|
|
295
|
+
message:
|
|
296
|
+
"JWT mint derives session roles from a membership but never calls stripForbiddenMembershipRoles — a reserved role resurrected by a projection rebuild would reach the session. Strip the membership portion (see engine/membership-roles).",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const g of findGlobalUserWritesMissingMembershipCheck(files)) {
|
|
301
|
+
violations.push({
|
|
302
|
+
file: path.relative(ROOT, g.file),
|
|
303
|
+
line: g.line,
|
|
304
|
+
message: `write handler "${g.name}" reads a global user row via ctx.db.raw and is reachable by a tenant-scoped admin, but never checks the target's membership in the caller's tenant — a TenantAdmin from another tenant could act on this account. Gate it like lift-restriction.write.ts (denyIfTargetOutsideAdminTenant, or isSystemAdminActor + tenantMembershipsTable lookup).`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { violations };
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
if (import.meta.main) runStandalone(guard);
|