@cosmicdrift/kumiko-guards 0.3.0 → 0.282.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/guard-kit.ts +58 -0
- package/src/changes.json +17 -0
- package/src/check-runtime-isolation.ts +214 -0
- package/src/cli.ts +55 -31
- package/src/guard-error-reasons.ts +13 -5
- package/src/guard-escape-hatch-declared.ts +134 -21
- package/src/guard-upgrade-state.ts +212 -0
- package/src/index.ts +3 -0
- package/src/run-guards.ts +28 -10
- package/src/run-repo-checks.ts +22 -4
- package/src/run-ui-guards.ts +19 -4
- package/src/runtime-isolation-classify.ts +337 -0
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
* R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
|
|
7
7
|
* `r.systemScope()` feature.
|
|
8
8
|
* R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
|
|
9
|
-
* systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`,
|
|
10
|
-
* handler/hook that lexically declares `escapeHatch
|
|
9
|
+
* systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, a
|
|
10
|
+
* handler/hook that lexically declares `escapeHatch`, or a standalone
|
|
11
|
+
* function whose direct body declares `declareEscapeHatch({ reason: "..." })`.
|
|
11
12
|
* R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
|
|
12
13
|
* with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
|
|
13
|
-
* call, a `*Job` function,
|
|
14
|
-
* `escapeHatch
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* call, a `*Job` function, a handler/hook that lexically declares
|
|
15
|
+
* `escapeHatch`, or a standalone function whose direct body declares
|
|
16
|
+
* `declareEscapeHatch({ reason: "..." })`.
|
|
17
|
+
* R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, a declared
|
|
18
|
+
* `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, or a
|
|
19
|
+
* `declareEscapeHatch({ reason })` call, given a placeholder reason
|
|
20
|
+
* literal — hard-fails everywhere, not baselined.
|
|
18
21
|
* R5 unsafe-all-tenants-outside-declared-scope: an `unsafeAllTenants: true`
|
|
19
22
|
* or `unsafeAllTenants: { reason: "..." }` option, passed directly as a
|
|
20
23
|
* call argument, outside systemScope, `.job.ts`, an `r.job(...)` call,
|
|
@@ -26,26 +29,49 @@
|
|
|
26
29
|
* FunctionExpression, under any key name — e.g. `handler`, `export`,
|
|
27
30
|
* `delete`), or in an options object passed to
|
|
28
31
|
* `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
|
|
29
|
-
* alongside the handler function argument.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* alongside the handler function argument. A standalone function (arrow
|
|
33
|
+
* function, function expression, method declaration, or function
|
|
34
|
+
* declaration) is additionally recognized when a statement in its own direct
|
|
35
|
+
* body — not a nested function's, not inside an `if` — calls the bare
|
|
36
|
+
* identifier `declareEscapeHatch` with exactly one object-literal argument
|
|
37
|
+
* carrying a literal, non-placeholder `reason` (from
|
|
38
|
+
* `@cosmicdrift/kumiko-framework/engine`'s `declareEscapeHatch`: a helper
|
|
39
|
+
* that escalates on a `HandlerContext` handed to it by its caller, rather
|
|
40
|
+
* than a `HandlerContext` from its own registration). The declaration does
|
|
41
|
+
* not propagate upward: it covers escalations inside that function's own
|
|
42
|
+
* body, not the function it is nested inside. This detection is purely
|
|
43
|
+
* lexical — the guard matches on the name `declareEscapeHatch`, not on where
|
|
44
|
+
* it was imported from, so a same-named local function clears just as well;
|
|
45
|
+
* consistent with `escapeHatch:` itself, which is likewise never checked for
|
|
46
|
+
* origin. Referenced-by-variable functions, spread options, computed/string
|
|
47
|
+
* keys, and non-literal escapeHatch/reason values are conservatively not
|
|
48
|
+
* recognized (miss, don't falsely clear).
|
|
32
49
|
*
|
|
33
50
|
* Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
|
|
34
|
-
* job (access-declarations.ts), not this guard's
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* falsely flag).
|
|
51
|
+
* job (access-declarations.ts), not this guard's — except a `declareEscapeHatch`
|
|
52
|
+
* reason, which has no boot validator behind it: an empty or placeholder
|
|
53
|
+
* reason there is never recognized as a valid declaration (see R4). Known
|
|
54
|
+
* false-negatives: multi-hop aliasing, `ctx["db"]` through an intermediate
|
|
55
|
+
* variable, and a TenantDb/system-identity handed to another function across
|
|
56
|
+
* file boundaries with no `declareEscapeHatch` call at the escalation site
|
|
57
|
+
* (declarable now, so no longer a blanket false-negative) — all conservative
|
|
58
|
+
* (miss, don't falsely flag). R5 additionally misses `unsafeAllTenants`
|
|
59
|
+
* given via an identifier, a ternary, `false`, or `undefined`, and an
|
|
60
|
+
* options object passed by variable reference or spread rather than as a
|
|
61
|
+
* literal call argument — all conservative (miss, don't falsely flag).
|
|
42
62
|
*
|
|
43
63
|
* Usage:
|
|
44
64
|
* bun guards/guard-escape-hatch-declared.ts
|
|
45
65
|
* Baseline: bun guards/run-guards.ts --write-security-baseline
|
|
46
66
|
*/
|
|
47
67
|
import * as path from "node:path";
|
|
48
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
type Node,
|
|
70
|
+
type ObjectLiteralExpression,
|
|
71
|
+
type PropertyAssignment,
|
|
72
|
+
type SourceFile,
|
|
73
|
+
SyntaxKind,
|
|
74
|
+
} from "ts-morph";
|
|
49
75
|
import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
|
|
50
76
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
51
77
|
|
|
@@ -330,15 +356,72 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
330
356
|
return false;
|
|
331
357
|
}
|
|
332
358
|
|
|
359
|
+
// The bare-identifier `reason` PropertyAssignment on an object literal —
|
|
360
|
+
// shared between the declareEscapeHatch statement check below and its R4
|
|
361
|
+
// generic-reason collector, so both agree on what counts as the reason.
|
|
362
|
+
function findReasonPropertyAssignment(
|
|
363
|
+
obj: ObjectLiteralExpression,
|
|
364
|
+
): PropertyAssignment | undefined {
|
|
365
|
+
return obj
|
|
366
|
+
.getProperties()
|
|
367
|
+
.find(
|
|
368
|
+
(prop): prop is PropertyAssignment =>
|
|
369
|
+
prop.isKind(SyntaxKind.PropertyAssignment) &&
|
|
370
|
+
prop.getNameNode().isKind(SyntaxKind.Identifier) &&
|
|
371
|
+
prop.getNameNode().getText() === "reason",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
|
|
376
|
+
// standalone function. No boot validator backs this form (unlike the
|
|
377
|
+
// escapeHatch: {...} property, which access-declarations.ts checks at boot),
|
|
378
|
+
// so an empty/placeholder reason is rejected here rather than left to it.
|
|
379
|
+
function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
|
|
380
|
+
if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return false;
|
|
381
|
+
const expr = stmt.getExpression();
|
|
382
|
+
if (!expr.isKind(SyntaxKind.CallExpression)) return false;
|
|
383
|
+
const callee = expr.getExpression();
|
|
384
|
+
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
const args = expr.getArguments();
|
|
388
|
+
const arg = args[0];
|
|
389
|
+
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
|
|
390
|
+
const reasonProp = findReasonPropertyAssignment(arg);
|
|
391
|
+
if (!reasonProp) return false;
|
|
392
|
+
const reasonText = literalReasonText(reasonProp.getInitializer());
|
|
393
|
+
return reasonText !== undefined && !isGenericReason(reasonText);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Only the function's own direct body — not a nested function's, not an
|
|
397
|
+
// `if`'s — so a declareEscapeHatch call does not cover the function it is
|
|
398
|
+
// itself nested inside (miss, don't falsely clear).
|
|
399
|
+
function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
|
|
400
|
+
let body: Node | undefined;
|
|
401
|
+
if (
|
|
402
|
+
fn.isKind(SyntaxKind.ArrowFunction) ||
|
|
403
|
+
fn.isKind(SyntaxKind.FunctionExpression) ||
|
|
404
|
+
fn.isKind(SyntaxKind.MethodDeclaration) ||
|
|
405
|
+
fn.isKind(SyntaxKind.FunctionDeclaration)
|
|
406
|
+
) {
|
|
407
|
+
body = fn.getBody();
|
|
408
|
+
}
|
|
409
|
+
if (!body?.isKind(SyntaxKind.Block)) return false;
|
|
410
|
+
return body.getStatements().some((stmt) => isValidDeclareEscapeHatchCall(stmt));
|
|
411
|
+
}
|
|
412
|
+
|
|
333
413
|
function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
|
|
334
414
|
let ancestor: Node | undefined = node.getParent();
|
|
335
415
|
while (ancestor) {
|
|
336
416
|
if (
|
|
337
417
|
ancestor.isKind(SyntaxKind.ArrowFunction) ||
|
|
338
418
|
ancestor.isKind(SyntaxKind.FunctionExpression) ||
|
|
339
|
-
ancestor.isKind(SyntaxKind.MethodDeclaration)
|
|
419
|
+
ancestor.isKind(SyntaxKind.MethodDeclaration) ||
|
|
420
|
+
ancestor.isKind(SyntaxKind.FunctionDeclaration)
|
|
340
421
|
) {
|
|
341
|
-
if (isEscapeHatchDeclaredFunction(ancestor))
|
|
422
|
+
if (isEscapeHatchDeclaredFunction(ancestor) || hasDeclaredEscapeHatchStatement(ancestor)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
342
425
|
}
|
|
343
426
|
ancestor = ancestor.getParent();
|
|
344
427
|
}
|
|
@@ -453,6 +536,35 @@ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReas
|
|
|
453
536
|
return out;
|
|
454
537
|
}
|
|
455
538
|
|
|
539
|
+
// The declareEscapeHatch({ reason }) form falls through both existing R4
|
|
540
|
+
// collectors: its callee is a bare identifier, not a PropertyAccessExpression
|
|
541
|
+
// (unlike acknowledgeCrossTenant/unsafeRaw), and its reason sits directly in
|
|
542
|
+
// the call argument, not under an escapeHatch:/unsafeAllTenants: property.
|
|
543
|
+
function findGenericReasonDeclareEscapeHatchCalls(
|
|
544
|
+
sf: SourceFile,
|
|
545
|
+
root: string,
|
|
546
|
+
): GenericReasonFinding[] {
|
|
547
|
+
const out: GenericReasonFinding[] = [];
|
|
548
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
549
|
+
const callee = call.getExpression();
|
|
550
|
+
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const arg = call.getArguments()[0];
|
|
554
|
+
if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
555
|
+
const reasonProp = findReasonPropertyAssignment(arg);
|
|
556
|
+
if (!reasonProp) continue;
|
|
557
|
+
const reasonText = literalReasonText(reasonProp.getInitializer());
|
|
558
|
+
if (reasonText === undefined || !isGenericReason(reasonText)) continue;
|
|
559
|
+
out.push({
|
|
560
|
+
file: path.relative(root, sf.getFilePath()),
|
|
561
|
+
line: call.getStartLineNumber(),
|
|
562
|
+
message: `declareEscapeHatch({ reason: "${reasonText}" }) uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
456
568
|
const REASON_OBJECT_PROPERTY_NAMES = ["escapeHatch", "unsafeAllTenants"] as const;
|
|
457
569
|
|
|
458
570
|
function findGenericReasonObjectProperty(
|
|
@@ -488,6 +600,7 @@ export function findGenericReasonCalls(
|
|
|
488
600
|
const out: GenericReasonFinding[] = [];
|
|
489
601
|
for (const sf of scannableFiles(files)) {
|
|
490
602
|
out.push(...findGenericReasonMethodCalls(sf, root));
|
|
603
|
+
out.push(...findGenericReasonDeclareEscapeHatchCalls(sf, root));
|
|
491
604
|
for (const propertyName of REASON_OBJECT_PROPERTY_NAMES) {
|
|
492
605
|
out.push(...findGenericReasonObjectProperty(sf, root, propertyName));
|
|
493
606
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: a consumer repo's `.kumiko/upgrade-state.json` marker must be
|
|
4
|
+
* caught up with the installed Kumiko framework version.
|
|
5
|
+
*
|
|
6
|
+
* The marker is written by `kumiko-upgrade --apply` (local bin from
|
|
7
|
+
* `@cosmicdrift/kumiko-dev-server`) and records the version it was applied
|
|
8
|
+
* at. This guard re-runs `kumiko-upgrade --from <marker version> --json` and
|
|
9
|
+
* fails if any changelog entries are still pending — meaning the marker is
|
|
10
|
+
* stale and the repo hasn't run the upgrade since.
|
|
11
|
+
*
|
|
12
|
+
* Single-repo only, like `guard-upgrade-state.ts` in infra/guards but
|
|
13
|
+
* without that package's multi-repo `resolveRepoRoots()` scan loop — this
|
|
14
|
+
* package's `resolveRepoRoots()` only ever resolves the one repo `roots[0]`
|
|
15
|
+
* sits in. Repos without the marker file are `notApplicable` — this guard
|
|
16
|
+
* only fires once a repo has adopted the upgrade-state workflow at all.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* bun guard-upgrade-state.ts
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import {
|
|
25
|
+
type GuardViolation,
|
|
26
|
+
type RepoCheck,
|
|
27
|
+
reportResults,
|
|
28
|
+
runRepoChecks,
|
|
29
|
+
} from "./_lib/guard-kit";
|
|
30
|
+
|
|
31
|
+
const MARKER_REL = ".kumiko/upgrade-state.json";
|
|
32
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
33
|
+
|
|
34
|
+
type UpgradeMarker = { readonly version: string };
|
|
35
|
+
|
|
36
|
+
type PendingEntry = {
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly type: string;
|
|
39
|
+
readonly title: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type UpgradeJson = {
|
|
43
|
+
readonly currentVersion: string;
|
|
44
|
+
readonly installedVersion?: string | null;
|
|
45
|
+
readonly pending: readonly PendingEntry[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function resolveInstalledVersion(json: UpgradeJson): string {
|
|
49
|
+
return json.installedVersion ?? json.currentVersion;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isPendingEntry(value: unknown): value is PendingEntry {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
const v = value as Record<string, unknown>;
|
|
55
|
+
return (
|
|
56
|
+
typeof v["version"] === "string" &&
|
|
57
|
+
typeof v["type"] === "string" &&
|
|
58
|
+
typeof v["title"] === "string"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isUpgradeJson(value: unknown): value is UpgradeJson {
|
|
63
|
+
if (!value || typeof value !== "object") return false;
|
|
64
|
+
const v = value as Record<string, unknown>;
|
|
65
|
+
const installed = v["installedVersion"];
|
|
66
|
+
return (
|
|
67
|
+
typeof v["currentVersion"] === "string" &&
|
|
68
|
+
(installed === undefined || installed === null || typeof installed === "string") &&
|
|
69
|
+
Array.isArray(v["pending"]) &&
|
|
70
|
+
v["pending"].every(isPendingEntry)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readMarker(root: string): UpgradeMarker | { error: string } {
|
|
75
|
+
const markerPath = join(root, MARKER_REL);
|
|
76
|
+
if (!existsSync(markerPath)) {
|
|
77
|
+
return {
|
|
78
|
+
error: `missing ${MARKER_REL} — run \`bun run kumiko-upgrade --apply\` once and commit the marker file`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let parsed: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(readFileSync(markerPath, "utf-8"));
|
|
84
|
+
} catch {
|
|
85
|
+
return { error: `${MARKER_REL} is not valid JSON — broken marker file` };
|
|
86
|
+
}
|
|
87
|
+
const version =
|
|
88
|
+
parsed && typeof parsed === "object"
|
|
89
|
+
? (parsed as Record<string, unknown>)["version"]
|
|
90
|
+
: undefined;
|
|
91
|
+
if (typeof version !== "string" || !SEMVER_RE.test(version)) {
|
|
92
|
+
return {
|
|
93
|
+
error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { version };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
|
|
100
|
+
export function pendingViolations(json: UpgradeJson, markerVersion: string): GuardViolation[] {
|
|
101
|
+
if (json.pending.length === 0) return [];
|
|
102
|
+
const installedVersion = resolveInstalledVersion(json);
|
|
103
|
+
return json.pending.map((entry) => ({
|
|
104
|
+
file: MARKER_REL,
|
|
105
|
+
line: 1,
|
|
106
|
+
message:
|
|
107
|
+
`${MARKER_REL} is at ${markerVersion}, installed is ${installedVersion} — pending: ` +
|
|
108
|
+
`${entry.version} · ${entry.type} · ${entry.title}. Run \`bun run kumiko-upgrade --apply\`.`,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveKumikoUpgradeBin(cwd: string): string | undefined {
|
|
113
|
+
const which = Bun.which("kumiko-upgrade");
|
|
114
|
+
if (which) return which;
|
|
115
|
+
let dir = cwd;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const candidate = join(dir, "node_modules", ".bin", "kumiko-upgrade");
|
|
118
|
+
if (existsSync(candidate)) return candidate;
|
|
119
|
+
const parent = join(dir, "..");
|
|
120
|
+
if (parent === dir) break;
|
|
121
|
+
dir = parent;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runKumikoUpgrade(
|
|
127
|
+
version: string,
|
|
128
|
+
cwd: string,
|
|
129
|
+
): Promise<{ ok: true; json: UpgradeJson } | { ok: false; error: string }> {
|
|
130
|
+
const binPath = resolveKumikoUpgradeBin(cwd);
|
|
131
|
+
if (!binPath) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error:
|
|
135
|
+
"`kumiko-upgrade` not resolvable — add `@cosmicdrift/kumiko-dev-server` as a devDependency or repair the install",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const proc = Bun.spawn({
|
|
139
|
+
cmd: [binPath, "--from", version, "--json"],
|
|
140
|
+
cwd,
|
|
141
|
+
env: { ...process.env, INIT_CWD: cwd },
|
|
142
|
+
stdout: "pipe",
|
|
143
|
+
stderr: "pipe",
|
|
144
|
+
});
|
|
145
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
146
|
+
new Response(proc.stdout).text(),
|
|
147
|
+
new Response(proc.stderr).text(),
|
|
148
|
+
proc.exited,
|
|
149
|
+
]);
|
|
150
|
+
if (exitCode !== 0) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `\`kumiko-upgrade --from ${version} --json\` exited ${exitCode}:\n${stderr || stdout}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
let parsed: unknown;
|
|
157
|
+
try {
|
|
158
|
+
const jsonStart = stdout.indexOf("{");
|
|
159
|
+
parsed = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : stdout);
|
|
160
|
+
} catch {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `\`kumiko-upgrade --from ${version} --json\` did not print valid JSON:\n${stdout}${stderr}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (!isUpgradeJson(parsed)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `\`kumiko-upgrade --from ${version} --json\` printed JSON without a valid "pending" array:\n${stdout}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, json: parsed };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const check: RepoCheck = {
|
|
176
|
+
name: "Upgrade-State Guard",
|
|
177
|
+
hint: "Marker is written by `kumiko-upgrade --apply` — run it once the pending changelog entries are handled.",
|
|
178
|
+
async run(roots) {
|
|
179
|
+
const root = roots[0];
|
|
180
|
+
if (!root) return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
181
|
+
const rootAbsPath = root.absPath;
|
|
182
|
+
if (!existsSync(join(rootAbsPath, MARKER_REL))) {
|
|
183
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
184
|
+
}
|
|
185
|
+
const marker = readMarker(rootAbsPath);
|
|
186
|
+
if ("error" in marker) {
|
|
187
|
+
return {
|
|
188
|
+
violations: [{ file: MARKER_REL, line: 1, message: marker.error }],
|
|
189
|
+
matchedFiles: 1,
|
|
190
|
+
notApplicable: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const result = await runKumikoUpgrade(marker.version, rootAbsPath);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
return {
|
|
196
|
+
violations: [{ file: MARKER_REL, line: 1, message: result.error }],
|
|
197
|
+
matchedFiles: 1,
|
|
198
|
+
notApplicable: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
violations: pendingViolations(result.json, marker.version),
|
|
203
|
+
matchedFiles: 1,
|
|
204
|
+
notApplicable: false,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (import.meta.main) {
|
|
210
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
211
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
212
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export {
|
|
2
2
|
type AstGuard,
|
|
3
|
+
buildGuardKitInventory,
|
|
3
4
|
buildSharedProject,
|
|
4
5
|
explainGuards,
|
|
5
6
|
filesForGuard,
|
|
7
|
+
type GuardKitInventory,
|
|
6
8
|
type GuardOutcome,
|
|
7
9
|
type GuardViolation,
|
|
8
10
|
isSecurityGuard,
|
|
@@ -14,6 +16,7 @@ export {
|
|
|
14
16
|
runGuards,
|
|
15
17
|
runRepoChecks,
|
|
16
18
|
type ScanSpec,
|
|
19
|
+
type SuiteInventory,
|
|
17
20
|
} from "./_lib/guard-kit";
|
|
18
21
|
export { findLocalRepo, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
19
22
|
export {
|
package/src/run-guards.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// security guards.
|
|
10
10
|
import {
|
|
11
11
|
buildSharedProject,
|
|
12
|
+
cliFlagsError,
|
|
12
13
|
explainGuards,
|
|
13
14
|
isSecurityGuard,
|
|
14
15
|
printGuardKitBanner,
|
|
@@ -86,26 +87,43 @@ export const GUARDS = [
|
|
|
86
87
|
libTestCoverage,
|
|
87
88
|
];
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
export const GUARD_FLAGS = [
|
|
91
|
+
"--explain",
|
|
92
|
+
"--write-security-baseline",
|
|
93
|
+
"--strict-security-baseline",
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
// Shared by the direct `bun run-guards.ts` invocation below and by the
|
|
97
|
+
// `guards` subcommand in cli.ts — one place for the flag behavior so the
|
|
98
|
+
// two entry points can never drift.
|
|
99
|
+
export function runGuardsCli(argv: readonly string[]): number {
|
|
100
|
+
const flagsError = cliFlagsError("guards", argv, GUARD_FLAGS);
|
|
101
|
+
if (flagsError !== undefined) {
|
|
102
|
+
console.error(flagsError);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (argv.includes("--explain")) {
|
|
93
106
|
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
94
107
|
console.log(line);
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
return 0;
|
|
97
110
|
}
|
|
98
|
-
if (
|
|
111
|
+
if (argv.includes("--write-security-baseline")) {
|
|
99
112
|
writeSecurityBaselines(
|
|
100
113
|
GUARDS.filter(isSecurityGuard),
|
|
101
114
|
buildSharedProject(GUARDS.filter(isSecurityGuard)),
|
|
102
115
|
);
|
|
103
|
-
|
|
116
|
+
return 0;
|
|
104
117
|
}
|
|
105
|
-
const strictSecurityBaseline =
|
|
118
|
+
const strictSecurityBaseline = argv.includes("--strict-security-baseline");
|
|
106
119
|
const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
|
|
107
120
|
const project = buildSharedProject(guards);
|
|
108
121
|
printGuardKitBanner(guards.length, project);
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Only run on direct invocation — otherwise `import { GUARDS }` would kick
|
|
126
|
+
// off the whole suite and the list wouldn't be testable.
|
|
127
|
+
if (import.meta.main) {
|
|
128
|
+
process.exit(runGuardsCli(process.argv.slice(2)));
|
|
111
129
|
}
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
2
|
+
import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
3
3
|
// Standalone-`main()` guards ported as RepoCheck — run in-process, no
|
|
4
4
|
// per-guard subprocess/project.
|
|
5
|
+
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
5
6
|
import { check as secretLiterals } from "./check-secret-literals";
|
|
6
7
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
7
8
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
@@ -10,6 +11,7 @@ import { check as rawSql } from "./guard-raw-sql";
|
|
|
10
11
|
import { check as rendererBoundaries } from "./guard-renderer-boundaries";
|
|
11
12
|
import { check as testStackDrift } from "./guard-test-stack-drift";
|
|
12
13
|
import { check as thinWrappers } from "./guard-thin-wrappers";
|
|
14
|
+
import { check as upgradeState } from "./guard-upgrade-state";
|
|
13
15
|
|
|
14
16
|
export const REPO_CHECKS = [
|
|
15
17
|
rawSql,
|
|
@@ -20,12 +22,28 @@ export const REPO_CHECKS = [
|
|
|
20
22
|
secretLiterals,
|
|
21
23
|
featureIntegrationTests,
|
|
22
24
|
testStackDrift,
|
|
25
|
+
runtimeIsolation,
|
|
26
|
+
upgradeState,
|
|
23
27
|
];
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
// No flags today — the array stays so an unknown flag still fails loud
|
|
30
|
+
// instead of silently doing nothing, and so a future flag has one place to land.
|
|
31
|
+
export const REPO_CHECK_FLAGS: readonly string[] = [];
|
|
32
|
+
|
|
33
|
+
// Shared by the direct `bun run-repo-checks.ts` invocation below and by the
|
|
34
|
+
// `checks` subcommand in cli.ts.
|
|
35
|
+
export async function runRepoChecksCli(argv: readonly string[]): Promise<number> {
|
|
36
|
+
const flagsError = cliFlagsError("checks", argv, REPO_CHECK_FLAGS);
|
|
37
|
+
if (flagsError !== undefined) {
|
|
38
|
+
console.error(flagsError);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
26
41
|
// No shared ts-morph Project here — RepoCheck.run() does its own file
|
|
27
42
|
// walk per check, so the banner omits the "Project: N files" line.
|
|
28
43
|
printGuardKitBanner(REPO_CHECKS.length);
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
return reportResults(await runRepoChecks(REPO_CHECKS));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (import.meta.main) {
|
|
48
|
+
process.exit(await runRepoChecksCli(process.argv.slice(2)));
|
|
31
49
|
}
|
package/src/run-ui-guards.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Project over the UI enforcement guards.
|
|
4
4
|
import {
|
|
5
5
|
buildSharedProject,
|
|
6
|
+
cliFlagsError,
|
|
6
7
|
printGuardKitBanner,
|
|
7
8
|
reportResults,
|
|
8
9
|
runGuards,
|
|
@@ -25,10 +26,24 @@ export const UI_GUARDS = [
|
|
|
25
26
|
i18nUiStrings,
|
|
26
27
|
];
|
|
27
28
|
|
|
28
|
-
//
|
|
29
|
-
|
|
29
|
+
// No flags today — the array stays so an unknown flag still fails loud
|
|
30
|
+
// instead of silently doing nothing, and so a future flag has one place to land.
|
|
31
|
+
export const UI_GUARD_FLAGS: readonly string[] = [];
|
|
32
|
+
|
|
33
|
+
// Shared by the direct `bun run-ui-guards.ts` invocation below and by the
|
|
34
|
+
// `ui` subcommand in cli.ts.
|
|
35
|
+
export function runUiGuardsCli(argv: readonly string[]): number {
|
|
36
|
+
const flagsError = cliFlagsError("ui", argv, UI_GUARD_FLAGS);
|
|
37
|
+
if (flagsError !== undefined) {
|
|
38
|
+
console.error(flagsError);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
30
41
|
const project = buildSharedProject(UI_GUARDS);
|
|
31
42
|
printGuardKitBanner(UI_GUARDS.length, project);
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
return reportResults(runGuards(UI_GUARDS, project));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Same as run-guards.ts: only run on direct invocation.
|
|
47
|
+
if (import.meta.main) {
|
|
48
|
+
process.exit(runUiGuardsCli(process.argv.slice(2)));
|
|
34
49
|
}
|