@cosmicdrift/kumiko-guards 0.333.0 → 0.335.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/framework-import.ts +29 -0
- package/src/_lib/git-env.ts +2 -10
- package/src/_lib/guard-kit.ts +8 -8
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +13 -0
- package/src/check-as-casts.ts +82 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +9 -2
- package/src/cli.ts +4 -0
- package/src/guard-access-denied-test.ts +2 -2
- package/src/guard-admin-api.ts +1 -2
- package/src/guard-app-feature-structure.ts +8 -6
- package/src/guard-direct-fetch.ts +1 -1
- package/src/guard-error-reasons.ts +7 -3
- package/src/guard-escape-hatch-declared.ts +12 -11
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +2 -2
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-framed-extension-sections.ts +53 -20
- package/src/guard-open-to-all-reason.ts +2 -2
- package/src/guard-raw-sql.ts +9 -3
- package/src/guard-section-fields-raw.ts +81 -9
- package/src/guard-test-stack-drift.ts +11 -1
- package/src/guard-test-template-drift.ts +48 -2
- package/src/guard-upgrade-state.ts +7 -4
- package/src/run-guards.ts +2 -1
- package/src/run-repo-checks.ts +1 -1
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +147 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.335.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>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.335.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { type Node, SyntaxKind } from "ts-morph";
|
|
3
|
+
|
|
4
|
+
const FRAMEWORK_PACKAGE = "@cosmicdrift/kumiko-framework";
|
|
5
|
+
// Inside the framework monorepo itself the helpers are imported relatively,
|
|
6
|
+
// so a relative specifier counts when it resolves into the framework package.
|
|
7
|
+
const FRAMEWORK_SOURCE_DIR_MARKER = "/packages/framework/src/";
|
|
8
|
+
|
|
9
|
+
function isFrameworkModuleSpecifier(specifier: string, importingFile: string): boolean {
|
|
10
|
+
if (specifier === FRAMEWORK_PACKAGE || specifier.startsWith(`${FRAMEWORK_PACKAGE}/`)) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (!specifier.startsWith(".")) return false;
|
|
14
|
+
const resolved = path.resolve(path.dirname(importingFile), specifier);
|
|
15
|
+
return `${resolved}/`.includes(FRAMEWORK_SOURCE_DIR_MARKER);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A text match on a function name lets a same-named local no-op clear an
|
|
19
|
+
// escape-hatch finding, so the identifier must resolve to an import of
|
|
20
|
+
// `exportedName` from the framework package. Aliased imports resolve too.
|
|
21
|
+
export function isFrameworkImportOf(node: Node, exportedName: string): boolean {
|
|
22
|
+
if (!node.isKind(SyntaxKind.Identifier)) return false;
|
|
23
|
+
const decls = node.getSymbol()?.getDeclarations() ?? [];
|
|
24
|
+
return decls.some((decl) => {
|
|
25
|
+
if (!decl.isKind(SyntaxKind.ImportSpecifier) || decl.getName() !== exportedName) return false;
|
|
26
|
+
const specifier = decl.getImportDeclaration().getModuleSpecifierValue();
|
|
27
|
+
return isFrameworkModuleSpecifier(specifier, decl.getSourceFile().getFilePath());
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/_lib/git-env.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* A caller running as a pre-push hook would otherwise inherit `GIT_DIR`/
|
|
4
|
-
* `GIT_WORK_TREE` from git, which git prefers over `cwd` — so an inherited
|
|
5
|
-
* environment would answer for the hook's repo instead of the path being
|
|
6
|
-
* asked about. `HOME` stays in, so the global config is still read.
|
|
7
|
-
*
|
|
8
|
-
* An allowlist (not a blocklist) so a future git env var neither of us has
|
|
9
|
-
* thought of yet is excluded by construction, not by omission.
|
|
10
|
-
*/
|
|
1
|
+
// Allowlist, not `...process.env`: git prefers an inherited GIT_DIR/GIT_WORK_TREE over cwd,
|
|
2
|
+
// so a pre-push hook's env would answer for the hook's repo. HOME stays so global config loads.
|
|
11
3
|
export const GIT_ENV_KEYS = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME"] as const;
|
|
12
4
|
|
|
13
5
|
/**
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -474,8 +474,13 @@ export function explainGuards(
|
|
|
474
474
|
|
|
475
475
|
function guardKitVersion(): string {
|
|
476
476
|
const pkgPath = resolve(import.meta.dir, "../../package.json");
|
|
477
|
-
|
|
478
|
-
|
|
477
|
+
// The version only decorates the banner; it must never abort a guard run.
|
|
478
|
+
try {
|
|
479
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
|
|
480
|
+
return pkg.version ?? "unknown";
|
|
481
|
+
} catch {
|
|
482
|
+
return "unknown";
|
|
483
|
+
}
|
|
479
484
|
}
|
|
480
485
|
|
|
481
486
|
/**
|
|
@@ -494,12 +499,7 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
|
|
|
494
499
|
return undefined;
|
|
495
500
|
}
|
|
496
501
|
|
|
497
|
-
|
|
498
|
-
* Validates CLI args for one subcommand against the flags it actually
|
|
499
|
-
* understands — every arg must match `known` exactly (not just a `--`-prefix
|
|
500
|
-
* check, which let a single-dash typo or a stray positional through
|
|
501
|
-
* unnoticed). An unknown arg must fail loud, never pass through silently.
|
|
502
|
-
*/
|
|
502
|
+
// Exact match, not a `--` prefix check: single-dash typos and positionals must not slip through.
|
|
503
503
|
export function cliFlagsError(
|
|
504
504
|
subcommand: string,
|
|
505
505
|
argv: readonly string[],
|
|
@@ -108,8 +108,12 @@ export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "a
|
|
|
108
108
|
const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
|
|
109
109
|
|
|
110
110
|
// kumiko-platform deliberately returns no scan dirs (0 files) — its docs-samples tree needs its own allowlist review before this guard scans it (follow-up issue).
|
|
111
|
+
export function isSqlScanExcluded(root: RepoRoot): boolean {
|
|
112
|
+
return root.name === "kumiko-platform";
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
export function sqlScanDirsFor(root: RepoRoot): readonly string[] {
|
|
112
|
-
if (root
|
|
116
|
+
if (isSqlScanExcluded(root)) return [];
|
|
113
117
|
return sourceRootDirs(root).map((dir) => dir.slice(root.absPath.length).replace(/^\/+/, ""));
|
|
114
118
|
}
|
|
115
119
|
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.335.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Stricter guard detection for raw-sql, test-template-drift, framed extension sections, runtime-isolation and test-stack-drift",
|
|
6
|
+
"detail": "Stricter guard detection; app repos that carried an undetected pattern can now turn red.\n- `guard-raw-sql`: only kumiko-platform is skipped. An app repo whose declared `sourceRoots` all do not exist now fails as vacuous (0 files scanned) instead of passing.\n- `test-template-drift`: `...defineAppE2eConfig(...)` spread next to `fullyParallel`, `retries`, `timeout`, `workers` or `expect`, and shorthand `{ viewport }` / `{ deviceScaleFactor }`, are flagged.\n- `no-framed-extension-sections`: an `extensionSectionComponents` registry held in a variable, shorthand or spread is resolved.\n- `runtime-isolation`: entries declared in package.json `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` count as browser code.\n- `test-stack-drift`: `@no-server-stack:` only opts out when it sits in a comment, not in a string literal.\n- Direct `run-guards.ts` / `run-ui-guards.ts` / `run-repo-checks.ts` invocations exit with 1 instead of the failure count.",
|
|
7
|
+
"migration": "App repos that carried a previously undetected pattern can now fail their guards. Fix the reported sites; a vacuous raw-sql scan (all declared sourceRoots missing) must declare existing source roots."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.335.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Escape-hatch guards accept only real framework imports, a corrupt cast baseline is an error, hints name kumiko-guards guards",
|
|
13
|
+
"detail": "The escape-hatch guards now only accept `declareEscapeHatch` and `withUnsafeRawGrant` imported from the framework itself; a local function or an import from another module with the same name no longer satisfies them. A corrupt `.kumiko-cast-baseline.json` is now an error instead of being treated as an empty baseline. Guard hints point at `kumiko-guards guards`."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.332.0",
|
|
4
17
|
"type": "fix",
|
package/src/check-as-casts.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
buildSharedProject,
|
|
49
49
|
filesForGuard,
|
|
50
50
|
type GuardOutcome,
|
|
51
|
+
type GuardViolation,
|
|
51
52
|
runStandalone,
|
|
52
53
|
type ScanSpec,
|
|
53
54
|
} from "./_lib/guard-kit";
|
|
@@ -546,26 +547,89 @@ function writeBaseline(all: readonly Site[]): void {
|
|
|
546
547
|
// Per File:target: aktueller Count gegen baseline. Mehr → Regression.
|
|
547
548
|
// Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
|
|
548
549
|
// obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
|
|
549
|
-
|
|
550
|
+
// Merge-conflict markers, truncated writes and hand edits all land here; the
|
|
551
|
+
// file is read once at the boundary so the comparison below can trust its shape.
|
|
552
|
+
function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
|
|
553
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
554
|
+
return Object.values(value).every(
|
|
555
|
+
(targets) =>
|
|
556
|
+
typeof targets === "object" &&
|
|
557
|
+
targets !== null &&
|
|
558
|
+
!Array.isArray(targets) &&
|
|
559
|
+
Object.values(targets).every((count) => typeof count === "number"),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
type BaselineLoad =
|
|
564
|
+
| { kind: "missing" }
|
|
565
|
+
| { kind: "format-drift"; format: unknown }
|
|
566
|
+
| { kind: "invalid"; reason: string }
|
|
567
|
+
| { kind: "ok"; baseline: Baseline };
|
|
568
|
+
|
|
569
|
+
export function loadBaseline(file: string): BaselineLoad {
|
|
570
|
+
if (!existsSync(file)) return { kind: "missing" };
|
|
571
|
+
let parsed: unknown;
|
|
572
|
+
try {
|
|
573
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { kind: "invalid", reason: error instanceof Error ? error.message : String(error) };
|
|
576
|
+
}
|
|
577
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
578
|
+
return { kind: "invalid", reason: "expected a JSON object" };
|
|
579
|
+
}
|
|
580
|
+
const record: Record<string, unknown> = { ...parsed };
|
|
581
|
+
if (record["format"] !== BASELINE_FORMAT_VERSION) {
|
|
582
|
+
return { kind: "format-drift", format: record["format"] };
|
|
583
|
+
}
|
|
584
|
+
const { generated, totalSuspect, perFile } = record;
|
|
585
|
+
if (
|
|
586
|
+
typeof generated !== "string" ||
|
|
587
|
+
typeof totalSuspect !== "number" ||
|
|
588
|
+
!isCountsByFile(perFile)
|
|
589
|
+
) {
|
|
590
|
+
return {
|
|
591
|
+
kind: "invalid",
|
|
592
|
+
reason: "expected { format, generated: string, totalSuspect: number, perFile: object }",
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
kind: "ok",
|
|
597
|
+
baseline: { format: BASELINE_FORMAT_VERSION, generated, totalSuspect, perFile },
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function reportBaseline(
|
|
602
|
+
all: readonly Site[],
|
|
603
|
+
baselineFile: string = baselinePath,
|
|
604
|
+
): GuardViolation[] {
|
|
550
605
|
const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
|
|
551
606
|
const repoLocalTotal = totalOf(repoLocalSuspects);
|
|
552
607
|
|
|
553
|
-
|
|
608
|
+
const loaded = loadBaseline(baselineFile);
|
|
609
|
+
if (loaded.kind === "missing") {
|
|
554
610
|
console.log(
|
|
555
611
|
"\n No baseline found. Freeze one with `--write-baseline` first — warning, no fail until then.",
|
|
556
612
|
);
|
|
557
|
-
return;
|
|
613
|
+
return [];
|
|
558
614
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
615
|
+
if (loaded.kind === "invalid") {
|
|
616
|
+
// A corrupt baseline must not read as "no regressions".
|
|
617
|
+
return [
|
|
618
|
+
{
|
|
619
|
+
file: baselineFile,
|
|
620
|
+
line: 1,
|
|
621
|
+
message: `Cannot read cast baseline ${baselineFile}: ${loaded.reason}. Fix the file (e.g. resolve merge-conflict markers) or regenerate it with \`bun packages/guards/src/check-as-casts.ts --write-baseline\`.`,
|
|
622
|
+
},
|
|
623
|
+
];
|
|
624
|
+
}
|
|
625
|
+
if (loaded.kind === "format-drift") {
|
|
562
626
|
console.log(
|
|
563
|
-
`\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${
|
|
627
|
+
`\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
|
|
564
628
|
);
|
|
565
629
|
console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
|
|
566
|
-
return;
|
|
630
|
+
return [];
|
|
567
631
|
}
|
|
568
|
-
const baseline =
|
|
632
|
+
const { baseline } = loaded;
|
|
569
633
|
|
|
570
634
|
type Regression = { file: string; target: string; baseline: number; current: number };
|
|
571
635
|
const regressions: Regression[] = [];
|
|
@@ -602,7 +666,7 @@ function reportBaseline(all: readonly Site[]): void {
|
|
|
602
666
|
" 3. If a cleanup reduction in one file offsets an increase in another: " +
|
|
603
667
|
"run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
|
|
604
668
|
);
|
|
605
|
-
return;
|
|
669
|
+
return [];
|
|
606
670
|
}
|
|
607
671
|
|
|
608
672
|
console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
|
|
@@ -611,17 +675,21 @@ function reportBaseline(all: readonly Site[]): void {
|
|
|
611
675
|
` ✓ ${reduced} suspect cast(s) reduced since baseline. Run \`--write-baseline\` if appropriate.`,
|
|
612
676
|
);
|
|
613
677
|
}
|
|
678
|
+
return [];
|
|
614
679
|
}
|
|
615
680
|
|
|
616
681
|
function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
617
682
|
const { all, scanned } = scanCasts(files);
|
|
618
683
|
reportCasts(all, scanned);
|
|
619
684
|
reportUnknownReasons(all);
|
|
620
|
-
if (compareBaseline)
|
|
621
|
-
|
|
685
|
+
if (!compareBaseline) {
|
|
686
|
+
console.log("\n Baseline comparison skipped (--no-baseline).");
|
|
687
|
+
return { violations: [] };
|
|
688
|
+
}
|
|
622
689
|
// WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
|
|
623
|
-
// unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken.
|
|
624
|
-
|
|
690
|
+
// unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken. Nur
|
|
691
|
+
// eine unlesbare Baseline schlägt fehl.
|
|
692
|
+
return { violations: reportBaseline(all) };
|
|
625
693
|
}
|
|
626
694
|
|
|
627
695
|
export const guard: AstGuard = {
|
|
@@ -133,6 +133,14 @@ function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): nu
|
|
|
133
133
|
return scanned;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// A bare substring match would accept the entry inside a TOML comment or under
|
|
137
|
+
// another key such as coveragePathIgnorePatterns, which leaves `bun test` unguarded.
|
|
138
|
+
function ignoresRealTests(bunfigToml: string): boolean {
|
|
139
|
+
const withoutComments = bunfigToml.replace(/^\s*#.*$/gm, "");
|
|
140
|
+
const match = /^\s*pathIgnorePatterns\s*=\s*\[([^\]]*)\]/m.exec(withoutComments);
|
|
141
|
+
return match?.[1]?.includes(`"${REAL_TEST_IGNORE_ENTRY}"`) ?? false;
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
// The generated bunfig template (packages/testing/src/bunfig.ts) is what app
|
|
137
145
|
// and library repos consume via `kumiko-testing bunfig` — the framework repo
|
|
138
146
|
// hand-maintains its own root bunfig*.toml files and doesn't run this
|
|
@@ -150,7 +158,7 @@ function bunfigMissingRealExclusion(root: RepoRoot, findings: TextLineFinding[])
|
|
|
150
158
|
continue;
|
|
151
159
|
}
|
|
152
160
|
scanned++;
|
|
153
|
-
if (!content
|
|
161
|
+
if (!ignoresRealTests(content)) {
|
|
154
162
|
findings.push({
|
|
155
163
|
file: rel,
|
|
156
164
|
line: 1,
|
|
@@ -68,9 +68,11 @@ import { type ScanSpec, scanFiles, scanRoots } from "./_lib/scan-scope";
|
|
|
68
68
|
import {
|
|
69
69
|
classify,
|
|
70
70
|
computeClientReachablePaths,
|
|
71
|
+
createDirectiveClassifier,
|
|
71
72
|
findRuntimeIsolationViolations,
|
|
72
73
|
isClientEntryPath,
|
|
73
74
|
type Runtime,
|
|
75
|
+
readDeclaredClientEntries,
|
|
74
76
|
} from "./runtime-isolation-classify";
|
|
75
77
|
|
|
76
78
|
const SCAN: ScanSpec = { scope: "source", extensions: ["ts", "tsx"] };
|
|
@@ -141,11 +143,15 @@ function scanRoot(root: RepoRoot): {
|
|
|
141
143
|
return !fp.includes("/node_modules/") && !fp.includes("/dist/");
|
|
142
144
|
});
|
|
143
145
|
|
|
146
|
+
const declaredClientEntries = readDeclaredClientEntries(root.absPath);
|
|
144
147
|
const workspaceCache = new Map<string, Runtime | null>();
|
|
148
|
+
const directiveOf = createDirectiveClassifier();
|
|
145
149
|
const clientReachable = computeClientReachablePaths(scannedFiles, (sf) => {
|
|
146
150
|
const rel = path.relative(root.absPath, sf.getFilePath());
|
|
147
|
-
if (isClientEntryPath(rel)) return true;
|
|
148
|
-
return
|
|
151
|
+
if (isClientEntryPath(rel, declaredClientEntries)) return true;
|
|
152
|
+
return (
|
|
153
|
+
classify(sf.getFilePath(), root.absPath, workspaceCache, undefined, directiveOf) === "client"
|
|
154
|
+
);
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
const { violations: allViolations, outsideRoot } = findRuntimeIsolationViolations(
|
|
@@ -153,6 +159,7 @@ function scanRoot(root: RepoRoot): {
|
|
|
153
159
|
root.absPath,
|
|
154
160
|
workspaceCache,
|
|
155
161
|
clientReachable,
|
|
162
|
+
directiveOf,
|
|
156
163
|
);
|
|
157
164
|
|
|
158
165
|
return { allViolations, outsideRoot, scannedFiles: scannedFiles.length };
|
package/src/cli.ts
CHANGED
|
@@ -45,6 +45,10 @@ async function main(): Promise<void> {
|
|
|
45
45
|
process.exit(1);
|
|
46
46
|
}
|
|
47
47
|
const flags = process.argv.slice(3);
|
|
48
|
+
if (subcommand !== undefined && (flags.includes("--help") || flags.includes("-h"))) {
|
|
49
|
+
printHelp();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
48
52
|
|
|
49
53
|
if (subcommand === "list") {
|
|
50
54
|
const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
15
|
* bun guards/guard-access-denied-test.ts
|
|
16
|
-
* Baseline:
|
|
16
|
+
* Baseline: kumiko-guards guards --write-security-baseline
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import * as path from "node:path";
|
|
@@ -117,7 +117,7 @@ export const guard: AstGuard = {
|
|
|
117
117
|
name: "Access-Denied-Test Guard",
|
|
118
118
|
scan: SCAN,
|
|
119
119
|
security: true,
|
|
120
|
-
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: `
|
|
120
|
+
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`.",
|
|
121
121
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
122
122
|
// consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
|
|
123
123
|
const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
|
package/src/guard-admin-api.ts
CHANGED
|
@@ -34,12 +34,11 @@ const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
|
|
|
34
34
|
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
35
35
|
|
|
36
36
|
// Allowed callers: migration runners (sample-local, src/ and bin/) +
|
|
37
|
-
// admin scripts + the definition itself
|
|
37
|
+
// admin scripts + the definition itself.
|
|
38
38
|
const ALLOWLIST: readonly RegExp[] = [
|
|
39
39
|
/^samples\/[^/]+\/src\/migration\//,
|
|
40
40
|
/^scripts\/migrations\//,
|
|
41
41
|
/^packages\/framework\/src\/event-store\/admin-api\.ts$/,
|
|
42
|
-
/^scripts\/guard-admin-api\.ts$/,
|
|
43
42
|
];
|
|
44
43
|
|
|
45
44
|
const GUARDED_CALLS = new Set(["appendRaw", "appendRawBatch"]);
|
|
@@ -40,8 +40,8 @@ export const guard: AstGuard = {
|
|
|
40
40
|
name: "App-Feature-Structure Guard (App-Repos)",
|
|
41
41
|
scan: SCAN,
|
|
42
42
|
hint:
|
|
43
|
-
"
|
|
44
|
-
`
|
|
43
|
+
"Convention: feature.ts is registration only, screens live under web/ (one file per screen), handlers under handlers/, " +
|
|
44
|
+
`domain logic under lib/. Custom screens need // ${IGNORE_TAG} <reason> (declarative screen types are the default).`,
|
|
45
45
|
run(files: readonly SourceFile[]) {
|
|
46
46
|
const violations: GuardViolation[] = [];
|
|
47
47
|
for (const sf of files) {
|
|
@@ -56,7 +56,7 @@ export const guard: AstGuard = {
|
|
|
56
56
|
file: filePath,
|
|
57
57
|
line: 1,
|
|
58
58
|
message:
|
|
59
|
-
"web
|
|
59
|
+
"web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -74,7 +74,7 @@ export const guard: AstGuard = {
|
|
|
74
74
|
violations.push({
|
|
75
75
|
file: filePath,
|
|
76
76
|
line: 1,
|
|
77
|
-
message: "JSX
|
|
77
|
+
message: "JSX component at feature root — move it under web/",
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -85,7 +85,7 @@ export const guard: AstGuard = {
|
|
|
85
85
|
violations.push({
|
|
86
86
|
file: filePath,
|
|
87
87
|
line: 1,
|
|
88
|
-
message: `feature.ts
|
|
88
|
+
message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -99,11 +99,13 @@ export const guard: AstGuard = {
|
|
|
99
99
|
// Nur r.screen-Kontext: das umgebende Call-Target muss auf .screen enden.
|
|
100
100
|
const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
|
|
101
101
|
if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
|
|
102
|
+
// Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
|
|
103
|
+
if (prop.getParent() !== call.getArguments()[0]) continue;
|
|
102
104
|
if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
|
|
103
105
|
violations.push({
|
|
104
106
|
file: filePath,
|
|
105
107
|
line: prop.getStartLineNumber(),
|
|
106
|
-
message: `r.screen type:"custom"
|
|
108
|
+
message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
}
|
|
@@ -118,7 +118,7 @@ export const guard: AstGuard = {
|
|
|
118
118
|
hint:
|
|
119
119
|
"Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
|
|
120
120
|
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch` on the line above. ' +
|
|
121
|
-
"Local bindings named fetch are flagged (false positive — unblock via that marker
|
|
121
|
+
"Local bindings named fetch are flagged (false positive — unblock via that marker). " +
|
|
122
122
|
'Known gap: bracket access (globalThis["fetch"](...)).',
|
|
123
123
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
124
124
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
@@ -60,6 +60,10 @@ const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
|
|
|
60
60
|
// justification, not an error-reason code.
|
|
61
61
|
const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
|
|
62
62
|
|
|
63
|
+
// Calls whose object-literal argument carries a prose `reason` for an
|
|
64
|
+
// access grant / escape hatch.
|
|
65
|
+
const ACCESS_DECLARATION_CALLS = new Set(["declareEscapeHatch", "withUnsafeRawGrant"]);
|
|
66
|
+
|
|
63
67
|
interface Violation {
|
|
64
68
|
readonly file: string;
|
|
65
69
|
readonly line: number;
|
|
@@ -135,8 +139,8 @@ function scanFile(sf: SourceFile): Violation[] {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
// A `reason` PropertyAssignment whose object literal is the initializer of
|
|
138
|
-
// an `openToAll` / `escapeHatch` PropertyAssignment, or
|
|
139
|
-
// object of a bare `declareEscapeHatch(...)` call, is an access-declaration
|
|
142
|
+
// an `openToAll` / `escapeHatch` PropertyAssignment, or an argument
|
|
143
|
+
// object of a bare `declareEscapeHatch(...)` / `withUnsafeRawGrant(...)` call, is an access-declaration
|
|
140
144
|
// justification, not an error-reason code.
|
|
141
145
|
export function isAccessDeclarationReason(prop: Node): boolean {
|
|
142
146
|
const objectLiteral = prop.getParent();
|
|
@@ -147,7 +151,7 @@ export function isAccessDeclarationReason(prop: Node): boolean {
|
|
|
147
151
|
}
|
|
148
152
|
if (owner?.isKind(SyntaxKind.CallExpression)) {
|
|
149
153
|
const callee = owner.getExpression();
|
|
150
|
-
return callee.isKind(SyntaxKind.Identifier) && callee.getText()
|
|
154
|
+
return callee.isKind(SyntaxKind.Identifier) && ACCESS_DECLARATION_CALLS.has(callee.getText());
|
|
151
155
|
}
|
|
152
156
|
return false;
|
|
153
157
|
}
|
|
@@ -46,10 +46,11 @@
|
|
|
46
46
|
* unresolved and the R2/R3 finding names that explicitly. The declaration
|
|
47
47
|
* does not propagate upward: it covers escalations inside that function's
|
|
48
48
|
* own body, not the function it is nested inside. This detection is purely
|
|
49
|
-
* lexical
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* lexical except for origin: `declareEscapeHatch` (and `withUnsafeRawGrant`
|
|
50
|
+
* for R2) must resolve to an import from `@cosmicdrift/kumiko-framework`
|
|
51
|
+
* (or, inside the framework package, a relative import into its `src`), so a
|
|
52
|
+
* same-named local function does not clear. `escapeHatch:` itself is a plain
|
|
53
|
+
* property and is never checked for origin. Referenced-by-variable functions, spread options, computed/string
|
|
53
54
|
* keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
|
|
54
55
|
* value are conservatively not recognized (miss, don't falsely clear).
|
|
55
56
|
*
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
*
|
|
69
70
|
* Usage:
|
|
70
71
|
* bun guards/guard-escape-hatch-declared.ts
|
|
71
|
-
* Baseline:
|
|
72
|
+
* Baseline: kumiko-guards guards --write-security-baseline
|
|
72
73
|
*/
|
|
73
74
|
import * as path from "node:path";
|
|
74
75
|
import {
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
type SourceFile,
|
|
79
80
|
SyntaxKind,
|
|
80
81
|
} from "ts-morph";
|
|
82
|
+
import { isFrameworkImportOf } from "./_lib/framework-import";
|
|
81
83
|
import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
|
|
82
84
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
83
85
|
|
|
@@ -230,7 +232,7 @@ function isExplicitUnsafeRawGrant(call: Node): boolean {
|
|
|
230
232
|
const receiver = expr.getExpression();
|
|
231
233
|
return (
|
|
232
234
|
receiver.isKind(SyntaxKind.CallExpression) &&
|
|
233
|
-
receiver.getExpression()
|
|
235
|
+
isFrameworkImportOf(receiver.getExpression(), "withUnsafeRawGrant")
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -387,9 +389,7 @@ function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
|
|
|
387
389
|
const expr = stmt.getExpression();
|
|
388
390
|
if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
|
|
389
391
|
const callee = expr.getExpression();
|
|
390
|
-
if (!
|
|
391
|
-
return undefined;
|
|
392
|
-
}
|
|
392
|
+
if (!isFrameworkImportOf(callee, "declareEscapeHatch")) return undefined;
|
|
393
393
|
const args = expr.getArguments();
|
|
394
394
|
const arg = args[0];
|
|
395
395
|
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
|
|
@@ -471,7 +471,8 @@ function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
|
|
|
471
471
|
body = fn.getBody();
|
|
472
472
|
}
|
|
473
473
|
if (!body?.isKind(SyntaxKind.Block)) return false;
|
|
474
|
-
|
|
474
|
+
const firstStatement = body.getStatements()[0];
|
|
475
|
+
return firstStatement !== undefined && isValidDeclareEscapeHatchCall(firstStatement);
|
|
475
476
|
}
|
|
476
477
|
|
|
477
478
|
function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
|
|
@@ -680,7 +681,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
|
|
|
680
681
|
name: "Escape-Hatch-Declared Guard",
|
|
681
682
|
scan: SCAN,
|
|
682
683
|
security: true,
|
|
683
|
-
hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `
|
|
684
|
+
hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
|
|
684
685
|
run(files) {
|
|
685
686
|
const violations: GuardViolation[] = [
|
|
686
687
|
...findGenericReasonCalls(files, opts.root).map((f) => ({
|
package/src/guard-i18n-keys.ts
CHANGED
|
@@ -63,7 +63,6 @@ function collectUsedKeys(sf: SourceFile): UsedKey[] {
|
|
|
63
63
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
64
64
|
const exprText = call.getExpression().getText();
|
|
65
65
|
if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
|
|
66
|
-
if (exprText === "test" || exprText === "expect") continue;
|
|
67
66
|
const args = call.getArguments();
|
|
68
67
|
if (args.length === 0) continue;
|
|
69
68
|
const first = args[0];
|
|
@@ -92,7 +92,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
|
|
|
92
92
|
violations.push({
|
|
93
93
|
file: sf.getFilePath(),
|
|
94
94
|
line: prop.getStartLineNumber(),
|
|
95
|
-
message: `[${locale}]
|
|
95
|
+
message: `[${locale}] forbidden term "${hit}" in translation value — use "${rule.preferred}" (fw#2200).`,
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
}
|
|
@@ -104,7 +104,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
|
|
|
104
104
|
export const guard: AstGuard = {
|
|
105
105
|
name: "i18n-Locale-Terminology Guard",
|
|
106
106
|
scan: SCAN,
|
|
107
|
-
hint: "DE: Mandant
|
|
107
|
+
hint: "DE: Mandant instead of Tenant/Organisation; ES: Organización instead of the tenant loanword. Roles like TenantAdmin are fine.",
|
|
108
108
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
109
109
|
const violations = findViolations(files)
|
|
110
110
|
.map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { dirname, resolve } from "node:path";
|
|
30
30
|
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
31
31
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
32
|
+
import { escapeRegExp } from "./_lib/handler-name-forms";
|
|
32
33
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
33
34
|
|
|
34
35
|
// isTestFile() classifies by regex on the file's own path, not by which within-glob matched it, so lib/ vs. test classification stays correct regardless of scan-scope resolution.
|
|
@@ -116,16 +117,20 @@ function bodyTextWithoutImports(testSf: SourceFile): string {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function referencesName(testSf: SourceFile, name: string): boolean {
|
|
119
|
-
|
|
120
|
+
// `\b` does not treat `$` as a word character, so identifiers like `format$`
|
|
121
|
+
// need explicit identifier-char lookarounds.
|
|
122
|
+
return new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`).test(
|
|
123
|
+
bodyTextWithoutImports(testSf),
|
|
124
|
+
);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
export const guard: AstGuard = {
|
|
123
128
|
name: "Lib-Test-Coverage Guard (App-Repos)",
|
|
124
129
|
scan: SCAN,
|
|
125
130
|
hint:
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
`
|
|
131
|
+
"Every lib file with an exported function needs a test that imports from the " +
|
|
132
|
+
"module and references each function by name. IO loaders that an " +
|
|
133
|
+
`integration test covers: // ${IGNORE_TAG} <reason, e.g. integration-covered via …>`,
|
|
129
134
|
run(files: readonly SourceFile[]) {
|
|
130
135
|
const violations: GuardViolation[] = [];
|
|
131
136
|
const testFiles: SourceFile[] = [];
|
|
@@ -145,7 +150,7 @@ export const guard: AstGuard = {
|
|
|
145
150
|
violations.push({
|
|
146
151
|
file: sf.getFilePath(),
|
|
147
152
|
line: 1,
|
|
148
|
-
message: `
|
|
153
|
+
message: `No test imports this lib module (${relevant.length} exported function(s) untested)`,
|
|
149
154
|
});
|
|
150
155
|
continue;
|
|
151
156
|
}
|
|
@@ -155,7 +160,7 @@ export const guard: AstGuard = {
|
|
|
155
160
|
violations.push({
|
|
156
161
|
file: sf.getFilePath(),
|
|
157
162
|
line: e.node.getStartLineNumber(),
|
|
158
|
-
message: `
|
|
163
|
+
message: `Exported function "${e.name}" is not referenced by name in any linked test`,
|
|
159
164
|
});
|
|
160
165
|
}
|
|
161
166
|
}
|