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