@checkstack/backend-api 0.20.0 → 0.21.1
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/CHANGELOG.md +169 -0
- package/package.json +15 -14
- package/src/auth-strategy.ts +6 -3
- package/src/bearer-token.ts +13 -0
- package/src/collector-strategy.ts +9 -0
- package/src/config-versioning.test.ts +227 -0
- package/src/config-versioning.ts +177 -11
- package/src/core-services.ts +14 -0
- package/src/esm-script-runner.test.ts +55 -16
- package/src/esm-script-runner.ts +212 -55
- package/src/index.ts +3 -0
- package/src/render-templatable-config.test.ts +168 -0
- package/src/render-templatable-config.ts +193 -0
- package/src/schema-utils.ts +3 -0
- package/src/script-sandbox/capabilities.test.ts +122 -0
- package/src/script-sandbox/capabilities.ts +372 -0
- package/src/script-sandbox/capped-output.test.ts +116 -0
- package/src/script-sandbox/capped-output.ts +172 -0
- package/src/script-sandbox/env-guard.test.ts +105 -0
- package/src/script-sandbox/env-guard.ts +129 -0
- package/src/script-sandbox/filesystem.test.ts +437 -0
- package/src/script-sandbox/filesystem.ts +514 -0
- package/src/script-sandbox/forkbomb.it.test.ts +121 -0
- package/src/script-sandbox/global-default.test.ts +161 -0
- package/src/script-sandbox/global-default.ts +100 -0
- package/src/script-sandbox/index.ts +14 -0
- package/src/script-sandbox/network.test.ts +356 -0
- package/src/script-sandbox/network.ts +373 -0
- package/src/script-sandbox/observability.test.ts +210 -0
- package/src/script-sandbox/observability.ts +168 -0
- package/src/script-sandbox/output-truncation.test.ts +53 -0
- package/src/script-sandbox/output-truncation.ts +69 -0
- package/src/script-sandbox/policy.test.ts +189 -0
- package/src/script-sandbox/policy.ts +220 -0
- package/src/script-sandbox/provider.test.ts +61 -0
- package/src/script-sandbox/provider.ts +134 -0
- package/src/script-sandbox/readiness.test.ts +80 -0
- package/src/script-sandbox/readiness.ts +117 -0
- package/src/script-sandbox/report.ts +88 -0
- package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
- package/src/script-sandbox/rootless-egress.test.ts +99 -0
- package/src/script-sandbox/rootless-egress.ts +218 -0
- package/src/script-sandbox/shell-quote.test.ts +32 -0
- package/src/script-sandbox/shell-quote.ts +10 -0
- package/src/script-sandbox/wrapper.test.ts +1194 -0
- package/src/script-sandbox/wrapper.ts +714 -0
- package/src/shell-script-runner.test.ts +243 -0
- package/src/shell-script-runner.ts +210 -45
- package/src/types.ts +5 -38
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- package/tsconfig.json +3 -0
package/src/esm-script-runner.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { detectSandboxCapabilities } from "./script-sandbox/capabilities";
|
|
9
|
+
import { readCappedOutput } from "./script-sandbox/capped-output";
|
|
10
|
+
import { pickSafeEnv } from "./script-sandbox/env-guard";
|
|
11
|
+
import { truncateCapturedOutput } from "./script-sandbox/output-truncation";
|
|
12
|
+
import {
|
|
13
|
+
FAIL_CLOSED_DOWNGRADE_REASON,
|
|
14
|
+
resolveActiveSandboxPolicy,
|
|
15
|
+
} from "./script-sandbox/provider";
|
|
16
|
+
import {
|
|
17
|
+
type EffectiveSandbox,
|
|
18
|
+
SandboxUnavailableError,
|
|
19
|
+
} from "./script-sandbox/report";
|
|
20
|
+
import { buildSpawnHardening } from "./script-sandbox/wrapper";
|
|
7
21
|
|
|
8
22
|
/**
|
|
9
23
|
* Shared sandbox for executing user-authored TypeScript / JavaScript
|
|
@@ -61,6 +75,14 @@ export interface EsmScriptRunResult {
|
|
|
61
75
|
stderr: string;
|
|
62
76
|
/** True if the timeout fired before the subprocess exited. */
|
|
63
77
|
timedOut: boolean;
|
|
78
|
+
/** True if captured output exceeded the sandbox `maxOutputBytes` cap and was trimmed. */
|
|
79
|
+
outputTruncated?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* What the OS-level sandbox actually enforced / degraded for this run.
|
|
82
|
+
* Always present: the runner resolves the active GLOBAL policy itself and
|
|
83
|
+
* reports the result so callers can surface downgrades.
|
|
84
|
+
*/
|
|
85
|
+
sandbox?: EffectiveSandbox;
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
export interface EsmScriptRunOptions {
|
|
@@ -78,9 +100,9 @@ export interface EsmScriptRunOptions {
|
|
|
78
100
|
* to point at that file. Skipped if either field is omitted.
|
|
79
101
|
*
|
|
80
102
|
* @example
|
|
81
|
-
* helperModuleName: "@checkstack/healthcheck"
|
|
103
|
+
* helperModuleName: "@checkstack/sdk/healthcheck"
|
|
82
104
|
* helperFunctionName: "defineHealthCheck"
|
|
83
|
-
* // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
|
|
105
|
+
* // editor: import { defineHealthCheck } from "@checkstack/sdk/healthcheck"
|
|
84
106
|
* // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
|
|
85
107
|
*/
|
|
86
108
|
helperModuleName?: string;
|
|
@@ -112,6 +134,10 @@ export interface EsmScriptRunOptions {
|
|
|
112
134
|
*
|
|
113
135
|
* The user's script reads these as `process.env.ENV_NAME`. On a key
|
|
114
136
|
* collision with a safe var, the injected value wins.
|
|
137
|
+
*
|
|
138
|
+
* Note: forbidden keys (`LD_PRELOAD`, `NODE_OPTIONS`, ...) are dropped from
|
|
139
|
+
* these overrides by the shared env denylist whenever the active sandbox
|
|
140
|
+
* policy is enabled.
|
|
115
141
|
*/
|
|
116
142
|
env?: Record<string, string>;
|
|
117
143
|
}
|
|
@@ -125,41 +151,6 @@ export interface EsmScriptRunner {
|
|
|
125
151
|
run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
|
|
126
152
|
}
|
|
127
153
|
|
|
128
|
-
// =============================================================================
|
|
129
|
-
// INTERNALS
|
|
130
|
-
// =============================================================================
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Vars passed through to the subprocess. We intentionally do NOT
|
|
134
|
-
* forward the satellite's full env so backend secrets (DB URLs, API
|
|
135
|
-
* tokens, signing keys) never reach user-authored scripts. PATH / HOME
|
|
136
|
-
* / LANG / ... are kept so `node:child_process`, `node:fs`, and
|
|
137
|
-
* locale-sensitive APIs behave normally.
|
|
138
|
-
*/
|
|
139
|
-
const SAFE_ENV_VARS = [
|
|
140
|
-
"PATH",
|
|
141
|
-
"HOME",
|
|
142
|
-
"USER",
|
|
143
|
-
"LANG",
|
|
144
|
-
"LC_ALL",
|
|
145
|
-
"LC_CTYPE",
|
|
146
|
-
"TZ",
|
|
147
|
-
"TMPDIR",
|
|
148
|
-
"HOSTNAME",
|
|
149
|
-
"SHELL",
|
|
150
|
-
];
|
|
151
|
-
|
|
152
|
-
function pickSafeEnv(): Record<string, string> {
|
|
153
|
-
const env: Record<string, string> = {};
|
|
154
|
-
for (const key of SAFE_ENV_VARS) {
|
|
155
|
-
const value = process.env[key];
|
|
156
|
-
if (value !== undefined) {
|
|
157
|
-
env[key] = value;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return env;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
154
|
// =============================================================================
|
|
164
155
|
// USER-SCRIPT NORMALISATION
|
|
165
156
|
// =============================================================================
|
|
@@ -332,6 +323,22 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
332
323
|
resolutionRoot,
|
|
333
324
|
env: injectedEnv,
|
|
334
325
|
}) {
|
|
326
|
+
// Reconcile the requested policy against this host's capabilities BEFORE
|
|
327
|
+
// any filesystem work or spawn. When `onUnavailable: "fail"` and a layer
|
|
328
|
+
// is unavailable this throws, and we return a clean failure WITHOUT
|
|
329
|
+
// touching disk or spawning a child.
|
|
330
|
+
const caps = detectSandboxCapabilities();
|
|
331
|
+
// Resolve the GLOBAL sandbox policy ourselves (policy is global-only; the
|
|
332
|
+
// runner no longer accepts a per-run override). With a provider wired at
|
|
333
|
+
// startup this is the durable cluster-wide default; with NO provider (or a
|
|
334
|
+
// provider that throws) it FAILS CLOSED to the most restrictive safe policy
|
|
335
|
+
// (deny egress, scratch + read-only managed packages, privilege drop) —
|
|
336
|
+
// never the permissive default. The fail-closed fallback is surfaced as a
|
|
337
|
+
// synthetic downgrade. On hosts lacking a primitive each layer
|
|
338
|
+
// degrades-and-surfaces (never hard-breaks) per the resolved
|
|
339
|
+
// `onUnavailable`.
|
|
340
|
+
const { policy, failedClosed } = await resolveActiveSandboxPolicy();
|
|
341
|
+
|
|
335
342
|
const sessionId = randomUUID();
|
|
336
343
|
const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
|
|
337
344
|
const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
|
|
@@ -340,8 +347,93 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
340
347
|
// so module resolution walks up to `<resolutionRoot>/node_modules`.
|
|
341
348
|
// Otherwise fall back to `os.tmpdir()` (today's behavior - no
|
|
342
349
|
// node_modules visible, fully backward compatible).
|
|
350
|
+
//
|
|
351
|
+
// The scratch dir is created BEFORE building the hardening so the
|
|
352
|
+
// filesystem layer (Phase 2) can bind it into the namespace. When a
|
|
353
|
+
// fail-closed policy refuses an unenforceable layer we clean the dir up
|
|
354
|
+
// again and never spawn — equivalent to "didn't touch disk" from the
|
|
355
|
+
// caller's view (the dir is gone).
|
|
343
356
|
const tmpBase = resolutionRoot ?? tmpdir();
|
|
344
357
|
const tmpDir = await mkdtemp(path.join(tmpBase, "checkstack-script-"));
|
|
358
|
+
// The reconciled managed-package tree, exposed read-only under
|
|
359
|
+
// `scratch-plus-ro`. Only meaningful when a resolutionRoot is set.
|
|
360
|
+
const nodeModulesDir =
|
|
361
|
+
resolutionRoot === undefined
|
|
362
|
+
? undefined
|
|
363
|
+
: path.join(resolutionRoot, "node_modules");
|
|
364
|
+
|
|
365
|
+
// Path at which we stage the network egress nftables ruleset (consumed by
|
|
366
|
+
// `nsjail --nftables_file`, or the rootless launcher's fail-closed `nft
|
|
367
|
+
// -f`). Resolved on the host before the namespace is entered, so a path
|
|
368
|
+
// inside the per-run dir is fine.
|
|
369
|
+
const nftRulesetPath = path.join(tmpDir, "egress.nft");
|
|
370
|
+
// Path at which we stage the rootless egress launcher script (slirp4netns +
|
|
371
|
+
// the fail-closed nft filter) when the network decision picks the rootless
|
|
372
|
+
// path. Same per-run dir.
|
|
373
|
+
const rootlessLauncherPath = path.join(tmpDir, "rootless-egress.sh");
|
|
374
|
+
|
|
375
|
+
let hardening;
|
|
376
|
+
try {
|
|
377
|
+
hardening = buildSpawnHardening({
|
|
378
|
+
policy,
|
|
379
|
+
caps,
|
|
380
|
+
baseEnv: pickSafeEnv(),
|
|
381
|
+
// Fold the controlled ESM memory fallback (NODE_OPTIONS) in AFTER the
|
|
382
|
+
// denylist runs on caller overrides, so a caller can't smuggle
|
|
383
|
+
// NODE_OPTIONS but the sandbox can still set the heap cap.
|
|
384
|
+
envOverrides: injectedEnv,
|
|
385
|
+
filesystem: {
|
|
386
|
+
scratchDir: tmpDir,
|
|
387
|
+
nodeModulesDir,
|
|
388
|
+
// Bind the Bun runtime read-only into the namespace; under FS
|
|
389
|
+
// confinement the host FS is hidden and the interpreter commonly
|
|
390
|
+
// lives outside /usr,/bin (e.g. ~/.bun/bin/bun), so without this the
|
|
391
|
+
// child cannot exec the runtime.
|
|
392
|
+
interpreterPath: realpathSync(process.execPath),
|
|
393
|
+
},
|
|
394
|
+
nftRulesetPath,
|
|
395
|
+
rootlessLauncherPath,
|
|
396
|
+
// The ESM runner execs a Bun/Node interpreter that honours
|
|
397
|
+
// NODE_OPTIONS=--max-old-space-size, so the per-run JS-heap memory cap
|
|
398
|
+
// IS applied here (unlike the shell runner).
|
|
399
|
+
appliesNodeMemoryCap: true,
|
|
400
|
+
});
|
|
401
|
+
// Surface the fail-closed fallback as a notice in the report so a
|
|
402
|
+
// missing/failed policy provider is never silent (the run still proceeds
|
|
403
|
+
// under the most restrictive policy).
|
|
404
|
+
if (failedClosed) {
|
|
405
|
+
hardening.effective.downgrades.push({
|
|
406
|
+
layer: "network",
|
|
407
|
+
reason: FAIL_CLOSED_DOWNGRADE_REASON,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (error instanceof SandboxUnavailableError) {
|
|
412
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
413
|
+
// Best-effort: nothing was written yet; the OS reaps any straggler.
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
stdout: "",
|
|
417
|
+
stderr: "",
|
|
418
|
+
timedOut: false,
|
|
419
|
+
error: error.message,
|
|
420
|
+
sandbox: {
|
|
421
|
+
requested: policy,
|
|
422
|
+
enforced: {
|
|
423
|
+
resources: false,
|
|
424
|
+
filesystem: false,
|
|
425
|
+
network: false,
|
|
426
|
+
privilege: false,
|
|
427
|
+
},
|
|
428
|
+
downgrades: error.downgrades,
|
|
429
|
+
notes: [],
|
|
430
|
+
platform: caps.platform,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
|
|
345
437
|
const userScriptPath = path.join(tmpDir, "user.mjs");
|
|
346
438
|
const runnerPath = path.join(tmpDir, "runner.mjs");
|
|
347
439
|
const bunfigPath = path.join(tmpDir, "bunfig.toml");
|
|
@@ -392,6 +484,20 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
392
484
|
// degradation the package feature requires.
|
|
393
485
|
await writeFile(bunfigPath, '[install]\nauto = "disable"\n', "utf8");
|
|
394
486
|
|
|
487
|
+
// Stage the network egress ruleset (if the sandbox produced one) so the
|
|
488
|
+
// wrapper can install it inside the child's net namespace.
|
|
489
|
+
if (hardening.nftRuleset !== undefined) {
|
|
490
|
+
await writeFile(nftRulesetPath, hardening.nftRuleset, "utf8");
|
|
491
|
+
}
|
|
492
|
+
// Stage the rootless egress launcher (slirp4netns orchestration) when the
|
|
493
|
+
// sandbox picked the rootless path. The prelude execs it as `sh <path>`.
|
|
494
|
+
if (hardening.rootlessLauncher !== undefined) {
|
|
495
|
+
await writeFile(rootlessLauncherPath, hardening.rootlessLauncher, {
|
|
496
|
+
encoding: "utf8",
|
|
497
|
+
mode: 0o700,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
395
501
|
await writeFile(userScriptPath, userSource, "utf8");
|
|
396
502
|
await writeFile(
|
|
397
503
|
runnerPath,
|
|
@@ -406,31 +512,61 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
406
512
|
);
|
|
407
513
|
|
|
408
514
|
proc = spawn({
|
|
409
|
-
|
|
515
|
+
// The sandbox may prepend an rlimit prelude (e.g. `prlimit --as=... --`)
|
|
516
|
+
// to the argv. CWD stays the per-run dir.
|
|
517
|
+
cmd: hardening.wrapCmd([process.execPath, runnerPath]),
|
|
410
518
|
// CWD = the per-run dir so Bun reads its `bunfig.toml`
|
|
411
519
|
// (auto-install disabled) and resolves modules from
|
|
412
520
|
// `<resolutionRoot>/node_modules` when set.
|
|
413
521
|
cwd: tmpDir,
|
|
414
|
-
//
|
|
415
|
-
// injected
|
|
416
|
-
//
|
|
417
|
-
|
|
522
|
+
// `hardening.env` is the safe-vars base overlaid with the caller's
|
|
523
|
+
// injected env (denylist applied when the sandbox is enabled). The
|
|
524
|
+
// controlled ESM memory fallback (NODE_OPTIONS=--max-old-space-size)
|
|
525
|
+
// is merged on top — it is a sandbox-set cap, not a caller override,
|
|
526
|
+
// so it is intentionally not subject to the denylist.
|
|
527
|
+
env: { ...hardening.env, ...hardening.nodeMemoryFlagEnv },
|
|
528
|
+
// NOTE: we deliberately do NOT pass `uid`/`gid` to Bun.spawn. On the
|
|
529
|
+
// shipped Bun versions it is a silent no-op (the privilege drop is
|
|
530
|
+
// carried by the namespace wrapper's `--uid`, or by inheritance from a
|
|
531
|
+
// non-root supervisor), AND passing it is a forward-compat hazard: a
|
|
532
|
+
// future Bun that honours it would spawn the WRAPPER itself as the
|
|
533
|
+
// dropped id, breaking unprivileged-userns creation. `hardening.uid` is
|
|
534
|
+
// observability-only. See wrapper.ts.
|
|
418
535
|
stdout: "pipe",
|
|
419
536
|
stderr: "pipe",
|
|
420
537
|
});
|
|
421
538
|
|
|
422
539
|
let stdout: string;
|
|
423
540
|
let stderr: string;
|
|
541
|
+
let streamTruncated = false;
|
|
424
542
|
|
|
425
543
|
try {
|
|
426
|
-
|
|
544
|
+
// Bounded-buffering capture: count bytes against the shared
|
|
545
|
+
// `maxOutputBytes` budget and kill + flag the child once exceeded,
|
|
546
|
+
// rather than buffering the whole output first (OOM guard on a degraded
|
|
547
|
+
// host without RLIMIT_AS — plan §5.1). NOTE: a script that floods past
|
|
548
|
+
// the cap loses its result marker (it is emitted last); that is an
|
|
549
|
+
// acceptable degradation for abusive output, and the run is reported as
|
|
550
|
+
// truncated + "no result".
|
|
551
|
+
const captureProc = proc;
|
|
552
|
+
const [captured] = (await Promise.race([
|
|
427
553
|
Promise.all([
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
554
|
+
readCappedOutput({
|
|
555
|
+
stdout: captureProc.stdout as ReadableStream<Uint8Array>,
|
|
556
|
+
stderr: captureProc.stderr as ReadableStream<Uint8Array>,
|
|
557
|
+
maxOutputBytes: hardening.maxOutputBytes,
|
|
558
|
+
onExceeded: () => captureProc.kill(),
|
|
559
|
+
}),
|
|
560
|
+
captureProc.exited,
|
|
431
561
|
]),
|
|
432
562
|
timeoutPromise,
|
|
433
|
-
])) as [
|
|
563
|
+
])) as [
|
|
564
|
+
{ stdout: string; stderr: string; truncated: boolean },
|
|
565
|
+
number,
|
|
566
|
+
];
|
|
567
|
+
stdout = captured.stdout;
|
|
568
|
+
stderr = captured.stderr;
|
|
569
|
+
streamTruncated = captured.truncated;
|
|
434
570
|
} catch (error) {
|
|
435
571
|
if (timedOut) {
|
|
436
572
|
return {
|
|
@@ -438,6 +574,7 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
438
574
|
stderr: "",
|
|
439
575
|
timedOut: true,
|
|
440
576
|
error: "Script execution timed out",
|
|
577
|
+
sandbox: hardening.effective,
|
|
441
578
|
};
|
|
442
579
|
}
|
|
443
580
|
throw error;
|
|
@@ -467,17 +604,33 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
467
604
|
.trim();
|
|
468
605
|
}
|
|
469
606
|
|
|
607
|
+
// Apply output truncation AFTER the result marker has been plucked, so
|
|
608
|
+
// the cap never corrupts the marker the parent relies on. Truncation is
|
|
609
|
+
// the portable resource-cap fallback (pure JS, every platform).
|
|
610
|
+
const {
|
|
611
|
+
stdout: cappedStdout,
|
|
612
|
+
stderr: cappedStderr,
|
|
613
|
+
truncated: trimTruncated,
|
|
614
|
+
} = truncateCapturedOutput({
|
|
615
|
+
stdout: stdout.trim(),
|
|
616
|
+
stderr: cleanStderr,
|
|
617
|
+
maxOutputBytes: hardening.maxOutputBytes,
|
|
618
|
+
});
|
|
619
|
+
const outputTruncated = streamTruncated || trimTruncated;
|
|
620
|
+
|
|
470
621
|
if (!payload) {
|
|
471
622
|
// The runner never got far enough to emit — typically a syntax
|
|
472
623
|
// error in the user module or a hard crash. Surface whatever the
|
|
473
624
|
// subprocess wrote to stderr as the error.
|
|
474
625
|
return {
|
|
475
|
-
stdout:
|
|
476
|
-
stderr:
|
|
626
|
+
stdout: cappedStdout,
|
|
627
|
+
stderr: cappedStderr,
|
|
477
628
|
timedOut: false,
|
|
629
|
+
outputTruncated,
|
|
630
|
+
sandbox: hardening.effective,
|
|
478
631
|
error:
|
|
479
|
-
|
|
480
|
-
?
|
|
632
|
+
cappedStderr.length > 0
|
|
633
|
+
? cappedStderr
|
|
481
634
|
: "Script exited without producing a result",
|
|
482
635
|
};
|
|
483
636
|
}
|
|
@@ -485,18 +638,22 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
|
485
638
|
if (payload.ok) {
|
|
486
639
|
return {
|
|
487
640
|
result: payload.result,
|
|
488
|
-
stdout:
|
|
489
|
-
stderr:
|
|
641
|
+
stdout: cappedStdout,
|
|
642
|
+
stderr: cappedStderr,
|
|
490
643
|
timedOut: false,
|
|
644
|
+
outputTruncated,
|
|
645
|
+
sandbox: hardening.effective,
|
|
491
646
|
};
|
|
492
647
|
}
|
|
493
648
|
|
|
494
649
|
return {
|
|
495
650
|
error: payload.error,
|
|
496
651
|
stack: payload.stack,
|
|
497
|
-
stdout:
|
|
498
|
-
stderr:
|
|
652
|
+
stdout: cappedStdout,
|
|
653
|
+
stderr: cappedStderr,
|
|
499
654
|
timedOut: false,
|
|
655
|
+
outputTruncated,
|
|
656
|
+
sandbox: hardening.effective,
|
|
500
657
|
};
|
|
501
658
|
} finally {
|
|
502
659
|
// Order matters:
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./esm-script-runner";
|
|
2
2
|
export * from "./shell-script-runner";
|
|
3
|
+
export * from "./script-sandbox";
|
|
3
4
|
export * from "./service-ref";
|
|
4
5
|
export * from "./extension-point";
|
|
5
6
|
export * from "./core-services";
|
|
@@ -10,6 +11,7 @@ export * from "./auth-strategy";
|
|
|
10
11
|
export * from "./zod-config";
|
|
11
12
|
export * from "./encryption";
|
|
12
13
|
export * from "./schema-utils";
|
|
14
|
+
export * from "./render-templatable-config";
|
|
13
15
|
export * from "./config-service";
|
|
14
16
|
export * from "zod";
|
|
15
17
|
export * from "./config-versioning";
|
|
@@ -34,3 +36,4 @@ export * from "./aggregated-result";
|
|
|
34
36
|
export * from "./ws-registry";
|
|
35
37
|
export * from "./readiness-registry";
|
|
36
38
|
export * from "./advisory-lock";
|
|
39
|
+
export * from "./bearer-token";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { configString, withConfigMeta } from "./zod-config";
|
|
5
|
+
import {
|
|
6
|
+
assertNoSecretTemplatableConflict,
|
|
7
|
+
renderTemplatableConfig,
|
|
8
|
+
renderTemplatePreview,
|
|
9
|
+
} from "./render-templatable-config";
|
|
10
|
+
|
|
11
|
+
const httpLikeSchema = z.object({
|
|
12
|
+
url: configString({ "x-templatable": true }),
|
|
13
|
+
method: z.string(),
|
|
14
|
+
headers: z
|
|
15
|
+
.array(
|
|
16
|
+
z.object({
|
|
17
|
+
name: z.string(),
|
|
18
|
+
value: configString({ "x-templatable": true }),
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
.optional(),
|
|
22
|
+
body: configString({ "x-templatable": true }).optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const context = {
|
|
26
|
+
environment: { baseUrl: "https://staging.example.com", tenant: "acme" },
|
|
27
|
+
check: { name: "API health" },
|
|
28
|
+
system: { id: "sys-1" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("renderTemplatableConfig", () => {
|
|
32
|
+
test("renders environment.* into url, header values, and body", () => {
|
|
33
|
+
const out = renderTemplatableConfig({
|
|
34
|
+
schema: httpLikeSchema,
|
|
35
|
+
context,
|
|
36
|
+
config: {
|
|
37
|
+
url: "{{ environment.baseUrl }}/healthz",
|
|
38
|
+
method: "GET",
|
|
39
|
+
headers: [
|
|
40
|
+
{ name: "Host", value: "{{ environment.baseUrl }}" },
|
|
41
|
+
{ name: "X-Tenant", value: "{{ environment.tenant }}" },
|
|
42
|
+
],
|
|
43
|
+
body: '{"tenant":"{{ environment.tenant }}"}',
|
|
44
|
+
},
|
|
45
|
+
}) as Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
expect(out.url).toBe("https://staging.example.com/healthz");
|
|
48
|
+
expect(out.headers).toEqual([
|
|
49
|
+
{ name: "Host", value: "https://staging.example.com" },
|
|
50
|
+
{ name: "X-Tenant", value: "acme" },
|
|
51
|
+
]);
|
|
52
|
+
expect(out.body).toBe('{"tenant":"acme"}');
|
|
53
|
+
// Non-templatable field passed through verbatim.
|
|
54
|
+
expect(out.method).toBe("GET");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("non-templatable field with a literal {{ is untouched", () => {
|
|
58
|
+
const out = renderTemplatableConfig({
|
|
59
|
+
schema: httpLikeSchema,
|
|
60
|
+
context,
|
|
61
|
+
config: {
|
|
62
|
+
url: "{{ environment.baseUrl }}",
|
|
63
|
+
// method is NOT templatable; its literal braces must survive verbatim.
|
|
64
|
+
method: "{{ environment.baseUrl }}",
|
|
65
|
+
},
|
|
66
|
+
}) as Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
expect(out.url).toBe("https://staging.example.com");
|
|
69
|
+
expect(out.method).toBe("{{ environment.baseUrl }}");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("missing path renders to empty string (strict: false)", () => {
|
|
73
|
+
const out = renderTemplatableConfig({
|
|
74
|
+
schema: httpLikeSchema,
|
|
75
|
+
context: { environment: {}, check: {}, system: {} },
|
|
76
|
+
config: { url: "{{ environment.baseUrl }}/healthz", method: "GET" },
|
|
77
|
+
}) as Record<string, unknown>;
|
|
78
|
+
|
|
79
|
+
expect(out.url).toBe("/healthz");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a resolved value containing {{ is not re-interpreted", () => {
|
|
83
|
+
// Simulates secrets-first ordering: a secret already resolved into a value
|
|
84
|
+
// that happens to contain `{{`. The templating pass only renders the
|
|
85
|
+
// x-templatable `url`; the rendered output itself is never re-parsed.
|
|
86
|
+
const out = renderTemplatableConfig({
|
|
87
|
+
schema: httpLikeSchema,
|
|
88
|
+
context: {
|
|
89
|
+
environment: { baseUrl: "https://h/{{ not_a_ref }}" },
|
|
90
|
+
check: {},
|
|
91
|
+
system: {},
|
|
92
|
+
},
|
|
93
|
+
config: { url: "{{ environment.baseUrl }}", method: "GET" },
|
|
94
|
+
}) as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
// The `{{ not_a_ref }}` came FROM the resolved value, not the template, so
|
|
97
|
+
// it is emitted literally (single-pass render).
|
|
98
|
+
expect(out.url).toBe("https://h/{{ not_a_ref }}");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("does not mutate the input config", () => {
|
|
102
|
+
const config = {
|
|
103
|
+
url: "{{ environment.baseUrl }}",
|
|
104
|
+
method: "GET",
|
|
105
|
+
headers: [{ name: "Host", value: "{{ environment.baseUrl }}" }],
|
|
106
|
+
};
|
|
107
|
+
renderTemplatableConfig({ schema: httpLikeSchema, context, config });
|
|
108
|
+
expect(config.url).toBe("{{ environment.baseUrl }}");
|
|
109
|
+
expect(config.headers[0].value).toBe("{{ environment.baseUrl }}");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("renderTemplatePreview", () => {
|
|
114
|
+
test("renders a single value against a sample context", () => {
|
|
115
|
+
expect(
|
|
116
|
+
renderTemplatePreview({
|
|
117
|
+
value: "{{ environment.baseUrl }}/v1",
|
|
118
|
+
context,
|
|
119
|
+
}),
|
|
120
|
+
).toBe("https://staging.example.com/v1");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("leaves a non-template value untouched", () => {
|
|
124
|
+
expect(
|
|
125
|
+
renderTemplatePreview({ value: "https://static.example", context }),
|
|
126
|
+
).toBe("https://static.example");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("assertNoSecretTemplatableConflict", () => {
|
|
131
|
+
test("throws when a field is both x-secret and x-templatable", () => {
|
|
132
|
+
const schema = z.object({
|
|
133
|
+
token: configString({ "x-secret": true, "x-templatable": true }),
|
|
134
|
+
});
|
|
135
|
+
expect(() =>
|
|
136
|
+
assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
|
|
137
|
+
).toThrow(/both/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("throws for nested both-flagged field naming the path", () => {
|
|
141
|
+
const schema = z.object({
|
|
142
|
+
headers: z.array(
|
|
143
|
+
z.object({
|
|
144
|
+
value: configString({
|
|
145
|
+
"x-secret-env": true,
|
|
146
|
+
"x-templatable": true,
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
});
|
|
151
|
+
expect(() =>
|
|
152
|
+
assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
|
|
153
|
+
).toThrow(/headers\[\]\.value/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("passes when secret and templatable are on different fields", () => {
|
|
157
|
+
const schema = z.object({
|
|
158
|
+
token: configString({ "x-secret": true }),
|
|
159
|
+
url: configString({ "x-templatable": true }),
|
|
160
|
+
env: withConfigMeta(z.record(z.string(), z.string()), {
|
|
161
|
+
"x-secret-env": true,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
expect(() =>
|
|
165
|
+
assertNoSecretTemplatableConflict({ schema, schemaName: "test" }),
|
|
166
|
+
).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
});
|