@cosmicdrift/kumiko-guards 0.356.0 → 0.357.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/package.json +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard: PII-
|
|
4
|
-
*
|
|
3
|
+
* Guard: PII-typical entity field names without annotation, with a baseline
|
|
4
|
+
* regression guard like check-complexity.ts.
|
|
5
5
|
*
|
|
6
6
|
* Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
|
|
7
7
|
* fields with { pii: true }, { userOwned }, { tenantOwned: true },
|
|
8
8
|
* { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
|
|
9
9
|
* successor to the four legacy subject annotations).
|
|
10
10
|
*
|
|
11
|
-
* `.kumiko-pii-annotations-baseline.json`
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* warning-only (
|
|
11
|
+
* `.kumiko-pii-annotations-baseline.json` in the repo root pins the frozen
|
|
12
|
+
* finding count per file:
|
|
13
|
+
* - current <= baseline per file: PASS
|
|
14
|
+
* - current > baseline per file: FAIL (new unannotated PII field)
|
|
15
|
+
* Reductions do NOT update the baseline automatically — run `--write-baseline`
|
|
16
|
+
* after annotation commits. Without a baseline file the guard stays
|
|
17
|
+
* warning-only (bootstrap: run `--write-baseline` once).
|
|
18
18
|
*
|
|
19
19
|
* Usage:
|
|
20
|
-
* bun guards/guard-pii-annotations.ts #
|
|
21
|
-
* bun guards/guard-pii-annotations.ts --write-baseline #
|
|
22
|
-
* bun guards/guard-pii-annotations.ts --no-baseline #
|
|
20
|
+
* bun guards/guard-pii-annotations.ts # compare against baseline
|
|
21
|
+
* bun guards/guard-pii-annotations.ts --write-baseline # rewrite baseline
|
|
22
|
+
* bun guards/guard-pii-annotations.ts --no-baseline # skip comparison
|
|
23
23
|
*/
|
|
24
24
|
import * as path from "node:path";
|
|
25
25
|
import {
|
|
@@ -258,10 +258,10 @@ export const guard: AstGuard = {
|
|
|
258
258
|
writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
-
// Flags
|
|
262
|
-
// (run-guards.ts)
|
|
263
|
-
// --write-baseline
|
|
264
|
-
//
|
|
261
|
+
// Flags are read ONLY here, not in run() — the shared runner
|
|
262
|
+
// (run-guards.ts) drives all guards with the same argv, and a
|
|
263
|
+
// --write-baseline there must not silently rewrite the
|
|
264
|
+
// baseline.
|
|
265
265
|
if (import.meta.main) {
|
|
266
266
|
const args = process.argv.slice(2);
|
|
267
267
|
if (args.includes("--write-baseline")) {
|
|
@@ -153,6 +153,7 @@ function isWebFile(absPath: string, scopeRoot: string): boolean {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
function walk(dir: string, out: string[]): void {
|
|
156
|
+
// skip: not every scope has every candidate directory
|
|
156
157
|
if (!fs.existsSync(dir)) return;
|
|
157
158
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
158
159
|
const full = path.join(dir, entry.name);
|
|
@@ -205,6 +206,52 @@ function hasIgnore(currentLine: string, prevLine: string): boolean {
|
|
|
205
206
|
return lineHasIgnoreTag(currentLine, IGNORE_TAG) || lineHasIgnoreTag(prevLine, IGNORE_TAG);
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
// Block-comment tracking (heuristic): `/* ... */` across multiple lines.
|
|
210
|
+
// Single-line `//` and `*` (inside /* */) are skipped.
|
|
211
|
+
function isCommentOnlyLine(trimmed: string, state: { inBlockComment: boolean }): boolean {
|
|
212
|
+
if (state.inBlockComment) {
|
|
213
|
+
if (trimmed.includes("*/")) state.inBlockComment = false;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
|
|
217
|
+
state.inBlockComment = true;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return trimmed.startsWith("//") || trimmed.startsWith("*");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type LineContext = Pick<Violation, "file" | "line" | "excerpt" | "scope">;
|
|
224
|
+
|
|
225
|
+
function violationsOnLine(
|
|
226
|
+
codeOnly: string,
|
|
227
|
+
context: LineContext,
|
|
228
|
+
publicPage: boolean,
|
|
229
|
+
): Violation[] {
|
|
230
|
+
const violations: Violation[] = [];
|
|
231
|
+
for (const { tag, counterpart, pattern } of TAG_PATTERNS) {
|
|
232
|
+
if (pattern.test(codeOnly)) violations.push({ ...context, kind: "tag", tag, counterpart });
|
|
233
|
+
}
|
|
234
|
+
for (const { call, counterpart, pattern } of CALL_PATTERNS) {
|
|
235
|
+
if (pattern.test(codeOnly)) {
|
|
236
|
+
violations.push({ ...context, kind: "call", tag: call, counterpart });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const { token, counterpart, pattern } of CLASS_PATTERNS) {
|
|
240
|
+
if (pattern.test(codeOnly)) {
|
|
241
|
+
violations.push({ ...context, kind: "class", tag: token, counterpart });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!publicPage && MX_AUTO_PATTERN.test(codeOnly) && MAX_W_PATTERN.test(codeOnly)) {
|
|
245
|
+
violations.push({
|
|
246
|
+
...context,
|
|
247
|
+
kind: "class",
|
|
248
|
+
tag: SCREEN_CONTAINER_LABEL,
|
|
249
|
+
counterpart: SCREEN_CONTAINER_COUNTERPART,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return violations;
|
|
253
|
+
}
|
|
254
|
+
|
|
208
255
|
export function checkFile(
|
|
209
256
|
file: string,
|
|
210
257
|
scope: ScopeKind,
|
|
@@ -213,22 +260,13 @@ export function checkFile(
|
|
|
213
260
|
const text = fs.readFileSync(file, "utf-8");
|
|
214
261
|
const lines = text.split("\n");
|
|
215
262
|
const violations: Violation[] = [];
|
|
216
|
-
const
|
|
217
|
-
|
|
263
|
+
const relativeFile = path.relative(root, file);
|
|
264
|
+
const publicPage = isPublicPage(relativeFile);
|
|
265
|
+
const commentState = { inBlockComment: false };
|
|
218
266
|
for (let i = 0; i < lines.length; i++) {
|
|
219
267
|
const line = lines[i] ?? "";
|
|
220
268
|
const trimmed = line.trim();
|
|
221
|
-
|
|
222
|
-
// Single-line `//` and `*` (inside /* */) are skipped.
|
|
223
|
-
if (inBlockComment) {
|
|
224
|
-
if (trimmed.includes("*/")) inBlockComment = false;
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
|
|
228
|
-
inBlockComment = true;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
269
|
+
if (isCommentOnlyLine(trimmed, commentState)) continue;
|
|
232
270
|
// Strip trailing inline comments — `foo(); // alert() would be wrong`
|
|
233
271
|
// must not match. (?<!:) leaves URLs (https://…) in strings alone;
|
|
234
272
|
// string contents themselves stay an accepted heuristic gap.
|
|
@@ -237,56 +275,14 @@ export function checkFile(
|
|
|
237
275
|
const prev = i > 0 ? (lines[i - 1] ?? "") : "";
|
|
238
276
|
if (hasIgnore(line, prev)) continue;
|
|
239
277
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
249
|
-
scope,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
for (const { call, counterpart, pattern } of CALL_PATTERNS) {
|
|
254
|
-
if (pattern.test(codeOnly)) {
|
|
255
|
-
violations.push({
|
|
256
|
-
file: path.relative(root, file),
|
|
257
|
-
line: i + 1,
|
|
258
|
-
kind: "call",
|
|
259
|
-
tag: call,
|
|
260
|
-
counterpart,
|
|
261
|
-
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
262
|
-
scope,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
for (const { token, counterpart, pattern } of CLASS_PATTERNS) {
|
|
267
|
-
if (pattern.test(codeOnly)) {
|
|
268
|
-
violations.push({
|
|
269
|
-
file: path.relative(root, file),
|
|
270
|
-
line: i + 1,
|
|
271
|
-
kind: "class",
|
|
272
|
-
tag: token,
|
|
273
|
-
counterpart,
|
|
274
|
-
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
275
|
-
scope,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (!publicPage && MX_AUTO_PATTERN.test(codeOnly) && MAX_W_PATTERN.test(codeOnly)) {
|
|
280
|
-
violations.push({
|
|
281
|
-
file: path.relative(root, file),
|
|
282
|
-
line: i + 1,
|
|
283
|
-
kind: "class",
|
|
284
|
-
tag: SCREEN_CONTAINER_LABEL,
|
|
285
|
-
counterpart: SCREEN_CONTAINER_COUNTERPART,
|
|
286
|
-
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
287
|
-
scope,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
278
|
+
const excerpt = trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
|
|
279
|
+
violations.push(
|
|
280
|
+
...violationsOnLine(
|
|
281
|
+
codeOnly,
|
|
282
|
+
{ file: relativeFile, line: i + 1, excerpt, scope },
|
|
283
|
+
publicPage,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
290
286
|
}
|
|
291
287
|
return violations;
|
|
292
288
|
}
|
|
@@ -306,8 +302,8 @@ export const check: RepoCheck = {
|
|
|
306
302
|
run(roots) {
|
|
307
303
|
const scopes = resolveScopes(roots);
|
|
308
304
|
if (scopes.length === 0) {
|
|
309
|
-
// infra exits 1 here ("
|
|
310
|
-
//
|
|
305
|
+
// infra exits 1 here ("neither FRAMEWORK_ROOT, APP_ROOT nor any other
|
|
306
|
+
// repo root resolved") — the vacuity floor below reproduces that.
|
|
311
307
|
return { violations: [], matchedFiles: 0, notApplicable: false };
|
|
312
308
|
}
|
|
313
309
|
// Pre-filter count (every file walk() found, before the web/-segment
|
|
@@ -64,8 +64,10 @@ function checkMetrics(
|
|
|
64
64
|
violations: GuardViolation[],
|
|
65
65
|
): void {
|
|
66
66
|
const metricsProp = root.getProperty("metrics");
|
|
67
|
+
// skip: screens without a literal metrics array have no entries to check
|
|
67
68
|
if (!metricsProp?.isKind(SyntaxKind.PropertyAssignment)) return;
|
|
68
69
|
const init = metricsProp.getInitializer();
|
|
70
|
+
// skip: a computed metrics value cannot be checked statically
|
|
69
71
|
if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) return;
|
|
70
72
|
for (const el of init.getElements()) {
|
|
71
73
|
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
package/src/guard-table-ddl.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Table-DDL Guard (
|
|
3
|
+
* Table-DDL Guard (WARNING, no fail).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `unsafeEnsureEntityTable`
|
|
7
|
-
*
|
|
8
|
-
* `r.rawTable()` (
|
|
9
|
-
* `unsafe
|
|
5
|
+
* Finds calls of `unsafePushTables` / `unsafeCreateEntityTable` /
|
|
6
|
+
* `unsafeEnsureEntityTable` outside the allowed paths. Apps
|
|
7
|
+
* declare tables via `r.entity()` (event-sourced) or
|
|
8
|
+
* `r.rawTable()` (declarative bypass with audit marker) — direct
|
|
9
|
+
* `unsafe*` calls bypass the event-sourcing system entirely.
|
|
10
10
|
*
|
|
11
11
|
* Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* (stage 2). Stage 1 renamed the symbols so every call is
|
|
13
|
+
* greppable; stage 3 introduced `r.rawTable()` as the clean alternative;
|
|
14
|
+
* stage 2 (this check) enforces consistency.
|
|
15
15
|
*
|
|
16
|
-
* Allowlist (
|
|
17
|
-
* - packages/framework/src/event-store/** ES
|
|
16
|
+
* Allowlist (path regexes, OR-combined):
|
|
17
|
+
* - packages/framework/src/event-store/** ES meta tables
|
|
18
18
|
* - packages/framework/src/pipeline/event-consumer-state.ts
|
|
19
19
|
* - packages/framework/src/pipeline/projection-state.ts
|
|
20
|
-
* - packages/framework/src/stack/**
|
|
21
|
-
* - **\/__tests__/**
|
|
22
|
-
* - **\/drizzle/** drizzle-kit
|
|
23
|
-
* - **\/bin/migrate.ts
|
|
24
|
-
* - packages/guards/src/guard-table-ddl.ts
|
|
25
|
-
* - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph
|
|
20
|
+
* - packages/framework/src/stack/** test-stack helpers, pushEntityProjectionTables, helper definitions
|
|
21
|
+
* - **\/__tests__/** test setup
|
|
22
|
+
* - **\/drizzle/** drizzle-kit configuration
|
|
23
|
+
* - **\/bin/migrate.ts app-owned migrate CLI
|
|
24
|
+
* - packages/guards/src/guard-table-ddl.ts the guard itself
|
|
25
|
+
* - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph rename
|
|
26
26
|
*
|
|
27
|
-
* Output:
|
|
27
|
+
* Output: warning + file:line + code snippet. Never blocks.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import path from "node:path";
|
|
31
|
-
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
31
|
+
import { type Identifier, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
32
32
|
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
33
33
|
|
|
34
34
|
const ROOT = process.cwd();
|
|
@@ -49,8 +49,8 @@ export const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
|
|
|
49
49
|
/\/__tests__\//,
|
|
50
50
|
/\/drizzle\//,
|
|
51
51
|
/\/bin\/migrate\.ts$/,
|
|
52
|
-
// Guards +
|
|
53
|
-
//
|
|
52
|
+
// Guards + the rename script may name the symbols in code — both are
|
|
53
|
+
// tooling, not a runtime path.
|
|
54
54
|
/\/packages\/guards\/src\/guard-table-ddl\.ts$/,
|
|
55
55
|
/\/scripts\/migrate-rename-table-ddl\.ts$/,
|
|
56
56
|
];
|
|
@@ -66,6 +66,26 @@ export interface Finding {
|
|
|
66
66
|
readonly snippet: string;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
const NEVER_CALL_SITE_PARENT_KINDS: ReadonlySet<SyntaxKind> = new Set([
|
|
70
|
+
SyntaxKind.ImportSpecifier,
|
|
71
|
+
SyntaxKind.ExportSpecifier,
|
|
72
|
+
SyntaxKind.PropertyAssignment,
|
|
73
|
+
SyntaxKind.ShorthandPropertyAssignment,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// The identifier names something other than a runtime call of the symbol: an
|
|
77
|
+
// import/export specifier (the violation is the call-site, not "having the
|
|
78
|
+
// name in scope"; the `as` alias case still flags the call), an object key,
|
|
79
|
+
// the right-hand side of a property access (`obj.unsafePushTables` — a method
|
|
80
|
+
// on an unrelated type), or a type-level member (`interface { unsafePushTables(): void }`).
|
|
81
|
+
function isNotRuntimeCallSite(id: Identifier, parent: Node): boolean {
|
|
82
|
+
if (NEVER_CALL_SITE_PARENT_KINDS.has(parent.getKind())) return true;
|
|
83
|
+
if (parent.isKind(SyntaxKind.PropertyAccessExpression)) return parent.getNameNode() === id;
|
|
84
|
+
if (parent.isKind(SyntaxKind.PropertySignature)) return parent.getNameNode() === id;
|
|
85
|
+
if (parent.isKind(SyntaxKind.MethodSignature)) return parent.getNameNode() === id;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
|
|
70
90
|
const absPath = sf.getFilePath();
|
|
71
91
|
if (isAllowed(absPath)) return [];
|
|
@@ -77,35 +97,9 @@ export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
|
|
|
77
97
|
const name = id.getText();
|
|
78
98
|
if (!UNSAFE_NAMES.has(name)) continue;
|
|
79
99
|
|
|
80
|
-
// Skip the import/export specifier itself — the violation is the
|
|
81
|
-
// call-site, not "having the name in scope". An app that imports
|
|
82
|
-
// unsafe* without calling it is weird but not the bypass we care
|
|
83
|
-
// about (and the `as` alias case still flags the call below).
|
|
84
100
|
const parent = id.getParent();
|
|
85
101
|
if (!parent) continue;
|
|
86
|
-
|
|
87
|
-
if (pk === SyntaxKind.ImportSpecifier) continue;
|
|
88
|
-
if (pk === SyntaxKind.ExportSpecifier) continue;
|
|
89
|
-
|
|
90
|
-
// Skip property-access right-hand-side (`obj.unsafePushTables`):
|
|
91
|
-
// a method on some unrelated type that happens to share the name.
|
|
92
|
-
if (pk === SyntaxKind.PropertyAccessExpression) {
|
|
93
|
-
const pae = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
94
|
-
if (pae.getNameNode() === id) continue;
|
|
95
|
-
}
|
|
96
|
-
if (pk === SyntaxKind.PropertyAssignment) continue;
|
|
97
|
-
if (pk === SyntaxKind.ShorthandPropertyAssignment) continue;
|
|
98
|
-
// Type-level shadows: `interface { unsafePushTables(): void }` or
|
|
99
|
-
// `type T = { unsafePushTables: () => void }`. Different namespace,
|
|
100
|
-
// not the runtime call.
|
|
101
|
-
if (pk === SyntaxKind.PropertySignature) {
|
|
102
|
-
const ps = parent.asKindOrThrow(SyntaxKind.PropertySignature);
|
|
103
|
-
if (ps.getNameNode() === id) continue;
|
|
104
|
-
}
|
|
105
|
-
if (pk === SyntaxKind.MethodSignature) {
|
|
106
|
-
const ms = parent.asKindOrThrow(SyntaxKind.MethodSignature);
|
|
107
|
-
if (ms.getNameNode() === id) continue;
|
|
108
|
-
}
|
|
102
|
+
if (isNotRuntimeCallSite(id, parent)) continue;
|
|
109
103
|
|
|
110
104
|
const line = id.getStartLineNumber();
|
|
111
105
|
const raw = (lines[line - 1] ?? "").trim();
|
|
@@ -124,19 +118,19 @@ function report(findings: readonly Finding[], scanned: number): void {
|
|
|
124
118
|
console.log(`Table-DDL Guard: ${scanned} files checked.`);
|
|
125
119
|
if (findings.length === 0) {
|
|
126
120
|
console.log(" No bypass calls outside the allowlist.");
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
121
|
+
} else {
|
|
122
|
+
console.log(` ${findings.length} bypass call(s) outside the allowlist:`);
|
|
123
|
+
for (const f of findings) {
|
|
124
|
+
console.log(` ${f.file}:${f.line} ${f.symbol}`);
|
|
125
|
+
console.log(` ${f.snippet}`);
|
|
126
|
+
}
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
|
|
129
|
+
console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
|
|
130
|
+
console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
|
|
131
|
+
console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
|
|
132
|
+
console.log(" Warning, no fail.");
|
|
133
133
|
}
|
|
134
|
-
console.log("");
|
|
135
|
-
console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
|
|
136
|
-
console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
|
|
137
|
-
console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
|
|
138
|
-
console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
|
|
139
|
-
console.log(" Warning, no fail.");
|
|
140
134
|
}
|
|
141
135
|
|
|
142
136
|
export const guard: AstGuard = {
|
|
@@ -150,8 +144,8 @@ export const guard: AstGuard = {
|
|
|
150
144
|
findings.push(...collectFindings(sf, ROOT));
|
|
151
145
|
}
|
|
152
146
|
report(findings, scanned);
|
|
153
|
-
//
|
|
154
|
-
//
|
|
147
|
+
// Warning, no fail (see module header): bypass calls are reported but
|
|
148
|
+
// never block the guard run.
|
|
155
149
|
return { violations: [] };
|
|
156
150
|
},
|
|
157
151
|
};
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import * as path from "node:path";
|
|
42
|
-
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
42
|
+
import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
43
43
|
import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
44
44
|
import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
|
|
45
45
|
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
@@ -187,6 +187,45 @@ const PLATFORM_WIDE_ACCESS = /\baccess\.(?:systemAdmin|system|privileged)\b/;
|
|
|
187
187
|
|
|
188
188
|
type GlobalUserWrite = { name: string; file: string; line: number };
|
|
189
189
|
|
|
190
|
+
function globalUserWriteMissingCheck(
|
|
191
|
+
call: CallExpression,
|
|
192
|
+
filePath: string,
|
|
193
|
+
): GlobalUserWrite | undefined {
|
|
194
|
+
if (call.getExpression().getText() !== "defineWriteHandler") return undefined;
|
|
195
|
+
const arg = call.getArguments()[0];
|
|
196
|
+
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
197
|
+
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
198
|
+
// `name` is only used for the violation message, not the security
|
|
199
|
+
// check itself — a factory-built handler with a non-literal name
|
|
200
|
+
// expression (`name: enable ? "enable" : "disable"`) must not skip
|
|
201
|
+
// Check C just because literalStringOf() can't resolve it.
|
|
202
|
+
const name = literalStringOf(obj.getProperty("name"));
|
|
203
|
+
const schema = obj.getProperty("schema");
|
|
204
|
+
const accessProp = obj.getProperty("access");
|
|
205
|
+
// No `access` key at all = deny-all (engine/access.ts: undefined
|
|
206
|
+
// returns false), so there is nothing to reach across a tenant.
|
|
207
|
+
if (!schema || !accessProp) return undefined;
|
|
208
|
+
// Presets that resolve to platform-wide actors only (engine/
|
|
209
|
+
// config-helpers `access`): no tenant boundary exists to cross.
|
|
210
|
+
// Everything else — access.admin, and the openToAll handlers that
|
|
211
|
+
// gate on isAdminActor at runtime (#1556's shape) — is reachable by
|
|
212
|
+
// a tenant-scoped TenantAdmin.
|
|
213
|
+
if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) return undefined;
|
|
214
|
+
if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) return undefined;
|
|
215
|
+
const body = call.getText();
|
|
216
|
+
// Must actually touch a *user* table — otherwise a handler reading
|
|
217
|
+
// ctx.db.raw against an unrelated global table (plans, audit log,
|
|
218
|
+
// invitations) gets flagged with a "reads a global user row" message
|
|
219
|
+
// that doesn't apply to it (false-positive on clean code).
|
|
220
|
+
if (!body.includes("ctx.db.raw") || !/\buserTable\b/.test(body)) return undefined;
|
|
221
|
+
if (MEMBERSHIP_GATE.test(body) || CROSS_TENANT_HELPER_GATE.test(body)) return undefined;
|
|
222
|
+
return {
|
|
223
|
+
name: name ?? path.basename(filePath),
|
|
224
|
+
file: filePath,
|
|
225
|
+
line: call.getStartLineNumber(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
190
229
|
export function findGlobalUserWritesMissingMembershipCheck(
|
|
191
230
|
files: readonly SourceFile[],
|
|
192
231
|
): GlobalUserWrite[] {
|
|
@@ -194,39 +233,8 @@ export function findGlobalUserWritesMissingMembershipCheck(
|
|
|
194
233
|
for (const sf of files) {
|
|
195
234
|
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
196
235
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
200
|
-
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
201
|
-
// `name` is only used for the violation message, not the security
|
|
202
|
-
// check itself — a factory-built handler with a non-literal name
|
|
203
|
-
// expression (`name: enable ? "enable" : "disable"`) must not skip
|
|
204
|
-
// Check C just because literalStringOf() can't resolve it.
|
|
205
|
-
const name = literalStringOf(obj.getProperty("name"));
|
|
206
|
-
const schema = obj.getProperty("schema");
|
|
207
|
-
const accessProp = obj.getProperty("access");
|
|
208
|
-
// No `access` key at all = deny-all (engine/access.ts: undefined
|
|
209
|
-
// returns false), so there is nothing to reach across a tenant.
|
|
210
|
-
if (!schema || !accessProp) continue;
|
|
211
|
-
// Presets that resolve to platform-wide actors only (engine/
|
|
212
|
-
// config-helpers `access`): no tenant boundary exists to cross.
|
|
213
|
-
// Everything else — access.admin, and the openToAll handlers that
|
|
214
|
-
// gate on isAdminActor at runtime (#1556's shape) — is reachable by
|
|
215
|
-
// a tenant-scoped TenantAdmin.
|
|
216
|
-
if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) continue;
|
|
217
|
-
if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) continue;
|
|
218
|
-
const body = call.getText();
|
|
219
|
-
// Must actually touch a *user* table — otherwise a handler reading
|
|
220
|
-
// ctx.db.raw against an unrelated global table (plans, audit log,
|
|
221
|
-
// invitations) gets flagged with a "reads a global user row" message
|
|
222
|
-
// that doesn't apply to it (false-positive on clean code).
|
|
223
|
-
if (!body.includes("ctx.db.raw") || !/\buserTable\b/.test(body)) continue;
|
|
224
|
-
if (MEMBERSHIP_GATE.test(body) || CROSS_TENANT_HELPER_GATE.test(body)) continue;
|
|
225
|
-
out.push({
|
|
226
|
-
name: name ?? path.basename(sf.getFilePath()),
|
|
227
|
-
file: sf.getFilePath(),
|
|
228
|
-
line: call.getStartLineNumber(),
|
|
229
|
-
});
|
|
236
|
+
const write = globalUserWriteMissingCheck(call, sf.getFilePath());
|
|
237
|
+
if (write) out.push(write);
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
return out;
|
|
@@ -29,9 +29,9 @@ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
|
29
29
|
|
|
30
30
|
const ROOT = process.cwd();
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
32
|
+
// Both suffixes: `.integration.ts` (legacy) + `.integration.test.ts` (canonical
|
|
33
|
+
// after the bun-test cutover). Transition-safe — matches main (legacy) and
|
|
34
|
+
// migrated branches alike.
|
|
35
35
|
const SCAN: ScanSpec = {
|
|
36
36
|
scope: "tests",
|
|
37
37
|
extensions: ["ts"],
|
|
@@ -66,7 +66,7 @@ export function isIntegrationTestFile(filePath: string): boolean {
|
|
|
66
66
|
|
|
67
67
|
export interface Violation {
|
|
68
68
|
file: string;
|
|
69
|
-
|
|
69
|
+
detail: string;
|
|
70
70
|
forbiddenCalls: Array<{ name: string; line: number }>;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -127,7 +127,7 @@ export function scanFile(sf: SourceFile): Violation | null {
|
|
|
127
127
|
|
|
128
128
|
return {
|
|
129
129
|
file: path.relative(ROOT, sf.getFilePath()),
|
|
130
|
-
|
|
130
|
+
detail: "calls pipeline internals without buildServer/setupTestStack",
|
|
131
131
|
forbiddenCalls: forbiddenHits,
|
|
132
132
|
};
|
|
133
133
|
}
|
|
@@ -156,7 +156,7 @@ export const check: RepoCheck = {
|
|
|
156
156
|
const v = scanFile(sf);
|
|
157
157
|
if (v === null) continue;
|
|
158
158
|
for (const c of v.forbiddenCalls) {
|
|
159
|
-
violations.push({ file: v.file, line: c.line, message: `${v.
|
|
159
|
+
violations.push({ file: v.file, line: c.line, message: `${v.detail}: ${c.name}(...)` });
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
@@ -106,11 +106,11 @@ function isSleepStatement(node: Node): boolean {
|
|
|
106
106
|
return false;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// Nested loops are their own finding — skipping them keeps one sleep from
|
|
110
|
-
// being counted once per enclosing loop.
|
|
111
109
|
function bodySleeps(loop: Node): boolean {
|
|
112
110
|
let found = false;
|
|
113
111
|
loop.forEachDescendant((node, traversal) => {
|
|
112
|
+
// skip: nested loops are their own finding, so one sleep is not counted
|
|
113
|
+
// once per enclosing loop
|
|
114
114
|
if (isLoop(node)) {
|
|
115
115
|
traversal.skip();
|
|
116
116
|
return;
|
|
@@ -230,8 +230,9 @@ export function scanTimeouts(
|
|
|
230
230
|
const findings: Finding[] = [];
|
|
231
231
|
const add = (node: Node, message: string): void => {
|
|
232
232
|
const reported = withExceptionNote(node, message);
|
|
233
|
-
if (reported
|
|
234
|
-
|
|
233
|
+
if (reported !== undefined) {
|
|
234
|
+
findings.push({ file, line: node.getStartLineNumber(), message: reported });
|
|
235
|
+
}
|
|
235
236
|
};
|
|
236
237
|
const aliases = testFrameworkAliases(sf);
|
|
237
238
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard: `createTextField`/`createLongTextField
|
|
4
|
-
*
|
|
3
|
+
* Guard: `createTextField`/`createLongTextField` calls without a `personal`
|
|
4
|
+
* stance, with a baseline regression guard like guard-pii-annotations.ts.
|
|
5
5
|
*
|
|
6
|
-
* kumiko-framework#2810: `createTextField`/`createLongTextField`
|
|
7
|
-
* fail-closed
|
|
8
|
-
* Workspace-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* kumiko-framework#2810: `createTextField`/`createLongTextField` should
|
|
7
|
+
* throw fail-closed when no `personal` (pattern #2558) is declared.
|
|
8
|
+
* Workspace-wide `personal` is missing at hundreds of call sites — a throw would
|
|
9
|
+
* break the build immediately. This guard covers the full set (every call),
|
|
10
|
+
* `guard-pii-annotations.ts` only the name heuristic (PII-suspicious
|
|
11
|
+
* field names) — the two baselines are deliberately separate.
|
|
12
12
|
*
|
|
13
|
-
* `.kumiko-text-field-stance-baseline.json`
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* warning-only (
|
|
13
|
+
* `.kumiko-text-field-stance-baseline.json` in the repo root pins the frozen
|
|
14
|
+
* finding count per file:
|
|
15
|
+
* - current <= baseline per file: PASS
|
|
16
|
+
* - current > baseline per file: FAIL (new call without a personal stance)
|
|
17
|
+
* Reductions do NOT update the baseline automatically — run `--write-baseline`
|
|
18
|
+
* after annotation commits. Without a baseline file the guard stays
|
|
19
|
+
* warning-only (bootstrap: run `--write-baseline` once).
|
|
20
20
|
*
|
|
21
21
|
* Usage:
|
|
22
|
-
* bun guards/guard-text-field-stance.ts #
|
|
23
|
-
* bun guards/guard-text-field-stance.ts --write-baseline #
|
|
24
|
-
* bun guards/guard-text-field-stance.ts --no-baseline #
|
|
22
|
+
* bun guards/guard-text-field-stance.ts # compare against baseline
|
|
23
|
+
* bun guards/guard-text-field-stance.ts --write-baseline # rewrite baseline
|
|
24
|
+
* bun guards/guard-text-field-stance.ts --no-baseline # skip comparison
|
|
25
25
|
*/
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
import {
|
|
@@ -227,10 +227,10 @@ export const guard: AstGuard = {
|
|
|
227
227
|
writeBaseline: (files) => textFieldStanceBaseline.write(baselineCounts(scan(files).findings)),
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
-
// Flags
|
|
231
|
-
// (run-guards.ts)
|
|
232
|
-
// --write-baseline
|
|
233
|
-
//
|
|
230
|
+
// Flags are read ONLY here, not in run() — the shared runner
|
|
231
|
+
// (run-guards.ts) drives all guards with the same argv, and a
|
|
232
|
+
// --write-baseline there must not silently rewrite the
|
|
233
|
+
// baseline.
|
|
234
234
|
if (import.meta.main) {
|
|
235
235
|
const args = process.argv.slice(2);
|
|
236
236
|
if (args.includes("--write-baseline")) {
|