@checkstack/backend-api 0.15.2 → 0.16.0

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 CHANGED
@@ -1,5 +1,119 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
8
+
9
+ **Removed (dead code)**
10
+
11
+ - `core/backend/src/plugin-manager/deregistration-guard.ts` deleted. The exported `assertCanDeregister()` was never called and was a less-complete version of the dependents+isUninstallable checks already done inline by `previewUninstallOriginator` / `uninstallOriginator` in `plugin-manager-orchestrator.ts`.
12
+ - `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
13
+
14
+ **New shared helpers**
15
+
16
+ - `@checkstack/backend-api` now exports `requestTimeoutMs()` — a Zod field builder for outbound HTTP request timeouts (1s..60s, default 10s). Replaces hand-rolled `configNumber({}).min(1000).max(60_000).default(10_000)` in `integration-webhook-backend`, `integration-script-backend`, and `healthcheck-script-backend`'s inline collector.
17
+ - `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
18
+ - `@checkstack/notification-backend` now exports:
19
+ - `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
20
+ - `postJson(opts)` — a timeout-bounded `fetch` wrapper that handles non-2xx logging and error mapping for webhook-style POSTs. Returns `{ ok: true, response } | { ok: false, error }`.
21
+
22
+ **Migrated to shared helpers**
23
+
24
+ - Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
25
+ - Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
26
+ - Teams, Webex, Backstage, Telegram kept their inline fetch/Bot logic: their error strings surface server response bodies to operators, or the transport isn't raw `fetch` (Telegram uses `grammy`'s `Bot`).
27
+
28
+ **API surface tightening**
29
+
30
+ - Per-plugin test-only re-exports in 6 notification backends (Pushover, Gotify, Backstage, Slack, Discord, Teams) and the `CertificateInfo` interface in `healthcheck-tls-backend/strategy.ts` are now JSDoc-tagged `@internal`. No behaviour change; signals that downstream consumers must not depend on them.
31
+
32
+ - a06b899: Extract shared `EsmScriptRunner` + `ShellScriptRunner` utilities, fix HIGH-severity privilege amplification in the integration TS provider, and harden the integration shell setupGuide example.
33
+
34
+ **SECURITY FIX (HIGH)**
35
+
36
+ The integration TS provider (`@checkstack/integration-script-backend` → `scriptProvider`) previously executed user scripts via `new Function(script)` in the satellite's main V8 isolate. A user with `integrationAccess.manage` could read `globalThis.process.env` directly (`DATABASE_URL`, `JWT_SECRET`, queue credentials, signing keys, …) and exfiltrate them through `result.id` — which round-trips into `delivery_logs.externalId` and is readable via the `getDeliveryLog` ORPC procedure. The same permission grants no legitimate API to those secrets; this was a privilege amplification.
37
+
38
+ The provider now runs user scripts in a fresh Bun subprocess (matching the healthcheck inline-script collector model). The subprocess receives only a curated `SAFE_ENV_VARS` whitelist (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`) — backend secrets are no longer visible to user code. Filesystem reads, network calls (`fetch`), and the rest of the Node/Bun standard library continue to work, just in an isolated process.
39
+
40
+ **BREAKING CHANGE (`@checkstack/integration-script-backend`)**
41
+
42
+ User scripts can no longer read the satellite process's environment variables (`process.env.DATABASE_URL` etc. return `undefined`). Scripts that legitimately need configuration should accept it via the provider's `script` field input, not by introspecting the host environment. The full Node/Bun stdlib remains available; only the env scrub is new.
43
+
44
+ **REFACTOR — new shared utilities in `@checkstack/backend-api`**
45
+
46
+ Both the healthcheck and integration plugins had near-identical inline implementations of "run a user script in a subprocess sandbox" (ESM path) and "run a user shell script through `sh -c`" (shell path). These are now canonical, single-source-of-truth utilities:
47
+
48
+ - **`defaultEsmScriptRunner.run({ script, context, timeoutMs, helperModuleName?, helperFunctionName? })`** — writes the user module to a fresh `mkdtemp` directory along with a generated runner module, spawns a Bun subprocess with `pickSafeEnv()`, parses the result back through a UUID-tagged stderr marker, and tears everything down in `finally` (`clearTimeout` + `proc.kill()` + recursive `rm`). The optional `helperModuleName` / `helperFunctionName` pair drops a sibling `_helpers.mjs` file and rewrites `import { <fn> } from "<module>"` to point at it (this is the trick that makes `@checkstack/healthcheck` / `@checkstack/integration` resolve at runtime even though they're not real npm packages).
49
+ - **`defaultShellScriptRunner.run({ script, timeoutMs, cwd?, env? })`** — invokes `sh -c <script>` via `Bun.spawn` with `SAFE_ENV_VARS` (user-supplied `env` merged on top), `Promise.race` timeout with `proc.kill()` on expiry, and the same `clearTimeout` + `proc.kill()` cleanup in `finally`.
50
+
51
+ Both runners expose `EsmScriptRunner` / `ShellScriptRunner` interfaces so tests can inject mocks without touching the spawn path. The four call sites (`plugins/healthcheck-script-backend/src/inline-script-collector.ts`, `strategy.ts` and `plugins/integration-script-backend/src/provider.ts`, `shell-provider.ts`) collapse from full inline implementations to ~8-line adapters.
52
+
53
+ **FIXES**
54
+
55
+ - Integration shell provider's `setupGuide` example replaced the unsafe `curl -d "{\"title\": \"$PAYLOAD_TITLE\"}"` JSON interpolation with a `jq -n --arg title "$PAYLOAD_TITLE" '{title: $title}'` pattern. The previous example demonstrated a shell-injection vulnerability whenever event payload values contained shell-special or JSON-special characters (which they can, since payloads come from other plugins / events / GitOps reconciles).
56
+ - The shared shell runner adds `clearTimeout` + idempotent `proc?.kill()` in `finally`, fixing a leaked event-loop timer in the integration shell provider's previous inline implementation.
57
+
58
+ **TESTS**
59
+
60
+ - New `core/backend-api/src/esm-script-runner.test.ts` covering `normaliseUserScript` + `rewriteHelperImports` across both healthcheck and integration helper-module names, including regex-metacharacter escape coverage.
61
+ - The plugin-local `inline-script-normaliser.test.ts` was deleted; the same coverage (plus more) lives at the canonical location with the utility.
62
+ - Integration TS provider console-logging tests updated: in the subprocess model, `console.warn` and `console.error` both write to stderr (Bun matches Node), so the provider forwards every stderr line to `logger.error`. `console.log({…})` uses Bun's native `util.inspect` format rather than `JSON.stringify`, so the JSON-logging test now asserts on substring presence instead of strict serialisation.
63
+
64
+ 2047 tests pass, lint + typecheck clean.
65
+
66
+ ### Patch Changes
67
+
68
+ - @checkstack/cache-api@0.3.3
69
+ - @checkstack/queue-api@0.3.3
70
+ - @checkstack/healthcheck-common@1.1.1
71
+
72
+ ## 0.15.3
73
+
74
+ ### Patch Changes
75
+
76
+ - 1909a61: Address open CodeQL code-scanning findings:
77
+
78
+ - **`@checkstack/ui` (`LinksEditor`)**: validate URL scheme on render and on
79
+ add; only `http:` / `https:` URLs are accepted, defeating stored XSS via
80
+ `javascript:` / `data:` schemes in user-supplied hotlinks
81
+ (`js/xss-through-dom`).
82
+ - **`@checkstack/backend-api` (`markdownToPlainText`)**: decode HTML entities
83
+ before stripping tags, then strip tags in a loop until the output
84
+ stabilizes. Decoding `&amp;` last avoids reintroducing tag delimiters
85
+ via `&amp;lt;` round-trips (`js/double-escaping`,
86
+ `js/incomplete-multi-character-sanitization`).
87
+ - **`@checkstack/backend` (`createScopedWsRegistry`)**: drop the
88
+ identity-replacement on the path suffix; the leading-slash invariant
89
+ is documented on `WebSocketRouteRegistry` (`js/identity-replacement`).
90
+
91
+ - b33fb4d: Refresh `bun.lock` to clear MEDIUM-severity Trivy advisories on transitive
92
+ runtime dependencies. No public API change — bumping every workspace
93
+ package that lists `@orpc/server` as a direct dep so consumers re-resolve
94
+ the optional `ws` peer to the patched release on their next install.
95
+
96
+ - `ws` `8.20.0` → `8.20.1` (CVE-2026-45736). Pulled into the install tree
97
+ as `@orpc/server`'s optional WebSocket peer; Bun auto-installs it into
98
+ every backend package that depends on `@orpc/server`, so a stale 8.20.0
99
+ ships in the consumer's `node_modules` until the parent package
100
+ re-resolves.
101
+ - `brace-expansion` `5.0.5` → `5.0.6` (CVE-2026-45149). Pulled in only
102
+ through dev tooling (`minimatch@10` via `@typescript-eslint` and
103
+ `storybook`'s `glob@13`), so it does not ship to consumers and no
104
+ workspace `package.json` lists it; the lockfile bump alone clears the
105
+ finding for the Docker image and the local dev tree. No version bump
106
+ is attributed to this advisory.
107
+
108
+ The fix lives entirely in `bun.lock` — no `package.json`, `overrides`, or
109
+ `resolutions` change is needed because both parent ranges (`minimatch@10
110
+ → brace-expansion@^5.0.5`, `@orpc/server / storybook / happy-dom →
111
+ ws@>=8.18.x`) already accept the patched releases, and `bun install`
112
+ keeps the resolved versions sticky after the initial `bun update`.
113
+
114
+ - @checkstack/cache-api@0.3.2
115
+ - @checkstack/queue-api@0.3.2
116
+
3
117
  ## 0.15.2
4
118
 
5
119
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,11 +10,11 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/common": "0.9.0",
14
- "@checkstack/healthcheck-common": "1.0.2",
15
- "@checkstack/cache-api": "0.3.0",
16
- "@checkstack/queue-api": "0.3.0",
17
- "@checkstack/signal-common": "0.2.2",
13
+ "@checkstack/common": "0.10.0",
14
+ "@checkstack/healthcheck-common": "1.1.0",
15
+ "@checkstack/cache-api": "0.3.2",
16
+ "@checkstack/queue-api": "0.3.2",
17
+ "@checkstack/signal-common": "0.2.3",
18
18
  "@orpc/client": "^1.13.14",
19
19
  "@orpc/contract": "^1.13.14",
20
20
  "@orpc/openapi": "^1.13.2",
@@ -28,7 +28,7 @@
28
28
  "devDependencies": {
29
29
  "@types/bun": "latest",
30
30
  "@checkstack/tsconfig": "0.0.7",
31
- "@checkstack/scripts": "0.3.1"
31
+ "@checkstack/scripts": "0.3.2"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "hono": "^4.12.14",
@@ -24,3 +24,22 @@ export const baseStrategyConfigSchema = z.object({
24
24
  * Base config type that all strategy configs must satisfy.
25
25
  */
26
26
  export type BaseStrategyConfig = z.infer<typeof baseStrategyConfigSchema>;
27
+
28
+ /**
29
+ * Shared timeout field for outbound HTTP requests (webhooks, integration
30
+ * deliveries, inline scripts). Standardises bounds and default across plugins
31
+ * so operators see a consistent UX in the config UI.
32
+ *
33
+ * Bounds: `1_000ms` to `60_000ms`, default `10_000ms`.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const provider = z.object({
38
+ * url: configString({}).url(),
39
+ * timeout: requestTimeoutMs().describe("Request timeout in milliseconds"),
40
+ * });
41
+ * ```
42
+ */
43
+ export function requestTimeoutMs() {
44
+ return configNumber({}).min(1000).max(60_000).default(10_000);
45
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ normaliseUserScript,
4
+ rewriteHelperImports,
5
+ } from "./esm-script-runner";
6
+
7
+ /**
8
+ * The shared ESM-script runner applies two text transforms to user
9
+ * source before writing it to a temp `.mjs` file:
10
+ *
11
+ * 1. `normaliseUserScript` — wraps legacy `return X;` style scripts in
12
+ * an async IIFE so they're valid as a module's default export,
13
+ * while leaving real ESM modules (those with top-level
14
+ * `import`/`export`) untouched.
15
+ *
16
+ * 2. `rewriteHelperImports` — rewrites `from "<helperModuleName>"`
17
+ * to point at a runtime helper file written next to the user
18
+ * script.
19
+ *
20
+ * Both are easy to regress (a regex tweak that gets greedier than
21
+ * intended, an edge case with comments, …) and both directly affect
22
+ * whether user scripts run at all. Keep this suite tight.
23
+ *
24
+ * These were previously tested under
25
+ * `plugins/healthcheck-script-backend/src/inline-script-normaliser.test.ts`;
26
+ * the utilities now live in `@checkstack/backend-api` and are shared by
27
+ * both healthcheck (inline-script-collector) and integration
28
+ * (script-provider).
29
+ */
30
+
31
+ describe("normaliseUserScript", () => {
32
+ it("wraps a bare `return` body in an async IIFE default export", () => {
33
+ const out = normaliseUserScript("return { success: true };");
34
+ expect(out).toContain("export default await (async () => {");
35
+ expect(out).toContain("return { success: true };");
36
+ expect(out).toContain("})();");
37
+ });
38
+
39
+ it("wraps a no-return body (side-effect script) the same way", () => {
40
+ const out = normaliseUserScript("console.log('hi');");
41
+ expect(out).toContain("export default await (async () => {");
42
+ expect(out).toContain("console.log('hi');");
43
+ });
44
+
45
+ it("leaves a real ESM module (with top-level `import`) untouched", () => {
46
+ const src = `import { loadavg } from "node:os";\nexport default { success: true };`;
47
+ expect(normaliseUserScript(src)).toBe(src);
48
+ });
49
+
50
+ it("leaves a module with only `export default` (no import) untouched", () => {
51
+ const src = `export default { success: true };`;
52
+ expect(normaliseUserScript(src)).toBe(src);
53
+ });
54
+
55
+ it("leaves a script with `export` after leading whitespace untouched", () => {
56
+ // Top-level matters regardless of leading whitespace — the regex is
57
+ // anchored with `^\s*` per-line via the `m` flag.
58
+ const src = ` export default 42;`;
59
+ expect(normaliseUserScript(src)).toBe(src);
60
+ });
61
+
62
+ it("treats indented `return`-style scripts as legacy (wraps them)", () => {
63
+ // Per current behaviour: only top-of-line `export`/`import` qualifies
64
+ // as ESM. Anything else gets wrapped. This is intentional — a script
65
+ // that contains the substring "import" inside a comment shouldn't be
66
+ // misclassified as ESM and have its `return` left dangling.
67
+ const src = ` // imports are great\n return true;`;
68
+ expect(normaliseUserScript(src)).toContain("export default await");
69
+ });
70
+
71
+ it("appends a trailing newline before `})` so a trailing `//` comment doesn't swallow the brace", () => {
72
+ const out = normaliseUserScript("return true; // trailing comment");
73
+ // Confirm the wrapper structure didn't get clobbered by the comment.
74
+ expect(out).toMatch(/return true; \/\/ trailing comment\s*\n\}\)\(\)/);
75
+ });
76
+ });
77
+
78
+ describe("rewriteHelperImports", () => {
79
+ const HELPER_URL = "file:///tmp/checkstack-script-abc/_helpers.mjs";
80
+
81
+ it("rewrites a named import from the helper module", () => {
82
+ const out = rewriteHelperImports({
83
+ userScript: `import { defineHealthCheck } from "@checkstack/healthcheck";`,
84
+ helperModuleName: "@checkstack/healthcheck",
85
+ helperUrl: HELPER_URL,
86
+ });
87
+ expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
88
+ });
89
+
90
+ it("works with single-quoted import specs too", () => {
91
+ const out = rewriteHelperImports({
92
+ userScript: `import { defineHealthCheck } from '@checkstack/healthcheck';`,
93
+ helperModuleName: "@checkstack/healthcheck",
94
+ helperUrl: HELPER_URL,
95
+ });
96
+ expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
97
+ });
98
+
99
+ it("rewrites a side-effect import too", () => {
100
+ const out = rewriteHelperImports({
101
+ userScript: `import "@checkstack/healthcheck";`,
102
+ helperModuleName: "@checkstack/healthcheck",
103
+ helperUrl: HELPER_URL,
104
+ });
105
+ expect(out).toBe(`import "${HELPER_URL}";`);
106
+ });
107
+
108
+ it("leaves other imports alone", () => {
109
+ const src = `import { loadavg } from "node:os";\nimport fs from "node:fs/promises";`;
110
+ expect(
111
+ rewriteHelperImports({
112
+ userScript: src,
113
+ helperModuleName: "@checkstack/healthcheck",
114
+ helperUrl: HELPER_URL,
115
+ }),
116
+ ).toBe(src);
117
+ });
118
+
119
+ it("rewrites multiple occurrences", () => {
120
+ const src = `
121
+ import { defineHealthCheck } from "@checkstack/healthcheck";
122
+ import type { HealthCheckScriptResult } from "@checkstack/healthcheck";
123
+ `;
124
+ const out = rewriteHelperImports({
125
+ userScript: src,
126
+ helperModuleName: "@checkstack/healthcheck",
127
+ helperUrl: HELPER_URL,
128
+ });
129
+ expect(out.match(/@checkstack\/healthcheck/g)).toBeNull();
130
+ expect([...out.matchAll(new RegExp(HELPER_URL, "g"))].length).toBe(2);
131
+ });
132
+
133
+ it("doesn't rewrite the package name if it appears in a string literal", () => {
134
+ // Conservative regex: it only matches the spec position of an import
135
+ // statement (`from "..."` / `import "..."`). A string containing the
136
+ // package name elsewhere must be left alone.
137
+ const src = `console.log("Look up @checkstack/healthcheck on npm");`;
138
+ expect(
139
+ rewriteHelperImports({
140
+ userScript: src,
141
+ helperModuleName: "@checkstack/healthcheck",
142
+ helperUrl: HELPER_URL,
143
+ }),
144
+ ).toBe(src);
145
+ });
146
+
147
+ it("rewrites the integration helper module too (regex is parameterised)", () => {
148
+ // The shared runner is used by both healthcheck and integration
149
+ // plugins; the regex is built from the supplied `helperModuleName`.
150
+ // Sanity check that the integration variant works.
151
+ const out = rewriteHelperImports({
152
+ userScript: `import { defineIntegration } from "@checkstack/integration";`,
153
+ helperModuleName: "@checkstack/integration",
154
+ helperUrl: HELPER_URL,
155
+ });
156
+ expect(out).toBe(`import { defineIntegration } from "${HELPER_URL}";`);
157
+ });
158
+
159
+ it("escapes regex metacharacters in the helper module name", () => {
160
+ // Defence-in-depth: if someone ever passes a name with special
161
+ // chars, we don't want it interpreted as a regex.
162
+ const out = rewriteHelperImports({
163
+ userScript: `import x from "weird.pkg+name";`,
164
+ helperModuleName: "weird.pkg+name",
165
+ helperUrl: HELPER_URL,
166
+ });
167
+ expect(out).toBe(`import x from "${HELPER_URL}";`);
168
+ });
169
+ });
@@ -0,0 +1,467 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ /**
9
+ * Shared sandbox for executing user-authored TypeScript / JavaScript
10
+ * modules in a fresh Bun subprocess.
11
+ *
12
+ * Used by both `@checkstack/healthcheck-script-backend` (the inline
13
+ * health-check collector) and `@checkstack/integration-script-backend`
14
+ * (the script integration provider). The two had near-identical inline
15
+ * implementations; this module is the canonical version.
16
+ *
17
+ * Why subprocess isolation matters:
18
+ *
19
+ * Running user code via `new Function(script)` in the satellite's
20
+ * own process gives the user `globalThis.process`, `node:fs`, etc.
21
+ * — they can read every secret in `process.env` (DB URLs, signing
22
+ * keys, queue creds) and exfiltrate them through the result. Even
23
+ * `manage`-level users typically have no legitimate API for those
24
+ * secrets, so in-process eval is a privilege amplification.
25
+ *
26
+ * By spawning a separate Bun process with a curated `SAFE_ENV_VARS`
27
+ * subset, the user's script gets the full Node/Bun standard library
28
+ * to work with but cannot see the satellite's environment.
29
+ *
30
+ * Concurrency note: each `run()` invocation is fully isolated.
31
+ *
32
+ * - `mkdtemp` guarantees a unique directory name (POSIX-atomic).
33
+ * - The result-marker session id is a `randomUUID`, so each
34
+ * subprocess's stderr is unambiguously its own — even if user
35
+ * scripts happen to write text that looks like another invocation's
36
+ * marker.
37
+ * - The subprocess is launched with `cmd: [bun, runner.mjs]` whose
38
+ * references to the temp dir are absolute paths in env/argv. Two
39
+ * concurrent subprocesses can never read each other's user.mjs.
40
+ *
41
+ * Cleanup is `finally`-guaranteed: the timeout handle is cleared, any
42
+ * straggler subprocess is killed (idempotent on an already-exited
43
+ * process), and the temp dir is removed recursively — on success, on
44
+ * thrown error, AND on timeout.
45
+ */
46
+
47
+ // =============================================================================
48
+ // PUBLIC TYPES
49
+ // =============================================================================
50
+
51
+ export interface EsmScriptRunResult {
52
+ /** Raw value the user script returned (default export or legacy IIFE return). */
53
+ result?: unknown;
54
+ /** Error message if the script threw or failed to load. */
55
+ error?: string;
56
+ /** Stack trace if available. */
57
+ stack?: string;
58
+ /** Anything the script wrote to stdout — caller can surface as logs. */
59
+ stdout: string;
60
+ /** Anything the script wrote to stderr (with our result-marker stripped). */
61
+ stderr: string;
62
+ /** True if the timeout fired before the subprocess exited. */
63
+ timedOut: boolean;
64
+ }
65
+
66
+ export interface EsmScriptRunOptions {
67
+ /** User-supplied script source (modern ESM with `import`/`export`, or legacy `return X;`). */
68
+ script: string;
69
+ /** Object to expose as `globalThis.context` inside the subprocess. JSON-serialised; no functions / cycles. */
70
+ context: unknown;
71
+ /** Maximum execution time in milliseconds. */
72
+ timeoutMs: number;
73
+ /**
74
+ * Optional virtual module name that the user's script can `import`
75
+ * from. We write a sibling `_helpers.mjs` in the temp dir that
76
+ * exports a single identity function under `helperFunctionName`, and
77
+ * rewrite any `from "<helperModuleName>"` import in the user source
78
+ * to point at that file. Skipped if either field is omitted.
79
+ *
80
+ * @example
81
+ * helperModuleName: "@checkstack/healthcheck"
82
+ * helperFunctionName: "defineHealthCheck"
83
+ * // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
84
+ * // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
85
+ */
86
+ helperModuleName?: string;
87
+ /** Name of the helper function injected as a global AND exported by the virtual module. */
88
+ helperFunctionName?: string;
89
+ }
90
+
91
+ /**
92
+ * Injectable interface. Production code calls
93
+ * `defaultEsmScriptRunner.run()`; tests can pass a mock to skip the
94
+ * actual subprocess spawn.
95
+ */
96
+ export interface EsmScriptRunner {
97
+ run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
98
+ }
99
+
100
+ // =============================================================================
101
+ // INTERNALS
102
+ // =============================================================================
103
+
104
+ /**
105
+ * Vars passed through to the subprocess. We intentionally do NOT
106
+ * forward the satellite's full env so backend secrets (DB URLs, API
107
+ * tokens, signing keys) never reach user-authored scripts. PATH / HOME
108
+ * / LANG / ... are kept so `node:child_process`, `node:fs`, and
109
+ * locale-sensitive APIs behave normally.
110
+ */
111
+ const SAFE_ENV_VARS = [
112
+ "PATH",
113
+ "HOME",
114
+ "USER",
115
+ "LANG",
116
+ "LC_ALL",
117
+ "LC_CTYPE",
118
+ "TZ",
119
+ "TMPDIR",
120
+ "HOSTNAME",
121
+ "SHELL",
122
+ ];
123
+
124
+ function pickSafeEnv(): Record<string, string> {
125
+ const env: Record<string, string> = {};
126
+ for (const key of SAFE_ENV_VARS) {
127
+ const value = process.env[key];
128
+ if (value !== undefined) {
129
+ env[key] = value;
130
+ }
131
+ }
132
+ return env;
133
+ }
134
+
135
+ // =============================================================================
136
+ // USER-SCRIPT NORMALISATION
137
+ // =============================================================================
138
+
139
+ const ESM_AT_TOP_LEVEL = /^\s*(import\s|export\s)/m;
140
+
141
+ /**
142
+ * Make the user's source loadable as an ES module.
143
+ *
144
+ * Three shapes are supported:
145
+ * 1. **Real module** (contains `import` / `export` at top level) — used as-is.
146
+ * 2. **Legacy IIFE-style** (`return X;` at top level) — wrapped in an
147
+ * async IIFE whose return value becomes the default export.
148
+ * 3. **Side-effect only** — treated as healthy unless it throws.
149
+ */
150
+ export function normaliseUserScript(userScript: string): string {
151
+ if (ESM_AT_TOP_LEVEL.test(userScript)) {
152
+ return userScript;
153
+ }
154
+ // Trailing newline so a `// comment` on the last line doesn't swallow
155
+ // the closing brace.
156
+ return `export default await (async () => {\n${userScript}\n})();\n`;
157
+ }
158
+
159
+ /**
160
+ * Rewrite imports of a virtual module to point at a real on-disk
161
+ * helper file. The user writes a clean package import in the editor
162
+ * (with IntelliSense from the virtual ambient module), and at runtime
163
+ * we redirect it to a local sibling.
164
+ *
165
+ * The regex is anchored to the literal spec position of `from "..."` /
166
+ * `import "..."` — it doesn't touch substrings of comments or string
167
+ * literals.
168
+ */
169
+ export function rewriteHelperImports({
170
+ userScript,
171
+ helperModuleName,
172
+ helperUrl,
173
+ }: {
174
+ userScript: string;
175
+ helperModuleName: string;
176
+ helperUrl: string;
177
+ }): string {
178
+ const escapedName = helperModuleName.replaceAll(
179
+ /[.*+?^${}()|[\]\\]/g,
180
+ String.raw`\$&`,
181
+ );
182
+ const fromRe = new RegExp(
183
+ String.raw`(from\s+)(["'])${escapedName}\2`,
184
+ "g",
185
+ );
186
+ const sideEffectRe = new RegExp(
187
+ String.raw`(import\s+)(["'])${escapedName}\2`,
188
+ "g",
189
+ );
190
+ return userScript
191
+ .replaceAll(fromRe, (_match, fromKw: string) =>
192
+ `${fromKw}${JSON.stringify(helperUrl)}`,
193
+ )
194
+ .replaceAll(sideEffectRe, (_match, importKw: string) =>
195
+ `${importKw}${JSON.stringify(helperUrl)}`,
196
+ );
197
+ }
198
+
199
+ // =============================================================================
200
+ // RUNNER GENERATION
201
+ // =============================================================================
202
+
203
+ function buildHelperSource(helperFunctionName: string): string {
204
+ return `// Auto-generated. Identity helper that exists only so the editor can
205
+ // type-check the user's return shape. The runtime is intentionally trivial.
206
+ export function ${helperFunctionName}(value) { return value; }
207
+ `;
208
+ }
209
+
210
+ function buildRunnerSource({
211
+ userScriptUrl,
212
+ contextJson,
213
+ helperFunctionName,
214
+ markerStart,
215
+ markerEnd,
216
+ }: {
217
+ userScriptUrl: string;
218
+ contextJson: string;
219
+ helperFunctionName: string | undefined;
220
+ markerStart: string;
221
+ markerEnd: string;
222
+ }): string {
223
+ const helperGlobal = helperFunctionName
224
+ ? `globalThis.${helperFunctionName} = (value) => value;\n`
225
+ : "";
226
+
227
+ // `String.raw` so embedded \n / \\ in the generated source survive
228
+ // verbatim to the temp file — the runner needs to write a real
229
+ // newline at runtime, which means the file on disk needs the two
230
+ // characters `\n` (not a real LF).
231
+ return String.raw`// Auto-generated runner for an inline user-script execution.
232
+ // Sets up the user-facing globals, imports the user module, captures the
233
+ // result, and writes it back to the parent through a stderr marker.
234
+
235
+ globalThis.context = ${contextJson};
236
+ ${helperGlobal}
237
+ const __markerStart = ${JSON.stringify(markerStart)};
238
+ const __markerEnd = ${JSON.stringify(markerEnd)};
239
+
240
+ function __emit(payload) {
241
+ // Single-line JSON, sandwiched between unique markers. The parent
242
+ // process does a lastIndexOf() to find it and is tolerant of
243
+ // arbitrary user output on stderr above.
244
+ process.stderr.write(__markerStart + JSON.stringify(payload) + __markerEnd + "\n");
245
+ }
246
+
247
+ try {
248
+ const __mod = await import(${JSON.stringify(userScriptUrl)});
249
+ let __result;
250
+ if (__mod && "default" in __mod && __mod.default !== undefined) {
251
+ const __def = __mod.default;
252
+ __result =
253
+ typeof __def === "function"
254
+ ? await __def(globalThis.context)
255
+ : __def;
256
+ }
257
+ __emit({ ok: true, result: __result ?? null });
258
+ } catch (err) {
259
+ const __message =
260
+ err && typeof err === "object" && "message" in err
261
+ ? String(err.message)
262
+ : String(err);
263
+ const __stack =
264
+ err && typeof err === "object" && "stack" in err
265
+ ? String(err.stack)
266
+ : undefined;
267
+ __emit({ ok: false, error: __message, stack: __stack });
268
+ process.exit(1);
269
+ }
270
+ `;
271
+ }
272
+
273
+ // =============================================================================
274
+ // MARKER PAYLOAD VALIDATION
275
+ // =============================================================================
276
+
277
+ type RunnerPayload =
278
+ | { ok: true; result: unknown }
279
+ | { ok: false; error: string; stack?: string };
280
+
281
+ function isRunnerPayload(value: unknown): value is RunnerPayload {
282
+ if (typeof value !== "object" || value === null) return false;
283
+ const v = value as Record<string, unknown>;
284
+ if (v.ok === true) return true;
285
+ if (v.ok === false && typeof v.error === "string") return true;
286
+ return false;
287
+ }
288
+
289
+ // =============================================================================
290
+ // DEFAULT RUNNER
291
+ // =============================================================================
292
+
293
+ /**
294
+ * Default runner implementation. Production code should use this; tests
295
+ * can substitute a mock that conforms to {@link EsmScriptRunner}.
296
+ */
297
+ export const defaultEsmScriptRunner: EsmScriptRunner = {
298
+ async run({
299
+ script,
300
+ context,
301
+ timeoutMs,
302
+ helperModuleName,
303
+ helperFunctionName,
304
+ }) {
305
+ const sessionId = randomUUID();
306
+ const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
307
+ const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
308
+
309
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "checkstack-script-"));
310
+ const userScriptPath = path.join(tmpDir, "user.mjs");
311
+ const runnerPath = path.join(tmpDir, "runner.mjs");
312
+
313
+ const hasHelper =
314
+ typeof helperModuleName === "string" &&
315
+ helperModuleName.length > 0 &&
316
+ typeof helperFunctionName === "string" &&
317
+ helperFunctionName.length > 0;
318
+ const helperPath = hasHelper ? path.join(tmpDir, "_helpers.mjs") : undefined;
319
+ const helperUrl = helperPath ? pathToFileURL(helperPath).href : undefined;
320
+
321
+ let proc: Subprocess | undefined;
322
+ let timedOut = false;
323
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
324
+
325
+ const timeoutPromise = new Promise<never>((_, reject) => {
326
+ timeoutHandle = setTimeout(() => {
327
+ timedOut = true;
328
+ proc?.kill();
329
+ reject(new Error("__TIMEOUT__"));
330
+ }, timeoutMs);
331
+ });
332
+
333
+ try {
334
+ // Helper module first so the user's
335
+ // `import { <fn> } from "<helperModuleName>"` (which we rewrite
336
+ // to point at this file's URL) resolves at module-evaluation time.
337
+ if (helperPath && helperFunctionName) {
338
+ await writeFile(helperPath, buildHelperSource(helperFunctionName), "utf8");
339
+ }
340
+
341
+ const normalisedSource = normaliseUserScript(script);
342
+ const userSource =
343
+ hasHelper && helperUrl
344
+ ? rewriteHelperImports({
345
+ userScript: normalisedSource,
346
+ helperModuleName: helperModuleName!,
347
+ helperUrl,
348
+ })
349
+ : normalisedSource;
350
+
351
+ await writeFile(userScriptPath, userSource, "utf8");
352
+ await writeFile(
353
+ runnerPath,
354
+ buildRunnerSource({
355
+ userScriptUrl: pathToFileURL(userScriptPath).href,
356
+ contextJson: JSON.stringify(context),
357
+ helperFunctionName: hasHelper ? helperFunctionName : undefined,
358
+ markerStart,
359
+ markerEnd,
360
+ }),
361
+ "utf8",
362
+ );
363
+
364
+ proc = spawn({
365
+ cmd: [process.execPath, runnerPath],
366
+ env: pickSafeEnv(),
367
+ stdout: "pipe",
368
+ stderr: "pipe",
369
+ });
370
+
371
+ let stdout: string;
372
+ let stderr: string;
373
+
374
+ try {
375
+ [stdout, stderr] = (await Promise.race([
376
+ Promise.all([
377
+ new Response(proc.stdout as ReadableStream).text(),
378
+ new Response(proc.stderr as ReadableStream).text(),
379
+ proc.exited,
380
+ ]),
381
+ timeoutPromise,
382
+ ])) as [string, string, number];
383
+ } catch (error) {
384
+ if (timedOut) {
385
+ return {
386
+ stdout: "",
387
+ stderr: "",
388
+ timedOut: true,
389
+ error: "Script execution timed out",
390
+ };
391
+ }
392
+ throw error;
393
+ }
394
+
395
+ // Pluck the runner payload out of stderr.
396
+ const startIdx = stderr.lastIndexOf(markerStart);
397
+ const endIdx = stderr.lastIndexOf(markerEnd);
398
+
399
+ let cleanStderr = stderr;
400
+ let payload: RunnerPayload | undefined;
401
+
402
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
403
+ const jsonStr = stderr.slice(startIdx + markerStart.length, endIdx);
404
+ try {
405
+ const parsed: unknown = JSON.parse(jsonStr);
406
+ if (isRunnerPayload(parsed)) {
407
+ payload = parsed;
408
+ }
409
+ } catch {
410
+ // Fall through to the "no marker" branch.
411
+ }
412
+ cleanStderr = (
413
+ stderr.slice(0, startIdx) + stderr.slice(endIdx + markerEnd.length)
414
+ )
415
+ .replace(/\n$/, "")
416
+ .trim();
417
+ }
418
+
419
+ if (!payload) {
420
+ // The runner never got far enough to emit — typically a syntax
421
+ // error in the user module or a hard crash. Surface whatever the
422
+ // subprocess wrote to stderr as the error.
423
+ return {
424
+ stdout: stdout.trim(),
425
+ stderr: cleanStderr,
426
+ timedOut: false,
427
+ error:
428
+ cleanStderr.length > 0
429
+ ? cleanStderr
430
+ : "Script exited without producing a result",
431
+ };
432
+ }
433
+
434
+ if (payload.ok) {
435
+ return {
436
+ result: payload.result,
437
+ stdout: stdout.trim(),
438
+ stderr: cleanStderr,
439
+ timedOut: false,
440
+ };
441
+ }
442
+
443
+ return {
444
+ error: payload.error,
445
+ stack: payload.stack,
446
+ stdout: stdout.trim(),
447
+ stderr: cleanStderr,
448
+ timedOut: false,
449
+ };
450
+ } finally {
451
+ // Order matters:
452
+ // 1. Clear the timer (otherwise a fast script leaks an
453
+ // event-loop handle for up to `timeoutMs`).
454
+ // 2. Kill any straggler subprocess. `.kill()` is idempotent on
455
+ // an already-exited process.
456
+ // 3. Remove the tempdir last — after the subprocess can no
457
+ // longer be touching its files.
458
+ if (timeoutHandle !== undefined) {
459
+ clearTimeout(timeoutHandle);
460
+ }
461
+ proc?.kill();
462
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {
463
+ // Best-effort. Anything left in /tmp will be reaped by the OS.
464
+ });
465
+ }
466
+ },
467
+ };
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./esm-script-runner";
2
+ export * from "./shell-script-runner";
1
3
  export * from "./service-ref";
2
4
  export * from "./extension-point";
3
5
  export * from "./core-services";
@@ -76,6 +76,21 @@ describe("markdownToPlainText", () => {
76
76
  const result = markdownToPlainText("A &amp; B");
77
77
  expect(result).toBe("A & B");
78
78
  });
79
+
80
+ it("does not double-unescape &amp;lt; back into a tag delimiter", () => {
81
+ // Without ordering the &amp; decode last, `&amp;lt;` would become `<`
82
+ // after entity decoding, reintroducing a control character.
83
+ const result = markdownToPlainText("A &amp;lt; B");
84
+ expect(result).toBe("A &lt; B");
85
+ });
86
+
87
+ it("strips nested tag attempts in raw HTML", () => {
88
+ // marked passes raw HTML through; the tag stripper must loop until
89
+ // stable so nested constructs cannot leak through.
90
+ const result = markdownToPlainText("<<script>script>alert(1)</script>");
91
+ expect(result).not.toContain("<script");
92
+ expect(result).not.toContain("</script");
93
+ });
79
94
  });
80
95
 
81
96
  describe("markdownToSlackMrkdwn", () => {
package/src/markdown.ts CHANGED
@@ -47,17 +47,26 @@ export function markdownToPlainText(markdown: string): string {
47
47
  // Convert to HTML first, then strip tags
48
48
  const html = markdownToHtml(markdown);
49
49
 
50
- // Strip HTML tags
51
- let text = html.replaceAll(/<[^>]*>/g, "");
52
-
53
- // Decode common HTML entities
54
- text = text
55
- .replaceAll("&amp;", "&")
50
+ // Decode HTML entities FIRST so any escaped markup like `&lt;script&gt;`
51
+ // is exposed to the tag stripper in the next pass. `&amp;` must be
52
+ // decoded last; otherwise `&amp;lt;` would round-trip to `<` and
53
+ // reintroduce a control character (CodeQL js/double-escaping).
54
+ let text = html
56
55
  .replaceAll("&lt;", "<")
57
56
  .replaceAll("&gt;", ">")
58
57
  .replaceAll("&quot;", '"')
59
58
  .replaceAll("&#39;", "'")
60
- .replaceAll("&nbsp;", " ");
59
+ .replaceAll("&nbsp;", " ")
60
+ .replaceAll("&amp;", "&");
61
+
62
+ // Strip HTML tags. Loop until the output stabilizes so adversarial inputs
63
+ // like `<scr<script>ipt>` cannot leave a residual `<script>` substring
64
+ // after a single pass (CodeQL js/incomplete-multi-character-sanitization).
65
+ let previous: string;
66
+ do {
67
+ previous = text;
68
+ text = text.replaceAll(/<[^>]*>/g, "");
69
+ } while (text !== previous);
61
70
 
62
71
  // Collapse multiple whitespace/newlines
63
72
  text = text.replaceAll(/\n\s*\n/g, "\n").trim();
@@ -0,0 +1,175 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+
3
+ /**
4
+ * Shared sandbox for executing user-authored shell scripts through
5
+ * `sh -c`.
6
+ *
7
+ * Used by both `@checkstack/healthcheck-script-backend` (the shell
8
+ * health-check strategy) and `@checkstack/integration-script-backend`
9
+ * (the shell integration provider). The two had near-identical inline
10
+ * implementations; this module is the canonical version.
11
+ *
12
+ * Why a curated env: a script author already has authority to execute
13
+ * arbitrary shell, but we must not leak the satellite's own secrets to
14
+ * them. The forwarded env is the minimum needed for ordinary commands
15
+ * (`awk`, `curl`, `git`, locale-aware tools) to behave correctly:
16
+ * `PATH` so binaries resolve, `HOME` / `USER` so tools find their
17
+ * config, `LANG` / `LC_*` so output is parseable, `TZ` so timestamps
18
+ * are consistent, `TMPDIR` so `mktemp` works. Everything else
19
+ * (DB URLs, signing keys, queue creds, etc.) is dropped.
20
+ *
21
+ * Cleanup is `finally`-guaranteed: the timeout handle is cleared so a
22
+ * fast script doesn't leak an event-loop timer, and any straggler
23
+ * subprocess is `.kill()`-ed (idempotent on an already-exited
24
+ * process). This matches the pattern in the ESM script runner.
25
+ */
26
+
27
+ // =============================================================================
28
+ // PUBLIC TYPES
29
+ // =============================================================================
30
+
31
+ export interface ShellScriptRunResult {
32
+ /** Exit code reported by the subprocess. -1 if it never started or timed out. */
33
+ exitCode: number;
34
+ /** Captured stdout, trimmed of trailing newlines. */
35
+ stdout: string;
36
+ /** Captured stderr, trimmed of trailing newlines. */
37
+ stderr: string;
38
+ /** True if the timeout fired before the subprocess exited. */
39
+ timedOut: boolean;
40
+ }
41
+
42
+ export interface ShellScriptRunOptions {
43
+ /** Shell-script source. Fed verbatim to `sh -c`, so pipes, redirects, etc. work. */
44
+ script: string;
45
+ /** Maximum execution time in milliseconds. */
46
+ timeoutMs: number;
47
+ /** Optional working directory for the subprocess. */
48
+ cwd?: string;
49
+ /**
50
+ * Optional extra environment variables. Merged on top of the
51
+ * safe-vars whitelist (`PATH`, `HOME`, ...). User-supplied values
52
+ * win on key collision.
53
+ *
54
+ * Note: callers should validate the keys themselves if they need to
55
+ * forbid `LD_PRELOAD`, `NODE_OPTIONS`, etc. — at the shared-runner
56
+ * layer we accept whatever the caller passes, because the legitimate
57
+ * use cases (e.g. integration shell scripts injecting `PAYLOAD_*`
58
+ * vars) vary too much.
59
+ */
60
+ env?: Record<string, string>;
61
+ }
62
+
63
+ /**
64
+ * Injectable interface. Production code calls
65
+ * `defaultShellScriptRunner.run()`; tests can pass a mock to skip the
66
+ * actual subprocess spawn.
67
+ */
68
+ export interface ShellScriptRunner {
69
+ run(options: ShellScriptRunOptions): Promise<ShellScriptRunResult>;
70
+ }
71
+
72
+ // =============================================================================
73
+ // INTERNALS
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Vars passed through to the subprocess. We intentionally do NOT
78
+ * forward the satellite's full env so backend secrets (DB URLs, API
79
+ * tokens, signing keys) never reach user-authored scripts.
80
+ */
81
+ const SAFE_ENV_VARS = [
82
+ "PATH",
83
+ "HOME",
84
+ "USER",
85
+ "LANG",
86
+ "LC_ALL",
87
+ "LC_CTYPE",
88
+ "TZ",
89
+ "TMPDIR",
90
+ "HOSTNAME",
91
+ "SHELL",
92
+ ];
93
+
94
+ function pickSafeEnv(): Record<string, string> {
95
+ const env: Record<string, string> = {};
96
+ for (const key of SAFE_ENV_VARS) {
97
+ const value = process.env[key];
98
+ if (value !== undefined) {
99
+ env[key] = value;
100
+ }
101
+ }
102
+ return env;
103
+ }
104
+
105
+ // =============================================================================
106
+ // DEFAULT RUNNER
107
+ // =============================================================================
108
+
109
+ /**
110
+ * Default runner implementation. Production code should use this; tests
111
+ * can substitute a mock that conforms to {@link ShellScriptRunner}.
112
+ */
113
+ export const defaultShellScriptRunner: ShellScriptRunner = {
114
+ async run({ script, timeoutMs, cwd, env }) {
115
+ let proc: Subprocess | undefined;
116
+ let timedOut = false;
117
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
118
+
119
+ const timeoutPromise = new Promise<never>((_, reject) => {
120
+ timeoutHandle = setTimeout(() => {
121
+ timedOut = true;
122
+ proc?.kill();
123
+ reject(new Error("Script execution timed out"));
124
+ }, timeoutMs);
125
+ });
126
+
127
+ try {
128
+ // Execute through `sh -c` so the user's script can use pipes,
129
+ // redirects, variable expansion, conditionals, command
130
+ // substitution, etc. — i.e. behave like a real shell script
131
+ // rather than a single argv vector.
132
+ proc = spawn({
133
+ cmd: ["sh", "-c", script],
134
+ cwd,
135
+ env: { ...pickSafeEnv(), ...env },
136
+ stdout: "pipe",
137
+ stderr: "pipe",
138
+ });
139
+
140
+ const [stdout, stderr, exitCode] = await Promise.race([
141
+ Promise.all([
142
+ new Response(proc.stdout as ReadableStream).text(),
143
+ new Response(proc.stderr as ReadableStream).text(),
144
+ proc.exited,
145
+ ]),
146
+ timeoutPromise,
147
+ ]);
148
+
149
+ return {
150
+ exitCode,
151
+ stdout: stdout.trim(),
152
+ stderr: stderr.trim(),
153
+ timedOut: false,
154
+ };
155
+ } catch (error) {
156
+ if (timedOut) {
157
+ return {
158
+ exitCode: -1,
159
+ stdout: "",
160
+ stderr: "Script execution timed out",
161
+ timedOut: true,
162
+ };
163
+ }
164
+ throw error;
165
+ } finally {
166
+ if (timeoutHandle !== undefined) {
167
+ clearTimeout(timeoutHandle);
168
+ }
169
+ // Idempotent — no-op when the subprocess has already exited
170
+ // cleanly, but guarantees we never leave a runaway `sh` from
171
+ // an exception path.
172
+ proc?.kill();
173
+ }
174
+ },
175
+ };