@cosmicdrift/kumiko-guards 0.1.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/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- package/src/run-ui-guards.ts +25 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: finds Date-API patterns in production code where they are banned.
|
|
4
|
+
*
|
|
5
|
+
* Checked patterns (all Date-specific, Temporal has its own equivalents):
|
|
6
|
+
* - `new Date(...)` → Temporal.Now.instant() / Temporal.Instant.from
|
|
7
|
+
* - `.toISOString()` → Temporal.Instant.toString() (canonical ISO)
|
|
8
|
+
* - `.getTime()` → Temporal.Instant.epochMilliseconds
|
|
9
|
+
*
|
|
10
|
+
* `Date.now()` and `Date.parse()` are NOT checked — Date.now() is the
|
|
11
|
+
* idiomatic way to measure a duration (`Date.now() - startedAt`), and
|
|
12
|
+
* Temporal.Now.instant().epochMilliseconds is 4x longer for no benefit.
|
|
13
|
+
*
|
|
14
|
+
* Background: kumiko has a Temporal-based time API via `ctx.tz`. Feature
|
|
15
|
+
* code should use wall-clock + IANA-TZ via ctx.tz.parse / ctx.tz.now /
|
|
16
|
+
* ctx.tz.fromLocatedJson, NOT `new Date(...)` directly — that's the JS-Date
|
|
17
|
+
* trap (implicit local-TZ conversion in the browser, Hermes lacked
|
|
18
|
+
* Temporal for a long time, etc.). Sprint F carried this through
|
|
19
|
+
* atomically.
|
|
20
|
+
*
|
|
21
|
+
* Allowlist: paths that legitimately need the Date API (HTTP header specs,
|
|
22
|
+
* polyfill detection, Date↔Temporal bridges, JSON wire-format backwards
|
|
23
|
+
* compat). Test files are generally allowed.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* bun guards/guard-no-date-api.ts
|
|
27
|
+
*
|
|
28
|
+
* Exit 1 on violations in non-allowlisted files, 0 when clean.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
33
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
34
|
+
|
|
35
|
+
const ROOT = process.cwd();
|
|
36
|
+
|
|
37
|
+
const SCAN: ScanSpec = {
|
|
38
|
+
scope: "source",
|
|
39
|
+
extensions: ["ts"],
|
|
40
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Files that are NOT checked — tests, type defs, generated code.
|
|
44
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|\.g\.ts$)/;
|
|
45
|
+
|
|
46
|
+
// Framework internals are still allowed to `new Date()`: they implement the
|
|
47
|
+
// TZ layer themselves or hang off the DB driver where Date is the native
|
|
48
|
+
// wire format. Migration plan: paths get removed from this allowlist per
|
|
49
|
+
// feature/sample as they migrate to ctx.tz. Goal: the allowlist shrinks.
|
|
50
|
+
const ALLOWLIST = [
|
|
51
|
+
// Framework time layer itself
|
|
52
|
+
/packages\/framework\/src\/time\//,
|
|
53
|
+
// DB layer: drizzle column wrapper, migration tooling — Date is a
|
|
54
|
+
// primitive wire format there
|
|
55
|
+
/packages\/framework\/src\/db\//,
|
|
56
|
+
// Bun.SQL layer (post-Drizzle replacement) — same boundary as db/,
|
|
57
|
+
// converts Date→Temporal at the driver boundary.
|
|
58
|
+
/packages\/framework\/src\/bun-db\//,
|
|
59
|
+
// Event-store internals: createdAt stamps, snapshot time, archive time
|
|
60
|
+
/packages\/framework\/src\/event-store\//,
|
|
61
|
+
// Pipeline: system hooks, idempotency cache (low-level timestamps)
|
|
62
|
+
/packages\/framework\/src\/pipeline\//,
|
|
63
|
+
// Errors: timestamp in the error body
|
|
64
|
+
/packages\/framework\/src\/errors\//,
|
|
65
|
+
// Jobs: job-run timestamps (the job runner is a time source for cron)
|
|
66
|
+
/packages\/framework\/src\/jobs\//,
|
|
67
|
+
// API layer: request timestamps in logs
|
|
68
|
+
/packages\/framework\/src\/api\//,
|
|
69
|
+
// Logging
|
|
70
|
+
/packages\/framework\/src\/logging\//,
|
|
71
|
+
// i18n
|
|
72
|
+
/packages\/framework\/src\/i18n\//,
|
|
73
|
+
// Observability
|
|
74
|
+
/packages\/framework\/src\/observability\//,
|
|
75
|
+
// Engine internals (factories, registry, config) — when they need Date
|
|
76
|
+
// it's defaults/audit stamps working together with the DB layer.
|
|
77
|
+
/packages\/framework\/src\/engine\/(types|registry|config-helpers|create-app|define-feature|state-machine|access|field-access|boot-validator|qualified-name)\.ts$/,
|
|
78
|
+
// Testing helpers
|
|
79
|
+
/packages\/framework\/src\/testing\//,
|
|
80
|
+
// Search adapter internals
|
|
81
|
+
/packages\/framework\/src\/search\//,
|
|
82
|
+
// File storage (coming in Gap-04)
|
|
83
|
+
/packages\/framework\/src\/files\//,
|
|
84
|
+
|
|
85
|
+
// Sprint F's atomic switch migrated bundled-features + samples to
|
|
86
|
+
// Temporal.Now.instant() — no blanket allowlist anymore. Beammycar's
|
|
87
|
+
// migration reads legacy-DB Date columns (V2 schema, no Temporal): the
|
|
88
|
+
// `pg` driver rows arrive as JS `Date`, the `dateToInstant` helper
|
|
89
|
+
// bridges them to `Temporal.Instant` via `.getTime()`. A pure bridge
|
|
90
|
+
// layer.
|
|
91
|
+
/samples\/showcases\/beammycar\/src\/migration\//,
|
|
92
|
+
|
|
93
|
+
// === Legitimate Date-API spots ===
|
|
94
|
+
// Polyfill detection: typeof globalThis.Temporal vs new Date(0) — no
|
|
95
|
+
// Temporal available because we're checking if it's there right now.
|
|
96
|
+
/packages\/framework\/src\/time\/polyfill\.ts$/,
|
|
97
|
+
// HTTP Date headers (RFC 7231): Last-Modified / Expires / signed-URL
|
|
98
|
+
// expiresAt are Date-typed in the HTTP spec, not a Temporal format.
|
|
99
|
+
/packages\/framework\/src\/files\/file-routes\.ts$/,
|
|
100
|
+
// Error serialization: timestamp in the JSON wire body for backwards
|
|
101
|
+
// compat with every client that has expected an ISO string since Sprint A.
|
|
102
|
+
/packages\/framework\/src\/errors\/serialize\.ts$/,
|
|
103
|
+
// Entity cache: parsing arbitrary input (could be Date or ISO string),
|
|
104
|
+
// Number.isNaN(d.getTime()) as a validity check.
|
|
105
|
+
/packages\/framework\/src\/pipeline\/entity-cache\.ts$/,
|
|
106
|
+
// Event-store insertSubsequentEvent: raw SQL row.created_at can be Date|string
|
|
107
|
+
// (postgres-js driver-config dependent), normalised to Temporal.Instant via
|
|
108
|
+
// .getTime() bridge.
|
|
109
|
+
/packages\/framework\/src\/event-store\/event-store\.ts$/,
|
|
110
|
+
// HTTP Last-Modified/ETag header: Bun/Node Response API expects a native
|
|
111
|
+
// Date object from fs.Stats.mtimeMs — wire boundary, not a Temporal format.
|
|
112
|
+
/packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
|
|
113
|
+
// Bun.SQL timestamptz columns arrive as native Date (driver wire format,
|
|
114
|
+
// same reason as packages/framework/src/bun-db/ above).
|
|
115
|
+
/packages\/publish\/src\/rollback\.ts$/,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
interface Violation {
|
|
119
|
+
file: string;
|
|
120
|
+
line: number;
|
|
121
|
+
snippet: string;
|
|
122
|
+
pattern: "new Date" | ".toISOString" | ".getTime";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isAllowlisted(filePath: string): boolean {
|
|
126
|
+
const rel = path.relative(ROOT, filePath);
|
|
127
|
+
return ALLOWLIST.some((re) => re.test(rel));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findDateApiUsages(sf: SourceFile): Omit<Violation, "file">[] {
|
|
131
|
+
const result: Omit<Violation, "file">[] = [];
|
|
132
|
+
|
|
133
|
+
// Pattern 1: `new Date(...)`
|
|
134
|
+
for (const expr of sf.getDescendantsOfKind(SyntaxKind.NewExpression)) {
|
|
135
|
+
if (expr.getExpression().getText() === "Date") {
|
|
136
|
+
result.push({
|
|
137
|
+
line: expr.getStartLineNumber(),
|
|
138
|
+
snippet: expr.getText(),
|
|
139
|
+
pattern: "new Date",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Pattern 2: `.toISOString()` and `.getTime()` — property access in a call.
|
|
145
|
+
// Temporal.Instant has neither method, so any hit is a Date-API call.
|
|
146
|
+
for (const expr of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
147
|
+
const callee = expr.getExpression();
|
|
148
|
+
if (callee.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
|
|
149
|
+
const name = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
|
|
150
|
+
if (name === "toISOString" || name === "getTime") {
|
|
151
|
+
result.push({
|
|
152
|
+
line: expr.getStartLineNumber(),
|
|
153
|
+
snippet: expr.getText(),
|
|
154
|
+
pattern: name === "toISOString" ? ".toISOString" : ".getTime",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const guard: AstGuard = {
|
|
163
|
+
name: "No-Date-API Guard",
|
|
164
|
+
scan: SCAN,
|
|
165
|
+
hint: "Ersetze mit Temporal.Now.instant() / Temporal.Instant.from / .toString() / .epochMilliseconds — siehe docs/plans/architecture/timezones.md.",
|
|
166
|
+
run(files) {
|
|
167
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
168
|
+
|
|
169
|
+
for (const sf of files) {
|
|
170
|
+
const file = sf.getFilePath();
|
|
171
|
+
if (EXCLUDE.test(file)) continue;
|
|
172
|
+
if (isAllowlisted(file)) continue;
|
|
173
|
+
for (const usage of findDateApiUsages(sf)) {
|
|
174
|
+
violations.push({
|
|
175
|
+
file: path.relative(ROOT, file),
|
|
176
|
+
line: usage.line,
|
|
177
|
+
message: `[${usage.pattern}] ${usage.snippet}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { violations };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: finds direct `node:fs`/`fs` imports outside an allowlist.
|
|
4
|
+
*
|
|
5
|
+
* Background: several path-traversal bugs happened because request-input
|
|
6
|
+
* paths were passed straight to `node:fs` instead of through the one
|
|
7
|
+
* guarded place (`packages/framework/src/files/local-provider.ts` —
|
|
8
|
+
* `resolveContainedPath()` resolves against `basePath` and rejects anything
|
|
9
|
+
* outside it). New runtime code should use `FileStorageProvider` instead of
|
|
10
|
+
* wiring up `fs` itself again.
|
|
11
|
+
*
|
|
12
|
+
* Scans every packages/<pkg>/src directory (each root, like guard-no-date-api
|
|
13
|
+
* / guard-restricted-symbols) — the traversal risk isn't limited to one repo.
|
|
14
|
+
* Build/CLI/script/e2e tooling is filtered out via an EXCLUDE directory
|
|
15
|
+
* pattern rather than a file-by-file allowlist; everything else under src
|
|
16
|
+
* stays checked, including in app repos (e.g. marketing render jobs).
|
|
17
|
+
*
|
|
18
|
+
* Allowlist entries are repo-scoped (repo name or "*") so a path justified in
|
|
19
|
+
* one repo cannot silently free node:fs in another under the same relative
|
|
20
|
+
* path.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* bun guards/guard-no-direct-fs.ts
|
|
24
|
+
*
|
|
25
|
+
* Exit 1 on violations in non-allowlisted files, 0 when clean.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { relative as pathRelative } from "node:path";
|
|
29
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
30
|
+
import {
|
|
31
|
+
type AstGuard,
|
|
32
|
+
findRepoRootFor,
|
|
33
|
+
relFromRepoRoot,
|
|
34
|
+
runStandalone,
|
|
35
|
+
type ScanSpec,
|
|
36
|
+
} from "./_lib/guard-kit";
|
|
37
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
38
|
+
|
|
39
|
+
const SCAN: ScanSpec = {
|
|
40
|
+
scope: "source",
|
|
41
|
+
extensions: ["ts"],
|
|
42
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Tests + build/CLI/script/e2e tooling: no request input as a path.
|
|
46
|
+
// (^|/) so relative paths like `tools/docgen/...` match — a leading-slash-
|
|
47
|
+
// only pattern only worked when relFromRepoRoot fell back to absolute paths.
|
|
48
|
+
const EXCLUDE =
|
|
49
|
+
/(__tests__|\.test\.ts$|\.integration\.ts$|\.spec\.ts$|\.d\.ts$|(^|\/)(scripts|e2e|tools|bin)\/)/;
|
|
50
|
+
|
|
51
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
52
|
+
|
|
53
|
+
type AllowEntry = {
|
|
54
|
+
readonly repo: string | "*";
|
|
55
|
+
readonly pattern: RegExp;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ALLOWLIST: readonly AllowEntry[] = [
|
|
59
|
+
// The one guarded place: path-traversal check via resolveContainedPath().
|
|
60
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/files\/local-provider\.ts$/ },
|
|
61
|
+
|
|
62
|
+
// Dev-server: CLI/scaffolding/codegen, runs locally on the developer's
|
|
63
|
+
// machine, no request input as a path.
|
|
64
|
+
{ repo: "kumiko-framework", pattern: /^packages\/dev-server\/src\// },
|
|
65
|
+
|
|
66
|
+
// CLI (@cosmicdrift/kumiko-cli): local dev/CI tooling reading the repo tree, no request input as path.
|
|
67
|
+
{ repo: "kumiko-framework", pattern: /^packages\/cli\/src\// },
|
|
68
|
+
|
|
69
|
+
// Framework: migrations/schema tooling, DB codegen, boot validator —
|
|
70
|
+
// CLI/build time, no request input.
|
|
71
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
|
|
72
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli\.ts$/ },
|
|
73
|
+
// same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
|
|
74
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli\.ts$/ },
|
|
75
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
|
|
76
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-generator\.ts$/ },
|
|
77
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-runner\.ts$/ },
|
|
78
|
+
{ repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/rebuild-marker\.ts$/ },
|
|
79
|
+
{
|
|
80
|
+
repo: "kumiko-framework",
|
|
81
|
+
pattern: /^packages\/framework\/src\/engine\/boot-validator\/custom-screen-write-qns\.ts$/,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
repo: "kumiko-framework",
|
|
85
|
+
pattern: /^packages\/framework\/src\/engine\/codemod\/pipeline-codemod\.ts$/,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// server-runtime: prod bundle build + static asset delivery from the
|
|
89
|
+
// build output (no user-controlled path).
|
|
90
|
+
{ repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/build-prod-bundle\.ts$/ },
|
|
91
|
+
{
|
|
92
|
+
repo: "kumiko-framework",
|
|
93
|
+
pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// create-kumiko-app: Scaffolding-CLI.
|
|
97
|
+
{ repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
|
|
98
|
+
|
|
99
|
+
// Sample apps: codegen/screenshot-mount helper, no request input.
|
|
100
|
+
{ repo: "kumiko-framework", pattern: /^samples\/apps\/use-all-bundled\/schema\/generate\.ts$/ },
|
|
101
|
+
{
|
|
102
|
+
repo: "kumiko-framework",
|
|
103
|
+
pattern: /^samples\/apps\/showcase\/src\/app\/mount-public-screenshots\.ts$/,
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// kumiko-enterprise: ai-cli reads/writes locally on the developer's
|
|
107
|
+
// machine (--api-key/KUMIKO_CORPUS_PATH), no server request path.
|
|
108
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/ai-cli\/src\/index\.ts$/ },
|
|
109
|
+
// Few-shot corpus loader: reads docs/few-shot-corpus.json via an upward
|
|
110
|
+
// walk from cwd, no user input as a path.
|
|
111
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/ai-foundation\/src\/prompt\/corpus\.ts$/ },
|
|
112
|
+
|
|
113
|
+
// App repos: marketing landing-page renderer, iterates over a fixed
|
|
114
|
+
// language enum (LANGS/SUPPORTED_LANGS) — no user input in the path.
|
|
115
|
+
// repo:"*" — same relative path is intentional across flat-layout apps.
|
|
116
|
+
{ repo: "*", pattern: /(^|\/)src\/marketing\/render-landing\.ts$/ },
|
|
117
|
+
{ repo: "*", pattern: /(^|\/)src\/marketing\/rebuild-pages-job\.ts$/ },
|
|
118
|
+
|
|
119
|
+
// kumiko-enterprise publish pipeline: materialize.ts is the guarded
|
|
120
|
+
// place (writeMaterializedTree — resolve()+startsWith(root+sep) check,
|
|
121
|
+
// same pattern as local-provider.ts). build.ts/feature.ts/validate.ts
|
|
122
|
+
// now only touch fs for tempdir housekeeping (mkdtemp/rm, fixed
|
|
123
|
+
// suffixes) and a static package.json upwalk — no user path, no direct
|
|
124
|
+
// write anymore.
|
|
125
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/materialize\.ts$/ },
|
|
126
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/build\.ts$/ },
|
|
127
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/feature\.ts$/ },
|
|
128
|
+
{ repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/validate\.ts$/ },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
export function isRepoAllowlisted(
|
|
132
|
+
repoName: string | undefined,
|
|
133
|
+
relPath: string,
|
|
134
|
+
allowlist: readonly AllowEntry[] = ALLOWLIST,
|
|
135
|
+
): boolean {
|
|
136
|
+
// Fail-closed without a resolved root: only "*" entries can match, so a
|
|
137
|
+
// synthetic in-memory path cannot hitch a ride on a framework/enterprise
|
|
138
|
+
// justification (same hole guard-direct-fetch closed).
|
|
139
|
+
return allowlist.some(
|
|
140
|
+
(e) =>
|
|
141
|
+
(e.repo === "*" || (repoName !== undefined && e.repo === repoName)) &&
|
|
142
|
+
e.pattern.test(relPath),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface Violation {
|
|
147
|
+
line: number;
|
|
148
|
+
moduleSpecifier: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findDynamicFsRequires(sf: SourceFile): Violation[] {
|
|
152
|
+
const violations: Violation[] = [];
|
|
153
|
+
|
|
154
|
+
// Dynamic `import("fs")` and CommonJS `require("fs")` bypass the static
|
|
155
|
+
// import/export declarations — both are CallExpressions in the AST.
|
|
156
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
157
|
+
const callee = call.getExpression();
|
|
158
|
+
const isRequireCall =
|
|
159
|
+
callee.getKind() === SyntaxKind.Identifier && callee.getText() === "require";
|
|
160
|
+
const isDynamicImport = callee.getKind() === SyntaxKind.ImportKeyword;
|
|
161
|
+
if (!isRequireCall && !isDynamicImport) continue;
|
|
162
|
+
const arg = call.getArguments()[0];
|
|
163
|
+
if (arg && Node.isStringLiteral(arg) && FS_MODULES.has(arg.getLiteralValue())) {
|
|
164
|
+
violations.push({ line: call.getStartLineNumber(), moduleSpecifier: arg.getLiteralValue() });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return violations;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findDirectFsImports(sf: SourceFile): Violation[] {
|
|
171
|
+
const violations: Violation[] = [];
|
|
172
|
+
|
|
173
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
174
|
+
const spec = decl.getModuleSpecifierValue();
|
|
175
|
+
if (FS_MODULES.has(spec)) {
|
|
176
|
+
violations.push({ line: decl.getStartLineNumber(), moduleSpecifier: spec });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const decl of sf.getExportDeclarations()) {
|
|
180
|
+
const spec = decl.getModuleSpecifierValue();
|
|
181
|
+
if (spec && FS_MODULES.has(spec)) {
|
|
182
|
+
violations.push({ line: decl.getStartLineNumber(), moduleSpecifier: spec });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const importEqualsDecl of sf.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) {
|
|
186
|
+
const ref = importEqualsDecl.getModuleReference();
|
|
187
|
+
if (!Node.isExternalModuleReference(ref)) continue;
|
|
188
|
+
const arg = ref.getExpression();
|
|
189
|
+
if (arg && Node.isStringLiteral(arg) && FS_MODULES.has(arg.getLiteralValue())) {
|
|
190
|
+
violations.push({
|
|
191
|
+
line: importEqualsDecl.getStartLineNumber(),
|
|
192
|
+
moduleSpecifier: arg.getLiteralValue(),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
violations.push(...findDynamicFsRequires(sf));
|
|
198
|
+
|
|
199
|
+
return violations;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const guard: AstGuard = {
|
|
203
|
+
name: "No-Direct-Fs Guard",
|
|
204
|
+
scan: SCAN,
|
|
205
|
+
security: true,
|
|
206
|
+
hint: "Direkter node:fs-Import außerhalb der Allowlist — nutze FileStorageProvider (packages/framework/src/files/) statt fs selbst zu verdrahten. Path-Traversal-Guard existiert nur dort (resolveContainedPath). Legitimer neuer Tooling-Caller? Allowlist in guard-no-direct-fs.ts erweitern, mit Begründung + repo-Scope.",
|
|
207
|
+
run(files) {
|
|
208
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
209
|
+
const roots = resolveRepoRoots();
|
|
210
|
+
|
|
211
|
+
for (const sf of files) {
|
|
212
|
+
const file = sf.getFilePath();
|
|
213
|
+
const rel = relFromRepoRoot(file, roots);
|
|
214
|
+
if (EXCLUDE.test(rel)) continue;
|
|
215
|
+
const root = findRepoRootFor(file, roots);
|
|
216
|
+
if (isRepoAllowlisted(root?.name, rel)) continue;
|
|
217
|
+
for (const v of findDirectFsImports(sf)) {
|
|
218
|
+
violations.push({
|
|
219
|
+
// cwd-relative, not repo-relative `rel` — the security baseline
|
|
220
|
+
// needs an unambiguous path to resolve back to (repo, relPath).
|
|
221
|
+
file: pathRelative(process.cwd(), file),
|
|
222
|
+
line: v.line,
|
|
223
|
+
message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { violations };
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* No-Direct-Process-Env Guard.
|
|
4
|
+
*
|
|
5
|
+
* Apps with composed env-schemas (`bin/env.ts`) must read runtime config from
|
|
6
|
+
* the validated `env` export — not scattered `process.env.X` in prod paths.
|
|
7
|
+
*
|
|
8
|
+
* Scans bin/main.ts, other bin ts files (excl. allowlist), and src (excl. tests)
|
|
9
|
+
* in every repo that ships `bin/env.ts`. Allowed raw process.env:
|
|
10
|
+
* - bin/env.ts — sole parseEnv input
|
|
11
|
+
* - bin/server.ts — dev entrypoint (runDevApp)
|
|
12
|
+
* - bin/kumiko.ts — schema/migrate CLI (DATABASE_URL, INIT_CWD)
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { Glob } from "bun";
|
|
17
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
18
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
19
|
+
import { scanLinesForPredicate } from "./_lib/scan-lines";
|
|
20
|
+
|
|
21
|
+
// .tsx deliberately excluded — client screens have no runtime process.env
|
|
22
|
+
// access (bundler boundary), only .ts server/bin code reads process.env
|
|
23
|
+
// directly. Add .tsx here if that ever stops being true.
|
|
24
|
+
const SCAN_PATTERNS: ReadonlyArray<string> = ["bin/*.ts", "src/**/*.ts"];
|
|
25
|
+
|
|
26
|
+
/** bin/*.ts paths that may read process.env directly (see header). */
|
|
27
|
+
export const ALLOWED_BIN_FILES: ReadonlySet<string> = new Set([
|
|
28
|
+
"bin/env.ts",
|
|
29
|
+
"bin/server.ts",
|
|
30
|
+
"bin/kumiko.ts",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const EXCLUDE_DIR = /(?:^|\/)(?:node_modules|dist|__tests__)\//;
|
|
34
|
+
const IS_TEST = /\.(?:test|integration)\.tsx?$/;
|
|
35
|
+
|
|
36
|
+
// process.env.FOO | process.env.foo | process.env["FOO"] | process.env[`FOO`]
|
|
37
|
+
const PROCESS_ENV_REF = /process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:'[^']+'|"[^"]+"|`[^`]+`)\])/;
|
|
38
|
+
|
|
39
|
+
export type ProcessEnvFinding = {
|
|
40
|
+
readonly file: string;
|
|
41
|
+
readonly line: number;
|
|
42
|
+
readonly text: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Returns true when the line contains a live process.env reference. */
|
|
46
|
+
export function processEnvOnLine(line: string): boolean {
|
|
47
|
+
const code = line.replace(/\/\/.*$/, "");
|
|
48
|
+
return PROCESS_ENV_REF.test(code);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isScannableBinFile(rel: string): boolean {
|
|
52
|
+
return rel.startsWith("bin/") && rel.endsWith(".ts") && !ALLOWED_BIN_FILES.has(rel);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fail-scope deliberately covers every repo with bin/env.ts (rather than only
|
|
56
|
+
// hard-failing kumiko-studio and warning on the rest). Kept as a
|
|
57
|
+
// known-candidate allowlist (documents which repos use the bin/env.ts
|
|
58
|
+
// convention at all — "solon" is deliberately not on it) and intersected
|
|
59
|
+
// with the resolved roots, so a restricted scope actually narrows what this
|
|
60
|
+
// guard scans instead of always walking every repo regardless of scope
|
|
61
|
+
// (infra#721 follow-up — a money-horse-scoped push must not fail on a
|
|
62
|
+
// finding in publicstatus).
|
|
63
|
+
const CANDIDATE_ENV_TS_REPOS: ReadonlySet<string> = new Set([
|
|
64
|
+
"kumiko-studio",
|
|
65
|
+
"publicstatus",
|
|
66
|
+
"money-horse",
|
|
67
|
+
"phronexsis",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export function reposWithEnvTs(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): RepoRoot[] {
|
|
71
|
+
return roots.filter(
|
|
72
|
+
(root) => CANDIDATE_ENV_TS_REPOS.has(root.name) && existsSync(join(root.absPath, "bin/env.ts")),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findDirectProcessEnv(roots?: ReadonlyArray<RepoRoot>): ProcessEnvFinding[] {
|
|
77
|
+
return scanDirectProcessEnv(roots).findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Like findDirectProcessEnv, additionally reports the number of files read
|
|
82
|
+
* and repos with bin/env.ts — without that an empty scan can't be told apart
|
|
83
|
+
* from a clean one (infra#433).
|
|
84
|
+
*/
|
|
85
|
+
export function scanDirectProcessEnv(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): {
|
|
86
|
+
readonly findings: ProcessEnvFinding[];
|
|
87
|
+
readonly scannedFiles: number;
|
|
88
|
+
readonly reposPresent: number;
|
|
89
|
+
} {
|
|
90
|
+
const findings: ProcessEnvFinding[] = [];
|
|
91
|
+
let scannedFiles = 0;
|
|
92
|
+
const repos = reposWithEnvTs(roots);
|
|
93
|
+
for (const repo of repos) {
|
|
94
|
+
const repoDir = repo.absPath;
|
|
95
|
+
for (const pattern of SCAN_PATTERNS) {
|
|
96
|
+
for (const rel of new Glob(pattern).scanSync({ cwd: repoDir })) {
|
|
97
|
+
if (EXCLUDE_DIR.test(`/${rel}`) || IS_TEST.test(rel)) continue;
|
|
98
|
+
if (rel.startsWith("bin/") && !isScannableBinFile(rel)) continue;
|
|
99
|
+
const abs = join(repoDir, rel);
|
|
100
|
+
scannedFiles++;
|
|
101
|
+
scanLinesForPredicate(abs, `${repo.name}/${rel}`, processEnvOnLine, findings);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { findings, scannedFiles, reposPresent: repos.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const check: RepoCheck = {
|
|
109
|
+
name: "guard-no-direct-process-env",
|
|
110
|
+
hint: "Read runtime config from bin/env.ts (validated env export), not process.env in bin/main.ts, bin helpers, or src/.",
|
|
111
|
+
run(roots) {
|
|
112
|
+
const scan = scanDirectProcessEnv(roots);
|
|
113
|
+
return {
|
|
114
|
+
violations: scan.findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
|
|
115
|
+
matchedFiles: scan.scannedFiles,
|
|
116
|
+
// reposPresent as the applicability signal: no repo with bin/env.ts means
|
|
117
|
+
// not applicable, repos present but nothing scanned means broken resolution.
|
|
118
|
+
notApplicable: scan.reposPresent === 0,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (import.meta.main) {
|
|
124
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
125
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
126
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// No inline CSS in app code: `style={...}` props and `React.CSSProperties`
|
|
3
|
+
// style objects bypass theme tokens and dark mode. Use widgets/tokens.
|
|
4
|
+
//
|
|
5
|
+
// Part of App-Mounting 2.0 (infra#208).
|
|
6
|
+
|
|
7
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
8
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
9
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
10
|
+
|
|
11
|
+
const SCAN: ScanSpec = {
|
|
12
|
+
scope: "source",
|
|
13
|
+
extensions: ["ts", "tsx"],
|
|
14
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
15
|
+
};
|
|
16
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
17
|
+
const IGNORE_TAG = "kumiko-lint-ignore no-inline-styles";
|
|
18
|
+
|
|
19
|
+
export const guard: AstGuard = {
|
|
20
|
+
name: "No-Inline-Styles Guard (App-Repos)",
|
|
21
|
+
scan: SCAN,
|
|
22
|
+
hint:
|
|
23
|
+
"style=/CSSProperties in App-Code durch Widgets + Theme-Tokens ersetzen. " +
|
|
24
|
+
`Begründete Ausnahme (z.B. dynamische Breite aus Daten): // ${IGNORE_TAG} <Grund>`,
|
|
25
|
+
run(files: readonly SourceFile[]) {
|
|
26
|
+
const violations: GuardViolation[] = [];
|
|
27
|
+
for (const sf of files) {
|
|
28
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
29
|
+
for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
30
|
+
if (attr.getNameNode().getText() !== "style") continue;
|
|
31
|
+
if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
|
|
32
|
+
violations.push({
|
|
33
|
+
file: sf.getFilePath(),
|
|
34
|
+
line: attr.getStartLineNumber(),
|
|
35
|
+
message: "style=-Prop in App-Code (Theme-Tokens/Widgets nutzen)",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// ponytail: name-text comparison doesn't tolerate aliased imports
|
|
39
|
+
// (import { CSSProperties as CSS }) and false-positives on same-named
|
|
40
|
+
// local types. The style=-JsxAttribute above is the primary, more
|
|
41
|
+
// robust signal path — this TypeReference check is deliberately
|
|
42
|
+
// best-effort.
|
|
43
|
+
for (const ref of sf.getDescendantsOfKind(SyntaxKind.TypeReference)) {
|
|
44
|
+
const name = ref.getTypeName().getText();
|
|
45
|
+
if (name !== "CSSProperties" && name !== "React.CSSProperties") continue;
|
|
46
|
+
if (hasIgnoreTag(ref, IGNORE_TAG)) continue;
|
|
47
|
+
violations.push({
|
|
48
|
+
file: sf.getFilePath(),
|
|
49
|
+
line: ref.getStartLineNumber(),
|
|
50
|
+
message: "CSSProperties-Style-Objekt in App-Code (Theme-Tokens/Widgets nutzen)",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { violations };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (import.meta.main) runStandalone(guard);
|