@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,387 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: forbids direct DB writes on tables registered as ES-entity
4
+ * projections. Every write to such a table MUST go through the
5
+ * event-store-executor or an inline projection — otherwise the projection
6
+ * row drifts away from the events stream.
7
+ *
8
+ * Detection in two phases:
9
+ *
10
+ * 1. **Collect ES tables.** Two sources:
11
+ * a) All `createEventStoreExecutor(<tableExpr>, <entity>, ...)` calls;
12
+ * `<tableExpr>` is the projection table (identifier).
13
+ * b) All `r.entity(<name>, <entityDef>)` registrations: every `r.entity`
14
+ * is a rebuildable implicit projection. The corresponding Drizzle
15
+ * table is linked via the SHARED entity-def identifier, NOT via a
16
+ * name string — `r.entity("user-session", ent)` and
17
+ * `buildEntityTable("user_session", ent)` disagree on the name
18
+ * (dash vs. underscore) but share the `ent` symbol. Plus an explicit
19
+ * `{ table }` override. Tables that are NOT statically resolvable
20
+ * are skipped (a miss is preferable to a false block).
21
+ *
22
+ * 2. **Find direct writes.** Two forms:
23
+ * - Method form `<receiver>.insert(<tableIdent>)` / `.update` / `.delete`.
24
+ * - Function form `insertOne|updateMany|deleteMany|...(<db>, <tableIdent>,
25
+ * ...)` from `@cosmicdrift/kumiko-framework/bun-db` (table = arg[1]) —
26
+ * exactly the path that created the sessions-instance bug
27
+ * (`updateMany(ctx.db.raw, userSessionTable, ...)` without a
28
+ * lifecycle event).
29
+ * Once `<tableIdent>` is in the ES set, check whether the call is allowed:
30
+ *
31
+ * - Receiver `tx` / `trx` → inline projection apply (OK, tx is the
32
+ * tx argument from `r.projection({ apply: (event, tx) => ... })`).
33
+ * - Test or testing-helper file → OK (fixture setup like
34
+ * seedTenantMembership deliberately goes through the executor, or
35
+ * tests reset state directly with `.delete()`).
36
+ * - Framework-internal file (event-store-executor.ts itself) → OK.
37
+ * - Everything else → BLOCK.
38
+ *
39
+ * This guard complements pre-ES patterns: that one catches "old APIs coming
40
+ * back", this one catches "a new feature writes past the ES".
41
+ *
42
+ * Usage:
43
+ * bun guards/guard-direct-entity-writes.ts
44
+ */
45
+
46
+ import * as path from "node:path";
47
+ import {
48
+ type CallExpression,
49
+ type Identifier,
50
+ type Node,
51
+ type SourceFile,
52
+ SyntaxKind,
53
+ } from "ts-morph";
54
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
55
+
56
+ const ROOT = process.cwd();
57
+
58
+ const SCAN: ScanSpec = {
59
+ scope: "source",
60
+ extensions: ["ts"],
61
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
62
+ };
63
+
64
+ // Files that are NOT checked. Tests + testing helpers + the event-store
65
+ // executor itself + dist.
66
+ const EXCLUDE =
67
+ /(^|\/)(dist|node_modules)\/|__tests__\/|\.test\.ts$|\.integration\.ts$|\/testing(\.ts|\/)|\/event-store-executor\.ts$|scripts\/guard-direct-entity-writes\.ts$/;
68
+
69
+ // Receivers that are *context-dependent* allowed for direct writes.
70
+ // `tx` / `trx` / `handle` are the tx parameters from
71
+ // `r.projection({ apply: (event, tx) => ... })` OR `defineApply((event,
72
+ // tx) => ...)` — both are the inline-projection path, which is legitimate.
73
+ //
74
+ // **Context-dependent** because the same receiver name can also appear in
75
+ // `db.transaction(async (tx) => {...})` — that is an ordinary sub-tx in
76
+ // production code and MUST be blocked, otherwise it bypasses the ES
77
+ // requirement (precedent: run-forget-cleanup.ts did exactly that). The
78
+ // guard checks per hit whether the enclosing function is actually a
79
+ // projection apply.
80
+ const TX_RECEIVER_NAMES = new Set(["tx", "trx", "handle"]);
81
+
82
+ // bun-db function helpers with signature `(db, table, ...)` — table is
83
+ // always arg[1]. A direct write on an ES table goes through these just as
84
+ // often as through the Drizzle method form. Maps to the coarse op bucket
85
+ // for the violation message.
86
+ const FN_WRITE_HELPERS = new Map<string, "insert" | "update" | "delete">([
87
+ ["insertOne", "insert"],
88
+ ["insertMany", "insert"],
89
+ ["upsertOnConflict", "insert"],
90
+ ["upsertByPk", "insert"],
91
+ ["updateMany", "update"],
92
+ ["deleteMany", "delete"],
93
+ ["deleteManyBatched", "delete"],
94
+ ]);
95
+
96
+ // Identity of a table declaration: absolute file path + identifier name.
97
+ // Text-only names collide across samples (currencies-global.invoiceTable vs.
98
+ // beammycar.invoiceTable would look the same); resolving to the underlying
99
+ // declaration disambiguates them.
100
+ type TableId = string; // "${filePath}::${name}"
101
+
102
+ function declIdOf(id: import("ts-morph").Identifier): TableId | undefined {
103
+ const symbol = id.getSymbol();
104
+ if (!symbol) return undefined;
105
+ const decls = symbol.getDeclarations();
106
+ if (decls.length === 0) return undefined;
107
+ // Param-only symbols are factory pass-throughs — skip.
108
+ if (decls.every((d) => d.getKind() === SyntaxKind.Parameter)) return undefined;
109
+ const first = decls[0];
110
+ if (!first) return undefined;
111
+ return `${first.getSourceFile().getFilePath()}::${id.getText()}`;
112
+ }
113
+
114
+ export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
115
+ const tables = new Set<TableId>();
116
+ for (const sf of files) {
117
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
118
+ const expr = call.getExpression();
119
+ if (expr.getText() !== "createEventStoreExecutor") continue;
120
+ const tableArg = call.getArguments()[0];
121
+ if (!tableArg || tableArg.getKind() !== SyntaxKind.Identifier) continue;
122
+ const id = tableArg.asKindOrThrow(SyntaxKind.Identifier);
123
+
124
+ // Repo convention: projection-tables follow `<name>Table`. Secondary
125
+ // filter that catches factory-of-factory pass-throughs even if
126
+ // symbol resolution somehow succeeds on a `table` parameter.
127
+ if (!/^[a-z]\w*Table$/.test(id.getText())) continue;
128
+
129
+ const did = declIdOf(id);
130
+ if (did) tables.add(did);
131
+ }
132
+ }
133
+ return tables;
134
+ }
135
+
136
+ // Tables registered via `r.entity(name, entityDef)` as a rebuildable implicit
137
+ // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
138
+ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
139
+ const rebuildableEntities = new Set<TableId>();
140
+ const tables = new Set<TableId>();
141
+
142
+ // Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
143
+ for (const sf of files) {
144
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
145
+ const expr = call.getExpression();
146
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
147
+ if (expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() !== "entity") continue;
148
+ const args = call.getArguments();
149
+ if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) continue;
150
+
151
+ const entityArg = args[1];
152
+ if (entityArg?.getKind() === SyntaxKind.Identifier) {
153
+ const did = declIdOf(entityArg.asKindOrThrow(SyntaxKind.Identifier));
154
+ if (did) rebuildableEntities.add(did);
155
+ }
156
+
157
+ const opts = args[2];
158
+ if (opts?.getKind() === SyntaxKind.ObjectLiteralExpression) {
159
+ const tableProp = opts
160
+ .asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
161
+ .getProperty("table");
162
+ if (tableProp?.getKind() === SyntaxKind.PropertyAssignment) {
163
+ const init = tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
164
+ if (init?.getKind() === SyntaxKind.Identifier) {
165
+ const did = declIdOf(init.asKindOrThrow(SyntaxKind.Identifier));
166
+ if (did) tables.add(did);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // Pass 2: `const xTable = buildEntityTable(<name>, <entityDef>)` where entityDef
174
+ // is in the rebuildable set → xTable is a rebuildable entity table.
175
+ for (const sf of files) {
176
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
177
+ if (call.getExpression().getText() !== "buildEntityTable") continue;
178
+ const entityArg = call.getArguments()[1];
179
+ if (entityArg?.getKind() !== SyntaxKind.Identifier) continue;
180
+ const entityDid = declIdOf(entityArg.asKindOrThrow(SyntaxKind.Identifier));
181
+ if (!entityDid || !rebuildableEntities.has(entityDid)) continue;
182
+
183
+ const varDecl = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
184
+ const nameNode = varDecl?.getNameNode();
185
+ if (nameNode?.getKind() !== SyntaxKind.Identifier) continue;
186
+ const did = declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
187
+ if (did) tables.add(did);
188
+ }
189
+ }
190
+
191
+ return tables;
192
+ }
193
+
194
+ type Violation = {
195
+ file: string;
196
+ line: number;
197
+ receiver: string;
198
+ op: "insert" | "update" | "delete";
199
+ table: string;
200
+ snippet: string;
201
+ /**
202
+ * Which check fired:
203
+ * - "non-tx-receiver" → receiver not in TX_RECEIVER_NAMES.
204
+ * - "tx-outside-apply" → tx receiver, but the enclosing fn is not a
205
+ * projection apply (e.g. a db.transaction sub-tx
206
+ * in production code).
207
+ */
208
+ reason: "non-tx-receiver" | "tx-outside-apply";
209
+ };
210
+
211
+ /**
212
+ * A tx receiver is legitimate only inside an inline projection apply —
213
+ * `r.projection({ apply: (event, tx) => ... })` (incl. a nested per-event apply) or `defineApply((event, tx) => ...)` — never a bare `db.transaction(...)`.
214
+ */
215
+ function isInsideProjectionApply(callNode: Node): boolean {
216
+ let cursor: Node | undefined = callNode.getParent();
217
+ while (cursor) {
218
+ const kind = cursor.getKind();
219
+ if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression) {
220
+ const parent = cursor.getParent();
221
+ if (!parent) return false;
222
+
223
+ // Pattern 3: defineApply(<this fn>)
224
+ if (parent.getKind() === SyntaxKind.CallExpression) {
225
+ const callExpr = parent.asKindOrThrow(SyntaxKind.CallExpression);
226
+ if (callExpr.getExpression().getText() === "defineApply") return true;
227
+ // Other call context (e.g. db.transaction(<this fn>)) → not a
228
+ // projection apply; keep walking up in case one is further out
229
+ // (rare, defensive).
230
+ }
231
+
232
+ // Pattern 1+2: PropertyAssignment in Object-Literal
233
+ if (parent.getKind() === SyntaxKind.PropertyAssignment) {
234
+ const propAssign = parent.asKindOrThrow(SyntaxKind.PropertyAssignment);
235
+ // Direct: { apply: <this fn> }
236
+ if (propAssign.getName() === "apply") return true;
237
+ // Nested: { apply: { [EVENT]: <this fn> } }
238
+ const objLit = propAssign.getParent();
239
+ if (objLit?.getKind() === SyntaxKind.ObjectLiteralExpression) {
240
+ const objParent = objLit.getParent();
241
+ if (objParent?.getKind() === SyntaxKind.PropertyAssignment) {
242
+ const outer = objParent.asKindOrThrow(SyntaxKind.PropertyAssignment);
243
+ if (outer.getName() === "apply") return true;
244
+ }
245
+ }
246
+ }
247
+ }
248
+ cursor = cursor.getParent();
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // Extracts (table identifier, db/receiver expression, op) from a write
254
+ // call — both forms: the Drizzle method `<recv>.insert(table)` and the
255
+ // bun-db function `updateMany(db, table, ...)`. undefined for non-writes or
256
+ // when the table arg isn't a plain identifier (not resolvable → skip, never
257
+ // a false block).
258
+ function resolveWrite(
259
+ call: CallExpression,
260
+ ): { tableArg: Identifier; receiver: Node; op: "insert" | "update" | "delete" } | undefined {
261
+ const expr = call.getExpression();
262
+ const args = call.getArguments();
263
+
264
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
265
+ const pa = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
266
+ const m = pa.getName();
267
+ if (m !== "insert" && m !== "update" && m !== "delete") return undefined;
268
+ if (args[0]?.getKind() !== SyntaxKind.Identifier) return undefined;
269
+ return {
270
+ tableArg: args[0].asKindOrThrow(SyntaxKind.Identifier),
271
+ receiver: pa.getExpression(),
272
+ op: m,
273
+ };
274
+ }
275
+
276
+ if (expr.getKind() === SyntaxKind.Identifier) {
277
+ const op = FN_WRITE_HELPERS.get(expr.getText());
278
+ if (!op) return undefined;
279
+ if (!args[0] || args[1]?.getKind() !== SyntaxKind.Identifier) return undefined;
280
+ return {
281
+ tableArg: args[1].asKindOrThrow(SyntaxKind.Identifier),
282
+ receiver: args[0],
283
+ op,
284
+ };
285
+ }
286
+
287
+ return undefined;
288
+ }
289
+
290
+ export function scanDirectWrites(
291
+ sf: SourceFile,
292
+ esTables: ReadonlySet<TableId>,
293
+ ): Omit<Violation, "file">[] {
294
+ const out: Omit<Violation, "file">[] = [];
295
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
296
+ const w = resolveWrite(call);
297
+ if (!w) continue;
298
+
299
+ const did = declIdOf(w.tableArg);
300
+ if (!did || !esTables.has(did)) continue;
301
+
302
+ // Walk the receiver chain down to its leftmost identifier. For `db.insert`
303
+ // that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
304
+ // `tx` that's `tx`. Checked against the TX-receiver-Set.
305
+ let receiver: Node = w.receiver;
306
+ while (receiver.getKind() === SyntaxKind.PropertyAccessExpression) {
307
+ receiver = receiver.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getExpression();
308
+ }
309
+ const receiverName = receiver.getText();
310
+
311
+ if (TX_RECEIVER_NAMES.has(receiverName)) {
312
+ // A tx receiver is only legitimate when the write actually sits
313
+ // inside a projection-apply callback. A sub-tx pattern like
314
+ // `db.transaction(async (tx) => { tx.update(esTable)...})` falls
315
+ // through here because there's no apply-property/defineApply wrapper.
316
+ if (isInsideProjectionApply(call)) continue;
317
+ out.push({
318
+ line: call.getStartLineNumber(),
319
+ receiver: receiverName,
320
+ op: w.op,
321
+ table: w.tableArg.getText(),
322
+ snippet: call.getText().slice(0, 120),
323
+ reason: "tx-outside-apply",
324
+ });
325
+ continue;
326
+ }
327
+
328
+ out.push({
329
+ line: call.getStartLineNumber(),
330
+ receiver: receiverName,
331
+ op: w.op,
332
+ table: w.tableArg.getText(),
333
+ snippet: call.getText().slice(0, 120),
334
+ reason: "non-tx-receiver",
335
+ });
336
+ }
337
+ return out;
338
+ }
339
+
340
+ export const guard: AstGuard = {
341
+ name: "Direct-Entity-Writes Guard",
342
+ scan: SCAN,
343
+ security: true,
344
+ run(files) {
345
+ // Merge both collections BEFORE the empty-set check — otherwise a repo
346
+ // that only uses r.entity (no createEventStoreExecutor anymore) would
347
+ // skip every scan: the canary violation fires falsely, and writes on
348
+ // entity tables go undetected.
349
+ const esTables = new Set<TableId>([
350
+ ...collectEsTables(files),
351
+ ...collectEntityProjectionTables(files),
352
+ ]);
353
+ if (esTables.size === 0) {
354
+ return {
355
+ violations: [
356
+ {
357
+ file: "<scan>",
358
+ line: 0,
359
+ message:
360
+ "BLOCKED: guard found no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
361
+ },
362
+ ],
363
+ };
364
+ }
365
+
366
+ const violations: Array<{ file: string; line: number; message: string }> = [];
367
+
368
+ for (const sf of files) {
369
+ const file = sf.getFilePath();
370
+ if (EXCLUDE.test(file)) continue;
371
+
372
+ for (const hit of scanDirectWrites(sf, esTables)) {
373
+ violations.push({
374
+ file: path.relative(ROOT, file),
375
+ line: hit.line,
376
+ message: `[${hit.reason}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
377
+ });
378
+ }
379
+ }
380
+
381
+ return { violations };
382
+ },
383
+ };
384
+
385
+ // Run only when invoked as a script — tests import the helpers without
386
+ // triggering the scan + console output.
387
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: finds raw `fetch(` calls in server code outside the client-scoped
4
+ * exceptions.
5
+ *
6
+ * `egress(policy)` (@cosmicdrift/kumiko-framework/http, framework#2147) is
7
+ * the single exported way for server code to speak outward — it enforces an
8
+ * SSRF policy (external/internal/tenant-supplied) at the call site instead
9
+ * of leaving that to convention. This guard fails on raw `fetch(` in server
10
+ * code so a new call site can't silently skip the policy.
11
+ *
12
+ * Scoping: client code legitimately calls `fetch(` against its own origin
13
+ * (browser same-origin, not server egress) — excluded via path (`web/`,
14
+ * `public/`). Server scans are `*.ts` only (`SCAN`).
15
+ *
16
+ * Usage:
17
+ * bun guards/guard-direct-fetch.ts
18
+ *
19
+ * Exit 1 on violations in non-allowlisted files, 0 when clean.
20
+ */
21
+
22
+ import { relative as pathRelative } from "node:path";
23
+ import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
24
+ import {
25
+ type AstGuard,
26
+ findRepoRootFor,
27
+ isAllowlisted,
28
+ relFromRepoRoot,
29
+ runStandalone,
30
+ type ScanSpec,
31
+ } from "./_lib/guard-kit";
32
+ import { resolveRepoRoots } from "./_lib/roots";
33
+
34
+ const SCAN: ScanSpec = {
35
+ scope: "source",
36
+ extensions: ["ts"],
37
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
38
+ };
39
+
40
+ const EXCLUDE =
41
+ /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|(?:^|\/)web\/|(?:^|\/)public\/)/;
42
+
43
+ // Prefixed by RepoRoot.name (not kind): kind "app" is shared across
44
+ // unregistered repos (infra#560).
45
+
46
+ // packages/framework/src/http/egress.ts IS the egress() implementation.
47
+ const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
48
+ const ALLOW_MARKER = "guard-allow: same-origin fetch";
49
+
50
+ interface Violation {
51
+ line: number;
52
+ snippet: string;
53
+ }
54
+
55
+ // `self` omitted: Worker globals live under web/public (excluded); a local
56
+ // `const self = this` binding was a systematic FP (infra#582).
57
+ const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
58
+
59
+ function hasAllowMarker(sf: SourceFile, line: number): boolean {
60
+ const lines = sf.getFullText().split("\n");
61
+ const idx = line - 1;
62
+ const cur = lines[idx] ?? "";
63
+ const prev = lines[idx - 1] ?? "";
64
+ return cur.includes(ALLOW_MARKER) || prev.includes(ALLOW_MARKER);
65
+ }
66
+
67
+ /** Same-origin path literal (`"/api/..."`, '`/demo`') — not an SSRF risk. */
68
+ function isSameOriginLiteralArg(expr: CallExpression): boolean {
69
+ const arg = expr.getArguments()[0];
70
+ if (!arg) return false;
71
+
72
+ const template = arg.asKind(SyntaxKind.TemplateExpression);
73
+ if (template) {
74
+ const head = template.getHead().getLiteralText();
75
+ // A lone "/" head lets the first interpolation open with another "/",
76
+ // producing a protocol-relative "//evil.example" — require at least one
77
+ // static character after the leading slash so no interpolation can
78
+ // change the origin.
79
+ return head.startsWith("/") && !head.startsWith("//") && head.length >= 2;
80
+ }
81
+
82
+ if (
83
+ arg.getKind() !== SyntaxKind.StringLiteral &&
84
+ arg.getKind() !== SyntaxKind.NoSubstitutionTemplateLiteral
85
+ ) {
86
+ return false;
87
+ }
88
+ const path = arg.getText().slice(1, -1);
89
+ // Reject protocol-relative URLs ("//evil.example") — not same-origin.
90
+ return path.startsWith("/") && !path.startsWith("//");
91
+ }
92
+
93
+ function isGlobalFetchAccess(callee: Node): boolean {
94
+ const pae = callee.asKind(SyntaxKind.PropertyAccessExpression);
95
+ if (!pae || pae.getName() !== "fetch") return false;
96
+ const receiver = pae.getExpression();
97
+ return (
98
+ receiver.getKind() === SyntaxKind.Identifier && GLOBAL_FETCH_RECEIVERS.has(receiver.getText())
99
+ );
100
+ }
101
+
102
+ function findRawFetchCalls(sf: SourceFile): Violation[] {
103
+ const violations: Violation[] = [];
104
+ for (const expr of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
105
+ const callee = expr.getExpression();
106
+ const isBareFetch = callee.getKind() === SyntaxKind.Identifier && callee.getText() === "fetch";
107
+ if (!(isBareFetch || isGlobalFetchAccess(callee))) continue;
108
+ if (isSameOriginLiteralArg(expr)) continue;
109
+ violations.push({
110
+ line: expr.getStartLineNumber(),
111
+ snippet: expr.getText().slice(0, 120),
112
+ });
113
+ }
114
+ return violations;
115
+ }
116
+
117
+ export const guard: AstGuard = {
118
+ name: "Direct-Fetch Guard",
119
+ scan: SCAN,
120
+ security: true,
121
+ hint:
122
+ "Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
123
+ 'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch` on the line above. ' +
124
+ "Local bindings named fetch are flagged (false positive — unblock via that marker or ALLOWLIST in infra/guards/guard-direct-fetch.ts). " +
125
+ 'Known gap: bracket access (globalThis["fetch"](...)).',
126
+ run(files) {
127
+ const violations: Array<{ file: string; line: number; message: string }> = [];
128
+ const roots = resolveRepoRoots();
129
+
130
+ for (const sf of files) {
131
+ const file = sf.getFilePath();
132
+ const rel = relFromRepoRoot(file, roots);
133
+ if (EXCLUDE.test(rel)) continue;
134
+ const root = findRepoRootFor(file, roots);
135
+ const key =
136
+ root === undefined ? undefined : `${root.name}/${pathRelative(root.absPath, file)}`;
137
+ if (key !== undefined && isAllowlisted(key, ALLOWLIST)) continue;
138
+ for (const v of findRawFetchCalls(sf)) {
139
+ if (hasAllowMarker(sf, v.line)) continue;
140
+ violations.push({
141
+ // cwd-relative, not repo-relative `rel` — the security baseline
142
+ // needs an unambiguous path to resolve back to (repo, relPath).
143
+ file: pathRelative(process.cwd(), file),
144
+ line: v.line,
145
+ message: `raw fetch() call: ${v.snippet}`,
146
+ });
147
+ }
148
+ }
149
+
150
+ return { violations };
151
+ },
152
+ };
153
+
154
+ if (import.meta.main) runStandalone(guard);