@cosmicdrift/kumiko-guards 0.1.0 → 0.3.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 +4 -1
- package/src/_lib/git-env.ts +22 -0
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/roots.ts +1 -18
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +40 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
package/src/guard-admin-api.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const guard: AstGuard = {
|
|
|
111
111
|
scan: SCAN,
|
|
112
112
|
// App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
|
|
113
113
|
security: true,
|
|
114
|
-
hint: "Admin
|
|
114
|
+
hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
|
|
115
115
|
run(files) {
|
|
116
116
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
117
117
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// App-Features folgen der bundled-features-Konvention (Referenz: tenant/):
|
|
3
|
+
// feature.ts = nur Registrierung, Screens unter web/, Handler unter handlers/.
|
|
4
|
+
// Dieser Guard flaggt die drei teuersten Abweichungen:
|
|
5
|
+
// 1. web.tsx/web.ts-Monolith bzw. JSX-Screens direkt am Feature-Root
|
|
6
|
+
// 2. feature.ts als Logik-Dump (> MAX_FEATURE_TS_LINES Zeilen)
|
|
7
|
+
// 3. r.screen({ type: "custom" }) ohne Allowlist-Tag — deklarative
|
|
8
|
+
// Screen-Typen (entityList/dashboard/data-table) sind der Default.
|
|
9
|
+
//
|
|
10
|
+
// ponytail: Handler-Datei-Konvention (*.query.ts/*.write.ts unter handlers/)
|
|
11
|
+
// wird noch nicht erzwungen — nachziehen, wenn die Registrierungs-API-Formen
|
|
12
|
+
// stabil inventarisiert sind.
|
|
13
|
+
//
|
|
14
|
+
// Teil von App-Mounting 2.0 (infra#208).
|
|
15
|
+
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
18
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
19
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
20
|
+
|
|
21
|
+
const SCAN: ScanSpec = {
|
|
22
|
+
scope: "source",
|
|
23
|
+
extensions: ["ts", "tsx"],
|
|
24
|
+
within: ["features/**"],
|
|
25
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
26
|
+
};
|
|
27
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
28
|
+
const IGNORE_TAG = "kumiko-lint-ignore app-feature-structure";
|
|
29
|
+
|
|
30
|
+
const MAX_FEATURE_TS_LINES = 300;
|
|
31
|
+
|
|
32
|
+
// src/features/<name>/<file> bzw. packages/bundled-features/src/<name>/<file>
|
|
33
|
+
// — genau eine Ebene unter dem Feature-Ordner.
|
|
34
|
+
function isFeatureRootFile(filePath: string): boolean {
|
|
35
|
+
const m = filePath.match(/(src\/features|packages\/bundled-features\/src)\/[^/]+\/[^/]+$/);
|
|
36
|
+
return m !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const guard: AstGuard = {
|
|
40
|
+
name: "App-Feature-Structure Guard (App-Repos)",
|
|
41
|
+
scan: SCAN,
|
|
42
|
+
hint:
|
|
43
|
+
"Konvention: feature.ts nur Registrierung, Screens unter web/ (eine Datei pro Screen), Handler unter handlers/, " +
|
|
44
|
+
`Domain-Logik unter lib/. Custom-Screens brauchen // ${IGNORE_TAG} <Grund> (deklarative Screen-Typen sind der Default).`,
|
|
45
|
+
run(files: readonly SourceFile[]) {
|
|
46
|
+
const violations: GuardViolation[] = [];
|
|
47
|
+
for (const sf of files) {
|
|
48
|
+
const filePath = sf.getFilePath();
|
|
49
|
+
if (EXCLUDE.test(filePath)) continue;
|
|
50
|
+
const base = path.basename(filePath);
|
|
51
|
+
|
|
52
|
+
// 1a. web.ts(x)-Monolith am Feature-Root
|
|
53
|
+
if (isFeatureRootFile(filePath) && (base === "web.tsx" || base === "web.ts")) {
|
|
54
|
+
if (!hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)) {
|
|
55
|
+
violations.push({
|
|
56
|
+
file: filePath,
|
|
57
|
+
line: 1,
|
|
58
|
+
message:
|
|
59
|
+
"web-Monolith am Feature-Root — Screens/Client-Def gehören unter web/ (index.ts + eine Datei pro Screen)",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 1b. JSX direkt am Feature-Root (Screens gehören unter web/)
|
|
65
|
+
if (
|
|
66
|
+
isFeatureRootFile(filePath) &&
|
|
67
|
+
filePath.endsWith(".tsx") &&
|
|
68
|
+
base !== "web.tsx" &&
|
|
69
|
+
sf.getDescendantsOfKind(SyntaxKind.JsxElement).length +
|
|
70
|
+
sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length >
|
|
71
|
+
0 &&
|
|
72
|
+
!hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)
|
|
73
|
+
) {
|
|
74
|
+
violations.push({
|
|
75
|
+
file: filePath,
|
|
76
|
+
line: 1,
|
|
77
|
+
message: "JSX-Komponente am Feature-Root — unter web/ verschieben",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. feature.ts als Logik-Dump
|
|
82
|
+
if (base === "feature.ts" && isFeatureRootFile(filePath)) {
|
|
83
|
+
const lines = sf.getEndLineNumber();
|
|
84
|
+
if (lines > MAX_FEATURE_TS_LINES && !hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)) {
|
|
85
|
+
violations.push({
|
|
86
|
+
file: filePath,
|
|
87
|
+
line: 1,
|
|
88
|
+
message: `feature.ts hat ${lines} Zeilen (max ${MAX_FEATURE_TS_LINES}) — Handler nach handlers/, Schemas nach schema/, Logik nach lib/`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. type: "custom" ohne Allowlist-Tag
|
|
94
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
95
|
+
if (prop.getName() !== "type") continue;
|
|
96
|
+
const init = prop.getInitializer();
|
|
97
|
+
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
98
|
+
if (init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText() !== "custom") continue;
|
|
99
|
+
// Nur r.screen-Kontext: das umgebende Call-Target muss auf .screen enden.
|
|
100
|
+
const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
|
|
101
|
+
if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
|
|
102
|
+
if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
|
|
103
|
+
violations.push({
|
|
104
|
+
file: filePath,
|
|
105
|
+
line: prop.getStartLineNumber(),
|
|
106
|
+
message: `r.screen type:"custom" ohne Allowlist-Tag — deklarativen Screen-Typ nutzen oder // ${IGNORE_TAG} <Grund>`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { violations };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard R8: verbietet `broker.subscribe(...)` ausserhalb des Frameworks.
|
|
4
|
+
*
|
|
5
|
+
* Feature- und App-Code soll NIE direkt an den Event-Broker subscriben — die
|
|
6
|
+
* Registrar-API ist der einzige erlaubte Weg, Events zu konsumieren:
|
|
7
|
+
* - `r.onEvent("event-name", handler)`
|
|
8
|
+
* - `r.job({ trigger: { on: "event-name" }, handler })`
|
|
9
|
+
*
|
|
10
|
+
* Ein direktes `broker.subscribe(...)` umgeht Lifecycle, Idempotency, Dedup
|
|
11
|
+
* und Replay des Dispatchers — genau die Garantien, die die Registrar-API
|
|
12
|
+
* gibt. Heute gibt es im Kumiko-Code KEINEN solchen Call (der Broker ist
|
|
13
|
+
* framework-intern, nicht exportiert) — der Guard ist ein Tripwire, der
|
|
14
|
+
* zuschlägt, sobald jemand eine Broker-Abstraktion einführt und Feature-Code
|
|
15
|
+
* direkt daran hängt.
|
|
16
|
+
*
|
|
17
|
+
* Erkennung (Name-Heuristik, keine Typ-Resolution): `X.subscribe(...)` wo der
|
|
18
|
+
* terminale Bezeichner von `X` `broker` oder `eventBroker` heisst (case-
|
|
19
|
+
* insensitive — fängt `broker`, `eventBroker`, `ctx.broker`, `this.eventBroker`).
|
|
20
|
+
* Store-/Observable-`.subscribe` (RxJS, React `controller.subscribe`) heisst
|
|
21
|
+
* nicht `broker` → kein Treffer.
|
|
22
|
+
*
|
|
23
|
+
* Ausnahme: `packages/framework/src/pipeline/**` — dort lebt das Broker-
|
|
24
|
+
* Plumbing selbst (framework-intern, erlaubt).
|
|
25
|
+
*
|
|
26
|
+
* Usage: bun guards/guard-broker-subscribe.ts
|
|
27
|
+
* Exit 1 bei Fund, 0 wenn sauber.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
32
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
33
|
+
|
|
34
|
+
const ROOT = process.cwd();
|
|
35
|
+
|
|
36
|
+
const SCAN: ScanSpec = {
|
|
37
|
+
scope: "source",
|
|
38
|
+
extensions: ["ts"],
|
|
39
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|\.g\.ts$)/;
|
|
43
|
+
|
|
44
|
+
// Broker-Plumbing selbst — framework-intern, darf subscriben.
|
|
45
|
+
const ALLOW = /(^|\/)packages\/framework\/src\/pipeline\//;
|
|
46
|
+
|
|
47
|
+
const BROKER_RECEIVER = /^(event)?broker$/i;
|
|
48
|
+
|
|
49
|
+
export function isAllowed(filePath: string): boolean {
|
|
50
|
+
return ALLOW.test(path.relative(ROOT, filePath));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Terminaler Bezeichner einer Receiver-Expression: `ctx.eventBroker` → "eventBroker". */
|
|
54
|
+
function receiverName(node: import("ts-morph").Node): string | undefined {
|
|
55
|
+
if (node.getKind() === SyntaxKind.Identifier) return node.getText();
|
|
56
|
+
const pae = node.asKind(SyntaxKind.PropertyAccessExpression);
|
|
57
|
+
return pae?.getName();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function collectBrokerSubscribeViolations(
|
|
61
|
+
sf: SourceFile,
|
|
62
|
+
): Array<{ line: number; message: string }> {
|
|
63
|
+
const hits: Array<{ line: number; message: string }> = [];
|
|
64
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
65
|
+
const callee = call.getExpression();
|
|
66
|
+
const pae = callee.asKind(SyntaxKind.PropertyAccessExpression);
|
|
67
|
+
if (!pae || pae.getName() !== "subscribe") continue;
|
|
68
|
+
const recv = receiverName(pae.getExpression());
|
|
69
|
+
if (!recv || !BROKER_RECEIVER.test(recv)) continue;
|
|
70
|
+
hits.push({
|
|
71
|
+
line: call.getStartLineNumber(),
|
|
72
|
+
message: `[${recv}.subscribe] ${recv}.subscribe(...) — use r.onEvent(...) or r.job({ trigger: { on } })`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return hits;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const guard: AstGuard = {
|
|
79
|
+
name: "No-Broker-Subscribe Guard",
|
|
80
|
+
scan: SCAN,
|
|
81
|
+
hint: "Consume events through the registrar API (r.onEvent / r.job trigger.on), not directly on the broker — see docs/plans/architecture/lint-rules.md (R8).",
|
|
82
|
+
run(files) {
|
|
83
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
84
|
+
for (const sf of files) {
|
|
85
|
+
const file = sf.getFilePath();
|
|
86
|
+
if (EXCLUDE.test(file) || isAllowed(file)) continue;
|
|
87
|
+
for (const hit of collectBrokerSubscribeViolations(sf)) {
|
|
88
|
+
violations.push({
|
|
89
|
+
file: path.relative(ROOT, file),
|
|
90
|
+
line: hit.line,
|
|
91
|
+
message: hit.message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { violations };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: `details.reason` strings and the first arg to UnprocessableError /
|
|
4
|
+
* failUnprocessable must follow the reason convention:
|
|
5
|
+
*
|
|
6
|
+
* ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$
|
|
7
|
+
*
|
|
8
|
+
* That is: lowercase ASCII, underscores for word breaks, optional dot
|
|
9
|
+
* namespaces for feature-scoped reasons (e.g. `order.already_cancelled`).
|
|
10
|
+
* No spaces, no camelCase, no dashes, no leading digits.
|
|
11
|
+
*
|
|
12
|
+
* Why: reason codes survive the wire + logs + i18n lookup. Clients key off
|
|
13
|
+
* them. Any drift (camelCase, typos like `stale_stat`) means a missed
|
|
14
|
+
* branch in the SDK. Catching it at commit time is dramatically cheaper
|
|
15
|
+
* than discovering a client-side dead branch in prod.
|
|
16
|
+
*
|
|
17
|
+
* What this checks:
|
|
18
|
+
* 1. `new UnprocessableError(X, ...)` — X must be a string-literal reason
|
|
19
|
+
* that matches the regex, OR a reference to a known Reasons const
|
|
20
|
+
* (FrameworkReasons.*, <Anything>Reasons.*, TenantErrors.*, etc.).
|
|
21
|
+
* 2. `failUnprocessable(X, ...)` — same rule.
|
|
22
|
+
* 3. Object literals containing `reason: "X"` — same rule for the X.
|
|
23
|
+
* Skips `openToAll: { reason: "..." }` / `escapeHatch: { reason: "..." }`
|
|
24
|
+
* — those are prose access-declaration justifications, enforced instead
|
|
25
|
+
* by guard-open-to-all-reason.ts / guard-escape-hatch-declared.ts.
|
|
26
|
+
*
|
|
27
|
+
* Non-literal reasons (computed, template strings with interpolation,
|
|
28
|
+
* identifier references) are assumed to be typed-from-a-const and pass.
|
|
29
|
+
* A stricter version could walk to the declaration; v1 stays pragmatic.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* bun guards/guard-error-reasons.ts
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
37
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
38
|
+
|
|
39
|
+
const ROOT = process.cwd();
|
|
40
|
+
|
|
41
|
+
const SCAN: ScanSpec = {
|
|
42
|
+
scope: "source",
|
|
43
|
+
extensions: ["ts"],
|
|
44
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Excluded: test files (they may legitimately fabricate broken reasons to
|
|
48
|
+
// prove the guard catches them) and the classes.ts / reasons.ts definitions
|
|
49
|
+
// themselves (message text in constructor defaults isn't a reason).
|
|
50
|
+
const EXCLUDE =
|
|
51
|
+
/(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|errors\/classes\.ts$|errors\/reasons\.ts$|node_modules)/;
|
|
52
|
+
|
|
53
|
+
const REASON_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
54
|
+
|
|
55
|
+
// Calls whose first positional arg is a reason string.
|
|
56
|
+
const UNPROC_CALL_NAMES = new Set(["UnprocessableError", "failUnprocessable"]);
|
|
57
|
+
|
|
58
|
+
// Property names whose `{ reason: "..." }` is a prose access-declaration
|
|
59
|
+
// justification, not an error-reason code.
|
|
60
|
+
const ACCESS_DECLARATION_NAMES = new Set(["openToAll", "escapeHatch"]);
|
|
61
|
+
|
|
62
|
+
interface Violation {
|
|
63
|
+
readonly file: string;
|
|
64
|
+
readonly line: number;
|
|
65
|
+
readonly kind: "unproc-arg" | "details-reason";
|
|
66
|
+
readonly value: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scanFile(sf: SourceFile): Violation[] {
|
|
70
|
+
const violations: Violation[] = [];
|
|
71
|
+
|
|
72
|
+
// ---------- (1) + (2): UnprocessableError / failUnprocessable calls ----------
|
|
73
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
74
|
+
const callee = call.getExpression();
|
|
75
|
+
// Strip `new `, drop qualifying module access. We only need the final
|
|
76
|
+
// identifier for the call name check.
|
|
77
|
+
const text = callee.getText();
|
|
78
|
+
const name = text.split(".").pop() ?? text;
|
|
79
|
+
|
|
80
|
+
// Also catch `new UnprocessableError(...)` — ts-morph models that as a
|
|
81
|
+
// NewExpression, but class-ref-as-callable in our codebase is exercised
|
|
82
|
+
// only in tests/internal, so the NewExpression pass below handles it.
|
|
83
|
+
if (!UNPROC_CALL_NAMES.has(name)) continue;
|
|
84
|
+
|
|
85
|
+
const arg = call.getArguments()[0];
|
|
86
|
+
const bad = checkReasonNode(arg);
|
|
87
|
+
if (bad !== null) {
|
|
88
|
+
violations.push({
|
|
89
|
+
file: path.relative(ROOT, sf.getFilePath()),
|
|
90
|
+
line: call.getStartLineNumber(),
|
|
91
|
+
kind: "unproc-arg",
|
|
92
|
+
value: bad,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const neu of sf.getDescendantsOfKind(SyntaxKind.NewExpression)) {
|
|
98
|
+
const callee = neu.getExpression();
|
|
99
|
+
const text = callee.getText();
|
|
100
|
+
const name = text.split(".").pop() ?? text;
|
|
101
|
+
if (name !== "UnprocessableError") continue;
|
|
102
|
+
|
|
103
|
+
const arg = neu.getArguments()[0];
|
|
104
|
+
const bad = checkReasonNode(arg);
|
|
105
|
+
if (bad !== null) {
|
|
106
|
+
violations.push({
|
|
107
|
+
file: path.relative(ROOT, sf.getFilePath()),
|
|
108
|
+
line: neu.getStartLineNumber(),
|
|
109
|
+
kind: "unproc-arg",
|
|
110
|
+
value: bad,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------- (3): object literals with `reason: "..."` ----------
|
|
116
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
117
|
+
const name = prop.getName();
|
|
118
|
+
if (name !== "reason") continue;
|
|
119
|
+
if (isAccessDeclarationReason(prop)) continue;
|
|
120
|
+
|
|
121
|
+
const initializer = prop.getInitializer();
|
|
122
|
+
const bad = checkReasonNode(initializer);
|
|
123
|
+
if (bad !== null) {
|
|
124
|
+
violations.push({
|
|
125
|
+
file: path.relative(ROOT, sf.getFilePath()),
|
|
126
|
+
line: prop.getStartLineNumber(),
|
|
127
|
+
kind: "details-reason",
|
|
128
|
+
value: bad,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return violations;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A `reason` PropertyAssignment whose object literal is the initializer of
|
|
137
|
+
// an `openToAll` or `escapeHatch` PropertyAssignment is an access-declaration
|
|
138
|
+
// justification, not an error-reason code.
|
|
139
|
+
export function isAccessDeclarationReason(prop: Node): boolean {
|
|
140
|
+
const objectLiteral = prop.getParent();
|
|
141
|
+
if (!objectLiteral?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
|
|
142
|
+
const owner = objectLiteral.getParent();
|
|
143
|
+
if (!owner?.isKind(SyntaxKind.PropertyAssignment)) return false;
|
|
144
|
+
return ACCESS_DECLARATION_NAMES.has(owner.getName());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Returns the offending string if this node is a string literal that does
|
|
148
|
+
// NOT match the reason regex; null otherwise (including for non-literals —
|
|
149
|
+
// those are assumed to come from a typed const and slip through).
|
|
150
|
+
function checkReasonNode(node: Node | undefined): string | null {
|
|
151
|
+
if (!node) return null;
|
|
152
|
+
if (node.isKind(SyntaxKind.StringLiteral)) {
|
|
153
|
+
const lit = node.getLiteralText();
|
|
154
|
+
return REASON_RE.test(lit) ? null : lit;
|
|
155
|
+
}
|
|
156
|
+
if (node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
157
|
+
const lit = node.getLiteralText();
|
|
158
|
+
return REASON_RE.test(lit) ? null : lit;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const guard: AstGuard = {
|
|
164
|
+
name: "Error-Reasons Guard",
|
|
165
|
+
scan: SCAN,
|
|
166
|
+
hint: `reason strings must match ${REASON_RE} (snake_case ASCII, optional dot-namespaced). Reusable? Add a const to FrameworkReasons/<Feature>Reasons.`,
|
|
167
|
+
run(files) {
|
|
168
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
169
|
+
for (const sf of files) {
|
|
170
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
171
|
+
for (const v of scanFile(sf)) {
|
|
172
|
+
const where =
|
|
173
|
+
v.kind === "unproc-arg" ? "UnprocessableError/failUnprocessable" : "details.reason";
|
|
174
|
+
violations.push({
|
|
175
|
+
file: v.file,
|
|
176
|
+
line: v.line,
|
|
177
|
+
message: `${where} "${v.value}"`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { violations };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -6,8 +6,8 @@
|
|
|
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
|
|
10
|
-
* `escapeHatch`.
|
|
9
|
+
* systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, or a
|
|
10
|
+
* handler/hook that lexically declares `escapeHatch`.
|
|
11
11
|
* R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
|
|
12
12
|
* with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
|
|
13
13
|
* call, a `*Job` function, or a handler/hook that lexically declares
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
*
|
|
23
23
|
* escapeHatch (R2/R3) is recognized only as a direct, literal `escapeHatch`
|
|
24
24
|
* property (object literal, or ternary of two object literals) either in the
|
|
25
|
-
* same object literal as
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* same object literal as any inline function property (ArrowFunction or
|
|
26
|
+
* FunctionExpression, under any key name — e.g. `handler`, `export`,
|
|
27
|
+
* `delete`), or in an options object passed to
|
|
28
|
+
* `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
|
|
29
|
+
* alongside the handler function argument. Referenced-by-variable functions,
|
|
30
|
+
* spread options, computed/string keys, and non-literal escapeHatch values
|
|
31
|
+
* are conservatively not recognized (miss, don't falsely clear).
|
|
30
32
|
*
|
|
31
33
|
* Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
|
|
32
34
|
* job (access-declarations.ts), not this guard's. Known false-negatives:
|
|
@@ -174,7 +176,7 @@ function findUnsafeRawFindings(
|
|
|
174
176
|
const expr = call.getExpression();
|
|
175
177
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
176
178
|
if (expr.getName() !== "unsafeRaw") continue;
|
|
177
|
-
if (
|
|
179
|
+
if (isAllowedEscapeHatchCall(call, sf, systemDirs) || isExplicitUnsafeRawGrant(call)) continue;
|
|
178
180
|
out.push({
|
|
179
181
|
file: path.relative(root, sf.getFilePath()),
|
|
180
182
|
line: call.getStartLineNumber(),
|
|
@@ -186,6 +188,19 @@ function findUnsafeRawFindings(
|
|
|
186
188
|
return out;
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
function isExplicitUnsafeRawGrant(call: Node): boolean {
|
|
192
|
+
if (!call.isKind(SyntaxKind.CallExpression)) return false;
|
|
193
|
+
const expr = call.getExpression();
|
|
194
|
+
if (!expr.isKind(SyntaxKind.PropertyAccessExpression) || expr.getName() !== "unsafeRaw") {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const receiver = expr.getExpression();
|
|
198
|
+
return (
|
|
199
|
+
receiver.isKind(SyntaxKind.CallExpression) &&
|
|
200
|
+
receiver.getExpression().getText() === "withUnsafeRawGrant"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
function isSystemIdentityExpression(node: Node): boolean {
|
|
190
205
|
if (node.isKind(SyntaxKind.CallExpression)) {
|
|
191
206
|
const calleeText = node.getExpression().getText();
|
|
@@ -270,6 +285,7 @@ const ESCAPE_HATCH_CALL_METHODS = new Set([
|
|
|
270
285
|
"writeHandler",
|
|
271
286
|
"queryHandler",
|
|
272
287
|
"streamHandler",
|
|
288
|
+
"useExtension",
|
|
273
289
|
]);
|
|
274
290
|
|
|
275
291
|
function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
@@ -277,13 +293,7 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
277
293
|
if (!parent) return false;
|
|
278
294
|
|
|
279
295
|
if (fn.isKind(SyntaxKind.MethodDeclaration)) {
|
|
280
|
-
|
|
281
|
-
return (
|
|
282
|
-
nameNode.isKind(SyntaxKind.Identifier) &&
|
|
283
|
-
nameNode.getText() === "handler" &&
|
|
284
|
-
parent.isKind(SyntaxKind.ObjectLiteralExpression) &&
|
|
285
|
-
objectDeclaresEscapeHatch(parent)
|
|
286
|
-
);
|
|
296
|
+
return parent.isKind(SyntaxKind.ObjectLiteralExpression) && objectDeclaresEscapeHatch(parent);
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
if (!fn.isKind(SyntaxKind.ArrowFunction) && !fn.isKind(SyntaxKind.FunctionExpression)) {
|
|
@@ -291,11 +301,8 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
|
|
|
291
301
|
}
|
|
292
302
|
|
|
293
303
|
if (parent.isKind(SyntaxKind.PropertyAssignment)) {
|
|
294
|
-
const nameNode = parent.getNameNode();
|
|
295
304
|
const obj = parent.getParent();
|
|
296
305
|
return (
|
|
297
|
-
nameNode.isKind(SyntaxKind.Identifier) &&
|
|
298
|
-
nameNode.getText() === "handler" &&
|
|
299
306
|
parent.getInitializer() === fn &&
|
|
300
307
|
obj.isKind(SyntaxKind.ObjectLiteralExpression) &&
|
|
301
308
|
objectDeclaresEscapeHatch(obj)
|
|
@@ -493,7 +500,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
|
|
|
493
500
|
name: "Escape-Hatch-Declared Guard",
|
|
494
501
|
scan: SCAN,
|
|
495
502
|
security: true,
|
|
496
|
-
hint: '
|
|
503
|
+
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: `bun guards/run-guards.ts --write-security-baseline`',
|
|
497
504
|
run(files) {
|
|
498
505
|
const violations: GuardViolation[] = [
|
|
499
506
|
...findGenericReasonCalls(files, opts.root).map((f) => ({
|
package/src/guard-fake-tests.ts
CHANGED
|
@@ -117,7 +117,7 @@ function scanFile(sf: SourceFile): Violation[] {
|
|
|
117
117
|
export const guard: AstGuard = {
|
|
118
118
|
name: "Fake-Test Guard",
|
|
119
119
|
scan: SCAN,
|
|
120
|
-
hint: "Test
|
|
120
|
+
hint: "Test without expect() or with a tautology — check real behavior, not existence.",
|
|
121
121
|
run(files) {
|
|
122
122
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
123
123
|
for (const sf of files) {
|