@cosmicdrift/kumiko-guards 0.383.0 → 0.384.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 +3 -3
- package/src/changes.json +28 -0
- package/src/check-runtime-isolation.ts +1 -1
- package/src/guard-i18n-keys.ts +23 -154
- package/src/guard-no-custom-primitives.ts +2 -3
- package/src/guard-tailwind-scan-surface.ts +1 -1
- package/src/run-guards.ts +0 -6
- package/src/guard-access-denied-test.ts +0 -167
- package/src/guard-pii-annotations.ts +0 -279
- package/src/guard-unsafe-append-event.ts +0 -125
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.384.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.384.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.384.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.384.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Unsafe-Append-Event guard removed",
|
|
6
|
+
"detail": "The event store rejects unregistered event types and validates every payload against its schema at runtime, for appendEvent and unsafeAppendEvent alike, so the static guard is redundant.",
|
|
7
|
+
"migration": "Delete `.kumiko-unsafe-append-event-baseline.json`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.384.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Access-Denied-Test guard replaced by a contract test helper",
|
|
13
|
+
"detail": "Roles are checked centrally before the payload is parsed. The new `expectRoleRestrictedHandlersDenyForeignRole` test helper proves this over HTTP for every role-restricted write and query handler, so the guard that searched for a matching test per handler is removed.",
|
|
14
|
+
"migration": "In an app integration test with setupTestStack and the app features, call `expectRoleRestrictedHandlersDenyForeignRole(stack)` and remove the \"Access-Denied-Test Guard\" entry from `.kumiko-security-baseline.json`."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.384.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "i18n-Keys guard no longer checks locales",
|
|
20
|
+
"detail": "The guard only reports keys that are used without a definition. Locale completeness moved to the boot validator, which sees the real registered translations of every feature.",
|
|
21
|
+
"migration": "Delete `src/i18n-guard-locales.ts` if it exists."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.384.0",
|
|
25
|
+
"type": "improvement",
|
|
26
|
+
"title": "PII-Annotations guard removed",
|
|
27
|
+
"detail": "Entity text and longText fields now have to declare their personal stance by type and at boot, so the static guard that searched for unannotated fields is redundant.",
|
|
28
|
+
"migration": "Delete `.kumiko-pii-annotations-baseline.json`."
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "0.383.0",
|
|
4
32
|
"type": "improvement",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// own production surface — see kumiko-framework#2337 for the one currently
|
|
47
47
|
// frozen here). Exceptions are tracked via a per-repo baseline file
|
|
48
48
|
// (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
|
|
49
|
-
// mechanism as check-complexity/
|
|
49
|
+
// mechanism as check-complexity/etc.), not a
|
|
50
50
|
// hardcoded list in this source file: a consumer repo can freeze or clear
|
|
51
51
|
// its own findings the same way, with `--write-baseline`.
|
|
52
52
|
//
|
package/src/guard-i18n-keys.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* Guard: i18n keys must be defined before they are used.
|
|
4
4
|
*
|
|
5
5
|
* Scan: t() calls in app tsx, definitions from r.translations and i18n bundles.
|
|
6
|
-
* Declarative screen/nav keys: validateBoot (runtime).
|
|
6
|
+
* Declarative screen/nav keys and per-feature locale completeness: validateBoot (runtime).
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* bun guards/guard-i18n-keys.ts
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import {
|
|
15
15
|
type Node,
|
|
@@ -37,7 +37,6 @@ interface UsedKey {
|
|
|
37
37
|
|
|
38
38
|
interface DefinedKey {
|
|
39
39
|
fullKey: string;
|
|
40
|
-
locales: Set<string>;
|
|
41
40
|
file: string;
|
|
42
41
|
line: number;
|
|
43
42
|
}
|
|
@@ -105,10 +104,10 @@ function findEnclosingFeatureName(node: Node): string | null {
|
|
|
105
104
|
return null;
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const out:
|
|
107
|
+
type KeyEntry = { key: string; line: number };
|
|
108
|
+
|
|
109
|
+
function extractKeysFromTranslationsObject(obj: ObjectLiteralExpression): KeyEntry[] {
|
|
110
|
+
const out: KeyEntry[] = [];
|
|
112
111
|
for (const prop of obj.getProperties()) {
|
|
113
112
|
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
114
113
|
const nameNode = prop.getNameNode();
|
|
@@ -119,23 +118,13 @@ function extractKeysFromTranslationsObject(
|
|
|
119
118
|
keyName = nameNode.getText();
|
|
120
119
|
} else continue;
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const locales = new Set<string>();
|
|
125
|
-
for (const localeProp of initializer.getProperties()) {
|
|
126
|
-
if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
127
|
-
const localeName = localeProp.getNameNode();
|
|
128
|
-
if (localeName.isKind(SyntaxKind.StringLiteral)) locales.add(localeName.getLiteralText());
|
|
129
|
-
else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
|
|
130
|
-
}
|
|
131
|
-
out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
|
|
121
|
+
if (!prop.getInitializer()?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
122
|
+
out.push({ key: keyName, line: prop.getStartLineNumber() });
|
|
132
123
|
}
|
|
133
124
|
return out;
|
|
134
125
|
}
|
|
135
126
|
|
|
136
|
-
function extractLocaleFirstKeys(
|
|
137
|
-
obj: ObjectLiteralExpression,
|
|
138
|
-
): Array<{ key: string; locales: Set<string>; line: number }> {
|
|
127
|
+
function extractLocaleFirstKeys(obj: ObjectLiteralExpression): KeyEntry[] {
|
|
139
128
|
const localeMaps = new Map<string, ObjectLiteralExpression>();
|
|
140
129
|
for (const prop of obj.getProperties()) {
|
|
141
130
|
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
@@ -158,45 +147,26 @@ function extractLocaleFirstKeys(
|
|
|
158
147
|
}
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
|
|
162
|
-
for (const key of keySet) {
|
|
163
|
-
const locales = new Set<string>();
|
|
164
|
-
for (const [locale, map] of localeMaps) {
|
|
165
|
-
const has = map.getProperties().some((prop) => {
|
|
166
|
-
if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
|
|
167
|
-
const nameNode = prop.getNameNode();
|
|
168
|
-
return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getLiteralText() === key;
|
|
169
|
-
});
|
|
170
|
-
if (has) locales.add(locale);
|
|
171
|
-
}
|
|
172
|
-
out.push({ key, locales, line: obj.getStartLineNumber() });
|
|
173
|
-
}
|
|
174
|
-
return out;
|
|
150
|
+
return [...keySet].map((key) => ({ key, line: obj.getStartLineNumber() }));
|
|
175
151
|
}
|
|
176
152
|
|
|
177
|
-
function pushDefined(
|
|
178
|
-
defined
|
|
179
|
-
fullKey: string,
|
|
180
|
-
locales: Set<string>,
|
|
181
|
-
file: string,
|
|
182
|
-
line: number,
|
|
183
|
-
): void {
|
|
184
|
-
defined.push({ fullKey, locales, file, line });
|
|
153
|
+
function pushDefined(defined: DefinedKey[], fullKey: string, file: string, line: number): void {
|
|
154
|
+
defined.push({ fullKey, file, line });
|
|
185
155
|
}
|
|
186
156
|
|
|
187
157
|
function addDefinedEntries(
|
|
188
158
|
defined: DefinedKey[],
|
|
189
|
-
entries:
|
|
159
|
+
entries: KeyEntry[],
|
|
190
160
|
file: string,
|
|
191
161
|
featureName: string | null,
|
|
192
162
|
): void {
|
|
193
163
|
for (const entry of entries) {
|
|
194
164
|
if (entry.key.includes(":")) {
|
|
195
|
-
pushDefined(defined, entry.key,
|
|
165
|
+
pushDefined(defined, entry.key, file, entry.line);
|
|
196
166
|
continue;
|
|
197
167
|
}
|
|
198
168
|
if (featureName) {
|
|
199
|
-
pushDefined(defined, `${featureName}:${entry.key}`,
|
|
169
|
+
pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
|
|
200
170
|
}
|
|
201
171
|
}
|
|
202
172
|
}
|
|
@@ -255,9 +225,9 @@ function collectInlineKeysInitializer(
|
|
|
255
225
|
): void {
|
|
256
226
|
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
257
227
|
for (const entry of extractKeysFromTranslationsObject(initializer)) {
|
|
258
|
-
pushDefined(defined, `${featureName}:${entry.key}`,
|
|
228
|
+
pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
|
|
259
229
|
if (entry.key.includes(":")) {
|
|
260
|
-
pushDefined(defined, entry.key,
|
|
230
|
+
pushDefined(defined, entry.key, file, entry.line);
|
|
261
231
|
}
|
|
262
232
|
}
|
|
263
233
|
} else if (initializer?.isKind(SyntaxKind.Identifier)) {
|
|
@@ -305,77 +275,6 @@ export function isFrameworkMonorepo(root: string): boolean {
|
|
|
305
275
|
return existsSync(path.join(root, "packages/framework/package.json"));
|
|
306
276
|
}
|
|
307
277
|
|
|
308
|
-
/** Parse `export const NAME = [...]` locale literals. Returns null if absent;
|
|
309
|
-
* throws if the export exists but is not a static string-literal array
|
|
310
|
-
* (spread / alias / region-tag-only without literals) — silent de/en fallback
|
|
311
|
-
* would hide a real declaration (infra#602). */
|
|
312
|
-
export function parseLocaleConstArray(source: string, constName: string): string[] | null {
|
|
313
|
-
const exportRe = new RegExp(`export\\s+const\\s+${constName}\\s*(?::[^=]+)?=\\s*([^;]+)`);
|
|
314
|
-
const match = source.match(exportRe);
|
|
315
|
-
if (!match?.[1]) return null;
|
|
316
|
-
const rhs = match[1].trim();
|
|
317
|
-
if (!rhs.startsWith("[")) {
|
|
318
|
-
throw new Error(
|
|
319
|
-
`i18n-keys: export const ${constName} is not a static array literal — refuse silent de/en fallback`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
if (rhs.includes("...")) {
|
|
323
|
-
throw new Error(
|
|
324
|
-
`i18n-keys: export const ${constName} uses array spreads — refuse partial/silent locale set`,
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
const locales = [...rhs.matchAll(/["']([a-z]{2}(?:-[A-Z]{2})?)["']/g)]
|
|
328
|
-
.map((m) => m[1])
|
|
329
|
-
.filter((l): l is string => l !== undefined);
|
|
330
|
-
if (locales.length === 0) {
|
|
331
|
-
throw new Error(
|
|
332
|
-
`i18n-keys: export const ${constName} = [...] has no string locale literals — refuse silent de/en fallback`,
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
return locales;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Resolve owning repo root from a guard-relative path (may be `../…`). */
|
|
339
|
-
export function repoRootForRelFile(rel: string): string {
|
|
340
|
-
const abs = path.isAbsolute(rel) ? rel : path.resolve(ROOT, rel);
|
|
341
|
-
let curr = path.dirname(abs);
|
|
342
|
-
let fallback: string | undefined;
|
|
343
|
-
while (curr !== path.dirname(curr)) {
|
|
344
|
-
const base = path.basename(curr);
|
|
345
|
-
// Never treat worktree/parent markers as a repo root (infra#602).
|
|
346
|
-
if (base === ".." || base === ".wt") {
|
|
347
|
-
curr = path.dirname(curr);
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
if (existsSync(path.join(curr, "packages/framework/package.json"))) {
|
|
351
|
-
return curr;
|
|
352
|
-
}
|
|
353
|
-
if (existsSync(path.join(curr, "package.json"))) {
|
|
354
|
-
// Outermost package.json wins so nested packages/* resolve to the repo.
|
|
355
|
-
fallback = curr;
|
|
356
|
-
}
|
|
357
|
-
curr = path.dirname(curr);
|
|
358
|
-
}
|
|
359
|
-
return fallback ?? ROOT;
|
|
360
|
-
}
|
|
361
|
-
/** Repo-declared locales for translation completeness; empty set skips the check. */
|
|
362
|
-
export function resolveExpectedLocales(root: string = ROOT): Set<string> {
|
|
363
|
-
if (isFrameworkMonorepo(root)) return new Set();
|
|
364
|
-
|
|
365
|
-
const declPaths: Array<{ rel: string; constName: string }> = [
|
|
366
|
-
{ rel: "src/i18n-guard-locales.ts", constName: "I18N_GUARD_LOCALES" },
|
|
367
|
-
{ rel: "src/marketing/locale-routes.ts", constName: "LOCALES" },
|
|
368
|
-
];
|
|
369
|
-
for (const { rel, constName } of declPaths) {
|
|
370
|
-
const abs = path.join(root, rel);
|
|
371
|
-
if (!existsSync(abs)) continue;
|
|
372
|
-
const locales = parseLocaleConstArray(readFileSync(abs, "utf-8"), constName);
|
|
373
|
-
if (locales) return new Set(locales);
|
|
374
|
-
// parseLocaleConstArray throws on unreadable decls; null = no export.
|
|
375
|
-
}
|
|
376
|
-
return new Set(["de", "en"]);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
278
|
// Published packages ship `dist` (compiled JS) only; workspace links also carry `src`.
|
|
380
279
|
const INSTALLED_FRAMEWORK_SOURCES = [
|
|
381
280
|
"kumiko-*/{src,dist}/**/*.{ts,tsx,js}",
|
|
@@ -424,8 +323,6 @@ export function collectInstalledFrameworkKeys(root: string = ROOT): Set<string>
|
|
|
424
323
|
export type I18nKeysRunOptions = {
|
|
425
324
|
/** Inject the framework keys of the installed packages — tests must not scan node_modules. */
|
|
426
325
|
readonly installedFrameworkKeys?: () => ReadonlySet<string>;
|
|
427
|
-
/** Inject expected locales per owning root — tests must not rely on process.cwd(). */
|
|
428
|
-
readonly expectedLocalesForRoot?: (root: string) => Set<string>;
|
|
429
326
|
};
|
|
430
327
|
|
|
431
328
|
export function checkI18nKeys(
|
|
@@ -451,47 +348,19 @@ export function checkI18nKeys(
|
|
|
451
348
|
? new Set<string>()
|
|
452
349
|
: (options.installedFrameworkKeys ?? collectInstalledFrameworkKeys)();
|
|
453
350
|
const missing = undefinedInApp.filter((u) => !installedKeys.has(u.key));
|
|
454
|
-
const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
|
|
455
|
-
const expectedByRoot = new Map<string, Set<string>>();
|
|
456
|
-
const expectedForFile = (rel: string): Set<string> => {
|
|
457
|
-
const root = repoRootForRelFile(rel);
|
|
458
|
-
let locales = expectedByRoot.get(root);
|
|
459
|
-
if (!locales) {
|
|
460
|
-
locales = resolveLocales(root);
|
|
461
|
-
expectedByRoot.set(root, locales);
|
|
462
|
-
}
|
|
463
|
-
return locales;
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
const localeViolations = definedKeys.flatMap((d) => {
|
|
467
|
-
const expectedLocales = expectedForFile(d.file);
|
|
468
|
-
const missingLocales = [...expectedLocales].filter((l) => !d.locales.has(l));
|
|
469
|
-
if (missingLocales.length === 0) return [];
|
|
470
|
-
return [
|
|
471
|
-
{
|
|
472
|
-
file: d.file,
|
|
473
|
-
line: d.line,
|
|
474
|
-
message: `Key "${d.fullKey}" is missing locale: ${missingLocales.join(", ")}`,
|
|
475
|
-
},
|
|
476
|
-
];
|
|
477
|
-
});
|
|
478
|
-
|
|
479
351
|
return {
|
|
480
|
-
violations:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
})),
|
|
486
|
-
...localeViolations,
|
|
487
|
-
],
|
|
352
|
+
violations: missing.map((m) => ({
|
|
353
|
+
file: m.file,
|
|
354
|
+
line: m.line,
|
|
355
|
+
message: `used key without a definition: "${m.key}"`,
|
|
356
|
+
})),
|
|
488
357
|
};
|
|
489
358
|
}
|
|
490
359
|
|
|
491
360
|
export const guard: AstGuard = {
|
|
492
361
|
name: "i18n-Keys Guard",
|
|
493
362
|
scan: SCAN,
|
|
494
|
-
hint: "Used i18n key without a definition
|
|
363
|
+
hint: "Used i18n key without a definition — add the key to the feature's translations map.",
|
|
495
364
|
run(files) {
|
|
496
365
|
return checkI18nKeys(files);
|
|
497
366
|
},
|
|
@@ -173,10 +173,9 @@ export const guard: AstGuard = {
|
|
|
173
173
|
|
|
174
174
|
// Flags are read ONLY here, not in run() — the shared runner
|
|
175
175
|
// (run-guards.ts/run-ui-guards.ts) runs every guard with the same argv, a
|
|
176
|
-
// --write-baseline there must not silently rewrite the baseline
|
|
177
|
-
// pattern as guard-pii-annotations.ts).
|
|
176
|
+
// --write-baseline there must not silently rewrite the baseline.
|
|
178
177
|
//
|
|
179
|
-
// No --no-baseline:
|
|
178
|
+
// No --no-baseline: this guard has a
|
|
180
179
|
// second, non-baselined rule (PRIMITIVE_NAME) — a --no-baseline that
|
|
181
180
|
// discards analyse()'s return and exits 0 would silence that too. Nobody
|
|
182
181
|
// reads the flag (the shared runner only calls run()).
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* compare WITH their modifier prefixes intact (`hover:mb-2` is its own
|
|
23
23
|
* Tailwind candidate, distinct from `mb-2`).
|
|
24
24
|
*
|
|
25
|
-
* Baseline-regression guard
|
|
25
|
+
* Baseline-regression guard: pins the
|
|
26
26
|
* currently-known violation count per file. Reductions are allowed but
|
|
27
27
|
* don't auto-update the baseline. Without a baseline file the guard stays
|
|
28
28
|
* warning-only (bootstrap: run `--write-baseline` once).
|
package/src/run-guards.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
|
|
|
23
23
|
import { guard as asCasts } from "./check-as-casts";
|
|
24
24
|
import { guard as complexity } from "./check-complexity";
|
|
25
25
|
import { guard as predicateExtraction } from "./check-predicates";
|
|
26
|
-
import { guard as accessDeniedTest } from "./guard-access-denied-test";
|
|
27
26
|
import { guard as adminApi } from "./guard-admin-api";
|
|
28
27
|
import { guard as appFeatureStructure } from "./guard-app-feature-structure";
|
|
29
28
|
import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
|
|
@@ -42,19 +41,16 @@ import { guard as noDateApi } from "./guard-no-date-api";
|
|
|
42
41
|
import { guard as noDirectFs } from "./guard-no-direct-fs";
|
|
43
42
|
import { guard as noLogicInViews } from "./guard-no-logic-in-views";
|
|
44
43
|
import { guard as noTemporalPolyfillImport } from "./guard-no-temporal-polyfill-import";
|
|
45
|
-
import { guard as piiAnnotations } from "./guard-pii-annotations";
|
|
46
44
|
import { guard as restrictedSymbols } from "./guard-restricted-symbols";
|
|
47
45
|
import { guard as screenConventions } from "./guard-screen-conventions";
|
|
48
46
|
import { guard as silentSkip } from "./guard-silent-skip";
|
|
49
47
|
import { guard as tenantEscalation } from "./guard-tenant-escalation";
|
|
50
48
|
import { guard as testTemplateDrift } from "./guard-test-template-drift";
|
|
51
49
|
import { guard as testTimeouts } from "./guard-test-timeouts";
|
|
52
|
-
import { guard as unsafeAppendEvent } from "./guard-unsafe-append-event";
|
|
53
50
|
import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
|
|
54
51
|
import { guard as writeHandlerQns } from "./guard-write-handler-qns";
|
|
55
52
|
|
|
56
53
|
export const GUARDS = [
|
|
57
|
-
accessDeniedTest,
|
|
58
54
|
adminApi,
|
|
59
55
|
directEntityWrites,
|
|
60
56
|
directFetch,
|
|
@@ -74,7 +70,6 @@ export const GUARDS = [
|
|
|
74
70
|
i18nKeys,
|
|
75
71
|
i18nLocaleMount,
|
|
76
72
|
i18nLocaleTerminology,
|
|
77
|
-
piiAnnotations,
|
|
78
73
|
complexity,
|
|
79
74
|
predicateExtraction,
|
|
80
75
|
screenConventions,
|
|
@@ -85,7 +80,6 @@ export const GUARDS = [
|
|
|
85
80
|
libTestCoverage,
|
|
86
81
|
testTimeouts,
|
|
87
82
|
testTemplateDrift,
|
|
88
|
-
unsafeAppendEvent,
|
|
89
83
|
];
|
|
90
84
|
|
|
91
85
|
export const GUARD_FLAGS = [
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Guard: role-restricted write handlers need an access-denied test.
|
|
4
|
-
*
|
|
5
|
-
* Rule: any `defineWriteHandler`/`*.writeHandler` call whose `access` object
|
|
6
|
-
* has a `roles` property (and no `openToAll`, and no anonymous/all role) must
|
|
7
|
-
* have a test somewhere in the repo that mentions the handler AND asserts a
|
|
8
|
-
* rejection (AccessDeniedError / access_denied / 403). Matching is file-level
|
|
9
|
-
* and within the handler's own repo, same contract as guard-tenant-escalation.
|
|
10
|
-
*
|
|
11
|
-
* Tripwire: a false-negative (a weak name match hiding a real gap) is
|
|
12
|
-
* tolerated; a false-positive on a clean repo is not.
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* bun guards/guard-access-denied-test.ts
|
|
16
|
-
* Baseline: kumiko-guards guards --write-security-baseline
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import * as path from "node:path";
|
|
20
|
-
import {
|
|
21
|
-
type CallExpression,
|
|
22
|
-
type ObjectLiteralExpression,
|
|
23
|
-
type SourceFile,
|
|
24
|
-
SyntaxKind,
|
|
25
|
-
} from "ts-morph";
|
|
26
|
-
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
27
|
-
import {
|
|
28
|
-
ALL_REPO_KINDS,
|
|
29
|
-
type AstGuard,
|
|
30
|
-
type GuardViolation,
|
|
31
|
-
runStandalone,
|
|
32
|
-
type ScanSpec,
|
|
33
|
-
} from "./_lib/guard-kit";
|
|
34
|
-
import {
|
|
35
|
-
escapeRegExp,
|
|
36
|
-
literalStringOf,
|
|
37
|
-
mentionsAsWord,
|
|
38
|
-
nameForms,
|
|
39
|
-
} from "./_lib/handler-name-forms";
|
|
40
|
-
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
41
|
-
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
42
|
-
|
|
43
|
-
const ROOT = process.cwd();
|
|
44
|
-
const SCAN: ScanSpec = {
|
|
45
|
-
kinds: ALL_REPO_KINDS,
|
|
46
|
-
scope: "source+tests",
|
|
47
|
-
extensions: ["ts"],
|
|
48
|
-
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// Any role that lets an unauthenticated/unrestricted caller through means
|
|
52
|
-
// nobody is actually excluded by this handler's roles.
|
|
53
|
-
const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/;
|
|
54
|
-
|
|
55
|
-
type RoleRestrictedHandler = { name: string; file: string; line: number };
|
|
56
|
-
|
|
57
|
-
function writeHandlerOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
58
|
-
const callee = call.getExpression().getText();
|
|
59
|
-
if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) return undefined;
|
|
60
|
-
const arg = call.getArguments()[0];
|
|
61
|
-
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
62
|
-
return arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function accessObjectOf(obj: ObjectLiteralExpression): ObjectLiteralExpression | undefined {
|
|
66
|
-
const accessProp = obj.getProperty("access");
|
|
67
|
-
if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
|
|
68
|
-
const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
69
|
-
if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
70
|
-
return accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function restrictsToRoles(accessObj: ObjectLiteralExpression): boolean {
|
|
74
|
-
if (accessObj.getProperty("openToAll")) return false;
|
|
75
|
-
const rolesProp = accessObj.getProperty("roles");
|
|
76
|
-
if (!rolesProp) return false;
|
|
77
|
-
if (
|
|
78
|
-
rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
|
|
79
|
-
rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
|
|
80
|
-
)
|
|
81
|
-
return false;
|
|
82
|
-
return !OPEN_ROLE_RE.test(rolesProp.getText());
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function roleRestrictedHandlerOf(
|
|
86
|
-
call: CallExpression,
|
|
87
|
-
filePath: string,
|
|
88
|
-
): RoleRestrictedHandler | undefined {
|
|
89
|
-
const obj = writeHandlerOptions(call);
|
|
90
|
-
if (!obj) return undefined;
|
|
91
|
-
const name = literalStringOf(obj.getProperty("name"));
|
|
92
|
-
if (!name) return undefined;
|
|
93
|
-
const accessObj = accessObjectOf(obj);
|
|
94
|
-
if (!accessObj || !restrictsToRoles(accessObj)) return undefined;
|
|
95
|
-
return { name, file: filePath, line: call.getStartLineNumber() };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function findRoleRestrictedWriteHandlers(
|
|
99
|
-
files: readonly SourceFile[],
|
|
100
|
-
): RoleRestrictedHandler[] {
|
|
101
|
-
const out: RoleRestrictedHandler[] = [];
|
|
102
|
-
for (const sf of files) {
|
|
103
|
-
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
104
|
-
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
105
|
-
const handler = roleRestrictedHandlerOf(call, sf.getFilePath());
|
|
106
|
-
if (handler) out.push(handler);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const ACCESS_DENIED_ASSERTION = /\bAccessDeniedError\b|\baccess_denied\b|\b403\b/;
|
|
113
|
-
|
|
114
|
-
const SHORT_WORD = /^[a-z0-9]+$/;
|
|
115
|
-
|
|
116
|
-
// A single lowercase word ("create", "approve") is common enough in
|
|
117
|
-
// unrelated test scaffolding (createTestStack, approveAll) that a bare word-
|
|
118
|
-
// boundary match is too loose — only count it as a string-literal end
|
|
119
|
-
// ("approve"/"feat:write:approve") or a property access (Handlers.approve).
|
|
120
|
-
export function testMentionsHandler(text: string, handlerName: string): boolean {
|
|
121
|
-
return nameForms(handlerName).some((form) => {
|
|
122
|
-
if (!SHORT_WORD.test(form)) return mentionsAsWord(text, form);
|
|
123
|
-
const esc = escapeRegExp(form);
|
|
124
|
-
return new RegExp(`[:"'\`]${esc}["'\`]|\\.${esc}\\b`).test(text);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function findHandlersWithoutAccessDeniedTest(
|
|
129
|
-
files: readonly SourceFile[],
|
|
130
|
-
roots: readonly { readonly absPath: string }[] = [],
|
|
131
|
-
): RoleRestrictedHandler[] {
|
|
132
|
-
const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
|
|
133
|
-
const testTextsByRepo = new Map<string, string[]>();
|
|
134
|
-
for (const sf of files) {
|
|
135
|
-
if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
136
|
-
const text = sf.getFullText();
|
|
137
|
-
if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
|
|
138
|
-
const repoKey = repoKeyOf(sf.getFilePath());
|
|
139
|
-
const texts = testTextsByRepo.get(repoKey) ?? [];
|
|
140
|
-
texts.push(text);
|
|
141
|
-
testTextsByRepo.set(repoKey, texts);
|
|
142
|
-
}
|
|
143
|
-
return findRoleRestrictedWriteHandlers(files).filter((h) => {
|
|
144
|
-
const testTexts = testTextsByRepo.get(repoKeyOf(h.file)) ?? [];
|
|
145
|
-
return !testTexts.some((text) => testMentionsHandler(text, h.name));
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export const guard: AstGuard = {
|
|
150
|
-
name: "Access-Denied-Test Guard",
|
|
151
|
-
scan: SCAN,
|
|
152
|
-
security: true,
|
|
153
|
-
hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `kumiko-guards guards --write-security-baseline`.",
|
|
154
|
-
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
155
|
-
// consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
|
|
156
|
-
const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
|
|
157
|
-
(h) => ({
|
|
158
|
-
file: path.relative(ROOT, h.file),
|
|
159
|
-
line: h.line,
|
|
160
|
-
message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
|
|
161
|
-
}),
|
|
162
|
-
);
|
|
163
|
-
return { violations };
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
if (import.meta.main) runStandalone(guard);
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Guard: PII-typical entity field names without annotation, with a baseline
|
|
4
|
-
* regression guard like check-complexity.ts.
|
|
5
|
-
*
|
|
6
|
-
* Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
|
|
7
|
-
* fields with { pii: true }, { userOwned }, { tenantOwned: true },
|
|
8
|
-
* { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
|
|
9
|
-
* successor to the four legacy subject annotations).
|
|
10
|
-
*
|
|
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
|
-
*
|
|
19
|
-
* Usage:
|
|
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
|
-
*/
|
|
24
|
-
import * as path from "node:path";
|
|
25
|
-
import {
|
|
26
|
-
type CallExpression,
|
|
27
|
-
type Node,
|
|
28
|
-
type ObjectLiteralExpression,
|
|
29
|
-
type SourceFile,
|
|
30
|
-
SyntaxKind,
|
|
31
|
-
} from "ts-morph";
|
|
32
|
-
import {
|
|
33
|
-
type AstGuard,
|
|
34
|
-
baselineRatchet,
|
|
35
|
-
buildSharedProject,
|
|
36
|
-
filesForGuard,
|
|
37
|
-
type GuardOutcome,
|
|
38
|
-
type GuardViolation,
|
|
39
|
-
isLocalFinding,
|
|
40
|
-
relFromRepoRoot,
|
|
41
|
-
runStandalone,
|
|
42
|
-
type ScanSpec,
|
|
43
|
-
} from "./_lib/guard-kit";
|
|
44
|
-
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
45
|
-
|
|
46
|
-
const ROOT = process.cwd();
|
|
47
|
-
|
|
48
|
-
const SCAN: ScanSpec = {
|
|
49
|
-
scope: "source",
|
|
50
|
-
extensions: ["ts"],
|
|
51
|
-
frameworkWithin: ["packages/*/src/**"],
|
|
52
|
-
};
|
|
53
|
-
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
54
|
-
|
|
55
|
-
// Keep in sync with boot-validator/entity-handler.ts PII_*_NAME_HINTS.
|
|
56
|
-
const PII_DIRECT_NAME_HINTS = new Set([
|
|
57
|
-
"email",
|
|
58
|
-
"phone",
|
|
59
|
-
"phonenumber",
|
|
60
|
-
"mobile",
|
|
61
|
-
"address",
|
|
62
|
-
"street",
|
|
63
|
-
"postalcode",
|
|
64
|
-
"zipcode",
|
|
65
|
-
"zip",
|
|
66
|
-
"city",
|
|
67
|
-
"displayname",
|
|
68
|
-
"firstname",
|
|
69
|
-
"lastname",
|
|
70
|
-
"fullname",
|
|
71
|
-
"birthday",
|
|
72
|
-
"birthdate",
|
|
73
|
-
"dateofbirth",
|
|
74
|
-
"dob",
|
|
75
|
-
"ssn",
|
|
76
|
-
"taxid",
|
|
77
|
-
"vatid",
|
|
78
|
-
"passport",
|
|
79
|
-
"iban",
|
|
80
|
-
"bic",
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
const PII_USER_OWNED_NAME_HINTS = new Set([
|
|
84
|
-
"body",
|
|
85
|
-
"text",
|
|
86
|
-
"content",
|
|
87
|
-
"message",
|
|
88
|
-
"comment",
|
|
89
|
-
"description",
|
|
90
|
-
"note",
|
|
91
|
-
"notes",
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
95
|
-
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
99
|
-
const first = call.getArguments()[0];
|
|
100
|
-
return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Subject annotations answering "is this PII" (kept in sync with the
|
|
104
|
-
// author-facing PersonalAnnotations type, @cosmicdrift/kumiko-types
|
|
105
|
-
// packages/types/src/fields.ts). `personal` (0.210.0) is the successor
|
|
106
|
-
// to the four legacy fields below — every value counts as answered,
|
|
107
|
-
// including `personal: false` (the framework requires a `reason` for
|
|
108
|
-
// it). `find` is NOT a silencer: it resolves to lookupable/searchable/
|
|
109
|
-
// sensitive, which never answered the PII question either. `encrypted`
|
|
110
|
-
// isn't one either — the boot validator checks it only together with
|
|
111
|
-
// `sensitive` (ciphertext-at-rest), never as a substitute.
|
|
112
|
-
function isPiiSilencerAssignment(name: string, init: Node | undefined): boolean {
|
|
113
|
-
if (name === "personal") {
|
|
114
|
-
return (
|
|
115
|
-
init !== undefined &&
|
|
116
|
-
init.getKind() !== SyntaxKind.UndefinedKeyword &&
|
|
117
|
-
!(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
|
|
118
|
-
init.getKind() !== SyntaxKind.NullKeyword
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
if (name === "allowPlaintext") return init?.isKind(SyntaxKind.StringLiteral) ?? false;
|
|
122
|
-
if (name === "pii") return init?.getKind() === SyntaxKind.TrueKeyword;
|
|
123
|
-
if (name === "tenantOwned") return init?.getKind() === SyntaxKind.TrueKeyword;
|
|
124
|
-
if (name === "userOwned") return init?.isKind(SyntaxKind.ObjectLiteralExpression) ?? false;
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function objectHasPiiSilencer(obj: ObjectLiteralExpression): boolean {
|
|
129
|
-
for (const prop of obj.getProperties()) {
|
|
130
|
-
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
131
|
-
if (isPiiSilencerAssignment(prop.getName(), prop.getInitializer())) return true;
|
|
132
|
-
}
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function enclosingFieldName(call: CallExpression): string | null {
|
|
137
|
-
let node = call.getParent();
|
|
138
|
-
while (node) {
|
|
139
|
-
if (node.isKind(SyntaxKind.PropertyAssignment)) {
|
|
140
|
-
return node.getName();
|
|
141
|
-
}
|
|
142
|
-
node = node.getParent();
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export interface Finding {
|
|
148
|
-
file: string;
|
|
149
|
-
line: number;
|
|
150
|
-
fieldName: string;
|
|
151
|
-
hint: string;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
|
|
155
|
-
const findings: Finding[] = [];
|
|
156
|
-
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
157
|
-
const callee = call.getExpression().getText();
|
|
158
|
-
if (callee !== "createTextField" && callee !== "createLongTextField") continue;
|
|
159
|
-
|
|
160
|
-
const fieldName = enclosingFieldName(call);
|
|
161
|
-
if (!fieldName) continue;
|
|
162
|
-
|
|
163
|
-
const options = fieldFactoryOptions(call);
|
|
164
|
-
if (options && objectHasPiiSilencer(options)) continue;
|
|
165
|
-
|
|
166
|
-
const lower = fieldName.toLowerCase();
|
|
167
|
-
let hint: string | null = null;
|
|
168
|
-
if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
169
|
-
hint = `{ personal: ... } or { pii: true } or { allowPlaintext: "is-business-data" }`;
|
|
170
|
-
} else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
|
|
171
|
-
hint = `{ personal: { of: "<authorIdField>" }, find: "exact" } or { userOwned: { ownerField: "<authorIdField>" } } or { allowPlaintext: "..." }`;
|
|
172
|
-
}
|
|
173
|
-
if (!hint) continue;
|
|
174
|
-
|
|
175
|
-
const line = call.getStartLineNumber();
|
|
176
|
-
const file = relFile(sf, roots);
|
|
177
|
-
findings.push({ file, line, fieldName, hint });
|
|
178
|
-
}
|
|
179
|
-
return findings;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function scan(
|
|
183
|
-
files: readonly SourceFile[],
|
|
184
|
-
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
185
|
-
): {
|
|
186
|
-
findings: Finding[];
|
|
187
|
-
scanned: number;
|
|
188
|
-
} {
|
|
189
|
-
const findings: Finding[] = [];
|
|
190
|
-
let scanned = 0;
|
|
191
|
-
for (const sf of files) {
|
|
192
|
-
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
193
|
-
scanned++;
|
|
194
|
-
findings.push(...scanFieldFactories(sf, roots));
|
|
195
|
-
}
|
|
196
|
-
return { findings, scanned };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function localFindings(findings: readonly Finding[]): readonly Finding[] {
|
|
200
|
-
return findings.filter(isLocalFinding);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function countByFile(findings: readonly Finding[]): Record<string, number> {
|
|
204
|
-
const counts: Record<string, number> = {};
|
|
205
|
-
for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
206
|
-
return counts;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const BASELINE_FILE = ".kumiko-pii-annotations-baseline.json";
|
|
210
|
-
const piiBaseline = baselineRatchet({
|
|
211
|
-
file: path.join(ROOT, BASELINE_FILE),
|
|
212
|
-
formatVersion: 1,
|
|
213
|
-
unit: "PII finding(s)",
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// One place for "what goes into the baseline", used by both the compare and
|
|
217
|
-
// the write path — a sibling finding leaking into the written file would turn
|
|
218
|
-
// a foreign checkout's code into a local, unfixable red.
|
|
219
|
-
export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
|
|
220
|
-
return countByFile(localFindings(findings));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
|
|
224
|
-
const local = localFindings(findings);
|
|
225
|
-
const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
|
|
226
|
-
return piiBaseline.check(
|
|
227
|
-
baselineCounts(findings),
|
|
228
|
-
`Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
|
|
229
|
-
{
|
|
230
|
-
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"\` once.`,
|
|
231
|
-
resolveLine,
|
|
232
|
-
},
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function analyse(
|
|
237
|
-
files: readonly SourceFile[],
|
|
238
|
-
roots: readonly RepoRoot[],
|
|
239
|
-
compareBaseline: boolean,
|
|
240
|
-
): GuardOutcome {
|
|
241
|
-
const { findings } = scan(files, roots);
|
|
242
|
-
if (!compareBaseline) {
|
|
243
|
-
for (const f of findings)
|
|
244
|
-
console.warn(
|
|
245
|
-
` [pii-annotations WARN] ${f.file}:${f.line} field "${f.fieldName}" looks like PII — mark ${f.hint}`,
|
|
246
|
-
);
|
|
247
|
-
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
248
|
-
return { violations: [] };
|
|
249
|
-
}
|
|
250
|
-
return { violations: checkBaseline(findings) };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export const guard: AstGuard = {
|
|
254
|
-
name: "PII-Annotations Guard",
|
|
255
|
-
scan: SCAN,
|
|
256
|
-
hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"`',
|
|
257
|
-
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
258
|
-
writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
|
|
259
|
-
};
|
|
260
|
-
|
|
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
|
-
if (import.meta.main) {
|
|
266
|
-
const args = process.argv.slice(2);
|
|
267
|
-
if (args.includes("--write-baseline")) {
|
|
268
|
-
const project = buildSharedProject([guard]);
|
|
269
|
-
const { findings } = scan(filesForGuard(project, guard));
|
|
270
|
-
piiBaseline.write(baselineCounts(findings));
|
|
271
|
-
process.exit(0);
|
|
272
|
-
}
|
|
273
|
-
if (args.includes("--no-baseline")) {
|
|
274
|
-
const project = buildSharedProject([guard]);
|
|
275
|
-
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
276
|
-
process.exit(0);
|
|
277
|
-
}
|
|
278
|
-
runStandalone(guard);
|
|
279
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Guard: reports `ctx.unsafeAppendEvent` (also `ctx["unsafeAppendEvent"]` and destructuring) outside `migrations/`, `upcasters/` and tests.
|
|
4
|
-
*
|
|
5
|
-
* `appendEvent` is the typed default, `tryAppendEvent` the optimistic variant. The untyped
|
|
6
|
-
* `unsafeAppendEvent` skips payload typing and is meant for migrations and upcasters, where the
|
|
7
|
-
* event type is not knowable at compile time.
|
|
8
|
-
*
|
|
9
|
-
* Existing call sites are frozen per file in `.kumiko-unsafe-append-event-baseline.json`; a count
|
|
10
|
-
* over the baseline fails, at or under passes. Without the file (consumer repos) the guard only
|
|
11
|
-
* warns.
|
|
12
|
-
*
|
|
13
|
-
* Usage:
|
|
14
|
-
* bun packages/guards/src/guard-unsafe-append-event.ts # compare to baseline
|
|
15
|
-
* bun packages/guards/src/guard-unsafe-append-event.ts --write-baseline # freeze current
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import * as path from "node:path";
|
|
19
|
-
|
|
20
|
-
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
|
-
import {
|
|
22
|
-
ALL_REPO_KINDS,
|
|
23
|
-
type AstGuard,
|
|
24
|
-
baselineRatchet,
|
|
25
|
-
buildSharedProject,
|
|
26
|
-
filesForGuard,
|
|
27
|
-
isLocalFinding,
|
|
28
|
-
relFromRepoRoot,
|
|
29
|
-
runStandalone,
|
|
30
|
-
type ScanSpec,
|
|
31
|
-
} from "./_lib/guard-kit";
|
|
32
|
-
import { resolveRepoRoots } from "./_lib/roots";
|
|
33
|
-
|
|
34
|
-
const SCAN: ScanSpec = {
|
|
35
|
-
kinds: ALL_REPO_KINDS,
|
|
36
|
-
scope: "source",
|
|
37
|
-
extensions: ["ts"],
|
|
38
|
-
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const SYMBOL = "unsafeAppendEvent";
|
|
42
|
-
const ALLOWED_PATH = /(^|\/)(migrations|upcasters|__tests__)\/|\.test\.ts$|\.d\.ts$/;
|
|
43
|
-
|
|
44
|
-
function unsafeAppendEventLines(sf: SourceFile): number[] {
|
|
45
|
-
const propertyAccesses = sf
|
|
46
|
-
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
47
|
-
.filter((access) => access.getNameNode().getText() === SYMBOL);
|
|
48
|
-
const elementAccesses = sf
|
|
49
|
-
.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)
|
|
50
|
-
.filter((access) => {
|
|
51
|
-
const argument = access.getArgumentExpression();
|
|
52
|
-
return (
|
|
53
|
-
argument !== undefined &&
|
|
54
|
-
(Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) &&
|
|
55
|
-
argument.getLiteralText() === SYMBOL
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
const destructurings = sf
|
|
59
|
-
.getDescendantsOfKind(SyntaxKind.BindingElement)
|
|
60
|
-
.filter(
|
|
61
|
-
(element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === SYMBOL,
|
|
62
|
-
);
|
|
63
|
-
return [...propertyAccesses, ...elementAccesses, ...destructurings].map((node) =>
|
|
64
|
-
node.getStartLineNumber(),
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
type Finding = { readonly file: string; readonly line: number };
|
|
69
|
-
|
|
70
|
-
function scan(files: readonly SourceFile[], roots = resolveRepoRoots()): Finding[] {
|
|
71
|
-
const findings: Finding[] = [];
|
|
72
|
-
for (const sf of files) {
|
|
73
|
-
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
74
|
-
if (ALLOWED_PATH.test(file)) continue;
|
|
75
|
-
for (const line of unsafeAppendEventLines(sf)) findings.push({ file, line });
|
|
76
|
-
}
|
|
77
|
-
return findings;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function countsPerFile(findings: readonly Finding[]): Record<string, number> {
|
|
81
|
-
const counts: Record<string, number> = {};
|
|
82
|
-
for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
83
|
-
return counts;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const GUARD_NAME = "Unsafe-Append-Event Guard";
|
|
87
|
-
const DEFAULT_BASELINE_FILE = path.join(process.cwd(), ".kumiko-unsafe-append-event-baseline.json");
|
|
88
|
-
|
|
89
|
-
const REMEDIATION =
|
|
90
|
-
"unsafeAppendEvent drops payload typing. Use ctx.appendEvent (default) or ctx.tryAppendEvent (conflict as result); the untyped variant belongs in migrations/ and upcasters/.";
|
|
91
|
-
|
|
92
|
-
const REWRITE_COMMAND = `kumiko-guards guards --write-baseline --guard="${GUARD_NAME}"`;
|
|
93
|
-
|
|
94
|
-
export function createUnsafeAppendEventGuard(baselineFile: string): AstGuard {
|
|
95
|
-
const baseline = baselineRatchet({
|
|
96
|
-
file: baselineFile,
|
|
97
|
-
formatVersion: 1,
|
|
98
|
-
unit: "unsafeAppendEvent call(s)",
|
|
99
|
-
});
|
|
100
|
-
return {
|
|
101
|
-
name: GUARD_NAME,
|
|
102
|
-
scan: SCAN,
|
|
103
|
-
hint: `${REMEDIATION} After a deliberate change: \`${REWRITE_COMMAND}\`.`,
|
|
104
|
-
run(files, roots = resolveRepoRoots()) {
|
|
105
|
-
const findings = scan(files, roots);
|
|
106
|
-
const violations = baseline.check(countsPerFile(findings), REMEDIATION, {
|
|
107
|
-
formatDriftRemediation: `Run \`${REWRITE_COMMAND}\` once.`,
|
|
108
|
-
resolveLine: (file) => findings.find((f) => f.file === file)?.line ?? 1,
|
|
109
|
-
});
|
|
110
|
-
return { violations };
|
|
111
|
-
},
|
|
112
|
-
writeBaseline: (files, roots) => baseline.write(countsPerFile(scan(files, roots))),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export const guard = createUnsafeAppendEventGuard(DEFAULT_BASELINE_FILE);
|
|
117
|
-
|
|
118
|
-
if (import.meta.main) {
|
|
119
|
-
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
120
|
-
const project = buildSharedProject([guard]);
|
|
121
|
-
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
122
|
-
process.exit(0);
|
|
123
|
-
}
|
|
124
|
-
runStandalone(guard);
|
|
125
|
-
}
|