@cosmicdrift/kumiko-guards 0.322.0 → 0.323.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 +15 -2
- package/src/changes.json +12 -0
- package/src/guard-fake-tests.ts +58 -17
- package/src/guard-text-field-stance.ts +17 -3
- package/src/runtime-isolation-classify.ts +9 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.323.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.323.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -255,6 +255,10 @@ export type RunResult = {
|
|
|
255
255
|
readonly message?: string;
|
|
256
256
|
/** No root resolved at all: the target repos are not in this checkout. */
|
|
257
257
|
readonly notApplicable?: boolean;
|
|
258
|
+
/** Why `notApplicable` is set — `runGuards` only; `runRepoChecks` leaves this undefined. */
|
|
259
|
+
readonly skipReason?: "repos-missing" | "outside-kinds";
|
|
260
|
+
/** The guard's `scan.kinds`, for the "outside-kinds" skip message. */
|
|
261
|
+
readonly scanKinds?: readonly string[];
|
|
258
262
|
/** Files the scan matched — the runner's own count, basis of the floor. */
|
|
259
263
|
readonly matchedFiles?: number;
|
|
260
264
|
/** Roots whose declared sourceRoots hold no .ts/.tsx at all (D4, infra#427). */
|
|
@@ -333,13 +337,20 @@ export function runGuards(
|
|
|
333
337
|
? { violations: securityResult.blocking }
|
|
334
338
|
: outcome;
|
|
335
339
|
const { violatingRoots } = checkRootFloor(guard, scans);
|
|
340
|
+
const notApplicable = scans.length === 0;
|
|
336
341
|
results.push({
|
|
337
342
|
name: guard.name,
|
|
338
343
|
ok: effectiveOutcome.violations.length === 0 && violatingRoots.length === 0,
|
|
339
344
|
ms: Math.round(performance.now() - start),
|
|
340
345
|
outcome: effectiveOutcome,
|
|
341
346
|
hint: guard.hint,
|
|
342
|
-
notApplicable
|
|
347
|
+
notApplicable,
|
|
348
|
+
skipReason: notApplicable
|
|
349
|
+
? roots.length === 0
|
|
350
|
+
? "repos-missing"
|
|
351
|
+
: "outside-kinds"
|
|
352
|
+
: undefined,
|
|
353
|
+
scanKinds: notApplicable ? guard.scan.kinds : undefined,
|
|
343
354
|
matchedFiles: files.length,
|
|
344
355
|
violatingRoots: violatingRoots.length > 0 ? violatingRoots : undefined,
|
|
345
356
|
frozenFindings: securityResult?.frozen,
|
|
@@ -600,7 +611,9 @@ export function reportResults(results: readonly RunResult[]): number {
|
|
|
600
611
|
// Not applicable does not mean checked — that must stay visible,
|
|
601
612
|
// otherwise a standalone run reads like a complete one.
|
|
602
613
|
const scope = r.notApplicable
|
|
603
|
-
?
|
|
614
|
+
? r.skipReason === "outside-kinds"
|
|
615
|
+
? ` — skipped, repo kind outside guard kinds (${(r.scanKinds ?? []).join(", ")})`
|
|
616
|
+
: " — skipped, target repos not in checkout"
|
|
604
617
|
: ` (${r.matchedFiles ?? 0} files)`;
|
|
605
618
|
const frozen =
|
|
606
619
|
r.frozenFindings && r.frozenFindings > 0
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.323.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "guard-fake-tests now also runs in app repos, counts same-file assertion helpers and the writeOk/writeErr/queryOk/queryErr test APIs as assertions; skip output distinguishes \"outside guard kinds\" from \"target repos missing\"",
|
|
6
|
+
"migration": "A same-file helper function (or arrow/function-expression variable) whose\nbody calls `expect(...)` — directly or through up to two more levels of\nsame-file helpers — now counts as an assertion, same as calling `expect`\nin the test body itself. `stack.http.writeOk/writeErr` and\n`tenant.api.queryOk/queryErr` (matched on the last property name of the\ncallee) count as assertions too, since they throw on the wrong outcome.\nA bare Testing Library `waitFor(() => screen.getByTestId(...))` (or any\nother `getBy*`/`getAllBy*` query call) does NOT count as an assertion —\nit still needs its own `expect(...)` to prove the wait actually found\nsomething. The guard now also scans `kind: \"app\"` repos and `.tsx` test files,\nnot just framework/library `.ts`. `reportResults` now prints \"skipped,\nrepo kind outside guard kinds (<kinds>)\" instead of \"target repos not in\ncheckout\" when the checkout has repo roots but none match the guard's\n`kinds` — the missing-checkout case keeps its original message."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.323.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Text-Field Personal-Stance Guard exempts the two framework tests that assert the throw",
|
|
12
|
+
"detail": "`guard-text-field-stance.ts` now skips an explicit, exact\nrepo-relative-path allowlist\n(`packages/framework/src/engine/__tests__/factories-long-text.test.ts`,\n`packages/framework/src/engine/__tests__/factories-personal.test.ts`) —\nthese two deliberately call `createTextField`/`createLongTextField`\nwithout a stance to prove the fail-closed throw. A different file\nsharing one of those basenames is not exempt; the match is on the exact\nrepo-relative path. The guard also scans `samples/**` and `demo/**`\nnow, not only `packages/*/src/**`. The baseline\n(`.kumiko-text-field-stance-baseline.json`) drops to 0."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.321.0",
|
|
4
16
|
"type": "improvement",
|
package/src/guard-fake-tests.ts
CHANGED
|
@@ -20,8 +20,8 @@ const ROOT = process.cwd();
|
|
|
20
20
|
|
|
21
21
|
const SCAN: ScanSpec = {
|
|
22
22
|
scope: "tests",
|
|
23
|
-
extensions: ["ts"],
|
|
24
|
-
kinds: ["framework", "library"],
|
|
23
|
+
extensions: ["ts", "tsx"],
|
|
24
|
+
kinds: ["framework", "library", "app"],
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
interface Violation {
|
|
@@ -59,19 +59,60 @@ const ASSERTION_HELPER_NAMES = new Set([
|
|
|
59
59
|
"expectTypeOf",
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
62
|
+
// Matched on the LAST property name of a property-access callee
|
|
63
|
+
// (`stack.http.writeOk(...)`, `tenant.api.queryErr(...)`) — these throw on
|
|
64
|
+
// the wrong outcome, same as expect(), so a call to one counts as an
|
|
65
|
+
// assertion (framework request-helper.ts, testing auth-kit.ts successData).
|
|
66
|
+
const ASSERTION_API_METHODS = new Set(["writeOk", "writeErr", "queryOk", "queryErr"]);
|
|
67
|
+
|
|
68
|
+
const MAX_HELPER_DEPTH = 3;
|
|
69
|
+
|
|
70
|
+
function isDirectAssertionCall(call: CallExpression): boolean {
|
|
71
|
+
const expr = call.getExpression();
|
|
72
|
+
const text = expr.getText();
|
|
73
|
+
if (text === "expect" || text.startsWith("expect.")) return true;
|
|
74
|
+
if (ASSERTION_HELPER_NAMES.has(text)) return true;
|
|
75
|
+
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return false;
|
|
76
|
+
const name = expr.getName();
|
|
77
|
+
return ASSERTION_API_METHODS.has(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A bare identifier `foo(...)` counts as an assertion when `foo` is declared
|
|
81
|
+
// in the SAME file (function declaration, or a variable initialized with an
|
|
82
|
+
// arrow/function expression, at any nesting level) and its own body asserts —
|
|
83
|
+
// recursively, so a chain of thin wrappers around expect() still counts.
|
|
84
|
+
function resolveSameFileHelper(sf: SourceFile, name: string): Node | undefined {
|
|
85
|
+
const fn = sf
|
|
86
|
+
.getDescendantsOfKind(SyntaxKind.FunctionDeclaration)
|
|
87
|
+
.find((f) => f.getName() === name);
|
|
88
|
+
if (fn) return fn;
|
|
89
|
+
const varDecl = sf
|
|
90
|
+
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
|
|
91
|
+
.find((v) => v.getName() === name);
|
|
92
|
+
const init = varDecl?.getInitializer();
|
|
93
|
+
if (
|
|
94
|
+
init &&
|
|
95
|
+
(init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression))
|
|
96
|
+
) {
|
|
97
|
+
return init;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// depth limit + visited set: a helper chain deeper than the limit is treated
|
|
103
|
+
// as unproven (violation), and a recursive helper never infinite-loops.
|
|
104
|
+
function bodyHasAssertion(node: Node, sf: SourceFile, depth: number, visited: Set<Node>): boolean {
|
|
105
|
+
if (visited.has(node)) return false;
|
|
106
|
+
visited.add(node);
|
|
107
|
+
for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
108
|
+
if (isDirectAssertionCall(call)) return true;
|
|
109
|
+
if (depth >= MAX_HELPER_DEPTH) continue;
|
|
110
|
+
const expr = call.getExpression();
|
|
111
|
+
if (!expr.isKind(SyntaxKind.Identifier)) continue;
|
|
112
|
+
const helper = resolveSameFileHelper(sf, expr.getText());
|
|
113
|
+
if (helper && bodyHasAssertion(helper, sf, depth + 1, visited)) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
75
116
|
}
|
|
76
117
|
|
|
77
118
|
function scanFile(sf: SourceFile): Violation[] {
|
|
@@ -100,8 +141,8 @@ function scanFile(sf: SourceFile): Violation[] {
|
|
|
100
141
|
const isArrowOrFn =
|
|
101
142
|
body.isKind(SyntaxKind.ArrowFunction) || body.isKind(SyntaxKind.FunctionExpression);
|
|
102
143
|
if (!isArrowOrFn) continue;
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
144
|
+
const hasAssertion = bodyHasAssertion(body, sf, 0, new Set());
|
|
145
|
+
if (!hasAssertion) {
|
|
105
146
|
const nameArg = args[0]?.getText().slice(0, 50) ?? "<anonymous>";
|
|
106
147
|
violations.push({
|
|
107
148
|
file: path.relative(ROOT, sf.getFilePath()),
|
|
@@ -50,7 +50,11 @@ const ROOT = process.cwd();
|
|
|
50
50
|
const SCAN: ScanSpec = {
|
|
51
51
|
scope: "source",
|
|
52
52
|
extensions: ["ts"],
|
|
53
|
-
frameworkWithin: ["packages/*/src/**"],
|
|
53
|
+
frameworkWithin: ["packages/*/src/**", "samples/**", "demo/**"],
|
|
54
|
+
// `demo/` isn't a declared sourceRoot (kumiko.json) — extraGlobs pulls its
|
|
55
|
+
// files in on top of the scope's own hits; frameworkWithin above still
|
|
56
|
+
// gates them the same as every other hit.
|
|
57
|
+
extraGlobs: ["demo/**/*.ts"],
|
|
54
58
|
};
|
|
55
59
|
// Unlike guard-pii-annotations.ts, tests are IN scope: kumiko-framework#2810's
|
|
56
60
|
// fail-closed throw fires at call time regardless of test vs. production code —
|
|
@@ -60,6 +64,14 @@ const EXCLUDE = /\.d\.ts$/;
|
|
|
60
64
|
|
|
61
65
|
const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]);
|
|
62
66
|
|
|
67
|
+
// These two files deliberately call the factories without a stance to prove
|
|
68
|
+
// the fail-closed throw (kumiko-framework#2921) — exact repo-relative match,
|
|
69
|
+
// not basename, so a consumer file sharing a name stays flagged.
|
|
70
|
+
const STANCE_THROW_TEST_ALLOWLIST = new Set<string>([
|
|
71
|
+
"packages/framework/src/engine/__tests__/factories-long-text.test.ts",
|
|
72
|
+
"packages/framework/src/engine/__tests__/factories-personal.test.ts",
|
|
73
|
+
]);
|
|
74
|
+
|
|
63
75
|
const VALID_PERSONAL_HINT =
|
|
64
76
|
'{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
|
|
65
77
|
|
|
@@ -114,6 +126,9 @@ export interface Finding {
|
|
|
114
126
|
|
|
115
127
|
function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
|
|
116
128
|
const findings: Finding[] = [];
|
|
129
|
+
const file = relFile(sf, roots);
|
|
130
|
+
if (STANCE_THROW_TEST_ALLOWLIST.has(file)) return findings;
|
|
131
|
+
|
|
117
132
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
118
133
|
const callee = call.getExpression().getText();
|
|
119
134
|
if (!FIELD_FACTORY_CALLEES.has(callee)) continue;
|
|
@@ -128,7 +143,6 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
|
|
|
128
143
|
|
|
129
144
|
const place = enclosingFieldName(call) ?? `${callee}(...)`;
|
|
130
145
|
const line = call.getStartLineNumber();
|
|
131
|
-
const file = relFile(sf, roots);
|
|
132
146
|
findings.push({ file, line, place, callee });
|
|
133
147
|
console.warn(
|
|
134
148
|
` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
|
|
@@ -137,7 +151,7 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
|
|
|
137
151
|
return findings;
|
|
138
152
|
}
|
|
139
153
|
|
|
140
|
-
function scan(
|
|
154
|
+
export function scan(
|
|
141
155
|
files: readonly SourceFile[],
|
|
142
156
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
143
157
|
): {
|
|
@@ -85,15 +85,16 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* App-repo browser bundle entry, mirroring
|
|
89
|
-
* (`
|
|
88
|
+
* App-repo browser bundle entry, mirroring the naming convention apps use
|
|
89
|
+
* for `kumiko-build` entries (`resolveClientEntries` in kumiko-framework's
|
|
90
90
|
* `packages/server-runtime/src/build-prod-bundle.ts`): `src/client.tsx`/
|
|
91
|
-
* `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
91
|
+
* `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (declared via
|
|
92
|
+
* package.json `kumiko.clientEntries`, fw#2320 — no longer auto-discovered
|
|
93
|
+
* by this filename alone). Framework/enterprise packages never match —
|
|
94
|
+
* their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
|
|
95
|
+
* independent of the framework's own regex (cross-package import would be a
|
|
96
|
+
* build-vs-lint layering violation) — if apps stop following this naming
|
|
97
|
+
* convention, this drifts and needs a matching update.
|
|
97
98
|
*/
|
|
98
99
|
export function isClientEntryPath(repoRelativePath: string): boolean {
|
|
99
100
|
const rel = repoRelativePath.replace(/\\/g, "/");
|