rastack 0.0.40 → 0.0.42

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.
@@ -9,7 +9,8 @@
9
9
  * [--region <r>] [--prefix <p>]
10
10
  * [--reuse-oidc] [--print] [--yes]
11
11
  *
12
- * It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
12
+ * It deploys the `bootstrap.yaml` template bundled with the `rastack` package
13
+ * (resolved by `resolveDeployAsset`, so it works from any repo), which creates:
13
14
  * • the S3 buckets that hold the Lambda placeholder + hosted templates, and
14
15
  * • the GitHub OIDC provider + trust that lets CI assume a deploy role.
15
16
  * It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
@@ -43,6 +44,7 @@ import {
43
44
  canPrompt,
44
45
  gitRemoteUrl,
45
46
  loadConfig,
47
+ resolveDeployAsset,
46
48
  stackOutput,
47
49
  uploadPlaceholder,
48
50
  withPrompt,
@@ -152,8 +154,9 @@ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
152
154
  console.log(
153
155
  `\n Bootstrap plan (${bootstrapStackName(cfg)} @ ${cfg.region})\n`,
154
156
  );
157
+ const template = resolveDeployAsset("bootstrap.yaml") ?? undefined;
155
158
  console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
156
- console.log("\n " + bootstrapStackCommand(cfg, reuseOidc) + "\n");
159
+ console.log("\n " + bootstrapStackCommand(cfg, reuseOidc, template) + "\n");
157
160
  console.log(
158
161
  " Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
159
162
  " ArtifactsBucket to create app backends. Nothing here is app-specific.\n",
@@ -166,8 +169,16 @@ function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
166
169
  * already ask to reuse it.
167
170
  */
168
171
  function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
172
+ const template = resolveDeployAsset("bootstrap.yaml");
173
+ if (!template) {
174
+ console.error(
175
+ " ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
176
+ " rastack (`npm i -g rastack`) or run from the framework repo.",
177
+ );
178
+ process.exit(1);
179
+ }
169
180
  try {
170
- awsRun(bootstrapStackArgs(cfg, reuseOidc));
181
+ awsRun(bootstrapStackArgs(cfg, reuseOidc, template));
171
182
  } catch (err) {
172
183
  if (reuseOidc) {
173
184
  console.error(
@@ -182,7 +193,7 @@ function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
182
193
  " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
183
194
  );
184
195
  try {
185
- awsRun(bootstrapStackArgs(cfg, true));
196
+ awsRun(bootstrapStackArgs(cfg, true, template));
186
197
  } catch (retryErr) {
187
198
  console.error(
188
199
  `\n ✗ bootstrap stack deploy failed.\n ${(retryErr as Error).message}`,
@@ -237,8 +248,8 @@ function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
237
248
  console.log("\n▸ Uploading the Lambda placeholder bundle");
238
249
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
239
250
  console.error(
240
- " ✗ deploy/cloudformation/placeholder/bootstrap not found — the placeholder\n" +
241
- " was not uploaded. `rastack init` will upload it on the first app deploy.",
251
+ " ✗ Lambda placeholder bundle not found in the rastack package — the\n" +
252
+ " placeholder was not uploaded. `rastack init` will upload it on the first app deploy.",
242
253
  );
243
254
  } else {
244
255
  console.log(" ✓ placeholder uploaded");
@@ -56,6 +56,7 @@ import {
56
56
  ghAvailable,
57
57
  gitRemoteUrl,
58
58
  loadConfig,
59
+ resolveDeployAsset,
59
60
  saveConfig,
60
61
  setRepoVariable,
61
62
  stackOutput,
@@ -209,13 +210,15 @@ function printPlan(cfg: DeployConfig): void {
209
210
  const withBucket = mergeConfig(cfg, {
210
211
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
211
212
  });
213
+ const bootstrapTemplate = resolveDeployAsset("bootstrap.yaml") ?? undefined;
214
+ const appTemplate = resolveDeployAsset("app.yaml") ?? undefined;
212
215
  console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
213
216
  console.log(
214
217
  ` ── 1. Bootstrap stack (${bootstrapStackName(cfg)}) — run only if it does not exist ──`,
215
218
  );
216
- console.log("\n " + bootstrapStackCommand(cfg) + "\n");
219
+ console.log("\n " + bootstrapStackCommand(cfg, false, bootstrapTemplate) + "\n");
217
220
  console.log(" ── 2. Per-app backend (app.yaml) ──");
218
- console.log("\n " + deployStackCommand(withBucket) + "\n");
221
+ console.log("\n " + deployStackCommand(withBucket, appTemplate) + "\n");
219
222
  console.log(
220
223
  " ── 3. Wire these GitHub repo Variables from the stack Outputs ──",
221
224
  );
@@ -285,7 +288,7 @@ async function standUp(
285
288
  console.log("\n▸ Uploading the Lambda placeholder bundle");
286
289
  if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
287
290
  console.error(
288
- " ✗ deploy/cloudformation/placeholder/bootstrap not found — cannot create the function.",
291
+ " ✗ Lambda placeholder bundle not found in the rastack package — cannot create the function.",
289
292
  );
290
293
  process.exit(1);
291
294
  }
@@ -295,9 +298,17 @@ async function standUp(
295
298
  artifactsBucket,
296
299
  templatesBucket: templatesBucket ?? undefined,
297
300
  });
301
+ const appTemplate = resolveDeployAsset("app.yaml");
302
+ if (!appTemplate) {
303
+ console.error(
304
+ " ✗ app.yaml template not found in the rastack package. Reinstall rastack\n" +
305
+ " (`npm i -g rastack`) or run from the framework repo.",
306
+ );
307
+ process.exit(1);
308
+ }
298
309
  console.log(`\n▸ Deploying ${cfg.stackName}`);
299
310
  try {
300
- awsRun(deployStackArgs(cfg));
311
+ awsRun(deployStackArgs(cfg, appTemplate));
301
312
  } catch (err) {
302
313
  console.error(
303
314
  `\n ✗ aws cloudformation deploy failed for ${cfg.stackName}.\n ${(err as Error).message}`,
@@ -372,8 +383,16 @@ function wireRepoVariables(cfg: DeployConfig, interactive: boolean): void {
372
383
 
373
384
  /** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
374
385
  function deployBootstrap(cfg: DeployConfig): void {
386
+ const template = resolveDeployAsset("bootstrap.yaml");
387
+ if (!template) {
388
+ console.error(
389
+ " ✗ bootstrap.yaml template not found in the rastack package. Reinstall\n" +
390
+ " rastack (`npm i -g rastack`) or run from the framework repo.",
391
+ );
392
+ process.exit(1);
393
+ }
375
394
  try {
376
- awsRun(bootstrapStackArgs(cfg));
395
+ awsRun(bootstrapStackArgs(cfg, false, template));
377
396
  } catch {
378
397
  // The most common cause is an account that already has a GitHub OIDC
379
398
  // provider (only one is allowed per URL). Retry telling the template to
@@ -382,7 +401,7 @@ function deployBootstrap(cfg: DeployConfig): void {
382
401
  " • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
383
402
  );
384
403
  try {
385
- awsRun(bootstrapStackArgs(cfg, true));
404
+ awsRun(bootstrapStackArgs(cfg, true, template));
386
405
  } catch (err) {
387
406
  console.error(
388
407
  `\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
@@ -24,6 +24,7 @@ import {
24
24
  globalPrefixFromPackageDir,
25
25
  npmBinary,
26
26
  npmInstallArgs,
27
+ npmSpawnShell,
27
28
  parseUpdateFlags,
28
29
  updateAvailable,
29
30
  } from "./update";
@@ -31,6 +32,13 @@ import {
31
32
  /** The npm executable for this platform (`npm.cmd` on Windows, else `npm`). */
32
33
  const NPM = npmBinary();
33
34
 
35
+ /**
36
+ * Whether to launch npm through a shell. Required on Windows, where a bare
37
+ * `.cmd` spawn is rejected with `EINVAL` since the Node CVE-2024-27980 fix;
38
+ * false on POSIX so the direct spawn stays injection-free.
39
+ */
40
+ const NPM_SHELL = npmSpawnShell();
41
+
34
42
  /** The installed rastack package directory (…/rastack), one up from dist/. */
35
43
  const PKG_DIR = path.join(__dirname, "..");
36
44
  const PKG_JSON = path.join(PKG_DIR, "package.json");
@@ -49,6 +57,7 @@ function npmCapture(args: string[]): string | null {
49
57
  try {
50
58
  return execFileSync(NPM, args, {
51
59
  stdio: ["ignore", "pipe", "pipe"],
60
+ shell: NPM_SHELL,
52
61
  })
53
62
  .toString()
54
63
  .trim();
@@ -103,10 +112,12 @@ function main(argv: string[]): void {
103
112
  `\n▸ Updating rastack (${current} → ${flags.version}) in ${prefix}\n`,
104
113
  );
105
114
  try {
106
- // Re-runs the developer's own npm to update this global package — no
107
- // untrusted input, and execFileSync (no shell) avoids any injection.
115
+ // Re-runs the developer's own npm to update this global package. On Windows
116
+ // the `.cmd` shim is launched through a shell (EINVAL otherwise); the only
117
+ // caller-supplied token — the version — is sanitized in `parseUpdateFlags`,
118
+ // and every other arg is a fixed literal, so nothing injectable reaches it.
108
119
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
109
- execFileSync(NPM, args, { stdio: "inherit", env });
120
+ execFileSync(NPM, args, { stdio: "inherit", env, shell: NPM_SHELL });
110
121
  } catch (err) {
111
122
  console.error(
112
123
  `\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,
package/src/update.ts CHANGED
@@ -40,27 +40,56 @@ export function parseUpdateFlags(argv: string[]): UpdateFlags {
40
40
  break;
41
41
  case "--version":
42
42
  case "--to":
43
- version = argv[++i] ?? "latest";
43
+ version = sanitizeVersion(argv[++i]);
44
44
  break;
45
45
  default:
46
46
  // A bare, non-flag token is taken as the target version.
47
- if (!a.startsWith("-")) version = a;
47
+ if (!a.startsWith("-")) version = sanitizeVersion(a);
48
48
  }
49
49
  }
50
50
  return { version, check, dryRun };
51
51
  }
52
52
 
53
+ /**
54
+ * Constrain the update target to a safe npm version or dist-tag. The Windows
55
+ * update path runs npm through a shell (see `npmSpawnShell`), so the target —
56
+ * the one caller-supplied token that reaches the command line — must not be
57
+ * able to carry shell metacharacters. A valid npm spec starts alphanumeric and
58
+ * uses only `A-Za-z0-9._-` (e.g. `latest`, `next`, `0.0.40`, `1.2.3-beta.1`);
59
+ * anything else falls back to `latest`.
60
+ */
61
+ export function sanitizeVersion(version: string | undefined): string {
62
+ return version && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)
63
+ ? version
64
+ : "latest";
65
+ }
66
+
53
67
  /**
54
68
  * The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
55
69
  * not a real executable, so `execFile("npm", …)` (no shell) fails with
56
70
  * `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
57
71
  * never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
58
72
  * (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
73
+ *
74
+ * The `.cmd` must still be launched through a shell — see `npmSpawnShell`.
59
75
  */
60
76
  export function npmBinary(platform: string = process.platform): string {
61
77
  return platform === "win32" ? "npm.cmd" : "npm";
62
78
  }
63
79
 
80
+ /**
81
+ * Whether npm must be spawned through a shell on this platform. Since the Node
82
+ * fix for CVE-2024-27980 (18.20.2 / 20.12.2 / 21.7.2), spawning a `.cmd` shim
83
+ * like `npm.cmd` *without* a shell is rejected with `spawnSync npm.cmd EINVAL`
84
+ * — the very failure a Windows/PowerShell `rastack update` hits. cmd.exe knows
85
+ * how to run a `.cmd`, so Windows spawns through the shell; POSIX keeps the
86
+ * direct, shell-free spawn (npm is a real script resolved on PATH there, and no
87
+ * shell means no injection surface).
88
+ */
89
+ export function npmSpawnShell(platform: string = process.platform): boolean {
90
+ return platform === "win32";
91
+ }
92
+
64
93
  /** The npm package spec to install for a target version. */
65
94
  export function installSpec(version: string): string {
66
95
  return !version || version === "latest" ? "rastack" : `rastack@${version}`;
@@ -243,6 +243,19 @@ describe("stack parameters + launch command", () => {
243
243
  "deploy",
244
244
  ]);
245
245
  });
246
+
247
+ it("accepts a template-file override (the package-resolved path)", () => {
248
+ // `init` runs in a consumer repo that carries no framework IaC, so it passes
249
+ // the path the template was resolved to inside the `rastack` package.
250
+ const cfg = base({ artifactsBucket: "art" });
251
+ const abs = "/opt/rastack/dist/templates/cloudformation/app.yaml";
252
+ expect(deployStackArgs(cfg, abs)).toContain(abs);
253
+ expect(deployStackCommand(cfg, abs)).toContain(`--template-file ${abs}`);
254
+ // Default is unchanged when no override is given.
255
+ expect(deployStackCommand(cfg)).toContain(
256
+ "--template-file deploy/cloudformation/app.yaml",
257
+ );
258
+ });
246
259
  });
247
260
 
248
261
  describe("bootstrap stack", () => {
@@ -281,6 +294,21 @@ describe("bootstrap stack", () => {
281
294
  expect(`aws ${bootstrapStackArgs(base()).join(" ")}`).toBe(cmd);
282
295
  });
283
296
 
297
+ it("accepts a template-file override (the package-resolved path)", () => {
298
+ const abs = "/opt/rastack/dist/templates/cloudformation/bootstrap.yaml";
299
+ expect(bootstrapStackArgs(base(), false, abs)).toContain(abs);
300
+ expect(bootstrapStackCommand(base(), true, abs)).toContain(
301
+ `--template-file ${abs}`,
302
+ );
303
+ // Reuse-OIDC + override compose without disturbing the default path.
304
+ expect(bootstrapStackArgs(base(), true, abs)).toContain(
305
+ "CreateOIDCProvider=false",
306
+ );
307
+ expect(bootstrapStackCommand(base())).toContain(
308
+ "--template-file deploy/cloudformation/bootstrap.yaml",
309
+ );
310
+ });
311
+
284
312
  it("validates only the account-level fields, not the per-app ones", () => {
285
313
  // A config with no `app` is fine for bootstrap — it never touches app.yaml.
286
314
  const cfg = normalizeConfig({
@@ -513,3 +541,28 @@ describe("scaffold", () => {
513
541
  expect(files.map((f) => f.path)).toEqual([CONFIG_PATH]);
514
542
  });
515
543
  });
544
+
545
+ describe("resolveDeployAsset — bundled template resolution", () => {
546
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
547
+ const { resolveDeployAsset } = require("../src/deploy/io");
548
+ const path = require("path");
549
+
550
+ it("resolves the framework templates to an existing file", () => {
551
+ for (const name of ["app.yaml", "bootstrap.yaml"]) {
552
+ const resolved = resolveDeployAsset(name);
553
+ expect(resolved).not.toBeNull();
554
+ expect(path.isAbsolute(resolved)).toBe(true);
555
+ expect(resolved.endsWith(name)).toBe(true);
556
+ }
557
+ });
558
+
559
+ it("resolves the Lambda placeholder bundle", () => {
560
+ const resolved = resolveDeployAsset(path.join("placeholder", "bootstrap"));
561
+ expect(resolved).not.toBeNull();
562
+ expect(resolved.endsWith(path.join("placeholder", "bootstrap"))).toBe(true);
563
+ });
564
+
565
+ it("returns null for an asset that does not exist", () => {
566
+ expect(resolveDeployAsset("does-not-exist.yaml")).toBeNull();
567
+ });
568
+ });
@@ -4,7 +4,9 @@ import {
4
4
  installSpec,
5
5
  npmBinary,
6
6
  npmInstallArgs,
7
+ npmSpawnShell,
7
8
  parseUpdateFlags,
9
+ sanitizeVersion,
8
10
  updateAvailable,
9
11
  } from "../src/update";
10
12
 
@@ -56,6 +58,39 @@ describe("update — npm spec + args", () => {
56
58
  expect(npmBinary("linux")).toBe("npm");
57
59
  expect(npmBinary("darwin")).toBe("npm");
58
60
  });
61
+
62
+ it("runs npm through a shell only on Windows", () => {
63
+ // A `.cmd` shim can't be spawned directly since the Node CVE-2024-27980 fix
64
+ // (spawnSync npm.cmd EINVAL) — it must go through cmd.exe. POSIX spawns npm
65
+ // directly so there is no shell and no injection surface.
66
+ expect(npmSpawnShell("win32")).toBe(true);
67
+ expect(npmSpawnShell("linux")).toBe(false);
68
+ expect(npmSpawnShell("darwin")).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("update — version sanitization", () => {
73
+ it("keeps valid semver, dist-tags, and pinned versions", () => {
74
+ expect(sanitizeVersion("latest")).toBe("latest");
75
+ expect(sanitizeVersion("next")).toBe("next");
76
+ expect(sanitizeVersion("0.0.40")).toBe("0.0.40");
77
+ expect(sanitizeVersion("1.2.3-beta.1")).toBe("1.2.3-beta.1");
78
+ });
79
+
80
+ it("falls back to latest for empty or shell-unsafe input", () => {
81
+ expect(sanitizeVersion(undefined)).toBe("latest");
82
+ expect(sanitizeVersion("")).toBe("latest");
83
+ // Shell metacharacters must never reach the Windows shell-spawned npm.
84
+ expect(sanitizeVersion("0.0.40; rm -rf /")).toBe("latest");
85
+ expect(sanitizeVersion("$(whoami)")).toBe("latest");
86
+ expect(sanitizeVersion("a b")).toBe("latest");
87
+ });
88
+
89
+ it("is applied when parsing the bare positional and --version", () => {
90
+ expect(parseUpdateFlags(["0.0.40; evil"]).version).toBe("latest");
91
+ expect(parseUpdateFlags(["--version", "&& evil"]).version).toBe("latest");
92
+ expect(parseUpdateFlags(["0.0.40"]).version).toBe("0.0.40");
93
+ });
59
94
  });
60
95
 
61
96
  describe("update — global prefix derivation", () => {