@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,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);