@decocms/blocks-cli 7.20.4 → 7.20.6
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/scripts/audit-secrets.test.ts +74 -0
- package/scripts/audit-secrets.ts +327 -0
- package/scripts/migrate/phase-scaffold.ts +5 -0
- package/scripts/migrate/phase-verify.ts +2 -0
- package/scripts/migrate/templates/commerce-init.test.ts +55 -0
- package/scripts/migrate/templates/commerce-init.ts +42 -0
- package/scripts/migrate/templates/no-legacy-packages.test.ts +5 -0
- package/scripts/migrate/templates/server-entry.ts +8 -0
- package/scripts/migrate/templates/setup.ts +12 -9
- package/scripts/upgrade-loader-leak-fix.test.ts +72 -0
- package/scripts/upgrade-loader-leak-fix.ts +440 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"lint:unused": "knip"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@decocms/blocks": "7.20.
|
|
33
|
+
"@decocms/blocks": "7.20.6",
|
|
34
34
|
"ts-morph": "^27.0.0",
|
|
35
35
|
"tsx": "^4.22.5"
|
|
36
36
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isActionOrLoaderPath, scanFileForSecrets } from "./audit-secrets";
|
|
3
|
+
|
|
4
|
+
// All fixtures use a synthetic sentinel — never a real credential.
|
|
5
|
+
const FAKE = "FAKE0SECRET0SENTINEL0abcdef1234";
|
|
6
|
+
|
|
7
|
+
describe("isActionOrLoaderPath", () => {
|
|
8
|
+
it("matches Fresh- and TanStack-style action/loader paths", () => {
|
|
9
|
+
expect(isActionOrLoaderPath("actions/foo.ts")).toBe(true);
|
|
10
|
+
expect(isActionOrLoaderPath("src/actions/foo.ts")).toBe(true);
|
|
11
|
+
expect(isActionOrLoaderPath("src/loaders/bar/baz.ts")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("ignores non-action/loader paths", () => {
|
|
14
|
+
expect(isActionOrLoaderPath("src/sections/Hero.tsx")).toBe(false);
|
|
15
|
+
expect(isActionOrLoaderPath("src/components/actionsMenu.tsx")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("scanFileForSecrets — hardcoded credential in an action", () => {
|
|
20
|
+
it("flags a hardcoded Bearer token", () => {
|
|
21
|
+
const src = `export default async function h() {
|
|
22
|
+
const headers = { Authorization: "Bearer ${FAKE}" };
|
|
23
|
+
return fetch("https://api.example.com", { headers });
|
|
24
|
+
}`;
|
|
25
|
+
const f = scanFileForSecrets("src/actions/simulate.ts", src);
|
|
26
|
+
expect(f).toHaveLength(1);
|
|
27
|
+
expect(f[0].id).toBe("hardcoded_bearer_token");
|
|
28
|
+
expect(f[0].severity).toBe("error");
|
|
29
|
+
expect(f[0].line).toBe(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flags a keyed secret literal (apiKey/token/secret)", () => {
|
|
33
|
+
const src = `const apiKey = "${FAKE}";\n`;
|
|
34
|
+
const f = scanFileForSecrets("actions/pay.ts", src);
|
|
35
|
+
expect(f.map((x) => x.id)).toContain("hardcoded_secret_literal");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does NOT flag env/context/interpolated reads", () => {
|
|
39
|
+
const src = `const headers = {
|
|
40
|
+
Authorization: \`Bearer \${process.env.API_TOKEN}\`,
|
|
41
|
+
apiKey: ctx.state.config.apiKey,
|
|
42
|
+
token: process.env.TOKEN,
|
|
43
|
+
};`;
|
|
44
|
+
expect(scanFileForSecrets("src/actions/ok.ts", src)).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does NOT flag obvious placeholders", () => {
|
|
48
|
+
const src = `const token = "changeme";\nconst apiKey = "xxxxxxxxxxxx";\n`;
|
|
49
|
+
expect(scanFileForSecrets("src/actions/ph.ts", src)).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does NOT scan hardcoded literals OUTSIDE actions/loaders", () => {
|
|
53
|
+
const src = `const apiKey = "${FAKE}";\n`;
|
|
54
|
+
expect(scanFileForSecrets("src/sections/Hero.tsx", src)).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("scanFileForSecrets — server-only crypto in a client module", () => {
|
|
59
|
+
it('flags @decocms/blocks/sdk/crypto imported in a "use client" file', () => {
|
|
60
|
+
const src = `"use client";
|
|
61
|
+
import { resolveSecret } from "@decocms/blocks/sdk/crypto";
|
|
62
|
+
export function C() { return null; }`;
|
|
63
|
+
const f = scanFileForSecrets("src/components/C.tsx", src);
|
|
64
|
+
expect(f).toHaveLength(1);
|
|
65
|
+
expect(f[0].id).toBe("crypto_imported_in_client");
|
|
66
|
+
expect(f[0].severity).toBe("error");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does NOT flag crypto imported from a server module", () => {
|
|
70
|
+
const src = `import { resolveSecret } from "@decocms/blocks/sdk/crypto";
|
|
71
|
+
export function config() {}`;
|
|
72
|
+
expect(scanFileForSecrets("src/setup.ts", src)).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* @decocms/blocks-cli — hardcoded-secret audit
|
|
4
|
+
*
|
|
5
|
+
* Read-only source scanner. Catches the two ways a credential ends up
|
|
6
|
+
* publicly downloadable from a storefront's client assets:
|
|
7
|
+
*
|
|
8
|
+
* 1. A secret literal hardcoded in a site `action`/`loader` module. In a
|
|
9
|
+
* TanStack Start build these modules are registered in `.deco/loaders.gen.ts`
|
|
10
|
+
* behind a dynamic `import()`, and that file is reachable from the client
|
|
11
|
+
* entry (router -> setup -> commerce-loaders -> loaders.gen). Unless the
|
|
12
|
+
* framework stubs loaders.gen on the client (it now does — see
|
|
13
|
+
* packages/tanstack/src/vite/plugin.js), Vite emits each action/loader as a
|
|
14
|
+
* public chunk, baking the literal into the browser bundle. Even with the
|
|
15
|
+
* stub, a hardcoded credential in source is a committed-secret problem, so
|
|
16
|
+
* we flag it at author time.
|
|
17
|
+
*
|
|
18
|
+
* 2. An import of `@decocms/blocks/sdk/crypto` (the secret-decryption SDK)
|
|
19
|
+
* from a file marked `"use client"`. crypto is server-only; pulling it into
|
|
20
|
+
* a client module drags decryption code toward the browser bundle.
|
|
21
|
+
*
|
|
22
|
+
* The scanner intentionally only inspects `actions/` and `loaders/` directories
|
|
23
|
+
* for rule (1) — that's where request-time credentials live and where the
|
|
24
|
+
* bundle-leak path exists. Rule (2) applies to any file.
|
|
25
|
+
*
|
|
26
|
+
* CI-friendly: mirrors audit-observability-config.ts (Severity/Finding, --json,
|
|
27
|
+
* --mode warn|block, --github, exit 0/1/2).
|
|
28
|
+
*
|
|
29
|
+
* Usage (from a site or package directory):
|
|
30
|
+
* tsx audit-secrets.ts --source ./src
|
|
31
|
+
* tsx audit-secrets.ts --source ./src --mode block # exit 1 on error findings
|
|
32
|
+
* tsx audit-secrets.ts --json
|
|
33
|
+
* tsx audit-secrets.ts --github
|
|
34
|
+
*
|
|
35
|
+
* Exit codes:
|
|
36
|
+
* 0 — no findings, or `--mode warn` (default) regardless of findings
|
|
37
|
+
* 1 — `--mode block` and at least one `error`-severity finding
|
|
38
|
+
* 2 — source directory missing
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import * as fs from "node:fs";
|
|
42
|
+
import * as path from "node:path";
|
|
43
|
+
|
|
44
|
+
export type Severity = "error" | "warn" | "info";
|
|
45
|
+
|
|
46
|
+
export interface Finding {
|
|
47
|
+
id: string;
|
|
48
|
+
severity: Severity;
|
|
49
|
+
/** Repo-relative file the finding is in. */
|
|
50
|
+
file: string;
|
|
51
|
+
/** 1-indexed line the finding anchors to. */
|
|
52
|
+
line: number;
|
|
53
|
+
message: string;
|
|
54
|
+
/** Suggested remediation. */
|
|
55
|
+
fix?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type GateMode = "warn" | "block";
|
|
59
|
+
|
|
60
|
+
// A file is in scope for the hardcoded-secret rule if it lives under an
|
|
61
|
+
// actions/ or loaders/ directory (Fresh `actions/foo.ts` or TanStack
|
|
62
|
+
// `src/actions/foo.ts` both match on the path segment).
|
|
63
|
+
export function isActionOrLoaderPath(relPath: string): boolean {
|
|
64
|
+
const norm = relPath.replaceAll("\\", "/");
|
|
65
|
+
return /(^|\/)(actions|loaders)\//.test(norm);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Keys whose value being a plain string literal is a strong secret signal.
|
|
69
|
+
const SECRET_KEY =
|
|
70
|
+
"(?:api[_-]?key|token|secret|password|passwd|authorization|auth[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key)";
|
|
71
|
+
|
|
72
|
+
// A quoted literal with no `${` interpolation and a token-ish payload.
|
|
73
|
+
// Requires length >= 12 to avoid flagging short placeholders like "changeme".
|
|
74
|
+
const TOKENISH = "[A-Za-z0-9][A-Za-z0-9._\\-+/=]{11,}";
|
|
75
|
+
|
|
76
|
+
const BEARER_RE = new RegExp(`["'\`]\\s*Bearer\\s+(${TOKENISH})\\s*["'\`]`, "i");
|
|
77
|
+
const KEYED_SECRET_RE = new RegExp(`\\b${SECRET_KEY}\\b\\s*[:=]\\s*["'\`](${TOKENISH})["'\`]`, "i");
|
|
78
|
+
|
|
79
|
+
// Values that look like a real literal but are safe: obvious placeholders.
|
|
80
|
+
const PLACEHOLDER_RE =
|
|
81
|
+
/^(?:x+|y+|z+|changeme|placeholder|example|your[_-]?\w+|todo|xxx+|test|dummy|none|null|undefined)$/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scan a single file's source for hardcoded credentials + client crypto import.
|
|
85
|
+
* Pure — exported for unit testing.
|
|
86
|
+
*/
|
|
87
|
+
export function scanFileForSecrets(relPath: string, content: string): Finding[] {
|
|
88
|
+
const findings: Finding[] = [];
|
|
89
|
+
const lines = content.split("\n");
|
|
90
|
+
|
|
91
|
+
// Rule 2: crypto import in a "use client" file (any path).
|
|
92
|
+
const isClient = /^\s*["']use client["'];?\s*$/m.test(content);
|
|
93
|
+
if (isClient) {
|
|
94
|
+
lines.forEach((ln, i) => {
|
|
95
|
+
if (
|
|
96
|
+
/from\s+["']@decocms\/blocks\/sdk\/crypto["']/.test(ln) ||
|
|
97
|
+
/import\s*\(\s*["']@decocms\/blocks\/sdk\/crypto["']\s*\)/.test(ln)
|
|
98
|
+
) {
|
|
99
|
+
findings.push({
|
|
100
|
+
id: "crypto_imported_in_client",
|
|
101
|
+
severity: "error",
|
|
102
|
+
file: relPath,
|
|
103
|
+
line: i + 1,
|
|
104
|
+
message:
|
|
105
|
+
"`@decocms/blocks/sdk/crypto` (server-only secret decryption) is imported from a " +
|
|
106
|
+
'"use client" module. Decrypt secrets server-side (in an app configure()/loader) and ' +
|
|
107
|
+
"never in client-bundled code.",
|
|
108
|
+
fix: "Move the secret resolution to a server-only module (action/loader/app config).",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Rule 1: hardcoded secret literal in an action/loader.
|
|
115
|
+
if (isActionOrLoaderPath(relPath)) {
|
|
116
|
+
lines.forEach((ln, i) => {
|
|
117
|
+
for (const [id, re, label] of [
|
|
118
|
+
["hardcoded_bearer_token", BEARER_RE, "a Bearer token"],
|
|
119
|
+
["hardcoded_secret_literal", KEYED_SECRET_RE, "a credential"],
|
|
120
|
+
] as const) {
|
|
121
|
+
const m = re.exec(ln);
|
|
122
|
+
if (!m) continue;
|
|
123
|
+
const value = m[1];
|
|
124
|
+
if (PLACEHOLDER_RE.test(value)) continue;
|
|
125
|
+
// The regexes only match a literal token (the charset excludes `${}`),
|
|
126
|
+
// so `Bearer ${t}` / `token: process.env.X` never reach here. Both rules
|
|
127
|
+
// therefore only fire on a genuinely hardcoded string.
|
|
128
|
+
findings.push({
|
|
129
|
+
id,
|
|
130
|
+
severity: "error",
|
|
131
|
+
file: relPath,
|
|
132
|
+
line: i + 1,
|
|
133
|
+
message:
|
|
134
|
+
`Hardcoded ${label} in an action/loader. In a TanStack build this source can be ` +
|
|
135
|
+
"emitted as a public client chunk, exposing the credential in the browser assets. " +
|
|
136
|
+
"It is also a committed secret regardless of bundling.",
|
|
137
|
+
fix: "Move the value to an environment variable / CMS Secret and read it via ctx or process.env; then rotate the exposed credential.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const SKIP_DIRS = new Set([
|
|
147
|
+
"node_modules",
|
|
148
|
+
".git",
|
|
149
|
+
"dist",
|
|
150
|
+
"build",
|
|
151
|
+
".deco",
|
|
152
|
+
".vite",
|
|
153
|
+
".cache",
|
|
154
|
+
"coverage",
|
|
155
|
+
]);
|
|
156
|
+
const SCAN_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"]);
|
|
157
|
+
|
|
158
|
+
function walk(dir: string, out: string[]): void {
|
|
159
|
+
let entries: fs.Dirent[];
|
|
160
|
+
try {
|
|
161
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
const full = path.join(dir, e.name);
|
|
167
|
+
if (e.isDirectory()) {
|
|
168
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
169
|
+
walk(full, out);
|
|
170
|
+
} else if (e.isFile() && SCAN_EXT.has(path.extname(e.name))) {
|
|
171
|
+
// Don't scan test files or the scanner's own fixtures.
|
|
172
|
+
if (/\.test\.[cm]?[jt]sx?$/.test(e.name)) continue;
|
|
173
|
+
out.push(full);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Scan a directory tree. Pure-ish (reads the filesystem); returns findings. */
|
|
179
|
+
export function auditSourceDir(sourceDir: string): Finding[] {
|
|
180
|
+
const files: string[] = [];
|
|
181
|
+
walk(sourceDir, files);
|
|
182
|
+
const findings: Finding[] = [];
|
|
183
|
+
for (const full of files) {
|
|
184
|
+
const rel = path.relative(sourceDir, full);
|
|
185
|
+
let content: string;
|
|
186
|
+
try {
|
|
187
|
+
content = fs.readFileSync(full, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
findings.push(...scanFileForSecrets(rel, content));
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface CliOpts {
|
|
197
|
+
source: string;
|
|
198
|
+
json: boolean;
|
|
199
|
+
help: boolean;
|
|
200
|
+
mode: GateMode;
|
|
201
|
+
github: boolean;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseArgs(argv: string[]): CliOpts {
|
|
205
|
+
const opts: CliOpts = { source: ".", json: false, help: false, mode: "warn", github: false };
|
|
206
|
+
for (let i = 0; i < argv.length; i++) {
|
|
207
|
+
const flag = argv[i];
|
|
208
|
+
switch (flag) {
|
|
209
|
+
case "--source":
|
|
210
|
+
opts.source = argv[++i] ?? ".";
|
|
211
|
+
break;
|
|
212
|
+
case "--json":
|
|
213
|
+
opts.json = true;
|
|
214
|
+
break;
|
|
215
|
+
case "--mode": {
|
|
216
|
+
const value = argv[++i];
|
|
217
|
+
if (value !== "warn" && value !== "block") {
|
|
218
|
+
console.error(`audit-secrets: --mode must be "warn" or "block" (got "${value ?? ""}")`);
|
|
219
|
+
process.exit(2);
|
|
220
|
+
}
|
|
221
|
+
opts.mode = value;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "--github":
|
|
225
|
+
opts.github = true;
|
|
226
|
+
break;
|
|
227
|
+
case "--help":
|
|
228
|
+
case "-h":
|
|
229
|
+
opts.help = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return opts;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function showHelp(): void {
|
|
237
|
+
console.log(`
|
|
238
|
+
@decocms/blocks-cli — hardcoded-secret audit
|
|
239
|
+
|
|
240
|
+
Read-only scan for credentials hardcoded in action/loader source and for
|
|
241
|
+
server-only crypto imported into "use client" modules.
|
|
242
|
+
|
|
243
|
+
Usage:
|
|
244
|
+
tsx audit-secrets.ts [options]
|
|
245
|
+
|
|
246
|
+
Options:
|
|
247
|
+
--source <dir> Directory to scan (default: .)
|
|
248
|
+
--json Emit findings as JSON
|
|
249
|
+
--mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
|
|
250
|
+
--github Emit ::error::/::warning:: lines for GitHub Actions
|
|
251
|
+
--help, -h This message
|
|
252
|
+
|
|
253
|
+
Exit codes:
|
|
254
|
+
0 no findings, OR --mode warn (default)
|
|
255
|
+
1 --mode block AND at least one error-severity finding
|
|
256
|
+
2 source directory missing
|
|
257
|
+
`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function findingsToText(findings: Finding[]): string {
|
|
261
|
+
if (findings.length === 0) return "OK no hardcoded secrets found";
|
|
262
|
+
const lines = ["Hardcoded-secret findings:"];
|
|
263
|
+
for (const f of findings) {
|
|
264
|
+
lines.push(` [${f.severity.toUpperCase()}] ${f.id} — ${f.file}:${f.line}`);
|
|
265
|
+
lines.push(` ${f.message}`);
|
|
266
|
+
if (f.fix) lines.push(` fix: ${f.fix}`);
|
|
267
|
+
}
|
|
268
|
+
lines.push("");
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function main(): void {
|
|
273
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
274
|
+
if (opts.help) {
|
|
275
|
+
showHelp();
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const dir = path.resolve(opts.source);
|
|
280
|
+
if (!fs.existsSync(dir)) {
|
|
281
|
+
console.error(`audit-secrets: ${dir} not found`);
|
|
282
|
+
process.exit(2);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const findings = auditSourceDir(dir);
|
|
286
|
+
|
|
287
|
+
if (opts.json) {
|
|
288
|
+
process.stdout.write(
|
|
289
|
+
JSON.stringify({ source: dir, mode: opts.mode, findings }, null, 2) + "\n",
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
process.stdout.write(findingsToText(findings) + "\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (opts.github) {
|
|
296
|
+
for (const f of findings) {
|
|
297
|
+
const level =
|
|
298
|
+
opts.mode === "block" && f.severity === "error"
|
|
299
|
+
? "error"
|
|
300
|
+
: f.severity === "info"
|
|
301
|
+
? "notice"
|
|
302
|
+
: "warning";
|
|
303
|
+
const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
|
|
304
|
+
const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
305
|
+
process.stdout.write(`::${level} file=${f.file},line=${f.line},title=${f.id}::${escaped}\n`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const shouldFail = opts.mode === "block" && findings.some((f) => f.severity === "error");
|
|
310
|
+
process.exit(shouldFail ? 1 : 0);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const isCjsEntry =
|
|
314
|
+
typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
|
|
315
|
+
let isEsmEntry = false;
|
|
316
|
+
try {
|
|
317
|
+
isEsmEntry =
|
|
318
|
+
typeof process !== "undefined" &&
|
|
319
|
+
Array.isArray(process.argv) &&
|
|
320
|
+
process.argv[1] !== undefined &&
|
|
321
|
+
import.meta.url === `file://${process.argv[1]}`;
|
|
322
|
+
} catch {
|
|
323
|
+
// ignore in CJS
|
|
324
|
+
}
|
|
325
|
+
if (isCjsEntry || isEsmEntry) {
|
|
326
|
+
main();
|
|
327
|
+
}
|
|
@@ -14,6 +14,7 @@ import { generateAppCss } from "./templates/app-css";
|
|
|
14
14
|
import { generateTypeFiles } from "./templates/types-gen";
|
|
15
15
|
import { generateUiComponents } from "./templates/ui-components";
|
|
16
16
|
import { generateHooks } from "./templates/hooks";
|
|
17
|
+
import { generateCommerceInit } from "./templates/commerce-init";
|
|
17
18
|
import { generateCommerceLoaders } from "./templates/commerce-loaders";
|
|
18
19
|
import { generateSectionLoaders } from "./templates/section-loaders";
|
|
19
20
|
import { generateCacheConfig } from "./templates/cache-config";
|
|
@@ -110,6 +111,10 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
110
111
|
writeFile(ctx, "src/setup.ts", generateSetup(ctx));
|
|
111
112
|
writeFile(ctx, "src/cache-config.ts", generateCacheConfig(ctx));
|
|
112
113
|
writeFile(ctx, "src/setup/commerce-loaders.ts", generateCommerceLoaders(ctx));
|
|
114
|
+
// Server-only registration of COMMERCE_LOADERS + invoke — imported by
|
|
115
|
+
// worker-entry.ts, not setup.ts, so the loader/action graph stays out of the
|
|
116
|
+
// client bundle.
|
|
117
|
+
writeFile(ctx, "src/setup/commerce-init.ts", generateCommerceInit(ctx));
|
|
113
118
|
writeFile(ctx, "src/setup/section-loaders.ts", generateSectionLoaders(ctx));
|
|
114
119
|
|
|
115
120
|
// Theme extraction + Styles
|
|
@@ -32,6 +32,7 @@ const REQUIRED_FILES = [
|
|
|
32
32
|
"src/setup.ts",
|
|
33
33
|
"src/cache-config.ts",
|
|
34
34
|
"src/setup/commerce-loaders.ts",
|
|
35
|
+
"src/setup/commerce-init.ts",
|
|
35
36
|
"src/setup/section-loaders.ts",
|
|
36
37
|
"src/styles/app.css",
|
|
37
38
|
"src/apps/site.ts",
|
|
@@ -417,6 +418,7 @@ export const checks: Check[] = [
|
|
|
417
418
|
"src/setup.ts",
|
|
418
419
|
"src/cache-config.ts",
|
|
419
420
|
"src/setup/commerce-loaders.ts",
|
|
421
|
+
"src/setup/commerce-init.ts",
|
|
420
422
|
"src/setup/section-loaders.ts",
|
|
421
423
|
];
|
|
422
424
|
const missing = setupFiles.filter(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { MigrationContext } from "../types";
|
|
3
|
+
import { createContext } from "../types";
|
|
4
|
+
import { generateCommerceInit } from "./commerce-init";
|
|
5
|
+
import { generateServerEntry } from "./server-entry";
|
|
6
|
+
import { generateSetup } from "./setup";
|
|
7
|
+
|
|
8
|
+
function makeCtx(platform: MigrationContext["platform"]): MigrationContext {
|
|
9
|
+
const ctx = createContext("/tmp/commerce-init-template-fixture-site");
|
|
10
|
+
ctx.siteName = "acme-storefront";
|
|
11
|
+
ctx.platform = platform;
|
|
12
|
+
ctx.vtexAccount = platform === "vtex" ? "acme" : null;
|
|
13
|
+
return ctx;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Regression guard for the site loader/action bundle-leak boundary.
|
|
18
|
+
*
|
|
19
|
+
* COMMERCE_LOADERS (and every site loader/action module it imports) must be
|
|
20
|
+
* registered in a server-only module imported by the worker entry, NEVER by
|
|
21
|
+
* setup.ts — which router.tsx imports into the client bundle. If the
|
|
22
|
+
* registration leaks back into setup.ts, the whole loader/action graph (and any
|
|
23
|
+
* credential hardcoded in it) ships to the browser assets again.
|
|
24
|
+
*/
|
|
25
|
+
describe("server-only commerce/invoke registration boundary", () => {
|
|
26
|
+
for (const platform of ["vtex", "custom"] as const) {
|
|
27
|
+
describe(`platform: ${platform}`, () => {
|
|
28
|
+
const ctx = makeCtx(platform);
|
|
29
|
+
const commerceInit = generateCommerceInit(ctx);
|
|
30
|
+
const setup = generateSetup(ctx);
|
|
31
|
+
const serverFiles = generateServerEntry(ctx);
|
|
32
|
+
|
|
33
|
+
it("commerce-init registers COMMERCE_LOADERS + invoke server-side", () => {
|
|
34
|
+
expect(commerceInit).toContain(`import { COMMERCE_LOADERS } from "./commerce-loaders"`);
|
|
35
|
+
expect(commerceInit).toContain("registerCommerceLoaders(COMMERCE_LOADERS)");
|
|
36
|
+
expect(commerceInit).toContain("setInvokeLoaders(() => COMMERCE_LOADERS)");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("setup.ts (client-imported) does NOT import or register COMMERCE_LOADERS", () => {
|
|
40
|
+
// The doc comment may reference COMMERCE_LOADERS; what must be absent is
|
|
41
|
+
// the actual import + the server-only registration calls.
|
|
42
|
+
expect(setup).not.toContain('from "./setup/commerce-loaders"');
|
|
43
|
+
expect(setup).not.toContain("registerCommerceLoaders(");
|
|
44
|
+
expect(setup).not.toContain("setInvokeLoaders(");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("worker-entry imports commerce-init, router.tsx does not", () => {
|
|
48
|
+
expect(serverFiles["src/worker-entry.ts"]).toContain('import "./setup/commerce-init"');
|
|
49
|
+
expect(serverFiles["src/router.tsx"]).not.toContain("commerce-init");
|
|
50
|
+
// router.tsx still imports the client-safe setup for section registration.
|
|
51
|
+
expect(serverFiles["src/router.tsx"]).toContain('import "./setup"');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MigrationContext } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-only commerce + invoke registration.
|
|
5
|
+
*
|
|
6
|
+
* `commerce-loaders.ts` statically imports COMMERCE_LOADERS, which pulls in the
|
|
7
|
+
* site's loader/action modules and the platform commerce loaders. If that graph
|
|
8
|
+
* is reachable from the CLIENT entry (router.tsx -> setup.ts), Vite bundles all
|
|
9
|
+
* of it — and any credential hardcoded in a site loader/action — into the
|
|
10
|
+
* browser assets. So the registration lives here instead and is imported ONLY by
|
|
11
|
+
* the worker entry (server), never by router.tsx.
|
|
12
|
+
*
|
|
13
|
+
* This is safe because both consumers run server-side:
|
|
14
|
+
* - CMS commerce-loader resolution (loadCmsPage server fn), via
|
|
15
|
+
* registerCommerceLoaders.
|
|
16
|
+
* - the /deco/invoke handler, via setInvokeLoaders(() => COMMERCE_LOADERS)
|
|
17
|
+
* -> getRegisteredLoaders() inside handleInvoke.
|
|
18
|
+
*
|
|
19
|
+
* Client components reach loaders/actions exclusively through the HTTP `invoke`
|
|
20
|
+
* proxy (@decocms/blocks/sdk/invoke), which imports no modules.
|
|
21
|
+
*/
|
|
22
|
+
export function generateCommerceInit(_ctx: MigrationContext): string {
|
|
23
|
+
return `/**
|
|
24
|
+
* Server-only commerce + invoke registration.
|
|
25
|
+
*
|
|
26
|
+
* Imported ONLY by the worker entry (src/worker-entry.ts), never by
|
|
27
|
+
* src/router.tsx. This keeps COMMERCE_LOADERS — and every site loader/action
|
|
28
|
+
* module it imports — out of the client bundle, so no server-side credential
|
|
29
|
+
* can leak into the browser assets. Both consumers run server-side:
|
|
30
|
+
* - CMS commerce-loader resolution (registerCommerceLoaders)
|
|
31
|
+
* - the /deco/invoke handler (setInvokeLoaders)
|
|
32
|
+
* The client reaches loaders/actions only via the HTTP invoke proxy.
|
|
33
|
+
*/
|
|
34
|
+
import { registerCommerceLoaders } from "@decocms/blocks/cms";
|
|
35
|
+
import { setInvokeLoaders } from "@decocms/blocks-admin";
|
|
36
|
+
|
|
37
|
+
import { COMMERCE_LOADERS } from "./commerce-loaders";
|
|
38
|
+
|
|
39
|
+
registerCommerceLoaders(COMMERCE_LOADERS);
|
|
40
|
+
setInvokeLoaders(() => COMMERCE_LOADERS);
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
@@ -5,6 +5,7 @@ import { generateRoutes } from "./routes";
|
|
|
5
5
|
import { generateServerEntry } from "./server-entry";
|
|
6
6
|
import { generateSetup } from "./setup";
|
|
7
7
|
import { generateViteConfig } from "./vite-config";
|
|
8
|
+
import { generateCommerceInit } from "./commerce-init";
|
|
8
9
|
import { generateCommerceLoaders } from "./commerce-loaders";
|
|
9
10
|
import { generateSectionLoaders } from "./section-loaders";
|
|
10
11
|
import { generateHooks } from "./hooks";
|
|
@@ -76,6 +77,10 @@ describe("scaffolder templates never emit @decocms/start or @decocms/apps/*", ()
|
|
|
76
77
|
);
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
it("commerce-init.ts", () => {
|
|
81
|
+
assertNoLegacyPackageNames("commerce-init.ts", generateCommerceInit(ctx));
|
|
82
|
+
});
|
|
83
|
+
|
|
79
84
|
it("section-loaders.ts", () => {
|
|
80
85
|
assertNoLegacyPackageNames(
|
|
81
86
|
"section-loaders.ts",
|
|
@@ -65,6 +65,10 @@ function generateWorkerEntry(ctx: MigrationContext): string {
|
|
|
65
65
|
* npx -p @decocms/blocks-cli deco-cf-observability --write
|
|
66
66
|
*/
|
|
67
67
|
import "./setup";
|
|
68
|
+
// Server-only: registers COMMERCE_LOADERS + invoke. Kept out of ./setup (which
|
|
69
|
+
// router.tsx imports) so the loader/action module graph never enters the client
|
|
70
|
+
// bundle. See setup/commerce-init.ts.
|
|
71
|
+
import "./setup/commerce-init";
|
|
68
72
|
import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
|
|
69
73
|
import { createDecoWorkerEntry } from "@decocms/tanstack";
|
|
70
74
|
import { instrumentWorker } from "@decocms/blocks/sdk/observability";
|
|
@@ -109,6 +113,10 @@ function generateVtexWorkerEntry(ctx: MigrationContext): string {
|
|
|
109
113
|
const vtexAccount = ctx.vtexAccount || ctx.siteName;
|
|
110
114
|
|
|
111
115
|
return `import "./setup";
|
|
116
|
+
// Server-only: registers COMMERCE_LOADERS + invoke. Kept out of ./setup (which
|
|
117
|
+
// router.tsx imports) so the loader/action module graph never enters the client
|
|
118
|
+
// bundle. See setup/commerce-init.ts.
|
|
119
|
+
import "./setup/commerce-init";
|
|
112
120
|
import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
|
|
113
121
|
import { createDecoWorkerEntry } from "@decocms/tanstack";
|
|
114
122
|
import { instrumentWorker } from "@decocms/blocks/sdk/observability";
|
|
@@ -82,20 +82,23 @@ export function generateSetup(ctx: MigrationContext): string {
|
|
|
82
82
|
*
|
|
83
83
|
* Actual logic lives in focused modules:
|
|
84
84
|
* setup/commerce-loaders.ts — COMMERCE_LOADERS map (VTEX + site data fetchers)
|
|
85
|
+
* setup/commerce-init.ts — server-only registration of COMMERCE_LOADERS +
|
|
86
|
+
* invoke. Imported by worker-entry.ts, NOT here,
|
|
87
|
+
* so the loader/action module graph (and any
|
|
88
|
+
* credential in it) never enters the client bundle.
|
|
85
89
|
* setup/section-loaders.ts — registerSectionLoaders (per-section prop enrichment)
|
|
86
90
|
*
|
|
91
|
+
* This file IS imported by router.tsx (client) for section registration/hydration,
|
|
92
|
+
* so it must stay free of server-only imports (COMMERCE_LOADERS, invoke, secrets).
|
|
93
|
+
*
|
|
87
94
|
* Section metadata (eager, sync, layout, cache, LoadingFallback) is declared
|
|
88
95
|
* in each section file and auto-extracted by generate-sections.ts.
|
|
89
96
|
*/
|
|
90
97
|
|
|
91
98
|
import "./cache-config";
|
|
92
99
|
|
|
93
|
-
import {
|
|
94
|
-
|
|
95
|
-
applySectionConventions,
|
|
96
|
-
} from "@decocms/blocks/cms";
|
|
97
|
-
import { createSiteSetup } from "@decocms/blocks/setup";
|
|
98
|
-
import { setInvokeLoaders } from "@decocms/blocks-admin";${isVtex ? `
|
|
100
|
+
import { applySectionConventions } from "@decocms/blocks/cms";
|
|
101
|
+
import { createSiteSetup } from "@decocms/blocks/setup";${isVtex ? `
|
|
99
102
|
import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch";
|
|
100
103
|
import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${hasLocationMatcher ? `
|
|
101
104
|
import { registerLocationMatcher } from "./matchers/location";` : ""}
|
|
@@ -105,7 +108,6 @@ import { PreviewProviders } from "@decocms/tanstack";
|
|
|
105
108
|
// @ts-ignore Vite ?url import
|
|
106
109
|
import appCss from "./styles/app.css?url";
|
|
107
110
|
|
|
108
|
-
import { COMMERCE_LOADERS } from "./setup/commerce-loaders";
|
|
109
111
|
import "./setup/section-loaders";
|
|
110
112
|
|
|
111
113
|
// -- Framework setup --
|
|
@@ -142,7 +144,8 @@ applySectionConventions({
|
|
|
142
144
|
});
|
|
143
145
|
|
|
144
146
|
// -- Commerce + invoke --
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// Registration lives in setup/commerce-init.ts, imported ONLY by
|
|
148
|
+
// src/worker-entry.ts (server). Importing it here would drag COMMERCE_LOADERS —
|
|
149
|
+
// and every site loader/action module it references — into the client bundle.
|
|
147
150
|
`;
|
|
148
151
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyDepBumps,
|
|
4
|
+
isAffectedSite,
|
|
5
|
+
planDepBumps,
|
|
6
|
+
type SitePkg,
|
|
7
|
+
} from "./upgrade-loader-leak-fix";
|
|
8
|
+
|
|
9
|
+
function pkg(over: Partial<SitePkg> = {}): SitePkg {
|
|
10
|
+
return {
|
|
11
|
+
dependencies: {
|
|
12
|
+
"@decocms/blocks": "^7.20.2",
|
|
13
|
+
"@decocms/tanstack": "^7.20.2",
|
|
14
|
+
"@decocms/apps-vtex": "^7.20.2",
|
|
15
|
+
react: "19.0.0",
|
|
16
|
+
"@tanstack/react-start": "1.166.8",
|
|
17
|
+
},
|
|
18
|
+
devDependencies: {
|
|
19
|
+
"@decocms/blocks-cli": "^7.20.2",
|
|
20
|
+
typescript: "^5.9.0",
|
|
21
|
+
},
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("isAffectedSite", () => {
|
|
27
|
+
it("true when @decocms/tanstack is a dependency", () => {
|
|
28
|
+
expect(isAffectedSite(pkg())).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it("false for a non-deco or non-tanstack site", () => {
|
|
31
|
+
expect(isAffectedSite({ dependencies: { react: "19.0.0" } })).toBe(false);
|
|
32
|
+
expect(isAffectedSite({ dependencies: { "@decocms/nextjs": "^7.20.2" } })).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("planDepBumps", () => {
|
|
37
|
+
it("bumps every @decocms/* dep across deps + devDeps, leaving others alone", () => {
|
|
38
|
+
const bumps = planDepBumps(pkg(), "^7.21.0");
|
|
39
|
+
const names = bumps.map((b) => b.name).sort();
|
|
40
|
+
expect(names).toEqual([
|
|
41
|
+
"@decocms/apps-vtex",
|
|
42
|
+
"@decocms/blocks",
|
|
43
|
+
"@decocms/blocks-cli",
|
|
44
|
+
"@decocms/tanstack",
|
|
45
|
+
]);
|
|
46
|
+
// non-@decocms deps are never touched
|
|
47
|
+
expect(names).not.toContain("react");
|
|
48
|
+
expect(names).not.toContain("@tanstack/react-start");
|
|
49
|
+
expect(names).not.toContain("typescript");
|
|
50
|
+
// devDeps are covered
|
|
51
|
+
expect(bumps.find((b) => b.name === "@decocms/blocks-cli")?.field).toBe("devDependencies");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns nothing when already at the target spec (idempotent)", () => {
|
|
55
|
+
const bumped = applyDepBumps(pkg(), planDepBumps(pkg(), "^7.21.0"));
|
|
56
|
+
expect(planDepBumps(bumped, "^7.21.0")).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("applyDepBumps", () => {
|
|
61
|
+
it("rewrites only @decocms/* specs and does not mutate the input", () => {
|
|
62
|
+
const original = pkg();
|
|
63
|
+
const next = applyDepBumps(original, planDepBumps(original, "^7.21.0"));
|
|
64
|
+
expect(next.dependencies!["@decocms/tanstack"]).toBe("^7.21.0");
|
|
65
|
+
expect(next.dependencies!["@decocms/apps-vtex"]).toBe("^7.21.0");
|
|
66
|
+
expect(next.devDependencies!["@decocms/blocks-cli"]).toBe("^7.21.0");
|
|
67
|
+
// untouched
|
|
68
|
+
expect(next.dependencies!.react).toBe("19.0.0");
|
|
69
|
+
// input unchanged
|
|
70
|
+
expect(original.dependencies!["@decocms/tanstack"]).toBe("^7.20.2");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* @decocms/blocks-cli — loader/action bundle-leak rollout
|
|
4
|
+
*
|
|
5
|
+
* Updates one or more already-in-prod TanStack Start sites so site
|
|
6
|
+
* loaders/actions (and any credential hardcoded in them) stop being emitted
|
|
7
|
+
* into the public client bundle.
|
|
8
|
+
*
|
|
9
|
+
* The actual fix ships in the framework: `@decocms/tanstack`'s vite plugin
|
|
10
|
+
* stubs `.deco/loaders.gen.ts` on the client, so once a site is on a fixed
|
|
11
|
+
* version its NEXT build is safe — no site source change is required. This
|
|
12
|
+
* script therefore just:
|
|
13
|
+
*
|
|
14
|
+
* 1. bumps every `@decocms/*` dependency to the fixed version,
|
|
15
|
+
* 2. installs (updates the lockfile),
|
|
16
|
+
* 3. audits the site source for credentials hardcoded in actions/loaders
|
|
17
|
+
* (these must be ROTATED by hand — a leaked secret stays compromised even
|
|
18
|
+
* after it stops shipping), and
|
|
19
|
+
* 4. optionally rebuilds and greps the client output for a known leaked
|
|
20
|
+
* value to confirm it's gone.
|
|
21
|
+
*
|
|
22
|
+
* It never deploys. After a green run, redeploy with your normal pipeline
|
|
23
|
+
* (e.g. `wrangler deploy`).
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* tsx upgrade-loader-leak-fix.ts [siteDir...] [options]
|
|
27
|
+
* tsx upgrade-loader-leak-fix.ts ./site-a ./site-b --version 7.21.0
|
|
28
|
+
* tsx upgrade-loader-leak-fix.ts . --dry-run
|
|
29
|
+
* tsx upgrade-loader-leak-fix.ts . --build --verify-secret "$LEAKED_TOKEN"
|
|
30
|
+
*
|
|
31
|
+
* Options:
|
|
32
|
+
* --version <v> Target @decocms/* version (e.g. 7.21.0). Default: resolve
|
|
33
|
+
* the latest published @decocms/tanstack via `npm view`.
|
|
34
|
+
* --exact Pin exact ("7.21.0") instead of caret ("^7.21.0").
|
|
35
|
+
* --pm <bun|npm|pnpm> Package manager. Default: detect from lockfile.
|
|
36
|
+
* --no-install Don't run install after bumping package.json.
|
|
37
|
+
* --build Run the site's build script after install.
|
|
38
|
+
* --verify-secret <s> After --build, fail if <s> appears in the client output.
|
|
39
|
+
* Supply your known-leaked token at runtime; never commit it.
|
|
40
|
+
* --dry-run Show what would change; write/run nothing.
|
|
41
|
+
* --json Machine-readable summary.
|
|
42
|
+
* --help, -h This message.
|
|
43
|
+
*
|
|
44
|
+
* Exit codes:
|
|
45
|
+
* 0 every site updated cleanly (any hardcoded-secret findings are reported
|
|
46
|
+
* as warnings — rotation is a manual step this script can't do)
|
|
47
|
+
* 1 at least one site failed (install/build/verify error) OR --verify-secret
|
|
48
|
+
* was still present in a client build
|
|
49
|
+
* 2 bad arguments / no site found
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { execFileSync } from "node:child_process";
|
|
53
|
+
import * as fs from "node:fs";
|
|
54
|
+
import * as path from "node:path";
|
|
55
|
+
import { auditSourceDir, type Finding } from "./audit-secrets";
|
|
56
|
+
|
|
57
|
+
export interface SitePkg {
|
|
58
|
+
dependencies?: Record<string, string>;
|
|
59
|
+
devDependencies?: Record<string, string>;
|
|
60
|
+
scripts?: Record<string, string>;
|
|
61
|
+
[k: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DECO_SCOPE = /^@decocms\//;
|
|
65
|
+
|
|
66
|
+
/** A site is affected if it depends on the TanStack framework binding. */
|
|
67
|
+
export function isAffectedSite(pkg: SitePkg): boolean {
|
|
68
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
69
|
+
return Boolean(all["@decocms/tanstack"]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DepBump {
|
|
73
|
+
name: string;
|
|
74
|
+
field: "dependencies" | "devDependencies";
|
|
75
|
+
from: string;
|
|
76
|
+
to: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compute the version-spec changes needed to move every `@decocms/*` dep to
|
|
81
|
+
* `spec`. Pure — exported for testing. Only returns entries that actually change.
|
|
82
|
+
*/
|
|
83
|
+
export function planDepBumps(pkg: SitePkg, spec: string): DepBump[] {
|
|
84
|
+
const bumps: DepBump[] = [];
|
|
85
|
+
for (const field of ["dependencies", "devDependencies"] as const) {
|
|
86
|
+
const deps = pkg[field];
|
|
87
|
+
if (!deps) continue;
|
|
88
|
+
for (const [name, from] of Object.entries(deps)) {
|
|
89
|
+
if (!DECO_SCOPE.test(name)) continue;
|
|
90
|
+
if (from === spec) continue;
|
|
91
|
+
bumps.push({ name, field, from, to: spec });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return bumps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Apply bumps in place on a cloned pkg. Pure — returns the new object. */
|
|
98
|
+
export function applyDepBumps(pkg: SitePkg, bumps: DepBump[]): SitePkg {
|
|
99
|
+
const next: SitePkg = JSON.parse(JSON.stringify(pkg));
|
|
100
|
+
for (const b of bumps) {
|
|
101
|
+
const deps = next[b.field] as Record<string, string> | undefined;
|
|
102
|
+
if (deps) deps[b.name] = b.to;
|
|
103
|
+
}
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type PkgManager = "bun" | "npm" | "pnpm";
|
|
108
|
+
|
|
109
|
+
function detectPm(siteDir: string): PkgManager {
|
|
110
|
+
if (
|
|
111
|
+
fs.existsSync(path.join(siteDir, "bun.lock")) ||
|
|
112
|
+
fs.existsSync(path.join(siteDir, "bun.lockb"))
|
|
113
|
+
) {
|
|
114
|
+
return "bun";
|
|
115
|
+
}
|
|
116
|
+
if (fs.existsSync(path.join(siteDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
117
|
+
return "npm";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function run(cmd: string, args: string[], cwd: string): void {
|
|
121
|
+
execFileSync(cmd, args, { cwd, stdio: "inherit" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Resolve the latest published version of a package via `npm view`. */
|
|
125
|
+
function resolveLatest(pkgName: string): string {
|
|
126
|
+
const out = execFileSync("npm", ["view", pkgName, "version"], {
|
|
127
|
+
encoding: "utf8",
|
|
128
|
+
}).trim();
|
|
129
|
+
if (!/^\d+\.\d+\.\d+/.test(out)) {
|
|
130
|
+
throw new Error(`could not resolve latest version of ${pkgName} (got "${out}")`);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Directories a Vite/TanStack-Start + CF Workers build may emit client assets to.
|
|
136
|
+
const CLIENT_OUTPUT_DIRS = ["dist", ".output", "build", ".vercel/output", ".wrangler"];
|
|
137
|
+
const CLIENT_ASSET_EXT = new Set([".js", ".mjs", ".cjs", ".css", ".html", ".json", ".map"]);
|
|
138
|
+
|
|
139
|
+
/** Recursively collect built client asset files under a site dir. */
|
|
140
|
+
function collectClientAssets(siteDir: string): string[] {
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
const walk = (dir: string) => {
|
|
143
|
+
let entries: fs.Dirent[];
|
|
144
|
+
try {
|
|
145
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
146
|
+
} catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
const full = path.join(dir, e.name);
|
|
151
|
+
if (e.isDirectory()) {
|
|
152
|
+
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
153
|
+
walk(full);
|
|
154
|
+
} else if (e.isFile() && CLIENT_ASSET_EXT.has(path.extname(e.name))) {
|
|
155
|
+
out.push(full);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
for (const d of CLIENT_OUTPUT_DIRS) {
|
|
160
|
+
const full = path.join(siteDir, d);
|
|
161
|
+
if (fs.existsSync(full)) walk(full);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Grep built client assets for a literal. Returns matching file paths. */
|
|
167
|
+
export function findSecretInAssets(files: string[], secret: string): string[] {
|
|
168
|
+
const hits: string[] = [];
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
let content: string;
|
|
171
|
+
try {
|
|
172
|
+
content = fs.readFileSync(f, "utf8");
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (content.includes(secret)) hits.push(f);
|
|
177
|
+
}
|
|
178
|
+
return hits;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface SiteResult {
|
|
182
|
+
site: string;
|
|
183
|
+
affected: boolean;
|
|
184
|
+
bumps: DepBump[];
|
|
185
|
+
secretFindings: Finding[];
|
|
186
|
+
built: boolean;
|
|
187
|
+
leakedAssetFiles: string[];
|
|
188
|
+
error?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface CliOpts {
|
|
192
|
+
sites: string[];
|
|
193
|
+
version: string | null;
|
|
194
|
+
exact: boolean;
|
|
195
|
+
pm: PkgManager | null;
|
|
196
|
+
install: boolean;
|
|
197
|
+
build: boolean;
|
|
198
|
+
verifySecret: string | null;
|
|
199
|
+
dryRun: boolean;
|
|
200
|
+
json: boolean;
|
|
201
|
+
help: boolean;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseArgs(argv: string[]): CliOpts {
|
|
205
|
+
const opts: CliOpts = {
|
|
206
|
+
sites: [],
|
|
207
|
+
version: null,
|
|
208
|
+
exact: false,
|
|
209
|
+
pm: null,
|
|
210
|
+
install: true,
|
|
211
|
+
build: false,
|
|
212
|
+
verifySecret: null,
|
|
213
|
+
dryRun: false,
|
|
214
|
+
json: false,
|
|
215
|
+
help: false,
|
|
216
|
+
};
|
|
217
|
+
for (let i = 0; i < argv.length; i++) {
|
|
218
|
+
const a = argv[i];
|
|
219
|
+
switch (a) {
|
|
220
|
+
case "--version":
|
|
221
|
+
opts.version = argv[++i] ?? null;
|
|
222
|
+
break;
|
|
223
|
+
case "--exact":
|
|
224
|
+
opts.exact = true;
|
|
225
|
+
break;
|
|
226
|
+
case "--pm": {
|
|
227
|
+
const v = argv[++i];
|
|
228
|
+
if (v !== "bun" && v !== "npm" && v !== "pnpm") {
|
|
229
|
+
console.error(`upgrade: --pm must be bun|npm|pnpm (got "${v ?? ""}")`);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
opts.pm = v;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "--no-install":
|
|
236
|
+
opts.install = false;
|
|
237
|
+
break;
|
|
238
|
+
case "--build":
|
|
239
|
+
opts.build = true;
|
|
240
|
+
break;
|
|
241
|
+
case "--verify-secret":
|
|
242
|
+
opts.verifySecret = argv[++i] ?? null;
|
|
243
|
+
break;
|
|
244
|
+
case "--dry-run":
|
|
245
|
+
opts.dryRun = true;
|
|
246
|
+
break;
|
|
247
|
+
case "--json":
|
|
248
|
+
opts.json = true;
|
|
249
|
+
break;
|
|
250
|
+
case "--help":
|
|
251
|
+
case "-h":
|
|
252
|
+
opts.help = true;
|
|
253
|
+
break;
|
|
254
|
+
default:
|
|
255
|
+
if (a.startsWith("--")) {
|
|
256
|
+
console.error(`upgrade: unknown option "${a}"`);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
opts.sites.push(a);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (opts.sites.length === 0) opts.sites.push(".");
|
|
263
|
+
return opts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function showHelp(): void {
|
|
267
|
+
console.log(`
|
|
268
|
+
@decocms/blocks-cli — loader/action bundle-leak rollout
|
|
269
|
+
|
|
270
|
+
Bumps @decocms/* to the fixed framework version, installs, audits for
|
|
271
|
+
hardcoded credentials, and (optionally) rebuilds + verifies the client
|
|
272
|
+
output no longer contains a known leaked value. Never deploys.
|
|
273
|
+
|
|
274
|
+
Usage:
|
|
275
|
+
tsx upgrade-loader-leak-fix.ts [siteDir...] [options]
|
|
276
|
+
|
|
277
|
+
Options:
|
|
278
|
+
--version <v> Target @decocms/* version (default: latest published)
|
|
279
|
+
--exact Pin exact instead of caret
|
|
280
|
+
--pm <bun|npm|pnpm> Package manager (default: detect)
|
|
281
|
+
--no-install Skip install
|
|
282
|
+
--build Run the site build after install
|
|
283
|
+
--verify-secret <s> Fail if <s> appears in the client build (supply at runtime)
|
|
284
|
+
--dry-run Show changes; write/run nothing
|
|
285
|
+
--json Machine-readable summary
|
|
286
|
+
--help, -h This message
|
|
287
|
+
`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function upgradeSite(siteDir: string, opts: CliOpts, spec: string): SiteResult {
|
|
291
|
+
const result: SiteResult = {
|
|
292
|
+
site: siteDir,
|
|
293
|
+
affected: false,
|
|
294
|
+
bumps: [],
|
|
295
|
+
secretFindings: [],
|
|
296
|
+
built: false,
|
|
297
|
+
leakedAssetFiles: [],
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const pkgPath = path.join(siteDir, "package.json");
|
|
301
|
+
if (!fs.existsSync(pkgPath)) {
|
|
302
|
+
result.error = "no package.json";
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as SitePkg;
|
|
306
|
+
|
|
307
|
+
result.affected = isAffectedSite(pkg);
|
|
308
|
+
if (!result.affected) return result; // not a TanStack deco site — skip
|
|
309
|
+
|
|
310
|
+
// 1. Plan + apply dependency bumps.
|
|
311
|
+
result.bumps = planDepBumps(pkg, spec);
|
|
312
|
+
if (result.bumps.length > 0 && !opts.dryRun) {
|
|
313
|
+
const next = applyDepBumps(pkg, result.bumps);
|
|
314
|
+
fs.writeFileSync(pkgPath, JSON.stringify(next, null, 2) + "\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. Install (updates the lockfile so the fixed framework is resolved).
|
|
318
|
+
const pm = opts.pm ?? detectPm(siteDir);
|
|
319
|
+
if (result.bumps.length > 0 && opts.install && !opts.dryRun) {
|
|
320
|
+
run(pm, ["install"], siteDir);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 3. Audit source for hardcoded credentials — must be rotated manually.
|
|
324
|
+
// Scan the whole site tree (root actions/ + src/).
|
|
325
|
+
result.secretFindings = auditSourceDir(siteDir);
|
|
326
|
+
|
|
327
|
+
// 4. Optional rebuild + verify the leaked value is gone from the client output.
|
|
328
|
+
if (opts.build && !opts.dryRun) {
|
|
329
|
+
run(pm, ["run", "build"], siteDir);
|
|
330
|
+
result.built = true;
|
|
331
|
+
if (opts.verifySecret) {
|
|
332
|
+
const assets = collectClientAssets(siteDir);
|
|
333
|
+
result.leakedAssetFiles = findSecretInAssets(assets, opts.verifySecret);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function printReport(results: SiteResult[]): void {
|
|
341
|
+
for (const r of results) {
|
|
342
|
+
console.log(`\n── ${r.site} ──`);
|
|
343
|
+
if (r.error) {
|
|
344
|
+
console.log(` ERROR: ${r.error}`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!r.affected) {
|
|
348
|
+
console.log(" skipped — not a @decocms/tanstack site");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (r.bumps.length === 0) {
|
|
352
|
+
console.log(" deps already at target version");
|
|
353
|
+
} else {
|
|
354
|
+
console.log(` bumped ${r.bumps.length} @decocms/* dep(s):`);
|
|
355
|
+
for (const b of r.bumps) console.log(` ${b.name}: ${b.from} -> ${b.to}`);
|
|
356
|
+
}
|
|
357
|
+
if (r.built) {
|
|
358
|
+
console.log(" rebuilt client");
|
|
359
|
+
if (r.leakedAssetFiles.length > 0) {
|
|
360
|
+
console.log(` ✗ LEAK STILL PRESENT in ${r.leakedAssetFiles.length} asset(s):`);
|
|
361
|
+
for (const f of r.leakedAssetFiles) console.log(` ${f}`);
|
|
362
|
+
} else {
|
|
363
|
+
console.log(" ✓ verify-secret not found in client build");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const errors = r.secretFindings.filter((f) => f.severity === "error");
|
|
367
|
+
if (errors.length > 0) {
|
|
368
|
+
console.log(` ⚠ ${errors.length} hardcoded-credential finding(s) — ROTATE these:`);
|
|
369
|
+
for (const f of errors) console.log(` ${f.file}:${f.line} — ${f.id}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function main(): void {
|
|
375
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
376
|
+
if (opts.help) {
|
|
377
|
+
showHelp();
|
|
378
|
+
process.exit(0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Resolve the target spec.
|
|
382
|
+
let version = opts.version;
|
|
383
|
+
if (!version) {
|
|
384
|
+
try {
|
|
385
|
+
version = resolveLatest("@decocms/tanstack");
|
|
386
|
+
console.log(`Resolved latest @decocms/tanstack: ${version}`);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
console.error(`upgrade: ${(e as Error).message}. Pass --version explicitly.`);
|
|
389
|
+
process.exit(2);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const spec = opts.exact ? version : `^${version.replace(/^[\^~]/, "")}`;
|
|
393
|
+
|
|
394
|
+
const results: SiteResult[] = [];
|
|
395
|
+
for (const site of opts.sites) {
|
|
396
|
+
const dir = path.resolve(site);
|
|
397
|
+
try {
|
|
398
|
+
results.push(upgradeSite(dir, opts, spec));
|
|
399
|
+
} catch (e) {
|
|
400
|
+
results.push({
|
|
401
|
+
site: dir,
|
|
402
|
+
affected: true,
|
|
403
|
+
bumps: [],
|
|
404
|
+
secretFindings: [],
|
|
405
|
+
built: false,
|
|
406
|
+
leakedAssetFiles: [],
|
|
407
|
+
error: (e as Error).message,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (opts.json) {
|
|
413
|
+
process.stdout.write(JSON.stringify({ spec, dryRun: opts.dryRun, results }, null, 2) + "\n");
|
|
414
|
+
} else {
|
|
415
|
+
printReport(results);
|
|
416
|
+
console.log(
|
|
417
|
+
"\nNext steps: rotate any flagged credential, then redeploy each site " +
|
|
418
|
+
"with your pipeline (e.g. `wrangler deploy`).",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const failed = results.some((r) => r.error || r.leakedAssetFiles.length > 0);
|
|
423
|
+
process.exit(failed ? 1 : 0);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const isCjsEntry =
|
|
427
|
+
typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
|
|
428
|
+
let isEsmEntry = false;
|
|
429
|
+
try {
|
|
430
|
+
isEsmEntry =
|
|
431
|
+
typeof process !== "undefined" &&
|
|
432
|
+
Array.isArray(process.argv) &&
|
|
433
|
+
process.argv[1] !== undefined &&
|
|
434
|
+
import.meta.url === `file://${process.argv[1]}`;
|
|
435
|
+
} catch {
|
|
436
|
+
// ignore in CJS
|
|
437
|
+
}
|
|
438
|
+
if (isCjsEntry || isEsmEntry) {
|
|
439
|
+
main();
|
|
440
|
+
}
|