@cosmicdrift/kumiko-guards 0.334.0 → 0.336.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 +12 -8
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +26 -0
- package/src/check-as-casts.ts +89 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +11 -4
- package/src/check-secret-literals.ts +18 -13
- 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 +25 -13
- package/src/guard-fake-tests.ts +32 -25
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-custom-primitives.ts +3 -2
- package/src/guard-no-framed-extension-sections.ts +90 -26
- package/src/guard-no-inline-styles.ts +9 -3
- package/src/guard-no-logic-in-views.ts +8 -2
- package/src/guard-no-raw-hooks.ts +11 -4
- package/src/guard-open-to-all-reason.ts +2 -2
- package/src/guard-primitives-discipline.ts +3 -2
- package/src/guard-raw-classname.ts +8 -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-test-timeouts.ts +21 -2
- package/src/guard-thin-wrappers.ts +2 -2
- package/src/guard-upgrade-state.ts +7 -4
- package/src/pre-push.sh +2 -2
- package/src/run-guards.ts +6 -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.336.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.336.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
|
@@ -125,6 +125,10 @@ export function relFromRepoRoot(
|
|
|
125
125
|
return filePath.slice(filePath.indexOf(marker));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
export function relativeToCwd(absolutePath: string): string {
|
|
129
|
+
return pathRelative(process.cwd(), absolutePath);
|
|
130
|
+
}
|
|
131
|
+
|
|
128
132
|
export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): boolean {
|
|
129
133
|
return allowlist.some((re) => re.test(relPath));
|
|
130
134
|
}
|
|
@@ -474,8 +478,13 @@ export function explainGuards(
|
|
|
474
478
|
|
|
475
479
|
function guardKitVersion(): string {
|
|
476
480
|
const pkgPath = resolve(import.meta.dir, "../../package.json");
|
|
477
|
-
|
|
478
|
-
|
|
481
|
+
// The version only decorates the banner; it must never abort a guard run.
|
|
482
|
+
try {
|
|
483
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
|
|
484
|
+
return pkg.version ?? "unknown";
|
|
485
|
+
} catch {
|
|
486
|
+
return "unknown";
|
|
487
|
+
}
|
|
479
488
|
}
|
|
480
489
|
|
|
481
490
|
/**
|
|
@@ -494,12 +503,7 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
|
|
|
494
503
|
return undefined;
|
|
495
504
|
}
|
|
496
505
|
|
|
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
|
-
*/
|
|
506
|
+
// Exact match, not a `--` prefix check: single-dash typos and positionals must not slip through.
|
|
503
507
|
export function cliFlagsError(
|
|
504
508
|
subcommand: string,
|
|
505
509
|
argv: readonly string[],
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -164,7 +164,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
|
164
164
|
return { root, files, sourceSurface: sourceSurfaceHits.length };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
|
|
167
|
+
export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
|
|
168
168
|
if (spec.kinds) return spec.kinds.includes(root.kind);
|
|
169
169
|
return root.kind !== "tooling";
|
|
170
170
|
}
|
|
@@ -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,30 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Guards reject --guard without --write-baseline and catch more raw-form-HTML, locale and timeout cases",
|
|
6
|
+
"detail": "Guards are stricter and more consistent: `guards --guard=<name>` without `--write-baseline` is rejected instead of silently running every guard, the raw-form-HTML check also catches tags after `=`, `[` or in spaceless ternaries, the German locale check flags lowercase \"organisation\", `test-timeouts` flags `setDefaultTimeout` and `describe.configure({ timeout })`, the no-framed-extension-sections guard correlates registry keys and usages by resolved value, and the fake-tests helper lookup is scope-aware. UI guards report repo-relative file paths. The pre-push worktree detection compares absolute git dirs."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.336.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "secret-literal checks all fallbacks on a line; as-casts audit is quiet inside the shared runner",
|
|
12
|
+
"detail": "`secret-literal` now inspects every `?? \"literal\"` fallback on a line instead of only the first, so a hardcoded secret behind a trivial first fallback is flagged. The `as-casts` audit prints its per-site listings only when run standalone; inside the shared runner it prints just the counts and the baseline verdict.",
|
|
13
|
+
"migration": "A repo with a hardcoded secret fallback hidden behind an earlier trivial fallback on the same line can now fail the secret-literal guard. Replace the literal with a hard failure on a missing env value."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.335.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "Stricter guard detection for raw-sql, test-template-drift, framed extension sections, runtime-isolation and test-stack-drift",
|
|
19
|
+
"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.",
|
|
20
|
+
"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."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.335.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "Escape-hatch guards accept only real framework imports, a corrupt cast baseline is an error, hints name kumiko-guards guards",
|
|
26
|
+
"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`."
|
|
27
|
+
},
|
|
2
28
|
{
|
|
3
29
|
"version": "0.332.0",
|
|
4
30
|
"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";
|
|
@@ -404,6 +405,11 @@ function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
|
|
|
404
405
|
return byCat;
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
// The per-site listings are advisory and long; inside the shared runner (every consumer's
|
|
409
|
+
// `bun run check`) they would bury the output of guards that can fail, so only a standalone
|
|
410
|
+
// run of this file prints them.
|
|
411
|
+
const PRINT_SITE_LISTINGS = import.meta.main;
|
|
412
|
+
|
|
407
413
|
function reportCasts(all: readonly Site[], scanned: number): void {
|
|
408
414
|
const byCat = groupByCategory(all);
|
|
409
415
|
console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
|
|
@@ -411,6 +417,8 @@ function reportCasts(all: readonly Site[], scanned: number): void {
|
|
|
411
417
|
const count = byCat.get(c)?.length ?? 0;
|
|
412
418
|
console.log(` ${c.padEnd(18)} ${count}`);
|
|
413
419
|
}
|
|
420
|
+
// skip: site listings are printed only on a standalone run of this guard
|
|
421
|
+
if (!PRINT_SITE_LISTINGS) return;
|
|
414
422
|
|
|
415
423
|
const suspects = CATS.filter((c) => c.startsWith("suspect-"));
|
|
416
424
|
|
|
@@ -546,26 +554,89 @@ function writeBaseline(all: readonly Site[]): void {
|
|
|
546
554
|
// Per File:target: aktueller Count gegen baseline. Mehr → Regression.
|
|
547
555
|
// Cast-Tausch (Cast A weg, Cast B mit anderem target hinzu) wird so erkannt
|
|
548
556
|
// obwohl Total stabil bleibt. Nur Report — Warnung, kein Fail.
|
|
549
|
-
|
|
557
|
+
// Merge-conflict markers, truncated writes and hand edits all land here; the
|
|
558
|
+
// file is read once at the boundary so the comparison below can trust its shape.
|
|
559
|
+
function isCountsByFile(value: unknown): value is Record<string, Record<string, number>> {
|
|
560
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
561
|
+
return Object.values(value).every(
|
|
562
|
+
(targets) =>
|
|
563
|
+
typeof targets === "object" &&
|
|
564
|
+
targets !== null &&
|
|
565
|
+
!Array.isArray(targets) &&
|
|
566
|
+
Object.values(targets).every((count) => typeof count === "number"),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
type BaselineLoad =
|
|
571
|
+
| { kind: "missing" }
|
|
572
|
+
| { kind: "format-drift"; format: unknown }
|
|
573
|
+
| { kind: "invalid"; reason: string }
|
|
574
|
+
| { kind: "ok"; baseline: Baseline };
|
|
575
|
+
|
|
576
|
+
export function loadBaseline(file: string): BaselineLoad {
|
|
577
|
+
if (!existsSync(file)) return { kind: "missing" };
|
|
578
|
+
let parsed: unknown;
|
|
579
|
+
try {
|
|
580
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return { kind: "invalid", reason: error instanceof Error ? error.message : String(error) };
|
|
583
|
+
}
|
|
584
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
585
|
+
return { kind: "invalid", reason: "expected a JSON object" };
|
|
586
|
+
}
|
|
587
|
+
const record: Record<string, unknown> = { ...parsed };
|
|
588
|
+
if (record["format"] !== BASELINE_FORMAT_VERSION) {
|
|
589
|
+
return { kind: "format-drift", format: record["format"] };
|
|
590
|
+
}
|
|
591
|
+
const { generated, totalSuspect, perFile } = record;
|
|
592
|
+
if (
|
|
593
|
+
typeof generated !== "string" ||
|
|
594
|
+
typeof totalSuspect !== "number" ||
|
|
595
|
+
!isCountsByFile(perFile)
|
|
596
|
+
) {
|
|
597
|
+
return {
|
|
598
|
+
kind: "invalid",
|
|
599
|
+
reason: "expected { format, generated: string, totalSuspect: number, perFile: object }",
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
kind: "ok",
|
|
604
|
+
baseline: { format: BASELINE_FORMAT_VERSION, generated, totalSuspect, perFile },
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function reportBaseline(
|
|
609
|
+
all: readonly Site[],
|
|
610
|
+
baselineFile: string = baselinePath,
|
|
611
|
+
): GuardViolation[] {
|
|
550
612
|
const repoLocalSuspects = repoLocal(suspectByFileAndTarget(all));
|
|
551
613
|
const repoLocalTotal = totalOf(repoLocalSuspects);
|
|
552
614
|
|
|
553
|
-
|
|
615
|
+
const loaded = loadBaseline(baselineFile);
|
|
616
|
+
if (loaded.kind === "missing") {
|
|
554
617
|
console.log(
|
|
555
618
|
"\n No baseline found. Freeze one with `--write-baseline` first — warning, no fail until then.",
|
|
556
619
|
);
|
|
557
|
-
return;
|
|
620
|
+
return [];
|
|
558
621
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
622
|
+
if (loaded.kind === "invalid") {
|
|
623
|
+
// A corrupt baseline must not read as "no regressions".
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
file: baselineFile,
|
|
627
|
+
line: 1,
|
|
628
|
+
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\`.`,
|
|
629
|
+
},
|
|
630
|
+
];
|
|
631
|
+
}
|
|
632
|
+
if (loaded.kind === "format-drift") {
|
|
562
633
|
console.log(
|
|
563
|
-
`\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${
|
|
634
|
+
`\n Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, read format=${String(loaded.format ?? "<missing>")}.`,
|
|
564
635
|
);
|
|
565
636
|
console.log(" Run `bun packages/guards/src/check-as-casts.ts --write-baseline` once.");
|
|
566
|
-
return;
|
|
637
|
+
return [];
|
|
567
638
|
}
|
|
568
|
-
const baseline =
|
|
639
|
+
const { baseline } = loaded;
|
|
569
640
|
|
|
570
641
|
type Regression = { file: string; target: string; baseline: number; current: number };
|
|
571
642
|
const regressions: Regression[] = [];
|
|
@@ -602,7 +673,7 @@ function reportBaseline(all: readonly Site[]): void {
|
|
|
602
673
|
" 3. If a cleanup reduction in one file offsets an increase in another: " +
|
|
603
674
|
"run `bun packages/guards/src/check-as-casts.ts --write-baseline` after committing",
|
|
604
675
|
);
|
|
605
|
-
return;
|
|
676
|
+
return [];
|
|
606
677
|
}
|
|
607
678
|
|
|
608
679
|
console.log(`\n ✓ Baseline (${baseline.totalSuspect}) — current ${repoLocalTotal}`);
|
|
@@ -611,17 +682,21 @@ function reportBaseline(all: readonly Site[]): void {
|
|
|
611
682
|
` ✓ ${reduced} suspect cast(s) reduced since baseline. Run \`--write-baseline\` if appropriate.`,
|
|
612
683
|
);
|
|
613
684
|
}
|
|
685
|
+
return [];
|
|
614
686
|
}
|
|
615
687
|
|
|
616
688
|
function analyseCasts(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
617
689
|
const { all, scanned } = scanCasts(files);
|
|
618
690
|
reportCasts(all, scanned);
|
|
619
691
|
reportUnknownReasons(all);
|
|
620
|
-
if (compareBaseline)
|
|
621
|
-
|
|
692
|
+
if (!compareBaseline) {
|
|
693
|
+
console.log("\n Baseline comparison skipped (--no-baseline).");
|
|
694
|
+
return { violations: [] };
|
|
695
|
+
}
|
|
622
696
|
// WARNUNG, kein Fail (coding-standards.md → "Type Assertions"): weder
|
|
623
|
-
// unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken.
|
|
624
|
-
|
|
697
|
+
// unbekannte @cast-boundary-Reasons noch Baseline-Regression blocken. Nur
|
|
698
|
+
// eine unlesbare Baseline schlägt fehl.
|
|
699
|
+
return { violations: reportBaseline(all) };
|
|
625
700
|
}
|
|
626
701
|
|
|
627
702
|
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,
|
|
@@ -64,13 +64,15 @@ import {
|
|
|
64
64
|
runRepoChecks,
|
|
65
65
|
} from "./_lib/guard-kit";
|
|
66
66
|
import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
67
|
-
import { type ScanSpec, scanFiles
|
|
67
|
+
import { keepsRootKind, type ScanSpec, scanFiles } 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 };
|
|
@@ -170,7 +177,7 @@ export const check: RepoCheck = {
|
|
|
170
177
|
// The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
|
|
171
178
|
// default — that's a deliberate scope choice, not a vacuous scan, so it must
|
|
172
179
|
// report notApplicable instead of matchedFiles: 0.
|
|
173
|
-
if (
|
|
180
|
+
if (!keepsRootKind(SCAN, root)) {
|
|
174
181
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
175
182
|
}
|
|
176
183
|
const rootAbsPath = root.absPath;
|
|
@@ -42,7 +42,7 @@ const IS_TEST = /\.(?:test|integration)\.tsx?$/;
|
|
|
42
42
|
const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
|
|
43
43
|
|
|
44
44
|
// A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
|
|
45
|
-
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1
|
|
45
|
+
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/g;
|
|
46
46
|
// A secret-like identifier. Matched only against the text LEFT of the fallback
|
|
47
47
|
// (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
|
|
48
48
|
const SECRET_NAME =
|
|
@@ -116,19 +116,24 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
|
|
|
116
116
|
for (let i = 0; i < lines.length; i++) {
|
|
117
117
|
const raw = lines[i] ?? "";
|
|
118
118
|
const code = maskedLines[i] ?? "";
|
|
119
|
-
const match = STRING_FALLBACK.exec(code);
|
|
120
|
-
if (!match) continue;
|
|
121
|
-
const literal = match[2];
|
|
122
|
-
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
123
|
-
const left = code.slice(0, match.index);
|
|
124
|
-
const name =
|
|
125
|
-
SECRET_NAME.exec(left)?.[0] ??
|
|
126
|
-
(SECRET_LITERAL_TOKEN.test(literal)
|
|
127
|
-
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
128
|
-
: undefined);
|
|
129
|
-
if (name === undefined) continue;
|
|
130
119
|
if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
|
|
131
|
-
|
|
120
|
+
// Every fallback on the line is inspected; the name is read only from the text since the
|
|
121
|
+
// previous fallback, so an earlier assignee cannot lend its name to a later literal.
|
|
122
|
+
let previousEnd = 0;
|
|
123
|
+
for (const match of code.matchAll(STRING_FALLBACK)) {
|
|
124
|
+
const left = code.slice(previousEnd, match.index);
|
|
125
|
+
previousEnd = match.index + match[0].length;
|
|
126
|
+
const literal = match[2];
|
|
127
|
+
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
128
|
+
const name =
|
|
129
|
+
SECRET_NAME.exec(left)?.[0] ??
|
|
130
|
+
(SECRET_LITERAL_TOKEN.test(literal)
|
|
131
|
+
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
132
|
+
: undefined);
|
|
133
|
+
if (name === undefined) continue;
|
|
134
|
+
hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
return hits;
|
|
134
139
|
}
|
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
|
}
|